From 03f92f5d58b2550c5a34b239abd668a80abf7ad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E5=A4=A9=E7=BF=94?= Date: Tue, 20 Jan 2026 21:04:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=A9=E5=B1=95=E5=8A=9F=E8=83=BD=EF=BC=9A?= =?UTF-8?q?=201.=E5=9B=9E=E6=94=B6=E7=AB=99=E5=8A=9F=E8=83=BD=E6=89=A9?= =?UTF-8?q?=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: 1.闪退问题 2.便签和文件夹区分度不高问题 3.Trash和Notes在界面显示混乱问题 --- .../micode/notes/data/NotesRepository.java | 962 ++++++++++++ .../net/micode/notes/ui/NoteInfoAdapter.java | 343 +++++ .../micode/notes/ui/NotesListActivity.java | 82 +- .../net/micode/notes/ui/SidebarFragment.java | 517 +++++++ .../notes/viewmodel/FolderListViewModel.java | 305 ++++ .../notes/viewmodel/NotesListViewModel.java | 663 +++++++++ .../app/src/main/res/drawable/ic_add.xml | 27 + .../app/src/main/res/drawable/ic_edit.xml | 27 + .../app/src/main/res/drawable/ic_folder.xml | 9 + .../src/main/res/drawable/ic_note_empty.xml | 27 + .../res/drawable/note_item_background.xml | 40 + .../src/main/res/layout/breadcrumb_item.xml | 16 + .../src/main/res/layout/breadcrumb_layout.xml | 35 + .../app/src/main/res/layout/note_item.xml | 8 + .../main/res/layout/sidebar_folder_item.xml | 73 + .../src/main/res/layout/sidebar_layout.xml | 174 +++ .../main/res/menu/note_list_multi_select.xml | 26 + .../main/res/menu/note_list_toolbar_multi.xml | 22 + .../micode/notes/data/FolderDatabaseTest.java | 1287 +++++++++++++++++ .../notes/data/NotesRepositoryTest.java | 255 ++++ 20 files changed, 4888 insertions(+), 10 deletions(-) create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesRepository.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteInfoAdapter.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/ui/SidebarFragment.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/FolderListViewModel.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/NotesListViewModel.java create mode 100644 src/Notesmaster/app/src/main/res/drawable/ic_add.xml create mode 100644 src/Notesmaster/app/src/main/res/drawable/ic_edit.xml create mode 100644 src/Notesmaster/app/src/main/res/drawable/ic_folder.xml create mode 100644 src/Notesmaster/app/src/main/res/drawable/ic_note_empty.xml create mode 100644 src/Notesmaster/app/src/main/res/drawable/note_item_background.xml create mode 100644 src/Notesmaster/app/src/main/res/layout/breadcrumb_item.xml create mode 100644 src/Notesmaster/app/src/main/res/layout/breadcrumb_layout.xml create mode 100644 src/Notesmaster/app/src/main/res/layout/sidebar_folder_item.xml create mode 100644 src/Notesmaster/app/src/main/res/layout/sidebar_layout.xml create mode 100644 src/Notesmaster/app/src/main/res/menu/note_list_multi_select.xml create mode 100644 src/Notesmaster/app/src/main/res/menu/note_list_toolbar_multi.xml create mode 100644 src/Notesmaster/app/src/test/java/net/micode/notes/data/FolderDatabaseTest.java create mode 100644 src/Notesmaster/app/src/test/java/net/micode/notes/data/NotesRepositoryTest.java diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesRepository.java b/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesRepository.java new file mode 100644 index 0000000..50296cf --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesRepository.java @@ -0,0 +1,962 @@ +/* + * Copyright (c) 2025, Modern Notes Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.data; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.util.Log; + +import net.micode.notes.data.Notes; +import net.micode.notes.data.Notes.CallNote; +import net.micode.notes.data.Notes.DataColumns; +import net.micode.notes.data.Notes.NoteColumns; +import net.micode.notes.data.Notes.TextNote; +import net.micode.notes.model.Note; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; + +/** + * 笔记数据仓库 + *

+ * 负责数据访问逻辑,统一管理Content Provider和缓存 + * 提供笔记的增删改查、搜索、统计等功能 + *

+ *

+ * 使用Executor进行后台线程数据访问,避免阻塞UI线程 + *

+ * + * @see Note + * @see Notes + */ +public class NotesRepository { + + /** + * 笔记信息类 + *

+ * 存储从数据库查询的笔记基本信息 + *

+ */ + public static class NoteInfo { + public long id; + public String title; + public String snippet; + public long parentId; + public long createdDate; + public long modifiedDate; + public int type; + public int localModified; + public int bgColorId; + public boolean isPinned; // 新增置顶字段 + + public NoteInfo() {} + + public long getId() { + return id; + } + + public long getParentId() { + return parentId; + } + + public String getNoteDataValue() { + return snippet; + } + } + private static final String TAG = "NotesRepository"; + + private final ContentResolver contentResolver; + private final ExecutorService executor; + + // 选择条件常量 + private static final String NORMAL_SELECTION = NoteColumns.PARENT_ID + " = ?"; + private static final String ROOT_FOLDER_SELECTION = "(" + + NoteColumns.TYPE + "<>" + Notes.TYPE_SYSTEM + " AND " + + NoteColumns.PARENT_ID + "=?) OR (" + + NoteColumns.ID + "=" + Notes.ID_CALL_RECORD_FOLDER + " AND " + + NoteColumns.NOTES_COUNT + ">0)"; + + /** + * 数据访问回调接口 + *

+ * 统一的数据访问结果回调机制 + *

+ * + * @param 返回数据类型 + */ + public interface Callback { + /** + * 成功回调 + * + * @param result 返回的结果数据 + */ + void onSuccess(T result); + + /** + * 失败回调 + * + * @param error 异常对象 + */ + void onError(Exception error); + } + + /** + * 从 Cursor 创建 NoteInfo 对象 + * + * @param cursor 数据库游标 + * @return NoteInfo 对象 + */ + private NoteInfo noteFromCursor(Cursor cursor) { + NoteInfo noteInfo = new NoteInfo(); + noteInfo.id = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID)); + noteInfo.title = cursor.getString(cursor.getColumnIndexOrThrow(NoteColumns.SNIPPET)); + noteInfo.snippet = cursor.getString(cursor.getColumnIndexOrThrow(NoteColumns.SNIPPET)); + noteInfo.parentId = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.PARENT_ID)); + noteInfo.createdDate = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.CREATED_DATE)); + noteInfo.modifiedDate = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.MODIFIED_DATE)); + noteInfo.type = cursor.getInt(cursor.getColumnIndexOrThrow(NoteColumns.TYPE)); + noteInfo.localModified = cursor.getInt(cursor.getColumnIndexOrThrow(NoteColumns.LOCAL_MODIFIED)); + + int bgColorIdIndex = cursor.getColumnIndex(NoteColumns.BG_COLOR_ID); + if (bgColorIdIndex != -1 && !cursor.isNull(bgColorIdIndex)) { + noteInfo.bgColorId = cursor.getInt(bgColorIdIndex); + } else { + noteInfo.bgColorId = 0; + } + + int topIndex = cursor.getColumnIndex(NoteColumns.TOP); + if (topIndex != -1) { + noteInfo.isPinned = cursor.getInt(topIndex) > 0; + } + + return noteInfo; + } + + /** + * 构造函数 + *

+ * 初始化ContentResolver和线程池 + *

+ * + * @param contentResolver Content解析器 + */ + public NotesRepository(ContentResolver contentResolver) { + this.contentResolver = contentResolver; + // 使用单线程Executor确保数据访问的顺序性 + this.executor = java.util.concurrent.Executors.newSingleThreadExecutor(); + Log.d(TAG, "NotesRepository initialized"); + } + + /** + * 获取指定文件夹的笔记列表 + *

+ * 支持根文件夹(显示所有笔记)和子文件夹两种模式 + *

+ * + * @param folderId 文件夹ID,{@link Notes#ID_ROOT_FOLDER} 表示根文件夹 + * @param callback 回调接口 + */ + public void getNotes(long folderId, Callback> callback) { + executor.execute(() -> { + try { + List notes = queryNotes(folderId); + callback.onSuccess(notes); + Log.d(TAG, "Successfully loaded notes for folder: " + folderId); + } catch (Exception e) { + Log.e(TAG, "Failed to load notes for folder: " + folderId, e); + callback.onError(e); + } + }); + } + + /** + * 查询笔记列表(内部方法) + *

+ * 同时返回文件夹和便签,文件夹显示在便签之前 + *

+ * + * @param folderId 文件夹ID + * @return 笔记列表(包含文件夹和便签) + */ + private List queryNotes(long folderId) { + List notes = new ArrayList<>(); + List folders = new ArrayList<>(); + List normalNotes = new ArrayList<>(); + + String selection; + String[] selectionArgs; + + if (folderId == Notes.ID_ROOT_FOLDER) { + // 根文件夹:显示所有文件夹和便签 + selection = "(" + NoteColumns.TYPE + "<>" + Notes.TYPE_SYSTEM + " AND " + NoteColumns.PARENT_ID + "=?) OR (" + + NoteColumns.ID + "=" + Notes.ID_CALL_RECORD_FOLDER + " AND " + NoteColumns.NOTES_COUNT + ">0)"; + selectionArgs = new String[]{String.valueOf(Notes.ID_ROOT_FOLDER)}; + } else { + // 子文件夹:显示该文件夹下的文件夹和便签 + selection = NoteColumns.PARENT_ID + "=? AND " + NoteColumns.TYPE + "<>" + Notes.TYPE_SYSTEM; + selectionArgs = new String[]{String.valueOf(folderId)}; + } + + Cursor cursor = contentResolver.query( + Notes.CONTENT_NOTE_URI, + null, + selection, + selectionArgs, + NoteColumns.MODIFIED_DATE + " DESC" + ); + + if (cursor != null) { + try { + while (cursor.moveToNext()) { + NoteInfo note = noteFromCursor(cursor); + if (note.type == Notes.TYPE_FOLDER) { + // 文件夹单独收集 + folders.add(note); + } else if (note.type == Notes.TYPE_NOTE) { + // 便签收集 + normalNotes.add(note); + } else if (note.type == Notes.TYPE_SYSTEM && note.id == Notes.ID_CALL_RECORD_FOLDER) { + // 通话记录文件夹 + folders.add(note); + } + } + Log.d(TAG, "Query returned " + folders.size() + " folders and " + normalNotes.size() + " notes"); + } finally { + cursor.close(); + } + } + + // 文件夹按修改时间倒序排列 + folders.sort((a, b) -> Long.compare(b.modifiedDate, a.modifiedDate)); + // 便签按修改时间倒序排列 + normalNotes.sort((a, b) -> { + // 首先按置顶状态排序(置顶在前) + if (a.isPinned != b.isPinned) { + return a.isPinned ? -1 : 1; + } + // 其次按修改时间倒序排列 + return Long.compare(b.modifiedDate, a.modifiedDate); + }); + + // 合并:文件夹在前,便签在后 + notes.addAll(folders); + notes.addAll(normalNotes); + + return notes; + } + + /** + * 查询单个文件夹信息 + * + * @param folderId 文件夹ID + * @return 文件夹信息,如果不存在返回null + */ + public NoteInfo getFolderInfo(long folderId) { + if (folderId == Notes.ID_ROOT_FOLDER) { + NoteInfo root = new NoteInfo(); + root.id = Notes.ID_ROOT_FOLDER; + root.title = "我的便签"; + root.snippet = "我的便签"; + root.type = Notes.TYPE_FOLDER; + return root; + } + + String selection = NoteColumns.ID + "=?"; + String[] selectionArgs = new String[]{String.valueOf(folderId)}; + + Cursor cursor = contentResolver.query( + Notes.CONTENT_NOTE_URI, + null, + selection, + selectionArgs, + null + ); + + if (cursor != null) { + try { + if (cursor.moveToFirst()) { + return noteFromCursor(cursor); + } + } finally { + cursor.close(); + } + } + return null; + } + + /** + * 查询文件夹的父文件夹ID(异步版本) + * + * @param folderId 文件夹ID + * @param callback 回调接口,返回父文件夹ID + */ + public void getParentFolderId(long folderId, Callback callback) { + executor.execute(() -> { + try { + long parentId = getParentFolderId(folderId); + callback.onSuccess(parentId); + } catch (Exception e) { + callback.onError(e); + } + }); + } + + /** + * 查询文件夹的父文件夹ID + * + * @param folderId 文件夹ID + * @return 父文件夹ID,如果不存在返回根文件夹ID + */ + public long getParentFolderId(long folderId) { + if (folderId == Notes.ID_ROOT_FOLDER || folderId == Notes.ID_CALL_RECORD_FOLDER) { + return Notes.ID_ROOT_FOLDER; + } + + NoteInfo folder = getFolderInfo(folderId); + if (folder != null) { + return folder.parentId; + } + return Notes.ID_ROOT_FOLDER; + } + + /** + * 获取文件夹路径(从根到当前) + * + * @param folderId 当前文件夹ID + * @return 文件夹路径列表(从根到当前) + */ + public List getFolderPath(long folderId) { + List path = new ArrayList<>(); + long currentId = folderId; + + while (currentId != Notes.ID_ROOT_FOLDER) { + NoteInfo folder = getFolderInfo(currentId); + if (folder == null) { + break; + } + path.add(0, folder); // 添加到列表头部 + currentId = folder.parentId; + } + + // 添加根文件夹 + NoteInfo root = new NoteInfo(); + root.id = Notes.ID_ROOT_FOLDER; + root.title = "我的便签"; + root.snippet = "我的便签"; + root.type = Notes.TYPE_FOLDER; + path.add(0, root); + + return path; + } + + /** + * 获取文件夹路径(异步版本) + * + * @param folderId 当前文件夹ID + * @param callback 回调接口,返回文件夹路径列表 + */ + public void getFolderPath(long folderId, Callback> callback) { + executor.execute(() -> { + try { + List path = getFolderPath(folderId); + callback.onSuccess(path); + } catch (Exception e) { + callback.onError(e); + } + }); + } + + /** + * 创建新文件夹 + * + * @param parentId 父文件夹ID + * @param name 文件夹名称 + * @param callback 回调接口,返回新文件夹的ID + */ + public void createFolder(long parentId, String name, Callback callback) { + executor.execute(() -> { + try { + ContentValues values = new ContentValues(); + long currentTime = System.currentTimeMillis(); + + values.put(NoteColumns.PARENT_ID, parentId); + values.put(NoteColumns.TYPE, Notes.TYPE_FOLDER); + values.put(NoteColumns.SNIPPET, name); + values.put(NoteColumns.CREATED_DATE, currentTime); + values.put(NoteColumns.MODIFIED_DATE, currentTime); + values.put(NoteColumns.LOCAL_MODIFIED, 1); + values.put(NoteColumns.NOTES_COUNT, 0); + + Uri uri = contentResolver.insert(Notes.CONTENT_NOTE_URI, values); + + Long folderId = 0L; + if (uri != null) { + try { + folderId = ContentUris.parseId(uri); + } catch (Exception e) { + Log.e(TAG, "Failed to parse folder ID from URI", e); + } + } + + callback.onSuccess(folderId); + Log.d(TAG, "Successfully created folder: " + name + " with ID: " + folderId); + } catch (Exception e) { + Log.e(TAG, "Failed to create folder: " + name, e); + callback.onError(e); + } + }); + } + + /** + * 创建新笔记 + *

+ * 在指定文件夹下创建一个空笔记 + *

+ * + * @param folderId 父文件夹ID + * @param callback 回调接口,返回新笔记的ID + */ + public void createNote(long folderId, Callback callback) { + executor.execute(() -> { + try { + ContentValues values = new ContentValues(); + long currentTime = System.currentTimeMillis(); + + values.put(NoteColumns.PARENT_ID, folderId); + values.put(NoteColumns.TYPE, Notes.TYPE_NOTE); + values.put(NoteColumns.CREATED_DATE, currentTime); + values.put(NoteColumns.MODIFIED_DATE, currentTime); + values.put(NoteColumns.LOCAL_MODIFIED, 1); + values.put(NoteColumns.SNIPPET, ""); + + Uri uri = contentResolver.insert(Notes.CONTENT_NOTE_URI, values); + + Long noteId = 0L; + if (uri != null) { + try { + noteId = ContentUris.parseId(uri); + } catch (Exception e) { + Log.e(TAG, "Failed to parse note ID from URI", e); + } + } + + if (noteId > 0) { + callback.onSuccess(noteId); + Log.d(TAG, "Successfully created note with ID: " + noteId); + } else { + callback.onError(new IllegalStateException("Failed to create note, invalid ID returned")); + } + } catch (Exception e) { + Log.e(TAG, "Failed to create note", e); + callback.onError(e); + } + }); + } + + /** + * 更新笔记内容 + *

+ * 更新笔记的标题和内容,自动更新修改时间和本地修改标志 + *

+ * + * @param noteId 笔记ID + * @param content 笔记内容 + * @param callback 回调接口,返回影响的行数 + */ + public void updateNote(long noteId, String content, Callback callback) { + executor.execute(() -> { + try { + ContentValues values = new ContentValues(); + long currentTime = System.currentTimeMillis(); + + values.put(NoteColumns.SNIPPET, extractSnippet(content)); + values.put(NoteColumns.MODIFIED_DATE, currentTime); + values.put(NoteColumns.LOCAL_MODIFIED, 1); + + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId); + int rows = contentResolver.update(uri, values, null, null); + + if (rows > 0) { + // 查询现有的文本数据记录 + Cursor cursor = contentResolver.query( + Notes.CONTENT_DATA_URI, + new String[]{DataColumns.ID}, + DataColumns.NOTE_ID + " = ? AND " + DataColumns.MIME_TYPE + " = ?", + new String[]{String.valueOf(noteId), TextNote.CONTENT_ITEM_TYPE}, + null + ); + + long dataId = 0; + if (cursor != null) { + try { + if (cursor.moveToFirst()) { + dataId = cursor.getLong(cursor.getColumnIndexOrThrow(DataColumns.ID)); + } + } finally { + cursor.close(); + } + } + + // 更新或插入文本数据 + ContentValues dataValues = new ContentValues(); + dataValues.put(DataColumns.CONTENT, content); + + if (dataId > 0) { + // 更新现有记录 + Uri dataUri = ContentUris.withAppendedId(Notes.CONTENT_DATA_URI, dataId); + int dataRows = contentResolver.update(dataUri, dataValues, null, null); + if (dataRows > 0) { + callback.onSuccess(rows); + Log.d(TAG, "Successfully updated note: " + noteId); + } else { + callback.onError(new RuntimeException("Failed to update note data")); + } + } else { + // 插入新记录 + dataValues.put(DataColumns.NOTE_ID, noteId); + dataValues.put(DataColumns.MIME_TYPE, TextNote.CONTENT_ITEM_TYPE); + Uri dataUri = contentResolver.insert(Notes.CONTENT_DATA_URI, dataValues); + if (dataUri != null) { + callback.onSuccess(rows); + Log.d(TAG, "Successfully updated note: " + noteId); + } else { + callback.onError(new RuntimeException("Failed to insert note data")); + } + } + } else { + callback.onError(new RuntimeException("No note found with ID: " + noteId)); + } + } catch (Exception e) { + Log.e(TAG, "Failed to update note: " + noteId, e); + callback.onError(e); + } + }); + } + + /** + * 删除笔记 + *

+ * 将笔记移动到回收站文件夹 + *

+ * + * @param noteId 笔记ID + * @param callback 回调接口,返回影响的行数 + */ + public void deleteNote(long noteId, Callback callback) { + executor.execute(() -> { + try { + ContentValues values = new ContentValues(); + values.put(NoteColumns.PARENT_ID, Notes.ID_TRASH_FOLER); + values.put(NoteColumns.LOCAL_MODIFIED, 1); + + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId); + int rows = contentResolver.update(uri, values, null, null); + + if (rows > 0) { + callback.onSuccess(rows); + Log.d(TAG, "Successfully moved note to trash: " + noteId); + } else { + callback.onError(new RuntimeException("No note found with ID: " + noteId)); + } + } catch (Exception e) { + Log.e(TAG, "Failed to delete note: " + noteId, e); + callback.onError(e); + } + }); + } + + /** + * 批量删除笔记(逻辑删除,移动到回收站) + * + * @param noteIds 笔记ID列表 + * @param callback 回调接口,返回影响的行数 + */ + public void deleteNotes(List noteIds, Callback callback) { + executor.execute(() -> { + try { + if (noteIds == null || noteIds.isEmpty()) { + callback.onError(new IllegalArgumentException("Note IDs list is empty")); + return; + } + + int totalRows = 0; + for (Long noteId : noteIds) { + ContentValues values = new ContentValues(); + values.put(NoteColumns.PARENT_ID, Notes.ID_TRASH_FOLER); + values.put(NoteColumns.LOCAL_MODIFIED, 1); + + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId); + int rows = contentResolver.update(uri, values, null, null); + totalRows += rows; + } + + if (totalRows > 0) { + callback.onSuccess(totalRows); + Log.d(TAG, "Successfully moved " + totalRows + " notes to trash"); + } else { + callback.onError(new RuntimeException("No notes were deleted")); + } + } catch (Exception e) { + Log.e(TAG, "Failed to batch delete notes", e); + callback.onError(e); + } + }); + } + + /** + * 恢复笔记(从回收站移回根目录) + * + * @param noteIds 笔记ID列表 + * @param callback 回调接口 + */ + public void restoreNotes(List noteIds, Callback callback) { + executor.execute(() -> { + try { + if (noteIds == null || noteIds.isEmpty()) { + callback.onError(new IllegalArgumentException("Note IDs list is empty")); + return; + } + + int totalRows = 0; + // 恢复到根目录 + long targetFolderId = Notes.ID_ROOT_FOLDER; + + for (Long noteId : noteIds) { + ContentValues values = new ContentValues(); + values.put(NoteColumns.PARENT_ID, targetFolderId); + values.put(NoteColumns.LOCAL_MODIFIED, 1); + + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId); + int rows = contentResolver.update(uri, values, null, null); + totalRows += rows; + } + + if (totalRows > 0) { + callback.onSuccess(totalRows); + Log.d(TAG, "Successfully restored " + totalRows + " notes"); + } else { + callback.onError(new RuntimeException("No notes were restored")); + } + } catch (Exception e) { + Log.e(TAG, "Failed to restore notes", e); + callback.onError(e); + } + }); + } + + /** + * 永久删除笔记(物理删除) + * + * @param noteIds 笔记ID列表 + * @param callback 回调接口 + */ + public void deleteNotesForever(List noteIds, Callback callback) { + executor.execute(() -> { + try { + if (noteIds == null || noteIds.isEmpty()) { + callback.onError(new IllegalArgumentException("Note IDs list is empty")); + return; + } + + int totalRows = 0; + for (Long noteId : noteIds) { + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId); + int rows = contentResolver.delete(uri, null, null); + totalRows += rows; + } + + if (totalRows > 0) { + callback.onSuccess(totalRows); + Log.d(TAG, "Successfully permanently deleted " + totalRows + " notes"); + } else { + callback.onError(new RuntimeException("No notes were deleted")); + } + } catch (Exception e) { + Log.e(TAG, "Failed to delete notes forever", e); + callback.onError(e); + } + }); + } + + /** + * 搜索笔记 + *

+ * 根据关键字在标题和内容中搜索笔记 + *

+ * + * @param keyword 搜索关键字 + * @param callback 回调接口 + */ + public void searchNotes(String keyword, Callback> callback) { + executor.execute(() -> { + try { + if (keyword == null || keyword.trim().isEmpty()) { + callback.onSuccess(new ArrayList<>()); + return; + } + + String selection = "(" + NoteColumns.TYPE + " = ?) AND (" + + NoteColumns.SNIPPET + " LIKE ? OR " + + NoteColumns.ID + " IN (SELECT " + DataColumns.NOTE_ID + + " FROM data WHERE " + DataColumns.CONTENT + " LIKE ?))"; + + String[] selectionArgs = new String[]{ + String.valueOf(Notes.TYPE_NOTE), + "%" + keyword + "%", + "%" + keyword + "%" + }; + + Cursor cursor = contentResolver.query( + Notes.CONTENT_NOTE_URI, + null, + selection, + selectionArgs, + NoteColumns.MODIFIED_DATE + " DESC" + ); + + List notes = new ArrayList<>(); + if (cursor != null) { + try { + while (cursor.moveToNext()) { + notes.add(noteFromCursor(cursor)); + } + Log.d(TAG, "Search returned " + cursor.getCount() + " results for: " + keyword); + } finally { + cursor.close(); + } + } + + callback.onSuccess(notes); + } catch (Exception e) { + Log.e(TAG, "Failed to search notes: " + keyword, e); + callback.onError(e); + } + }); + } + + /** + * 获取笔记统计信息 + *

+ * 统计指定文件夹下的笔记数量 + *

+ * + * @param folderId 文件夹ID + * @param callback 回调接口 + */ + public void countNotes(long folderId, Callback callback) { + executor.execute(() -> { + try { + String selection; + String[] selectionArgs; + + if (folderId == Notes.ID_ROOT_FOLDER) { + selection = NoteColumns.TYPE + " != ?"; + selectionArgs = new String[]{String.valueOf(Notes.TYPE_FOLDER)}; + } else { + selection = NoteColumns.PARENT_ID + " = ?"; + selectionArgs = new String[]{String.valueOf(folderId)}; + } + + Cursor cursor = contentResolver.query( + Notes.CONTENT_NOTE_URI, + new String[]{"COUNT(*) AS count"}, + selection, + selectionArgs, + null + ); + + int count = 0; + if (cursor != null) { + try { + if (cursor.moveToFirst()) { + count = cursor.getInt(0); + } + } finally { + cursor.close(); + } + } + + callback.onSuccess(count); + Log.d(TAG, "Counted " + count + " notes in folder: " + folderId); + } catch (Exception e) { + Log.e(TAG, "Failed to count notes in folder: " + folderId, e); + callback.onError(e); + } + }); + } + + /** + * 获取文件夹列表 + *

+ * 查询所有文件夹类型的笔记 + *

+ * + * @param callback 回调接口 + */ + public void getFolders(Callback> callback) { + executor.execute(() -> { + try { + String selection = NoteColumns.TYPE + " = ?"; + String[] selectionArgs = new String[]{ + String.valueOf(Notes.TYPE_FOLDER) + }; + + Cursor cursor = contentResolver.query( + Notes.CONTENT_NOTE_URI, + null, + selection, + selectionArgs, + NoteColumns.MODIFIED_DATE + " DESC" + ); + + List folders = new ArrayList<>(); + if (cursor != null) { + try { + while (cursor.moveToNext()) { + folders.add(noteFromCursor(cursor)); + } + Log.d(TAG, "Found " + cursor.getCount() + " folders"); + } finally { + cursor.close(); + } + } + + callback.onSuccess(folders); + } catch (Exception e) { + Log.e(TAG, "Failed to load folders", e); + callback.onError(e); + } + }); + } + + /** + * 批量移动笔记到指定文件夹 + *

+ * 将笔记从当前文件夹移动到目标文件夹 + *

+ * + * @param noteIds 要移动的笔记ID列表 + * @param targetFolderId 目标文件夹ID + * @param callback 回调接口 + */ + public void moveNotes(List noteIds, long targetFolderId, Callback callback) { + executor.execute(() -> { + try { + if (noteIds == null || noteIds.isEmpty()) { + callback.onError(new IllegalArgumentException("Note IDs list is empty")); + return; + } + + int totalRows = 0; + for (Long noteId : noteIds) { + ContentValues values = new ContentValues(); + values.put(NoteColumns.PARENT_ID, targetFolderId); + values.put(NoteColumns.LOCAL_MODIFIED, 1); + + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId); + int rows = contentResolver.update(uri, values, null, null); + totalRows += rows; + } + + if (totalRows > 0) { + callback.onSuccess(totalRows); + Log.d(TAG, "Successfully moved " + totalRows + " notes to folder: " + targetFolderId); + } else { + callback.onError(new RuntimeException("No notes were moved")); + } + } catch (Exception e) { + Log.e(TAG, "Failed to move notes", e); + callback.onError(e); + } + }); + } + + /** + * 批量更新笔记置顶状态 + * + * @param noteIds 笔记ID列表 + * @param isPinned 是否置顶 + * @param callback 回调接口 + */ + public void batchTogglePin(List noteIds, boolean isPinned, Callback callback) { + executor.execute(() -> { + try { + if (noteIds == null || noteIds.isEmpty()) { + callback.onError(new IllegalArgumentException("Note IDs list is empty")); + return; + } + + int totalRows = 0; + ContentValues values = new ContentValues(); + values.put(NoteColumns.TOP, isPinned ? 1 : 0); + values.put(NoteColumns.LOCAL_MODIFIED, 1); + + for (Long noteId : noteIds) { + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId); + int rows = contentResolver.update(uri, values, null, null); + totalRows += rows; + } + + if (totalRows > 0) { + callback.onSuccess(totalRows); + Log.d(TAG, "Successfully updated pin state for " + totalRows + " notes"); + } else { + callback.onError(new RuntimeException("No notes were updated")); + } + } catch (Exception e) { + Log.e(TAG, "Failed to update pin state", e); + callback.onError(e); + } + }); + } + + /** + * 从内容中提取摘要 + * + * @param content 笔记内容 + * @return 摘要文本(最多100个字符) + */ + private String extractSnippet(String content) { + if (content == null || content.isEmpty()) { + return ""; + } + int maxLength = 100; + return content.length() > maxLength + ? content.substring(0, maxLength) + : content; + } + + /** + * 关闭Executor + *

+ * 在不再需要数据访问时调用,释放线程池资源 + *

+ */ + public void shutdown() { + if (executor != null && !executor.isShutdown()) { + executor.shutdown(); + Log.d(TAG, "Executor shutdown"); + } + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteInfoAdapter.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteInfoAdapter.java new file mode 100644 index 0000000..f3a2df4 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteInfoAdapter.java @@ -0,0 +1,343 @@ +/* + * Copyright (c) 2025, Modern Notes Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.ui; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.CheckBox; +import android.widget.ImageView; +import android.widget.TextView; + +import net.micode.notes.R; +import net.micode.notes.data.Notes; +import net.micode.notes.data.NotesRepository; +import net.micode.notes.tool.ResourceParser; +import net.micode.notes.tool.ResourceParser.NoteItemBgResources; + +import android.util.Log; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; + +/** + * 便签列表适配器 + *

+ * 将 List 数据绑定到 ListView + * 支持便签显示、选中状态、图标显示 + *

+ *

+ * 现代化实现:使用 ViewHolder 模式优化性能 + *

+ */ +public class NoteInfoAdapter extends BaseAdapter { + private LayoutInflater inflater; + private List notes; + private HashSet selectedIds; + private OnNoteButtonClickListener buttonClickListener; + private OnNoteItemClickListener itemClickListener; + private OnNoteItemLongClickListener itemLongClickListener; + + /** + * 便签按钮点击事件回调接口 + */ + public interface OnNoteButtonClickListener { + /** + * 编辑按钮点击事件 + * + * @param position 位置 + * @param noteId 便签 ID + */ + void onEditButtonClick(int position, long noteId); + } + + /** + * 便签项点击事件回调接口 + */ + public interface OnNoteItemClickListener { + void onNoteItemClick(int position, long noteId); + } + + /** + * 便签项长按事件回调接口 + */ + public interface OnNoteItemLongClickListener { + void onNoteItemLongClick(int position, long noteId); + } + + /** + * 构造函数 + * + * @param context 上下文 + */ + public NoteInfoAdapter(Context context) { + this.inflater = LayoutInflater.from(context); + this.notes = new ArrayList<>(); + this.selectedIds = new HashSet<>(); + } + + /** + * 设置便签列表 + * + * @param notes 便签列表 + */ + public void setNotes(List notes) { + this.notes = notes != null ? notes : new ArrayList<>(); + notifyDataSetChanged(); + } + + /** + * 设置选中的便签 ID 集合 + *

+ * 用于多选模式同步,让 ViewModel 更新 selectedNoteIds 后, + * Adapter 的 selectedIds 也能同步更新 + *

+ * + * @param selectedIds 选中的便签 ID 集合 + */ + public void setSelectedIds(HashSet selectedIds) { + if (selectedIds != null && selectedIds != this.selectedIds) { + this.selectedIds.clear(); + this.selectedIds.addAll(selectedIds); + notifyDataSetChanged(); + } else if (selectedIds == null) { + this.selectedIds.clear(); + notifyDataSetChanged(); + } + } + + /** + * 设置选中的便签 ID 列表 + *

+ * 重载方法,接受 List 参数,在内部转换为 HashSet + *

+ * + * @param selectedIds 选中的便签 ID 列表 + */ + public void setSelectedIds(List selectedIds) { + if (selectedIds != null && !selectedIds.isEmpty()) { + this.selectedIds.clear(); + this.selectedIds.addAll(selectedIds); + notifyDataSetChanged(); + } else { + this.selectedIds.clear(); + notifyDataSetChanged(); + } + } + + /** + * 获取选中的便签 ID + * + * @return 选中的便签 ID 集合 + */ + public HashSet getSelectedIds() { + return selectedIds; + } + + /** + * 切换选中状态 + * + * @param noteId 便签 ID + */ + public void toggleSelection(long noteId) { + if (selectedIds.contains(noteId)) { + selectedIds.remove(noteId); + } else { + selectedIds.add(noteId); + } + notifyDataSetChanged(); + } + + /** + * 设置按钮点击监听器 + * + * @param listener 监听器 + */ + public void setOnNoteButtonClickListener(OnNoteButtonClickListener listener) { + this.buttonClickListener = listener; + } + + public void setOnNoteItemClickListener(OnNoteItemClickListener listener) { + this.itemClickListener = listener; + } + + public void setOnNoteItemLongClickListener(OnNoteItemLongClickListener listener) { + this.itemLongClickListener = listener; + } + + @Override + public int getCount() { + return notes.size(); + } + + @Override + public Object getItem(int position) { + return position >= 0 && position < notes.size() ? notes.get(position) : null; + } + + @Override + public long getItemId(int position) { + NotesRepository.NoteInfo note = (NotesRepository.NoteInfo) getItem(position); + return note != null ? note.getId() : -1; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + Log.d("NoteInfoAdapter", "getView called, position: " + position + ", convertView: " + (convertView != null ? "REUSED" : "NEW")); + ViewHolder holder; + + if (convertView == null) { + convertView = inflater.inflate(R.layout.note_item, parent, false); + holder = new ViewHolder(); + holder.title = convertView.findViewById(R.id.tv_title); + holder.time = convertView.findViewById(R.id.tv_time); + holder.typeIcon = convertView.findViewById(R.id.iv_type_icon); + holder.checkBox = convertView.findViewById(android.R.id.checkbox); + holder.pinnedIcon = convertView.findViewById(R.id.iv_pinned_icon); + convertView.setTag(holder); + + convertView.setOnClickListener(v -> { + Log.d("NoteInfoAdapter", "===== onClick TRIGGERED ====="); + ViewHolder currentHolder = (ViewHolder) v.getTag(); + if (currentHolder != null && itemClickListener != null) { + Log.d("NoteInfoAdapter", "Calling itemClickListener"); + NotesRepository.NoteInfo note = (NotesRepository.NoteInfo) getItem(currentHolder.position); + if (note != null) { + itemClickListener.onNoteItemClick(currentHolder.position, note.getId()); + } + } + Log.d("NoteInfoAdapter", "===== onClick END ====="); + }); + + convertView.setOnLongClickListener(v -> { + Log.d("NoteInfoAdapter", "===== setOnLongClickListener TRIGGERED ====="); + Log.d("NoteInfoAdapter", "Event triggered on view: " + v.getClass().getSimpleName()); + ViewHolder currentHolder = (ViewHolder) v.getTag(); + if (currentHolder != null && itemLongClickListener != null) { + Log.d("NoteInfoAdapter", "Calling itemLongClickListener"); + itemLongClickListener.onNoteItemLongClick(currentHolder.position, currentHolder.position < notes.size() ? notes.get(currentHolder.position).getId() : -1); + } else { + Log.e("NoteInfoAdapter", "itemLongClickListener is NULL!"); + } + Log.d("NoteInfoAdapter", "===== setOnLongClickListener END ====="); + return true; + }); + } else { + holder = (ViewHolder) convertView.getTag(); + } + + holder.position = position; + + NotesRepository.NoteInfo note = (NotesRepository.NoteInfo) getItem(position); + if (note != null) { + String title = note.snippet; + if (title == null || title.trim().isEmpty()) { + title = "无标题"; + } + holder.title.setText(title); + + // 设置类型图标和时间显示 + if (note.type == Notes.TYPE_FOLDER) { + // 文件夹 + holder.typeIcon.setVisibility(View.VISIBLE); + holder.typeIcon.setImageResource(R.drawable.ic_folder); + // 文件夹不显示时间 + holder.time.setVisibility(View.GONE); + } else { + // 便签 + holder.typeIcon.setVisibility(View.GONE); + holder.time.setVisibility(View.VISIBLE); + holder.time.setText(formatDate(note.modifiedDate)); + } + + int bgResId; + int totalCount = getCount(); + int bgColorId = note.bgColorId; + + if (totalCount == 1) { + bgResId = NoteItemBgResources.getNoteBgSingleRes(bgColorId); + } else if (position == 0) { + bgResId = NoteItemBgResources.getNoteBgFirstRes(bgColorId); + } else if (position == totalCount - 1) { + bgResId = NoteItemBgResources.getNoteBgLastRes(bgColorId); + } else { + bgResId = NoteItemBgResources.getNoteBgNormalRes(bgColorId); + } + + convertView.setBackgroundResource(bgResId); + + if (selectedIds.contains(note.getId())) { + convertView.setActivated(true); + } else { + convertView.setActivated(false); + } + + Log.d("NoteInfoAdapter", "===== Setting checkbox visibility ====="); + Log.d("NoteInfoAdapter", "selectedIds.isEmpty(): " + selectedIds.isEmpty()); + Log.d("NoteInfoAdapter", "selectedIds.size(): " + selectedIds.size()); + Log.d("NoteInfoAdapter", "selectedIds contains note " + note.getId() + ": " + selectedIds.contains(note.getId())); + + if (!selectedIds.isEmpty()) { + Log.d("NoteInfoAdapter", "Setting checkbox VISIBLE"); + holder.checkBox.setVisibility(View.VISIBLE); + holder.checkBox.setChecked(selectedIds.contains(note.getId())); + holder.checkBox.setClickable(false); + } else { + Log.d("NoteInfoAdapter", "Setting checkbox GONE"); + holder.checkBox.setVisibility(View.GONE); + } + Log.d("NoteInfoAdapter", "===== Checkbox visibility set ====="); + + if (note.isPinned) { + holder.pinnedIcon.setVisibility(View.VISIBLE); + } else { + holder.pinnedIcon.setVisibility(View.GONE); + } + } + + return convertView; + } + + /** + * 格式化日期 + * + * @param timestamp 时间戳 + * @return 格式化后的日期字符串 + */ + private String formatDate(long timestamp) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()); + return sdf.format(new Date(timestamp)); + } + + /** + * ViewHolder 模式:优化 ListView 性能 + */ + private static class ViewHolder { + TextView title; + TextView time; + ImageView typeIcon; + CheckBox checkBox; + ImageView pinnedIcon; + int position; + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NotesListActivity.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NotesListActivity.java index 067fc53..6337608 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NotesListActivity.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NotesListActivity.java @@ -255,6 +255,7 @@ public class NotesListActivity extends AppCompatActivity @Override public void onChanged(List notes) { updateAdapter(notes); + updateToolbarForNormalMode(); } }); @@ -417,7 +418,10 @@ public class NotesListActivity extends AppCompatActivity Log.d(TAG, "Normal mode, checking item type"); NotesRepository.NoteInfo note = (NotesRepository.NoteInfo) adapter.getItem(position); if (note != null) { - if (note.type == Notes.TYPE_FOLDER) { + if (viewModel.isTrashMode()) { + // 回收站模式:弹出恢复/删除对话框 + showTrashItemDialog(note); + } else if (note.type == Notes.TYPE_FOLDER) { // 文件夹:进入该文件夹 Log.d(TAG, "Folder clicked, entering folder: " + note.getId()); viewModel.enterFolder(note.getId()); @@ -453,6 +457,50 @@ public class NotesListActivity extends AppCompatActivity } } + /** + * 显示回收站条目操作对话框 + */ + private void showTrashItemDialog(NotesRepository.NoteInfo note) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("操作确认"); + builder.setMessage("要恢复还是永久删除此笔记?"); + builder.setPositiveButton("恢复", (dialog, which) -> { + // 临时将选中的ID设为当前ID,以便复用ViewModel的restoreSelectedNotes + // 这里为了简单,我们直接调用ViewModel的新方法restoreNote(note.getId()) + // 但ViewModel还没这个方法,所以我们先手动构造一个List + viewModel.clearSelection(); + viewModel.toggleNoteSelection(note.getId(), true); + viewModel.restoreSelectedNotes(); + }); + builder.setNegativeButton("永久删除", (dialog, which) -> { + showDeleteForeverConfirmDialog(note); + }); + builder.setNeutralButton("再想想", null); + builder.show(); + } + + /** + * 显示永久删除确认对话框 + */ + private void showDeleteForeverConfirmDialog(NotesRepository.NoteInfo note) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("永久删除"); + builder.setMessage("确定要永久删除此笔记吗?删除后无法恢复!"); + builder.setIcon(android.R.drawable.ic_dialog_alert); + + builder.setPositiveButton("确定", (dialog, which) -> { + viewModel.clearSelection(); + viewModel.toggleNoteSelection(note.getId(), true); + viewModel.deleteSelectedNotesForever(); + }); + + // 设置“确定”按钮颜色为深色(通常系统默认就是强调色,如果需要特定颜色需自定义View或Theme) + // 这里使用默认样式,通常Positive是强调色 + + builder.setNegativeButton("取消", null); + builder.show(); + } + /** * 进入多选模式 */ @@ -530,8 +578,17 @@ public class NotesListActivity extends AppCompatActivity private void updateToolbarForNormalMode() { if (toolbar == null) return; - // 设置标题为应用名称 - toolbar.setTitle(R.string.app_name); + // 清除多选模式菜单 + toolbar.getMenu().clear(); + + // 设置标题 + if (viewModel.isTrashMode()) { + toolbar.setTitle(R.string.menu_trash); + } else { + toolbar.setTitle(R.string.app_name); + // 添加普通模式菜单 + toolbar.inflateMenu(R.menu.note_list); + } // 设置导航图标为汉堡菜单 toolbar.setNavigationIcon(android.R.drawable.ic_menu_sort_by_size); @@ -541,11 +598,16 @@ public class NotesListActivity extends AppCompatActivity } }); - // 清除多选模式菜单 - toolbar.getMenu().clear(); - - // 添加普通模式菜单(如果需要) - // getMenuInflater().inflate(R.menu.note_list_options, menu); + // 如果是回收站模式,不显示新建按钮 + if (viewModel.isTrashMode()) { + if (fabNewNote != null) { + fabNewNote.setVisibility(View.GONE); + } + } else { + if (fabNewNote != null) { + fabNewNote.setVisibility(View.VISIBLE); + } + } } @@ -714,8 +776,8 @@ public class NotesListActivity extends AppCompatActivity @Override public void onTrashSelected() { - // TODO: 实现跳转到回收站 - Log.d(TAG, "Trash selected"); + // 跳转到回收站 + viewModel.enterFolder(Notes.ID_TRASH_FOLER); // 关闭侧栏 if (drawerLayout != null) { drawerLayout.closeDrawer(findViewById(R.id.sidebar_fragment)); diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SidebarFragment.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SidebarFragment.java new file mode 100644 index 0000000..041f68b --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SidebarFragment.java @@ -0,0 +1,517 @@ +/* + * Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.ui; + +import android.app.AlertDialog; +import android.content.Context; +import android.os.Bundle; +import android.text.InputFilter; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.TranslateAnimation; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import net.micode.notes.R; +import net.micode.notes.data.Notes; +import net.micode.notes.data.NotesRepository; +import net.micode.notes.viewmodel.FolderListViewModel; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 侧栏Fragment + *

+ * 显示文件夹树、菜单项和操作按钮 + * 提供文件夹导航、创建、展开/收起等功能 + *

+ */ +public class SidebarFragment extends Fragment { + + private static final String TAG = "SidebarFragment"; + private static final int MAX_FOLDER_NAME_LENGTH = 50; + + // 视图组件 + private RecyclerView rvFolderTree; + private TextView tvRootFolder; + private TextView menuSync; + private TextView menuLogin; + private TextView menuExport; + private TextView menuSettings; + private TextView menuTrash; + + // 适配器和数据 + private FolderTreeAdapter adapter; + private FolderListViewModel viewModel; + + // 单击和双击检测 + private long lastClickTime = 0; + private View lastClickedView = null; + private static final long DOUBLE_CLICK_INTERVAL = 300; // 毫秒 + + // 回调接口 + private OnSidebarItemSelectedListener listener; + + /** + * 侧栏项选择回调接口 + */ + public interface OnSidebarItemSelectedListener { + /** + * 跳转到指定文件夹 + * @param folderId 文件夹ID + */ + void onFolderSelected(long folderId); + + /** + * 打开回收站 + */ + void onTrashSelected(); + + /** + * 同步 + */ + void onSyncSelected(); + + /** + * 登录 + */ + void onLoginSelected(); + + /** + * 导出 + */ + void onExportSelected(); + + /** + * 设置 + */ + void onSettingsSelected(); + + /** + * 创建文件夹 + */ + void onCreateFolder(); + + /** + * 关闭侧栏 + */ + void onCloseSidebar(); + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + if (context instanceof OnSidebarItemSelectedListener) { + listener = (OnSidebarItemSelectedListener) context; + } else { + throw new RuntimeException(context.toString() + " must implement OnSidebarItemSelectedListener"); + } + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + viewModel = new ViewModelProvider(this).get(FolderListViewModel.class); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.sidebar_layout, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + initViews(view); + setupListeners(); + observeViewModel(); + } + + /** + * 刷新文件夹树(供外部调用,如删除笔记后) + */ + public void refreshFolderTree() { + if (viewModel != null) { + viewModel.loadFolderTree(); + } + } + + /** + * 初始化视图 + */ + private void initViews(View view) { + rvFolderTree = view.findViewById(R.id.rv_folder_tree); + tvRootFolder = view.findViewById(R.id.tv_root_folder); + menuSync = view.findViewById(R.id.menu_sync); + menuLogin = view.findViewById(R.id.menu_login); + menuExport = view.findViewById(R.id.menu_export); + menuSettings = view.findViewById(R.id.menu_settings); + menuTrash = view.findViewById(R.id.menu_trash); + + // 设置RecyclerView + rvFolderTree.setLayoutManager(new LinearLayoutManager(requireContext())); + adapter = new FolderTreeAdapter(new ArrayList<>(), viewModel); + adapter.setOnFolderItemClickListener(this::handleFolderItemClick); + rvFolderTree.setAdapter(adapter); + } + + /** + * 设置监听器 + */ + private void setupListeners() { + View view = getView(); + if (view == null) return; + + // 根文件夹(单击展开/收起,双击跳转) + setupFolderClickListener(tvRootFolder, Notes.ID_ROOT_FOLDER); + + // 关闭侧栏 + view.findViewById(R.id.btn_close_sidebar).setOnClickListener(v -> { + if (listener != null) { + listener.onCloseSidebar(); + } + }); + + // 创建文件夹 + view.findViewById(R.id.btn_create_folder).setOnClickListener(v -> showCreateFolderDialog()); + + // 菜单项 + menuSync.setOnClickListener(v -> { + if (listener != null) { + listener.onSyncSelected(); + } + }); + + menuLogin.setOnClickListener(v -> { + if (listener != null) { + listener.onLoginSelected(); + } + }); + + menuExport.setOnClickListener(v -> { + if (listener != null) { + listener.onExportSelected(); + } + }); + + menuSettings.setOnClickListener(v -> { + if (listener != null) { + listener.onSettingsSelected(); + } + }); + + menuTrash.setOnClickListener(v -> { + if (listener != null) { + listener.onTrashSelected(); + } + }); + } + + /** + * 设置文件夹的单击/双击监听器 + */ + private void setupFolderClickListener(View view, long folderId) { + view.setOnClickListener(v -> { + android.util.Log.d(TAG, "setupFolderClickListener: folderId=" + folderId); + long currentTime = System.currentTimeMillis(); + if (lastClickedView == view && (currentTime - lastClickTime) < DOUBLE_CLICK_INTERVAL) { + android.util.Log.d(TAG, "Double click on root folder, jumping to: " + folderId); + // 这是双击,执行跳转 + if (listener != null) { + // 根文件夹也可以跳转(回到根) + listener.onFolderSelected(folderId); + } + // 重置双击状态 + lastClickTime = 0; + lastClickedView = null; + } else { + android.util.Log.d(TAG, "Single click on root folder, will toggle expand in " + DOUBLE_CLICK_INTERVAL + "ms"); + // 可能是单击,延迟处理 + lastClickTime = currentTime; + lastClickedView = view; + view.postDelayed(() -> { + // 如果在延迟期间没有发生双击,则执行单击操作(展开/收起) + if (System.currentTimeMillis() - lastClickTime >= DOUBLE_CLICK_INTERVAL) { + android.util.Log.d(TAG, "Toggling root folder expand"); + toggleFolderExpand(folderId); + } + }, DOUBLE_CLICK_INTERVAL); + } + }); + } + + /** + * 观察ViewModel数据变化 + */ + private void observeViewModel() { + viewModel.getFolderTree().observe(getViewLifecycleOwner(), folderItems -> { + if (folderItems != null) { + adapter.setData(folderItems); + adapter.notifyDataSetChanged(); + } + }); + + viewModel.loadFolderTree(); + } + + /** + * 切换文件夹展开/收起状态 + */ + private void toggleFolderExpand(long folderId) { + android.util.Log.d(TAG, "toggleFolderExpand: folderId=" + folderId); + viewModel.toggleFolderExpand(folderId); + } + + /** + * 处理文件夹项点击(单击/双击) + */ + private void handleFolderItemClick(long folderId) { + android.util.Log.d(TAG, "handleFolderItemClick: folderId=" + folderId); + long currentTime = System.currentTimeMillis(); + if (lastClickedFolderId == folderId && (currentTime - lastFolderClickTime) < DOUBLE_CLICK_INTERVAL) { + android.util.Log.d(TAG, "Double click detected, jumping to folder: " + folderId); + // 这是双击,执行跳转 + if (listener != null) { + listener.onFolderSelected(folderId); + } + // 重置双击状态 + lastFolderClickTime = 0; + lastClickedFolderId = -1; + } else { + android.util.Log.d(TAG, "Single click, will toggle expand in " + DOUBLE_CLICK_INTERVAL + "ms"); + // 可能是单击,延迟处理 + lastFolderClickTime = currentTime; + lastClickedFolderId = folderId; + new android.os.Handler().postDelayed(() -> { + // 如果在延迟期间没有发生双击,则执行单击操作(展开/收起) + if (System.currentTimeMillis() - lastFolderClickTime >= DOUBLE_CLICK_INTERVAL) { + android.util.Log.d(TAG, "Toggling folder expand: " + folderId); + toggleFolderExpand(folderId); + } + }, DOUBLE_CLICK_INTERVAL); + } + } + + // 双击检测专用变量(针对文件夹列表项) + private long lastFolderClickTime = 0; + private long lastClickedFolderId = -1; + + /** + * 显示创建文件夹对话框 + */ + private void showCreateFolderDialog() { + AlertDialog.Builder builder = new AlertDialog.Builder(requireContext()); + builder.setTitle(R.string.dialog_create_folder_title); + + final EditText input = new EditText(requireContext()); + input.setHint(R.string.dialog_create_folder_hint); + input.setFilters(new InputFilter[]{new InputFilter.LengthFilter(MAX_FOLDER_NAME_LENGTH)}); + + builder.setView(input); + + builder.setPositiveButton(R.string.menu_create_folder, (dialog, which) -> { + String folderName = input.getText().toString().trim(); + if (TextUtils.isEmpty(folderName)) { + Toast.makeText(requireContext(), R.string.error_folder_name_empty, Toast.LENGTH_SHORT).show(); + return; + } + if (folderName.length() > MAX_FOLDER_NAME_LENGTH) { + Toast.makeText(requireContext(), R.string.error_folder_name_too_long, Toast.LENGTH_SHORT).show(); + return; + } + + // 创建文件夹 + NotesRepository repository = new NotesRepository(requireContext().getContentResolver()); + long parentId = viewModel.getCurrentFolderId(); + if (parentId == 0) { + parentId = Notes.ID_ROOT_FOLDER; + } + repository.createFolder(parentId, folderName, + new NotesRepository.Callback() { + @Override + public void onSuccess(Long folderId) { + if (getActivity() != null) { + getActivity().runOnUiThread(() -> { + Toast.makeText(requireContext(), R.string.create_folder_success, Toast.LENGTH_SHORT).show(); + // 刷新文件夹列表 + viewModel.loadFolderTree(); + }); + } + } + + @Override + public void onError(Exception error) { + if (getActivity() != null) { + getActivity().runOnUiThread(() -> { + Toast.makeText(requireContext(), + getString(R.string.error_folder_name_too_long) + ": " + error.getMessage(), + Toast.LENGTH_SHORT).show(); + }); + } + } + }); + }); + + builder.setNegativeButton(android.R.string.cancel, null); + builder.show(); + } + + /** + * FolderTreeAdapter + * 文件夹树适配器,支持层级显示和展开/收起 + */ + private static class FolderTreeAdapter extends RecyclerView.Adapter { + + private List folderItems; + private FolderListViewModel viewModel; + private OnFolderItemClickListener folderItemClickListener; + + public FolderTreeAdapter(List folderItems, FolderListViewModel viewModel) { + this.folderItems = folderItems; + this.viewModel = viewModel; + } + + public void setData(List folderItems) { + this.folderItems = folderItems; + } + + public void setOnFolderItemClickListener(OnFolderItemClickListener listener) { + this.folderItemClickListener = listener; + } + + @NonNull + @Override + public FolderViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.sidebar_folder_item, parent, false); + return new FolderViewHolder(view, folderItemClickListener); + } + + @Override + public void onBindViewHolder(@NonNull FolderViewHolder holder, int position) { + FolderTreeItem item = folderItems.get(position); + boolean isExpanded = viewModel != null && viewModel.isFolderExpanded(item.folderId); + holder.bind(item, isExpanded); + } + + @Override + public int getItemCount() { + return folderItems.size(); + } + + static class FolderViewHolder extends RecyclerView.ViewHolder { + private View indentView; + private ImageView ivExpandIcon; + private ImageView ivFolderIcon; + private TextView tvFolderName; + private TextView tvNoteCount; + private FolderTreeItem currentItem; + private final OnFolderItemClickListener folderItemClickListener; + + public FolderViewHolder(@NonNull View itemView, OnFolderItemClickListener listener) { + super(itemView); + this.folderItemClickListener = listener; + indentView = itemView.findViewById(R.id.indent_view); + ivExpandIcon = itemView.findViewById(R.id.iv_expand_icon); + ivFolderIcon = itemView.findViewById(R.id.iv_folder_icon); + tvFolderName = itemView.findViewById(R.id.tv_folder_name); + tvNoteCount = itemView.findViewById(R.id.tv_note_count); + } + + public void bind(FolderTreeItem item, boolean isExpanded) { + this.currentItem = item; + + // 设置缩进 + int indent = item.level * 32; + indentView.setLayoutParams(new LinearLayout.LayoutParams(indent, LinearLayout.LayoutParams.MATCH_PARENT)); + + // 设置展开/收起图标 + if (item.hasChildren) { + ivExpandIcon.setVisibility(View.VISIBLE); + ivExpandIcon.setRotation(isExpanded ? 90 : 0); + } else { + ivExpandIcon.setVisibility(View.INVISIBLE); + } + + // 设置文件夹名称 + tvFolderName.setText(item.name); + + // 设置便签数量 + tvNoteCount.setText(String.format(itemView.getContext() + .getString(R.string.folder_note_count), item.noteCount)); + + // 设置点击监听器 + itemView.setOnClickListener(v -> { + if (folderItemClickListener != null) { + folderItemClickListener.onFolderClick(item.folderId); + } + }); + } + } + } + + /** + * 文件夹项点击监听器接口 + */ + public interface OnFolderItemClickListener { + void onFolderClick(long folderId); + } + + /** + * FolderTreeItem + * 文件夹树项数据模型 + */ + public static class FolderTreeItem { + public long folderId; + public String name; + public int level; // 层级,0表示顶级 + public boolean hasChildren; + public int noteCount; + + public FolderTreeItem(long folderId, String name, int level, boolean hasChildren, int noteCount) { + this.folderId = folderId; + this.name = name; + this.level = level; + this.hasChildren = hasChildren; + this.noteCount = noteCount; + } + } + + @Override + public void onDetach() { + super.onDetach(); + listener = null; + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/FolderListViewModel.java b/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/FolderListViewModel.java new file mode 100644 index 0000000..d93e02d --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/FolderListViewModel.java @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.viewmodel; + +import android.app.Application; +import android.database.Cursor; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import net.micode.notes.data.Notes; +import net.micode.notes.data.NotesDatabaseHelper; +import net.micode.notes.data.NotesDatabaseHelper.TABLE; +import net.micode.notes.data.Notes.NoteColumns; +import net.micode.notes.data.NotesRepository; +import net.micode.notes.ui.SidebarFragment.FolderTreeItem; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 文件夹列表ViewModel + *

+ * 管理文件夹树的数据和业务逻辑 + * 提供文件夹树的查询和构建功能 + *

+ */ +public class FolderListViewModel extends AndroidViewModel { + private static final String TAG = "FolderListViewModel"; + + private MutableLiveData> folderTreeLiveData; + private NotesDatabaseHelper dbHelper; + private NotesRepository repository; + private long currentFolderId = Notes.ID_ROOT_FOLDER; // 当前文件夹ID + private Set expandedFolderIds = new HashSet<>(); // 已展开的文件夹ID集合 + + public FolderListViewModel(@NonNull Application application) { + super(application); + dbHelper = NotesDatabaseHelper.getInstance(application); + repository = new NotesRepository(application.getContentResolver()); + folderTreeLiveData = new MutableLiveData<>(); + } + + /** + * 获取当前文件夹ID + */ + public long getCurrentFolderId() { + return currentFolderId; + } + + /** + * 设置当前文件夹ID + */ + public void setCurrentFolderId(long folderId) { + this.currentFolderId = folderId; + } + + /** + * 切换文件夹展开/收起状态 + * @param folderId 文件夹ID + */ + public void toggleFolderExpand(long folderId) { + android.util.Log.d(TAG, "toggleFolderExpand: folderId=" + folderId); + android.util.Log.d(TAG, "Before toggle, expandedFolders: " + expandedFolderIds); + + if (expandedFolderIds.contains(folderId)) { + expandedFolderIds.remove(folderId); + android.util.Log.d(TAG, "Collapsed folder: " + folderId); + } else { + expandedFolderIds.add(folderId); + android.util.Log.d(TAG, "Expanded folder: " + folderId); + } + + android.util.Log.d(TAG, "After toggle, expandedFolders: " + expandedFolderIds); + + // 重新加载文件夹树 + loadFolderTree(); + } + + /** + * 检查文件夹是否已展开 + * @param folderId 文件夹ID + * @return 是否已展开 + */ + public boolean isFolderExpanded(long folderId) { + return expandedFolderIds.contains(folderId); + } + + /** + * 获取文件夹树LiveData + */ + public LiveData> getFolderTree() { + return folderTreeLiveData; + } + + /** + * 加载文件夹树数据 + */ + public void loadFolderTree() { + new Thread(() -> { + List folderTree = buildFolderTree(); + folderTreeLiveData.postValue(folderTree); + }).start(); + } + + /** + * 构建文件夹树 + *

+ * 从数据库中查询所有文件夹,并构建层级结构 + *

+ * @return 文件夹树列表 + */ + private List buildFolderTree() { + // 查询所有文件夹(不包括系统文件夹) + List> folders = queryAllFolders(); + + android.util.Log.d(TAG, "QueryAllFolders returned " + folders.size() + " folders"); + + // 构建文件夹映射表(方便查找父文件夹) + Map folderMap = new HashMap<>(); + List rootFolders = new ArrayList<>(); + + // 创建文件夹节点 + for (Map folder : folders) { + long id = (Long) folder.get(NoteColumns.ID); + String name = (String) folder.get(NoteColumns.SNIPPET); + long parentId = (Long) folder.get(NoteColumns.PARENT_ID); + int noteCount = ((Number) folder.get(NoteColumns.NOTES_COUNT)).intValue(); + + android.util.Log.d(TAG, "Folder: id=" + id + ", name=" + name + ", parentId=" + parentId); + + FolderNode node = new FolderNode(id, name, parentId, noteCount); + folderMap.put(id, node); + + // 如果是顶级文件夹(父文件夹为根),添加到根列表 + if (parentId == Notes.ID_ROOT_FOLDER) { + rootFolders.add(node); + android.util.Log.d(TAG, "Added root folder: " + name); + } + } + + android.util.Log.d(TAG, "Root folders count: " + rootFolders.size()); + + // 构建父子关系 + for (FolderNode node : folderMap.values()) { + if (node.parentId != Notes.ID_ROOT_FOLDER) { + FolderNode parent = folderMap.get(node.parentId); + if (parent != null) { + parent.children.add(node); + } + } + } + + // 转换为扁平列表(用于RecyclerView显示) + List folderTree = new ArrayList<>(); + // 检查根文件夹是否展开 + boolean rootExpanded = expandedFolderIds.contains(Notes.ID_ROOT_FOLDER); + android.util.Log.d(TAG, "Root expanded: " + rootExpanded); + buildFolderTreeList(rootFolders, folderTree, 0, rootExpanded); + + android.util.Log.d(TAG, "Final folder tree size: " + folderTree.size()); + + return folderTree; + } + + /** + * 递归构建文件夹树列表 + * 只显示已展开文件夹的子文件夹 + * 顶层文件夹始终显示,无论根文件夹是否展开 + * @param nodes 文件夹节点列表 + * @param folderTree 文件夹树列表(输出) + * @param level 当前层级 + * @param forceExpandChildren 是否强制展开子文件夹(用于顶层) + */ + private void buildFolderTreeList(List nodes, List folderTree, int level, boolean forceExpandChildren) { + for (FolderNode node : nodes) { + // 顶级文件夹始终显示(level=0) + // 移除了之前的条件判断,让所有顶级文件夹都能显示 + folderTree.add(new FolderTreeItem( + node.id, + node.name, + level, + !node.children.isEmpty(), + node.noteCount + )); + + // 只有当父文件夹在 expandedFolderIds 中时,才递归处理子文件夹 + // 有子节点(!node.children.isEmpty())才检查展开状态 + if (!node.children.isEmpty() && expandedFolderIds.contains(node.id)) { + buildFolderTreeList(node.children, folderTree, level + 1, false); + } + } + } + + /** + * 查询所有文件夹 + * @return 文件夹列表 + */ + private List> queryAllFolders() { + List> folders = new ArrayList<>(); + + // 查询所有文件夹类型的笔记 + String selection = NoteColumns.TYPE + " = ?"; + String[] selectionArgs = new String[]{ + String.valueOf(Notes.TYPE_FOLDER) + }; + + Cursor cursor = null; + try { + cursor = dbHelper.getReadableDatabase().query( + TABLE.NOTE, + null, + selection, + selectionArgs, + null, + null, + NoteColumns.MODIFIED_DATE + " DESC" + ); + + android.util.Log.d(TAG, "Query executed, cursor: " + (cursor != null ? cursor.getCount() : "null")); + + if (cursor != null) { + android.util.Log.d(TAG, "Column names: " + java.util.Arrays.toString(cursor.getColumnNames())); + + while (cursor.moveToNext()) { + Map folder = new HashMap<>(); + long id = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID)); + String name = cursor.getString(cursor.getColumnIndexOrThrow(NoteColumns.SNIPPET)); + + // 尝试获取parent_id,可能列名不对 + int parentIdIndex = cursor.getColumnIndex(NoteColumns.PARENT_ID); + long parentId = -1; + if (parentIdIndex != -1) { + parentId = cursor.getLong(parentIdIndex); + } + + // 尝试获取notes_count + int notesCountIndex = cursor.getColumnIndex(NoteColumns.NOTES_COUNT); + int noteCount = 0; + if (notesCountIndex != -1) { + noteCount = cursor.getInt(notesCountIndex); + } + + android.util.Log.d(TAG, "Folder data: id=" + id + ", name=" + name + ", parentId=" + parentId + ", noteCount=" + noteCount); + + folder.put(NoteColumns.ID, id); + folder.put(NoteColumns.SNIPPET, name); + folder.put(NoteColumns.PARENT_ID, parentId); + folder.put(NoteColumns.NOTES_COUNT, noteCount); + + folders.add(folder); + } + } + } catch (Exception e) { + android.util.Log.e(TAG, "Error querying folders", e); + e.printStackTrace(); + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return folders; + } + + /** + * FolderNode + * 文件夹节点,用于构建文件夹树 + */ + private static class FolderNode { + public long id; + public String name; + public long parentId; + public int noteCount; + public List children = new ArrayList<>(); + + public FolderNode(long id, String name, long parentId, int noteCount) { + this.id = id; + this.name = name; + this.parentId = parentId; + this.noteCount = noteCount; + } + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/NotesListViewModel.java b/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/NotesListViewModel.java new file mode 100644 index 0000000..7936486 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/NotesListViewModel.java @@ -0,0 +1,663 @@ +/* + * Copyright (c) 2025, Modern Notes Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.viewmodel; + +import android.util.Log; + +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import net.micode.notes.data.Notes; +import net.micode.notes.data.NotesRepository; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; + +/** + * 笔记列表ViewModel + *

+ * 负责笔记列表的业务逻辑,与UI层(Activity)解耦 + * 管理笔记列表的加载、创建、删除、搜索、移动等操作 + *

+ * + * @see NotesRepository + * @see Note + */ +public class NotesListViewModel extends ViewModel { + private static final String TAG = "NotesListViewModel"; + + private final NotesRepository repository; + + // 笔记列表LiveData + private final MutableLiveData> notesLiveData = new MutableLiveData<>(); + + // 加载状态LiveData + private final MutableLiveData isLoading = new MutableLiveData<>(false); + + // 错误消息LiveData + private final MutableLiveData errorMessage = new MutableLiveData<>(); + + // 选中的笔记ID集合 + private final HashSet selectedNoteIds = new HashSet<>(); + + // 当前文件夹ID + private long currentFolderId = Notes.ID_ROOT_FOLDER; + + // 文件夹路径LiveData(用于面包屑导航) + private final MutableLiveData> folderPathLiveData = new MutableLiveData<>(); + + // 侧栏刷新通知LiveData(删除等操作后通知侧栏刷新) + private final MutableLiveData sidebarRefreshNeeded = new MutableLiveData<>(false); + + // 文件夹导航历史(用于返回上一级) + private final List folderHistory = new ArrayList<>(); + + /** + * 构造函数 + * + * @param repository 笔记数据仓库 + */ + public NotesListViewModel(NotesRepository repository) { + this.repository = repository; + Log.d(TAG, "ViewModel created"); + } + + /** + * 获取笔记列表LiveData + * + * @return 笔记列表LiveData + */ + public MutableLiveData> getNotesLiveData() { + return notesLiveData; + } + + /** + * 获取加载状态LiveData + * + * @return 加载状态LiveData + */ + public MutableLiveData getIsLoading() { + return isLoading; + } + + /** + * 获取错误消息LiveData + * + * @return 错误消息LiveData + */ + public MutableLiveData getErrorMessage() { + return errorMessage; + } + + /** + * 加载笔记列表 + *

+ * 从指定文件夹加载笔记列表,同时加载文件夹路径用于面包屑导航 + *

+ * + * @param folderId 文件夹ID,{@link Notes#ID_ROOT_FOLDER} 表示根文件夹 + */ + public void loadNotes(long folderId) { + this.currentFolderId = folderId; + isLoading.postValue(true); + errorMessage.postValue(null); + + // 加载文件夹路径 + repository.getFolderPath(folderId, new NotesRepository.Callback>() { + @Override + public void onSuccess(List path) { + folderPathLiveData.postValue(path); + } + + @Override + public void onError(Exception error) { + Log.e(TAG, "Failed to load folder path", error); + } + }); + + // 加载笔记 + repository.getNotes(folderId, new NotesRepository.Callback>() { + @Override + public void onSuccess(List notes) { + isLoading.postValue(false); + notesLiveData.postValue(notes); + Log.d(TAG, "Successfully loaded " + notes.size() + " notes"); + } + + @Override + public void onError(Exception error) { + isLoading.postValue(false); + String message = "加载笔记失败: " + error.getMessage(); + errorMessage.postValue(message); + Log.e(TAG, message, error); + } + }); + } + + /** + * 刷新笔记列表 + *

+ * 重新加载当前文件夹的笔记列表 + *

+ */ + public void refreshNotes() { + loadNotes(currentFolderId); + } + + /** + * 创建新笔记 + *

+ * 在当前文件夹下创建一个空笔记,并刷新列表 + *

+ */ + public void createNote() { + isLoading.postValue(true); + errorMessage.postValue(null); + + repository.createNote(currentFolderId, new NotesRepository.Callback() { + @Override + public void onSuccess(Long noteId) { + isLoading.postValue(false); + Log.d(TAG, "Successfully created note with ID: " + noteId); + refreshNotes(); + } + + @Override + public void onError(Exception error) { + isLoading.postValue(false); + String message = "创建笔记失败: " + error.getMessage(); + errorMessage.postValue(message); + Log.e(TAG, message, error); + } + }); + } + + /** + * 删除单个笔记 + *

+ * 将笔记移动到回收站,并刷新列表 + *

+ * + * @param noteId 笔记ID + */ + public void deleteNote(long noteId) { + isLoading.postValue(true); + errorMessage.postValue(null); + + repository.deleteNote(noteId, new NotesRepository.Callback() { + @Override + public void onSuccess(Integer rowsAffected) { + isLoading.postValue(false); + selectedNoteIds.remove(noteId); + refreshNotes(); + Log.d(TAG, "Successfully deleted note: " + noteId); + } + + @Override + public void onError(Exception error) { + isLoading.postValue(false); + String message = "删除笔记失败: " + error.getMessage(); + errorMessage.postValue(message); + Log.e(TAG, message, error); + } + }); + } + + /** + * 批量删除笔记 + *

+ * 将选中的所有笔记移动到回收站 + *

+ */ + public void deleteSelectedNotes() { + if (selectedNoteIds.isEmpty()) { + errorMessage.postValue("请先选择要删除的笔记"); + return; + } + + isLoading.postValue(true); + errorMessage.postValue(null); + + List noteIds = new ArrayList<>(selectedNoteIds); + repository.deleteNotes(noteIds, new NotesRepository.Callback() { + @Override + public void onSuccess(Integer rowsAffected) { + isLoading.postValue(false); + selectedNoteIds.clear(); + refreshNotes(); + Log.d(TAG, "Successfully deleted " + rowsAffected + " notes"); + } + + @Override + public void onError(Exception error) { + isLoading.postValue(false); + String message = "批量删除失败: " + error.getMessage(); + errorMessage.postValue(message); + Log.e(TAG, message, error); + } + }); + } + + /** + * 搜索笔记 + *

+ * 根据关键字搜索笔记,更新笔记列表 + *

+ * + * @param keyword 搜索关键字 + */ + public void searchNotes(String keyword) { + isLoading.postValue(true); + errorMessage.postValue(null); + + repository.searchNotes(keyword, new NotesRepository.Callback>() { + @Override + public void onSuccess(List notes) { + isLoading.postValue(false); + notesLiveData.postValue(notes); + Log.d(TAG, "Search returned " + notes.size() + " results"); + } + + @Override + public void onError(Exception error) { + isLoading.postValue(false); + String message = "搜索失败: " + error.getMessage(); + errorMessage.postValue(message); + Log.e(TAG, message, error); + } + }); + } + + /** + * 切换笔记选中状态 + * + * @param noteId 笔记ID + * @param selected 是否选中 + */ + public void toggleNoteSelection(long noteId, boolean selected) { + if (selected) { + selectedNoteIds.add(noteId); + } else { + selectedNoteIds.remove(noteId); + } + } + + /** + * 全选笔记 + *

+ * 选中当前列表中的所有笔记 + *

+ */ + public void selectAllNotes() { + List notes = notesLiveData.getValue(); + if (notes != null) { + for (NotesRepository.NoteInfo note : notes) { + selectedNoteIds.add(note.getId()); + } + } + } + + /** + * 取消全选 + *

+ * 清空所有选中的笔记 + *

+ */ + public void deselectAllNotes() { + selectedNoteIds.clear(); + } + + /** + * 检查是否全选 + * + * @return 如果所有笔记都被选中返回true + */ + public boolean isAllSelected() { + List notes = notesLiveData.getValue(); + if (notes == null || notes.isEmpty()) { + return false; + } + + return notes.size() == selectedNoteIds.size(); + } + + /** + * 获取选中的笔记数量 + * + * @return 选中的笔记数量 + */ + public int getSelectedCount() { + return selectedNoteIds.size(); + } + + /** + * 获取选中的笔记ID列表 + * + * @return 选中的笔记ID列表 + */ + public List getSelectedNoteIds() { + return new ArrayList<>(selectedNoteIds); + } + + /** + * 获取当前文件夹ID + * + * @return 当前文件夹ID + */ + public long getCurrentFolderId() { + return currentFolderId; + } + + /** + * 设置当前文件夹 + * + * @param folderId 文件夹ID + */ + public void setCurrentFolderId(long folderId) { + this.currentFolderId = folderId; + } + + /** + * 获取文件夹路径LiveData + * + * @return 文件夹路径LiveData + */ + public MutableLiveData> getFolderPathLiveData() { + return folderPathLiveData; + } + + /** + * 获取侧栏刷新通知LiveData + * + * @return 侧栏刷新通知LiveData + */ + public MutableLiveData getSidebarRefreshNeeded() { + return sidebarRefreshNeeded; + } + + /** + * 触发侧栏刷新 + */ + public void triggerSidebarRefresh() { + sidebarRefreshNeeded.postValue(true); + } + + /** + * 进入指定文件夹 + * + * @param folderId 文件夹ID + */ + public void enterFolder(long folderId) { + // 将当前文件夹添加到历史记录 + if (currentFolderId != Notes.ID_ROOT_FOLDER && currentFolderId != Notes.ID_CALL_RECORD_FOLDER) { + folderHistory.add(currentFolderId); + } + loadNotes(folderId); + } + + /** + * 返回上一级文件夹 + * + * @return 是否成功返回上一级 + */ + public boolean navigateUp() { + if (!folderHistory.isEmpty()) { + long parentFolderId = folderHistory.remove(folderHistory.size() - 1); + loadNotes(parentFolderId); + return true; + } + return false; + } + + /** + * 清除选择状态 + *

+ * 退出多选模式时调用 + *

+ */ + public void clearSelection() { + selectedNoteIds.clear(); + } + + /** + * 获取文件夹列表 + *

+ * 加载所有文件夹类型的笔记 + *

+ */ + public void loadFolders() { + isLoading.postValue(true); + errorMessage.postValue(null); + + repository.getFolders(new NotesRepository.Callback>() { + @Override + public void onSuccess(List folders) { + isLoading.postValue(false); + notesLiveData.postValue(folders); + Log.d(TAG, "Successfully loaded " + folders.size() + " folders"); + } + + @Override + public void onError(Exception error) { + isLoading.postValue(false); + String message = "加载文件夹失败: " + error.getMessage(); + errorMessage.postValue(message); + Log.e(TAG, message, error); + } + }); + } + + /** + * 移动选中的笔记到指定文件夹 + *

+ * 批量移动笔记到目标文件夹 + *

+ * + * @param targetFolderId 目标文件夹ID + */ + public void moveSelectedNotesToFolder(long targetFolderId) { + if (selectedNoteIds.isEmpty()) { + errorMessage.postValue("请先选择要移动的笔记"); + return; + } + + isLoading.postValue(true); + errorMessage.postValue(null); + + List noteIds = new ArrayList<>(selectedNoteIds); + repository.moveNotes(noteIds, targetFolderId, new NotesRepository.Callback() { + @Override + public void onSuccess(Integer rowsAffected) { + isLoading.postValue(false); + selectedNoteIds.clear(); + refreshNotes(); + Log.d(TAG, "Successfully moved " + rowsAffected + " notes"); + } + + @Override + public void onError(Exception error) { + isLoading.postValue(false); + String message = "移动笔记失败: " + error.getMessage(); + errorMessage.postValue(message); + Log.e(TAG, message, error); + } + }); + } + + /** + * 切换选中笔记的置顶状态 + */ + public void toggleSelectedNotesPin() { + if (selectedNoteIds.isEmpty()) { + errorMessage.postValue("请先选择要操作的笔记"); + return; + } + + isLoading.postValue(true); + errorMessage.postValue(null); + + // 检查当前选中笔记的置顶状态 + List allNotes = notesLiveData.getValue(); + if (allNotes == null) return; + + boolean hasUnpinned = false; + for (NotesRepository.NoteInfo note : allNotes) { + if (selectedNoteIds.contains(note.getId())) { + if (!note.isPinned) { + hasUnpinned = true; + break; + } + } + } + + // 如果有未置顶的,则全部置顶;否则全部取消置顶 + final boolean newPinState = hasUnpinned; + List noteIds = new ArrayList<>(selectedNoteIds); + + repository.batchTogglePin(noteIds, newPinState, new NotesRepository.Callback() { + @Override + public void onSuccess(Integer rowsAffected) { + isLoading.postValue(false); + // 保持选中状态,方便用户查看 + refreshNotes(); + Log.d(TAG, "Successfully toggled pin state to " + newPinState); + } + + @Override + public void onError(Exception error) { + isLoading.postValue(false); + String message = "置顶操作失败: " + error.getMessage(); + errorMessage.postValue(message); + Log.e(TAG, message, error); + } + }); + } + + /** + * 检查选中的笔记是否全部已置顶 + * + * @return 如果所有选中的笔记都已置顶返回true + */ + public boolean isAllSelectedPinned() { + if (selectedNoteIds.isEmpty()) return false; + + List allNotes = notesLiveData.getValue(); + if (allNotes == null) return false; + + for (NotesRepository.NoteInfo note : allNotes) { + if (selectedNoteIds.contains(note.getId())) { + if (!note.isPinned) { + return false; + } + } + } + return true; + } + + /** + * 恢复选中的笔记 + *

+ * 将选中的回收站笔记移回根目录 + *

+ */ + public void restoreSelectedNotes() { + if (selectedNoteIds.isEmpty()) { + errorMessage.postValue("请先选择要恢复的笔记"); + return; + } + + isLoading.postValue(true); + errorMessage.postValue(null); + + List noteIds = new ArrayList<>(selectedNoteIds); + repository.restoreNotes(noteIds, new NotesRepository.Callback() { + @Override + public void onSuccess(Integer rowsAffected) { + isLoading.postValue(false); + selectedNoteIds.clear(); + refreshNotes(); + Log.d(TAG, "Successfully restored " + rowsAffected + " notes"); + } + + @Override + public void onError(Exception error) { + isLoading.postValue(false); + String message = "恢复失败: " + error.getMessage(); + errorMessage.postValue(message); + Log.e(TAG, message, error); + } + }); + } + + /** + * 永久删除选中的笔记 + *

+ * 物理删除选中的笔记 + *

+ */ + public void deleteSelectedNotesForever() { + if (selectedNoteIds.isEmpty()) { + errorMessage.postValue("请先选择要删除的笔记"); + return; + } + + isLoading.postValue(true); + errorMessage.postValue(null); + + List noteIds = new ArrayList<>(selectedNoteIds); + repository.deleteNotesForever(noteIds, new NotesRepository.Callback() { + @Override + public void onSuccess(Integer rowsAffected) { + isLoading.postValue(false); + selectedNoteIds.clear(); + refreshNotes(); + Log.d(TAG, "Successfully permanently deleted " + rowsAffected + " notes"); + } + + @Override + public void onError(Exception error) { + isLoading.postValue(false); + String message = "永久删除失败: " + error.getMessage(); + errorMessage.postValue(message); + Log.e(TAG, message, error); + } + }); + } + + /** + * 判断当前是否处于回收站模式 + * + * @return 如果当前文件夹是回收站返回true + */ + public boolean isTrashMode() { + return currentFolderId == Notes.ID_TRASH_FOLER; + } + + /** + * ViewModel销毁时的清理 + *

+ * 清理资源和状态 + *

+ */ + @Override + protected void onCleared() { + super.onCleared(); + selectedNoteIds.clear(); + Log.d(TAG, "ViewModel cleared"); + } +} diff --git a/src/Notesmaster/app/src/main/res/drawable/ic_add.xml b/src/Notesmaster/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..52e3394 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/ic_edit.xml b/src/Notesmaster/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 0000000..0b61789 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/ic_folder.xml b/src/Notesmaster/app/src/main/res/drawable/ic_folder.xml new file mode 100644 index 0000000..1e53dbe --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/ic_folder.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/src/Notesmaster/app/src/main/res/drawable/ic_note_empty.xml b/src/Notesmaster/app/src/main/res/drawable/ic_note_empty.xml new file mode 100644 index 0000000..e01ea77 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/ic_note_empty.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/note_item_background.xml b/src/Notesmaster/app/src/main/res/drawable/note_item_background.xml new file mode 100644 index 0000000..5004626 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/note_item_background.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/layout/breadcrumb_item.xml b/src/Notesmaster/app/src/main/res/layout/breadcrumb_item.xml new file mode 100644 index 0000000..6316b1b --- /dev/null +++ b/src/Notesmaster/app/src/main/res/layout/breadcrumb_item.xml @@ -0,0 +1,16 @@ + + + diff --git a/src/Notesmaster/app/src/main/res/layout/breadcrumb_layout.xml b/src/Notesmaster/app/src/main/res/layout/breadcrumb_layout.xml new file mode 100644 index 0000000..c625053 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/layout/breadcrumb_layout.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/layout/note_item.xml b/src/Notesmaster/app/src/main/res/layout/note_item.xml index b23af8f..42b8700 100644 --- a/src/Notesmaster/app/src/main/res/layout/note_item.xml +++ b/src/Notesmaster/app/src/main/res/layout/note_item.xml @@ -31,6 +31,14 @@ android:orientation="horizontal" android:gravity="center_vertical"> + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/layout/sidebar_layout.xml b/src/Notesmaster/app/src/main/res/layout/sidebar_layout.xml new file mode 100644 index 0000000..738c0aa --- /dev/null +++ b/src/Notesmaster/app/src/main/res/layout/sidebar_layout.xml @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/menu/note_list_multi_select.xml b/src/Notesmaster/app/src/main/res/menu/note_list_multi_select.xml new file mode 100644 index 0000000..dfcd448 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/menu/note_list_multi_select.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/menu/note_list_toolbar_multi.xml b/src/Notesmaster/app/src/main/res/menu/note_list_toolbar_multi.xml new file mode 100644 index 0000000..1b43649 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/menu/note_list_toolbar_multi.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/src/Notesmaster/app/src/test/java/net/micode/notes/data/FolderDatabaseTest.java b/src/Notesmaster/app/src/test/java/net/micode/notes/data/FolderDatabaseTest.java new file mode 100644 index 0000000..35eaabc --- /dev/null +++ b/src/Notesmaster/app/src/test/java/net/micode/notes/data/FolderDatabaseTest.java @@ -0,0 +1,1287 @@ +/* + * Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.data; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * 文件夹数据库操作测试类 + * + * 测试文件夹的创建、读取、更新、删除等数据库操作 + * 测试文件夹树查询、notes_count维护、系统文件夹保护等功能 + */ +public class FolderDatabaseTest { + + /** + * 测试数据库实例(内存数据库) + */ + private SQLiteDatabase mDatabase; + + /** + * 测试用的数据库帮助类 + */ + private NotesDatabaseHelper mHelper; + + /** + * 测试前的初始化 + * 创建内存数据库,初始化表结构和系统文件夹 + */ + @Before + public void setUp() { + // 创建内存数据库用于测试 + mDatabase = SQLiteDatabase.openDatabase(":memory:", null, + SQLiteDatabase.OPEN_READWRITE | SQLiteDatabase.CREATE_IF_NECESSARY); + + // 创建数据库帮助类(但不使用其数据库实例) + mHelper = new NotesDatabaseHelper(null); + + // 手动创建表结构和触发器 + mHelper.createNoteTable(mDatabase); + mHelper.createDataTable(mDatabase); + } + + /** + * 测试后的清理 + * 关闭数据库连接 + */ + @After + public void tearDown() { + if (mDatabase != null && mDatabase.isOpen()) { + mDatabase.close(); + } + } + + // ==================== 测试1:文件夹CRUD操作 ==================== + + /** + * 测试创建文件夹 + * 验证能够成功创建文件夹,并且返回的ID大于0 + */ + @Test + public void testCreateFolder() { + // 准备测试数据 + ContentValues values = new ContentValues(); + values.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + values.put(Notes.NoteColumns.SNIPPET, "测试文件夹"); + + // 执行插入 + long folderId = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, values); + + // 验证结果 + assertTrue("文件夹ID应该大于0", folderId > 0); + + // 验证数据是否正确插入 + Cursor cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + null, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folderId)}, + null, null, null + ); + + assertNotNull("查询结果不应该为null", cursor); + assertTrue("应该找到一条记录", cursor.moveToFirst()); + assertEquals("类型应该为文件夹", Notes.TYPE_FOLDER, cursor.getInt(cursor.getColumnIndexOrThrow(Notes.NoteColumns.TYPE))); + assertEquals("名称应该正确", "测试文件夹", cursor.getString(cursor.getColumnIndexOrThrow(Notes.NoteColumns.SNIPPET))); + assertEquals("父文件夹ID应该正确", Notes.ID_ROOT_FOLDER, cursor.getLong(cursor.getColumnIndexOrThrow(Notes.NoteColumns.PARENT_ID))); + + cursor.close(); + } + + /** + * 测试读取文件夹 + * 验证能够正确读取文件夹信息 + */ + @Test + public void testReadFolder() { + // 先创建一个文件夹 + ContentValues values = new ContentValues(); + values.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + values.put(Notes.NoteColumns.SNIPPET, "测试文件夹"); + long folderId = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, values); + + // 读取文件夹 + Cursor cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + null, + Notes.NoteColumns.ID + "=? AND " + Notes.NoteColumns.TYPE + "=?", + new String[]{String.valueOf(folderId), String.valueOf(Notes.TYPE_FOLDER)}, + null, null, null + ); + + assertNotNull("查询结果不应该为null", cursor); + assertTrue("应该找到一条记录", cursor.moveToFirst()); + + // 验证数据 + assertEquals("ID应该匹配", folderId, cursor.getLong(cursor.getColumnIndexOrThrow(Notes.NoteColumns.ID))); + assertEquals("名称应该正确", "测试文件夹", cursor.getString(cursor.getColumnIndexOrThrow(Notes.NoteColumns.SNIPPET))); + + cursor.close(); + } + + /** + * 测试更新文件夹 + * 验证能够成功更新文件夹名称 + */ + @Test + public void testUpdateFolder() { + // 先创建一个文件夹 + ContentValues values = new ContentValues(); + values.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + values.put(Notes.NoteColumns.SNIPPET, "测试文件夹"); + long folderId = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, values); + + // 更新文件夹 + ContentValues updateValues = new ContentValues(); + updateValues.put(Notes.NoteColumns.SNIPPET, "更新后的文件夹名称"); + int updated = mDatabase.update( + NotesDatabaseHelper.TABLE.NOTE, + updateValues, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folderId)} + ); + + // 验证更新 + assertEquals("应该更新1条记录", 1, updated); + + // 验证数据是否正确更新 + Cursor cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + null, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folderId)}, + null, null, null + ); + + assertTrue("应该找到一条记录", cursor.moveToFirst()); + assertEquals("名称应该已更新", "更新后的文件夹名称", cursor.getString(cursor.getColumnIndexOrThrow(Notes.NoteColumns.SNIPPET))); + + cursor.close(); + } + + /** + * 测试删除文件夹(物理删除) + * 验证能够成功删除文件夹 + */ + @Test + public void testDeleteFolder() { + // 先创建一个文件夹 + ContentValues values = new ContentValues(); + values.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + values.put(Notes.NoteColumns.SNIPPET, "测试文件夹"); + long folderId = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, values); + + // 删除文件夹 + int deleted = mDatabase.delete( + NotesDatabaseHelper.TABLE.NOTE, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folderId)} + ); + + // 验证删除 + assertEquals("应该删除1条记录", 1, deleted); + + // 验证数据是否已删除 + Cursor cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + null, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folderId)}, + null, null, null + ); + + assertFalse("不应该找到任何记录", cursor.moveToFirst()); + cursor.close(); + } + + /** + * 测试文件夹名称不能为空 + * 验证当尝试创建空名称的文件夹时,应该失败或使用默认值 + */ + @Test + public void testFolderNameCannotBeNull() { + // 尝试创建名称为空的文件夹 + ContentValues values = new ContentValues(); + values.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + values.put(Notes.NoteColumns.SNIPPET, ""); // 空字符串 + + // 尝试插入 + long folderId = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, values); + + // SQLite允许空字符串,但应该使用默认值 + assertTrue("文件夹ID应该大于0", folderId > 0); + + // 验证数据 + Cursor cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + null, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folderId)}, + null, null, null + ); + + assertTrue("应该找到一条记录", cursor.moveToFirst()); + // 数据库定义了DEFAULT '',所以应该接受空字符串 + assertEquals("名称应该为空字符串", "", cursor.getString(cursor.getColumnIndexOrThrow(Notes.NoteColumns.SNIPPET))); + + cursor.close(); + } + + /** + * 测试创建嵌套文件夹 + * 验证能够创建多级嵌套的文件夹结构 + */ + @Test + public void testCreateNestedFolder() { + // 创建第一级文件夹 + ContentValues folder1 = new ContentValues(); + folder1.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + folder1.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folder1.put(Notes.NoteColumns.SNIPPET, "一级文件夹"); + long folder1Id = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folder1); + + // 创建第二级文件夹 + ContentValues folder2 = new ContentValues(); + folder2.put(Notes.NoteColumns.PARENT_ID, folder1Id); + folder2.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folder2.put(Notes.NoteColumns.SNIPPET, "二级文件夹"); + long folder2Id = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folder2); + + // 创建第三级文件夹 + ContentValues folder3 = new ContentValues(); + folder3.put(Notes.NoteColumns.PARENT_ID, folder2Id); + folder3.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folder3.put(Notes.NoteColumns.SNIPPET, "三级文件夹"); + long folder3Id = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folder3); + + // 验证所有文件夹都创建成功 + assertTrue("一级文件夹ID应该大于0", folder1Id > 0); + assertTrue("二级文件夹ID应该大于0", folder2Id > 0); + assertTrue("三级文件夹ID应该大于0", folder3Id > 0); + + // 验证层级关系 + Cursor cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + null, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folder2Id)}, + null, null, null + ); + + assertTrue("应该找到二级文件夹", cursor.moveToFirst()); + assertEquals("二级文件夹的父ID应该是一级文件夹", folder1Id, cursor.getLong(cursor.getColumnIndexOrThrow(Notes.NoteColumns.PARENT_ID))); + + cursor.close(); + } + + /** + * 测试查询特定父文件夹下的所有文件夹 + * 验证能够正确查询某个文件夹下的所有子文件夹 + */ + @Test + public void testQueryFoldersByParentId() { + // 在根文件夹下创建多个子文件夹 + for (int i = 0; i < 5; i++) { + ContentValues values = new ContentValues(); + values.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + values.put(Notes.NoteColumns.SNIPPET, "文件夹" + i); + mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, values); + } + + // 查询根文件夹下的所有文件夹 + Cursor cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + null, + Notes.NoteColumns.PARENT_ID + "=? AND " + Notes.NoteColumns.TYPE + "=?", + new String[]{String.valueOf(Notes.ID_ROOT_FOLDER), String.valueOf(Notes.TYPE_FOLDER)}, + null, null, null + ); + + assertNotNull("查询结果不应该为null", cursor); + assertEquals("应该找到5个文件夹", 5, cursor.getCount()); + + // 验证每个文件夹的父ID都是根文件夹 + while (cursor.moveToNext()) { + assertEquals("父ID应该都是根文件夹", Notes.ID_ROOT_FOLDER, cursor.getLong(cursor.getColumnIndexOrThrow(Notes.NoteColumns.PARENT_ID))); + assertEquals("类型应该都是文件夹", Notes.TYPE_FOLDER, cursor.getInt(cursor.getColumnIndexOrThrow(Notes.NoteColumns.TYPE))); + } + + cursor.close(); + } + + /** + * 测试查询所有文件夹(不包含系统文件夹) + * 验证能够查询所有用户创建的文件夹 + */ + @Test + public void testQueryAllUserFolders() { + // 创建多个用户文件夹 + for (int i = 0; i < 3; i++) { + ContentValues values = new ContentValues(); + values.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + values.put(Notes.NoteColumns.SNIPPET, "用户文件夹" + i); + mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, values); + } + + // 查询所有文件夹(排除系统文件夹) + Cursor cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + null, + Notes.NoteColumns.TYPE + "=? AND " + Notes.NoteColumns.ID + ">0", + new String[]{String.valueOf(Notes.TYPE_FOLDER)}, + null, null, null + ); + + assertNotNull("查询结果不应该为null", cursor); + assertEquals("应该找到3个用户文件夹", 3, cursor.getCount()); + + cursor.close(); + } + + // ==================== 测试2:notes_count维护 ==================== + + /** + * 测试插入笔记时增加文件夹的notes_count + * 验证触发器是否正确维护notes_count + */ + @Test + public void testIncreaseNotesCountOnInsert() { + // 创建一个文件夹 + ContentValues folderValues = new ContentValues(); + folderValues.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + folderValues.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folderValues.put(Notes.NoteColumns.SNIPPET, "测试文件夹"); + long folderId = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folderValues); + + // 验证初始notes_count为0 + Cursor folderCursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + new String[]{Notes.NoteColumns.NOTES_COUNT}, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folderId)}, + null, null, null + ); + assertTrue("应该找到文件夹", folderCursor.moveToFirst()); + assertEquals("初始notes_count应该为0", 0, folderCursor.getInt(0)); + folderCursor.close(); + + // 向该文件夹插入笔记 + ContentValues noteValues = new ContentValues(); + noteValues.put(Notes.NoteColumns.PARENT_ID, folderId); + noteValues.put(Notes.NoteColumns.TYPE, Notes.TYPE_NOTE); + noteValues.put(Notes.NoteColumns.SNIPPET, "测试笔记"); + mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, noteValues); + + // 验证notes_count增加 + folderCursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + new String[]{Notes.NoteColumns.NOTES_COUNT}, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folderId)}, + null, null, null + ); + assertTrue("应该找到文件夹", folderCursor.moveToFirst()); + assertEquals("notes_count应该增加到1", 1, folderCursor.getInt(0)); + folderCursor.close(); + + // 插入第二条笔记 + noteValues.clear(); + noteValues.put(Notes.NoteColumns.PARENT_ID, folderId); + noteValues.put(Notes.NoteColumns.TYPE, Notes.TYPE_NOTE); + noteValues.put(Notes.NoteColumns.SNIPPET, "测试笔记2"); + mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, noteValues); + + // 验证notes_count增加到2 + folderCursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + new String[]{Notes.NoteColumns.NOTES_COUNT}, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folderId)}, + null, null, null + ); + assertTrue("应该找到文件夹", folderCursor.moveToFirst()); + assertEquals("notes_count应该增加到2", 2, folderCursor.getInt(0)); + folderCursor.close(); + } + + /** + * 测试删除笔记时减少文件夹的notes_count + * 验证触发器是否正确维护notes_count + */ + @Test + public void testDecreaseNotesCountOnDelete() { + // 创建一个文件夹 + ContentValues folderValues = new ContentValues(); + folderValues.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + folderValues.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folderValues.put(Notes.NoteColumns.SNIPPET, "测试文件夹"); + long folderId = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folderValues); + + // 向该文件夹插入3条笔记 + for (int i = 0; i < 3; i++) { + ContentValues noteValues = new ContentValues(); + noteValues.put(Notes.NoteColumns.PARENT_ID, folderId); + noteValues.put(Notes.NoteColumns.TYPE, Notes.TYPE_NOTE); + noteValues.put(Notes.NoteColumns.SNIPPET, "测试笔记" + i); + mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, noteValues); + } + + // 验证notes_count为3 + Cursor folderCursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + new String[]{Notes.NoteColumns.NOTES_COUNT}, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folderId)}, + null, null, null + ); + assertTrue("应该找到文件夹", folderCursor.moveToFirst()); + assertEquals("notes_count应该为3", 3, folderCursor.getInt(0)); + folderCursor.close(); + + // 删除一条笔记 + // 先查询笔记ID + Cursor noteCursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + new String[]{Notes.NoteColumns.ID}, + Notes.NoteColumns.PARENT_ID + "=? AND " + Notes.NoteColumns.TYPE + "=?", + new String[]{String.valueOf(folderId), String.valueOf(Notes.TYPE_NOTE)}, + null, null, null + ); + assertTrue("应该找到笔记", noteCursor.moveToFirst()); + long noteId = noteCursor.getLong(0); + noteCursor.close(); + + // 删除笔记 + mDatabase.delete( + NotesDatabaseHelper.TABLE.NOTE, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(noteId)} + ); + + // 验证notes_count减少到2 + folderCursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + new String[]{Notes.NoteColumns.NOTES_COUNT}, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folderId)}, + null, null, null + ); + assertTrue("应该找到文件夹", folderCursor.moveToFirst()); + assertEquals("notes_count应该减少到2", 2, folderCursor.getInt(0)); + folderCursor.close(); + } + + /** + * 测试笔记移动时更新两个文件夹的notes_count + * 验证笔记从一个文件夹移动到另一个文件夹时,两个文件夹的notes_count都正确更新 + */ + @Test + public void testUpdateNotesCountOnMove() { + // 创建两个文件夹 + ContentValues folder1Values = new ContentValues(); + folder1Values.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + folder1Values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folder1Values.put(Notes.NoteColumns.SNIPPET, "文件夹1"); + long folder1Id = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folder1Values); + + ContentValues folder2Values = new ContentValues(); + folder2Values.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + folder2Values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folder2Values.put(Notes.NoteColumns.SNIPPET, "文件夹2"); + long folder2Id = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folder2Values); + + // 向文件夹1插入2条笔记 + for (int i = 0; i < 2; i++) { + ContentValues noteValues = new ContentValues(); + noteValues.put(Notes.NoteColumns.PARENT_ID, folder1Id); + noteValues.put(Notes.NoteColumns.TYPE, Notes.TYPE_NOTE); + noteValues.put(Notes.NoteColumns.SNIPPET, "测试笔记" + i); + mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, noteValues); + } + + // 验证文件夹1的notes_count为2,文件夹2为0 + Cursor folder1Cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + new String[]{Notes.NoteColumns.NOTES_COUNT}, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folder1Id)}, + null, null, null + ); + assertTrue("应该找到文件夹1", folder1Cursor.moveToFirst()); + assertEquals("文件夹1的notes_count应该为2", 2, folder1Cursor.getInt(0)); + folder1Cursor.close(); + + Cursor folder2Cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + new String[]{Notes.NoteColumns.NOTES_COUNT}, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folder2Id)}, + null, null, null + ); + assertTrue("应该找到文件夹2", folder2Cursor.moveToFirst()); + assertEquals("文件夹2的notes_count应该为0", 0, folder2Cursor.getInt(0)); + folder2Cursor.close(); + + // 查询笔记ID + Cursor noteCursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + new String[]{Notes.NoteColumns.ID}, + Notes.NoteColumns.PARENT_ID + "=? AND " + Notes.NoteColumns.TYPE + "=?", + new String[]{String.valueOf(folder1Id), String.valueOf(Notes.TYPE_NOTE)}, + null, null, null + ); + assertTrue("应该找到笔记", noteCursor.moveToFirst()); + long noteId = noteCursor.getLong(0); + noteCursor.close(); + + // 移动笔记:从文件夹1移动到文件夹2 + ContentValues updateValues = new ContentValues(); + updateValues.put(Notes.NoteColumns.PARENT_ID, folder2Id); + mDatabase.update( + NotesDatabaseHelper.TABLE.NOTE, + updateValues, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(noteId)} + ); + + // 验证文件夹1的notes_count减少到1 + folder1Cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + new String[]{Notes.NoteColumns.NOTES_COUNT}, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folder1Id)}, + null, null, null + ); + assertTrue("应该找到文件夹1", folder1Cursor.moveToFirst()); + assertEquals("文件夹1的notes_count应该减少到1", 1, folder1Cursor.getInt(0)); + folder1Cursor.close(); + + // 验证文件夹2的notes_count增加到1 + folder2Cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + new String[]{Notes.NoteColumns.NOTES_COUNT}, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folder2Id)}, + null, null, null + ); + assertTrue("应该找到文件夹2", folder2Cursor.moveToFirst()); + assertEquals("文件夹2的notes_count应该增加到1", 1, folder2Cursor.getInt(0)); + folder2Cursor.close(); + } + + /** + * 测试删除文件夹时不会触发级联删除笔记的notes_count更新 + * 验证删除文件夹时,笔记的级联删除不影响其他文件夹的notes_count + */ + @Test + public void testDeleteFolderDoesNotAffectOtherFoldersNotesCount() { + // 创建文件夹1 + ContentValues folder1Values = new ContentValues(); + folder1Values.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + folder1Values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folder1Values.put(Notes.NoteColumns.SNIPPET, "文件夹1"); + long folder1Id = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folder1Values); + + // 创建文件夹2(文件夹1的子文件夹) + ContentValues folder2Values = new ContentValues(); + folder2Values.put(Notes.NoteColumns.PARENT_ID, folder1Id); + folder2Values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folder2Values.put(Notes.NoteColumns.SNIPPET, "文件夹2"); + long folder2Id = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folder2Values); + + // 向文件夹2插入笔记 + ContentValues noteValues = new ContentValues(); + noteValues.put(Notes.NoteColumns.PARENT_ID, folder2Id); + noteValues.put(Notes.NoteColumns.TYPE, Notes.TYPE_NOTE); + noteValues.put(Notes.NoteColumns.SNIPPET, "测试笔记"); + mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, noteValues); + + // 验证文件夹2的notes_count为1 + Cursor folder2Cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + new String[]{Notes.NoteColumns.NOTES_COUNT}, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folder2Id)}, + null, null, null + ); + assertTrue("应该找到文件夹2", folder2Cursor.moveToFirst()); + assertEquals("文件夹2的notes_count应该为1", 1, folder2Cursor.getInt(0)); + folder2Cursor.close(); + + // 删除文件夹1(应该级联删除文件夹2及其笔记) + mDatabase.delete( + NotesDatabaseHelper.TABLE.NOTE, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folder1Id)} + ); + + // 验证文件夹2和笔记都已被删除 + folder2Cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + new String[]{Notes.NoteColumns.ID}, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folder2Id)}, + null, null, null + ); + assertFalse("文件夹2应该已被删除", folder2Cursor.moveToFirst()); + folder2Cursor.close(); + } + + /** + * 测试移动文件夹时不影响notes_count + * 验证移动文件夹时,notes_count保持不变 + */ + @Test + public void testMoveFolderDoesNotChangeNotesCount() { + // 创建文件夹1 + ContentValues folder1Values = new ContentValues(); + folder1Values.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + folder1Values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folder1Values.put(Notes.NoteColumns.SNIPPET, "文件夹1"); + long folder1Id = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folder1Values); + + // 创建文件夹2 + ContentValues folder2Values = new ContentValues(); + folder2Values.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + folder2Values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folder2Values.put(Notes.NoteColumns.SNIPPET, "文件夹2"); + long folder2Id = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folder2Values); + + // 向文件夹1插入笔记 + ContentValues noteValues = new ContentValues(); + noteValues.put(Notes.NoteColumns.PARENT_ID, folder1Id); + noteValues.put(Notes.NoteColumns.TYPE, Notes.TYPE_NOTE); + noteValues.put(Notes.NoteColumns.SNIPPET, "测试笔记"); + mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, noteValues); + + // 验证文件夹1的notes_count为1 + Cursor folder1Cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + new String[]{Notes.NoteColumns.NOTES_COUNT}, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folder1Id)}, + null, null, null + ); + assertTrue("应该找到文件夹1", folder1Cursor.moveToFirst()); + assertEquals("文件夹1的notes_count应该为1", 1, folder1Cursor.getInt(0)); + folder1Cursor.close(); + + // 将文件夹1移动到文件夹2下 + ContentValues updateValues = new ContentValues(); + updateValues.put(Notes.NoteColumns.PARENT_ID, folder2Id); + mDatabase.update( + NotesDatabaseHelper.TABLE.NOTE, + updateValues, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folder1Id)} + ); + + // 验证文件夹1的notes_count仍为1 + folder1Cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + new String[]{Notes.NoteColumns.NOTES_COUNT}, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folder1Id)}, + null, null, null + ); + assertTrue("应该找到文件夹1", folder1Cursor.moveToFirst()); + assertEquals("文件夹1的notes_count应该仍为1", 1, folder1Cursor.getInt(0)); + folder1Cursor.close(); + } + + // ==================== 测试3:系统文件夹保护 ==================== + + /** + * 测试不能删除系统文件夹 + * 验证系统文件夹(ID <= 0)不能被删除 + */ + @Test + public void testCannotDeleteSystemFolder() { + // 尝试删除根文件夹 + int deleted = mDatabase.delete( + NotesDatabaseHelper.TABLE.NOTE, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(Notes.ID_ROOT_FOLDER)} + ); + + // 验证删除失败 + assertEquals("不应该删除任何记录", 0, deleted); + + // 验证根文件夹仍然存在 + Cursor cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + null, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(Notes.ID_ROOT_FOLDER)}, + null, null, null + ); + assertTrue("根文件夹应该仍然存在", cursor.moveToFirst()); + cursor.close(); + + // 尝试删除回收站 + deleted = mDatabase.delete( + NotesDatabaseHelper.TABLE.NOTE, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(Notes.ID_TRASH_FOLER)} + ); + + // 验证删除失败 + assertEquals("不应该删除回收站", 0, deleted); + } + + /** + * 测试系统文件夹的type字段 + * 验证系统文件夹的type为TYPE_SYSTEM + */ + @Test + public void testSystemFolderType() { + // 验证根文件夹的类型 + Cursor cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + new String[]{Notes.NoteColumns.TYPE}, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(Notes.ID_ROOT_FOLDER)}, + null, null, null + ); + assertTrue("应该找到根文件夹", cursor.moveToFirst()); + assertEquals("根文件夹的类型应该为TYPE_SYSTEM", Notes.TYPE_SYSTEM, cursor.getInt(0)); + cursor.close(); + + // 验证回收站的类型 + cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + new String[]{Notes.NoteColumns.TYPE}, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(Notes.ID_TRASH_FOLER)}, + null, null, null + ); + assertTrue("应该找到回收站", cursor.moveToFirst()); + assertEquals("回收站的类型应该为TYPE_SYSTEM", Notes.TYPE_SYSTEM, cursor.getInt(0)); + cursor.close(); + } + + /** + * 测试系统文件夹不能被重命名 + * 验证即使尝试更新系统文件夹的名称,也应该失败或无效 + */ + @Test + public void testCannotRenameSystemFolder() { + // 尝试重命名根文件夹 + ContentValues updateValues = new ContentValues(); + updateValues.put(Notes.NoteColumns.SNIPPET, "新名称"); + int updated = mDatabase.update( + NotesDatabaseHelper.TABLE.NOTE, + updateValues, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(Notes.ID_ROOT_FOLDER)} + ); + + // 验证更新(数据库层面允许更新,但应用层应该阻止) + // 这里测试数据库层面 + assertTrue("数据库层面允许更新", updated > 0); + + // 验证名称已更改 + Cursor cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + new String[]{Notes.NoteColumns.SNIPPET}, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(Notes.ID_ROOT_FOLDER)}, + null, null, null + ); + assertTrue("应该找到根文件夹", cursor.moveToFirst()); + assertEquals("名称应该已更改", "新名称", cursor.getString(0)); + cursor.close(); + + // 注意:实际应用中,ContentProvider或业务层应该阻止此操作 + } + + // ==================== 测试4:回收站功能 ==================== + + /** + * 测试将便签移动到回收站 + * 验证便签的parent_id更新为回收站ID + */ + @Test + public void testMoveNoteToTrash() { + // 创建一个文件夹 + ContentValues folderValues = new ContentValues(); + folderValues.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + folderValues.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folderValues.put(Notes.NoteColumns.SNIPPET, "测试文件夹"); + long folderId = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folderValues); + + // 向文件夹插入笔记 + ContentValues noteValues = new ContentValues(); + noteValues.put(Notes.NoteColumns.PARENT_ID, folderId); + noteValues.put(Notes.NoteColumns.TYPE, Notes.TYPE_NOTE); + noteValues.put(Notes.NoteColumns.SNIPPET, "测试笔记"); + long noteId = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, noteValues); + + // 将笔记移动到回收站 + ContentValues updateValues = new ContentValues(); + updateValues.put(Notes.NoteColumns.PARENT_ID, Notes.ID_TRASH_FOLER); + int updated = mDatabase.update( + NotesDatabaseHelper.TABLE.NOTE, + updateValues, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(noteId)} + ); + + // 验证更新成功 + assertEquals("应该更新1条记录", 1, updated); + + // 验证笔记已在回收站中 + Cursor cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + null, + Notes.NoteColumns.ID + "=? AND " + Notes.NoteColumns.PARENT_ID + "=?", + new String[]{String.valueOf(noteId), String.valueOf(Notes.ID_TRASH_FOLER)}, + null, null, null + ); + assertTrue("笔记应该在回收站中", cursor.moveToFirst()); + cursor.close(); + + // 验证原文件夹的notes_count减少 + Cursor folderCursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + new String[]{Notes.NoteColumns.NOTES_COUNT}, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folderId)}, + null, null, null + ); + assertTrue("应该找到文件夹", folderCursor.moveToFirst()); + assertEquals("文件夹的notes_count应该为0", 0, folderCursor.getInt(0)); + folderCursor.close(); + } + + /** + * 测试将文件夹移动到回收站 + * 验证文件夹及其所有子项都移动到回收站 + */ + @Test + public void testMoveFolderToTrash() { + // 创建父文件夹 + ContentValues folder1Values = new ContentValues(); + folder1Values.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + folder1Values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folder1Values.put(Notes.NoteColumns.SNIPPET, "文件夹1"); + long folder1Id = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folder1Values); + + // 创建子文件夹 + ContentValues folder2Values = new ContentValues(); + folder2Values.put(Notes.NoteColumns.PARENT_ID, folder1Id); + folder2Values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folder2Values.put(Notes.NoteColumns.SNIPPET, "文件夹2"); + long folder2Id = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folder2Values); + + // 向子文件夹插入笔记 + ContentValues noteValues = new ContentValues(); + noteValues.put(Notes.NoteColumns.PARENT_ID, folder2Id); + noteValues.put(Notes.NoteColumns.TYPE, Notes.TYPE_NOTE); + noteValues.put(Notes.NoteColumns.SNIPPET, "测试笔记"); + mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, noteValues); + + // 将父文件夹移动到回收站 + ContentValues updateValues = new ContentValues(); + updateValues.put(Notes.NoteColumns.PARENT_ID, Notes.ID_TRASH_FOLER); + mDatabase.update( + NotesDatabaseHelper.TABLE.NOTE, + updateValues, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folder1Id)} + ); + + // 验证父文件夹在回收站中 + Cursor cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + null, + Notes.NoteColumns.ID + "=? AND " + Notes.NoteColumns.PARENT_ID + "=?", + new String[]{String.valueOf(folder1Id), String.valueOf(Notes.ID_TRASH_FOLER)}, + null, null, null + ); + assertTrue("父文件夹应该在回收站中", cursor.moveToFirst()); + cursor.close(); + + // 验证子文件夹也在回收站中 + cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + null, + Notes.NoteColumns.ID + "=? AND " + Notes.NoteColumns.PARENT_ID + "=?", + new String[]{String.valueOf(folder2Id), String.valueOf(Notes.ID_TRASH_FOLER)}, + null, null, null + ); + assertTrue("子文件夹应该在回收站中", cursor.moveToFirst()); + cursor.close(); + + // 验证笔记也在回收站中 + cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + null, + Notes.NoteColumns.PARENT_ID + "=? AND " + Notes.NoteColumns.TYPE + "=?", + new String[]{String.valueOf(Notes.ID_TRASH_FOLER), String.valueOf(Notes.TYPE_NOTE)}, + null, null, null + ); + assertTrue("笔记应该在回收站中", cursor.moveToFirst()); + cursor.close(); + } + + /** + * 测试从回收站恢复便签 + * 验证便签可以恢复到指定文件夹 + */ + @Test + public void testRestoreNoteFromTrash() { + // 创建文件夹 + ContentValues folderValues = new ContentValues(); + folderValues.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + folderValues.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folderValues.put(Notes.NoteColumns.SNIPPET, "测试文件夹"); + long folderId = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folderValues); + + // 创建笔记并直接插入到回收站 + ContentValues noteValues = new ContentValues(); + noteValues.put(Notes.NoteColumns.PARENT_ID, Notes.ID_TRASH_FOLER); + noteValues.put(Notes.NoteColumns.TYPE, Notes.TYPE_NOTE); + noteValues.put(Notes.NoteColumns.SNIPPET, "回收站中的笔记"); + long noteId = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, noteValues); + + // 将笔记恢复到文件夹 + ContentValues updateValues = new ContentValues(); + updateValues.put(Notes.NoteColumns.PARENT_ID, folderId); + int updated = mDatabase.update( + NotesDatabaseHelper.TABLE.NOTE, + updateValues, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(noteId)} + ); + + // 验证更新成功 + assertEquals("应该更新1条记录", 1, updated); + + // 验证笔记已在文件夹中 + Cursor cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + null, + Notes.NoteColumns.ID + "=? AND " + Notes.NoteColumns.PARENT_ID + "=?", + new String[]{String.valueOf(noteId), String.valueOf(folderId)}, + null, null, null + ); + assertTrue("笔记应该在文件夹中", cursor.moveToFirst()); + cursor.close(); + + // 验证文件夹的notes_count增加 + Cursor folderCursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + new String[]{Notes.NoteColumns.NOTES_COUNT}, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folderId)}, + null, null, null + ); + assertTrue("应该找到文件夹", folderCursor.moveToFirst()); + assertEquals("文件夹的notes_count应该为1", 1, folderCursor.getInt(0)); + folderCursor.close(); + } + + /** + * 测试从回收站恢复文件夹 + * 验证文件夹及其子项可以恢复到指定位置 + */ + @Test + public void testRestoreFolderFromTrash() { + // 创建父文件夹并直接放入回收站 + ContentValues folder1Values = new ContentValues(); + folder1Values.put(Notes.NoteColumns.PARENT_ID, Notes.ID_TRASH_FOLER); + folder1Values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folder1Values.put(Notes.NoteColumns.SNIPPET, "回收站中的文件夹"); + long folder1Id = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folder1Values); + + // 创建子文件夹 + ContentValues folder2Values = new ContentValues(); + folder2Values.put(Notes.NoteColumns.PARENT_ID, folder1Id); + folder2Values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folder2Values.put(Notes.NoteColumns.SNIPPET, "子文件夹"); + long folder2Id = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folder2Values); + + // 向子文件夹插入笔记 + ContentValues noteValues = new ContentValues(); + noteValues.put(Notes.NoteColumns.PARENT_ID, folder2Id); + noteValues.put(Notes.NoteColumns.TYPE, Notes.TYPE_NOTE); + noteValues.put(Notes.NoteColumns.SNIPPET, "测试笔记"); + mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, noteValues); + + // 将父文件夹恢复到根目录 + ContentValues updateValues = new ContentValues(); + updateValues.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + mDatabase.update( + NotesDatabaseHelper.TABLE.NOTE, + updateValues, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folder1Id)} + ); + + // 验证父文件夹已恢复 + Cursor cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + null, + Notes.NoteColumns.ID + "=? AND " + Notes.NoteColumns.PARENT_ID + "=?", + new String[]{String.valueOf(folder1Id), String.valueOf(Notes.ID_ROOT_FOLDER)}, + null, null, null + ); + assertTrue("父文件夹应该在根目录中", cursor.moveToFirst()); + cursor.close(); + + // 验证子文件夹和笔记也跟随恢复(仍然在父文件夹下) + cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + null, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folder2Id)}, + null, null, null + ); + assertTrue("子文件夹应该仍然存在", cursor.moveToFirst()); + assertEquals("子文件夹的父ID应该是父文件夹", folder1Id, cursor.getLong(cursor.getColumnIndexOrThrow(Notes.NoteColumns.PARENT_ID))); + cursor.close(); + } + + /** + * 测试查询回收站中的项目 + * 验证能够正确查询回收站中的所有项目 + */ + @Test + public void testQueryTrashItems() { + // 创建文件夹和笔记 + ContentValues folderValues = new ContentValues(); + folderValues.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + folderValues.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folderValues.put(Notes.NoteColumns.SNIPPET, "测试文件夹"); + long folderId = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folderValues); + + ContentValues noteValues = new ContentValues(); + noteValues.put(Notes.NoteColumns.PARENT_ID, folderId); + noteValues.put(Notes.NoteColumns.TYPE, Notes.TYPE_NOTE); + noteValues.put(Notes.NoteColumns.SNIPPET, "测试笔记"); + mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, noteValues); + + // 将它们移动到回收站 + ContentValues updateValues = new ContentValues(); + updateValues.put(Notes.NoteColumns.PARENT_ID, Notes.ID_TRASH_FOLER); + + // 移动文件夹 + mDatabase.update( + NotesDatabaseHelper.TABLE.NOTE, + updateValues, + Notes.NoteColumns.ID + "=?", + new String[]{String.valueOf(folderId)} + ); + + // 查询回收站中的项目 + Cursor cursor = mDatabase.query( + NotesDatabaseHelper.TABLE.NOTE, + null, + Notes.NoteColumns.PARENT_ID + "=?", + new String[]{String.valueOf(Notes.ID_TRASH_FOLER)}, + null, null, null + ); + + assertNotNull("查询结果不应该为null", cursor); + assertEquals("回收站中应该有2个项目", 2, cursor.getCount()); + + // 排除系统文件夹(回收站本身) + cursor.close(); + } + + // ==================== 测试5:循环依赖检测 ==================== + + /** + * 测试检测将文件夹移动到其子文件夹的循环依赖 + * 验证能够检测并阻止循环依赖 + */ + @Test + public void testDetectCircularDependency() { + // 创建父文件夹 + ContentValues folder1Values = new ContentValues(); + folder1Values.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + folder1Values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folder1Values.put(Notes.NoteColumns.SNIPPET, "父文件夹"); + long folder1Id = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folder1Values); + + // 创建子文件夹 + ContentValues folder2Values = new ContentValues(); + folder2Values.put(Notes.NoteColumns.PARENT_ID, folder1Id); + folder2Values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folder2Values.put(Notes.NoteColumns.SNIPPET, "子文件夹"); + long folder2Id = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folder2Values); + + // 创建孙子文件夹 + ContentValues folder3Values = new ContentValues(); + folder3Values.put(Notes.NoteColumns.PARENT_ID, folder2Id); + folder3Values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folder3Values.put(Notes.NoteColumns.SNIPPET, "孙子文件夹"); + long folder3Id = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folder3Values); + + // 检测:尝试将父文件夹移动到孙子文件夹(应该检测到循环) + boolean hasCircularDependency = hasCircularDependency(mDatabase, folder1Id, folder3Id); + + // 验证检测到循环 + assertTrue("应该检测到循环依赖", hasCircularDependency); + + // 检测:尝试将子文件夹移动到父文件夹(不应该检测到循环) + hasCircularDependency = hasCircularDependency(mDatabase, folder2Id, folder1Id); + + // 验证没有检测到循环 + assertFalse("不应该检测到循环依赖", hasCircularDependency); + + // 检测:尝试将子文件夹移动到孙子文件夹(不应该检测到循环) + hasCircularDependency = hasCircularDependency(mDatabase, folder2Id, folder3Id); + + // 验证没有检测到循环 + assertFalse("不应该检测到循环依赖", hasCircularDependency); + } + + /** + * 检测循环依赖的辅助方法 + * 递归检查目标文件夹是否为源文件夹的子节点 + * + * @param db 数据库实例 + * @param sourceFolderId 源文件夹ID + * @param targetFolderId 目标文件夹ID + * @return 如果存在循环依赖返回true,否则返回false + */ + private boolean hasCircularDependency(SQLiteDatabase db, long sourceFolderId, long targetFolderId) { + // 递归检查目标文件夹的所有子文件夹 + return hasCircularDependencyRecursive(db, sourceFolderId, targetFolderId); + } + + /** + * 递归检查循环依赖 + * + * @param db 数据库实例 + * @param sourceFolderId 源文件夹ID(正在移动的文件夹) + * @param targetFolderId 目标文件夹ID(检查是否为源文件夹的子节点) + * @return 如果目标文件夹是源文件夹的子节点返回true,否则返回false + */ + private boolean hasCircularDependencyRecursive(SQLiteDatabase db, long sourceFolderId, long targetFolderId) { + // 如果目标文件夹ID等于源文件夹ID,说明存在循环 + if (targetFolderId == sourceFolderId) { + return true; + } + + // 查询目标文件夹的所有子文件夹 + Cursor cursor = db.query( + NotesDatabaseHelper.TABLE.NOTE, + new String[]{Notes.NoteColumns.ID}, + Notes.NoteColumns.PARENT_ID + "=? AND " + Notes.NoteColumns.TYPE + "=?", + new String[]{String.valueOf(targetFolderId), String.valueOf(Notes.TYPE_FOLDER)}, + null, null, null + ); + + boolean result = false; + if (cursor.moveToFirst()) { + do { + long childFolderId = cursor.getLong(0); + // 递归检查子文件夹 + if (hasCircularDependencyRecursive(db, sourceFolderId, childFolderId)) { + result = true; + break; + } + } while (cursor.moveToNext()); + } + + cursor.close(); + return result; + } + + /** + * 测试移动文件夹到根目录不会产生循环 + * 验证将任何文件夹移动到根目录都是安全的 + */ + @Test + public void testMoveFolderToRootHasNoCircularDependency() { + // 创建嵌套文件夹 + ContentValues folder1Values = new ContentValues(); + folder1Values.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + folder1Values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folder1Values.put(Notes.NoteColumns.SNIPPET, "文件夹1"); + long folder1Id = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folder1Values); + + ContentValues folder2Values = new ContentValues(); + folder2Values.put(Notes.NoteColumns.PARENT_ID, folder1Id); + folder2Values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folder2Values.put(Notes.NoteColumns.SNIPPET, "文件夹2"); + long folder2Id = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folder2Values); + + // 检测:将文件夹1移动到根目录(不应该检测到循环) + boolean hasCircularDependency = hasCircularDependency(mDatabase, folder1Id, Notes.ID_ROOT_FOLDER); + + // 验证没有检测到循环 + assertFalse("移动到根目录不应该产生循环", hasCircularDependency); + + // 检测:将文件夹2移动到根目录(不应该检测到循环) + hasCircularDependency = hasCircularDependency(mDatabase, folder2Id, Notes.ID_ROOT_FOLDER); + + // 验证没有检测到循环 + assertFalse("移动到根目录不应该产生循环", hasCircularDependency); + } + + /** + * 测试移动文件夹到同级目录不会产生循环 + * 验证将文件夹移动到同级目录是安全的 + */ + @Test + public void testMoveFolderToSiblingHasNoCircularDependency() { + // 创建父文件夹 + ContentValues parentFolderValues = new ContentValues(); + parentFolderValues.put(Notes.NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + parentFolderValues.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + parentFolderValues.put(Notes.NoteColumns.SNIPPET, "父文件夹"); + long parentFolderId = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, parentFolderValues); + + // 创建两个子文件夹 + ContentValues folder1Values = new ContentValues(); + folder1Values.put(Notes.NoteColumns.PARENT_ID, parentFolderId); + folder1Values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folder1Values.put(Notes.NoteColumns.SNIPPET, "文件夹1"); + long folder1Id = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folder1Values); + + ContentValues folder2Values = new ContentValues(); + folder2Values.put(Notes.NoteColumns.PARENT_ID, parentFolderId); + folder2Values.put(Notes.NoteColumns.TYPE, Notes.TYPE_FOLDER); + folder2Values.put(Notes.NoteColumns.SNIPPET, "文件夹2"); + long folder2Id = mDatabase.insert(NotesDatabaseHelper.TABLE.NOTE, null, folder2Values); + + // 检测:将文件夹1移动到文件夹2(不应该检测到循环) + boolean hasCircularDependency = hasCircularDependency(mDatabase, folder1Id, folder2Id); + + // 验证没有检测到循环 + assertFalse("移动到同级文件夹不应该产生循环", hasCircularDependency); + } +} diff --git a/src/Notesmaster/app/src/test/java/net/micode/notes/data/NotesRepositoryTest.java b/src/Notesmaster/app/src/test/java/net/micode/notes/data/NotesRepositoryTest.java new file mode 100644 index 0000000..53c977c --- /dev/null +++ b/src/Notesmaster/app/src/test/java/net/micode/notes/data/NotesRepositoryTest.java @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2025, Modern Notes Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.data; + +import android.content.ContentResolver; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import net.micode.notes.model.Note; +import net.micode.notes.data.NotesRepository.NoteInfo; + +import java.util.List; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +/** + * NotesRepository 单元测试 + *

+ * 测试笔记数据仓库的各个功能 + *

+ * + * @see NotesRepository + */ +@RunWith(MockitoJUnitRunner.class) +public class NotesRepositoryTest { + + private static final String TAG = "NotesRepositoryTest"; + + @Mock + private ContentResolver mockContentResolver; + + private NotesRepository repository; + + @Before + public void setUp() { + repository = new NotesRepository(mockContentResolver); + } + + @After + public void tearDown() { + repository.shutdown(); + } + + /** + * 测试创建Repository实例 + */ + @Test + public void testRepositoryCreation() { + assertNotNull("Repository should not be null", repository); + } + + /** + * 测试获取笔记列表 + */ + @Test + public void testGetNotes() { + // Arrange + long folderId = Notes.ID_ROOT_FOLDER; + + // Act + repository.getNotes(folderId, new NotesRepository.Callback>() { + @Override + public void onSuccess(List result) { + assertNotNull("Notes list should not be null", result); + // Mock返回空列表,实际应用中会有数据 + assertTrue("Notes count should be >= 0", result.size() >= 0); + } + + @Override + public void onError(Exception error) { + fail("Should not throw error: " + error.getMessage()); + } + }); + } + + /** + * 测试创建笔记 + */ + @Test + public void testCreateNote() { + // Arrange + long folderId = Notes.ID_ROOT_FOLDER; + + // Act + repository.createNote(folderId, new NotesRepository.Callback() { + @Override + public void onSuccess(Long noteId) { + assertNotNull("Note ID should not be null", noteId); + assertTrue("Note ID should be > 0", noteId > 0); + } + + @Override + public void onError(Exception error) { + fail("Should not throw error: " + error.getMessage()); + } + }); + } + + /** + * 测试更新笔记内容 + */ + @Test + public void testUpdateNote() { + // Arrange + long noteId = 1L; + String content = "测试笔记内容"; + + // Act + repository.updateNote(noteId, content, new NotesRepository.Callback() { + @Override + public void onSuccess(Integer rowsAffected) { + assertNotNull("Rows affected should not be null", rowsAffected); + assertTrue("Rows affected should be >= 0", rowsAffected >= 0); + } + + @Override + public void onError(Exception error) { + fail("Should not throw error: " + error.getMessage()); + } + }); + } + + /** + * 测试删除笔记 + */ + @Test + public void testDeleteNote() { + // Arrange + long noteId = 1L; + + // Act + repository.deleteNote(noteId, new NotesRepository.Callback() { + @Override + public void onSuccess(Integer rowsAffected) { + assertNotNull("Rows affected should not be null", rowsAffected); + assertTrue("Rows affected should be >= 0", rowsAffected >= 0); + } + + @Override + public void onError(Exception error) { + fail("Should not throw error: " + error.getMessage()); + } + }); + } + + /** + * 测试搜索笔记 + */ + @Test + public void testSearchNotes() { + // Arrange + String keyword = "测试"; + + // Act + repository.searchNotes(keyword, new NotesRepository.Callback>() { + @Override + public void onSuccess(List result) { + assertNotNull("Search results should not be null", result); + assertTrue("Search results count should be >= 0", result.size() >= 0); + } + + @Override + public void onError(Exception error) { + fail("Should not throw error: " + error.getMessage()); + } + }); + } + + /** + * 测试搜索空关键字 + */ + @Test + public void testSearchNotesWithEmptyKeyword() { + // Arrange + String keyword = ""; + + // Act + repository.searchNotes(keyword, new NotesRepository.Callback>() { + @Override + public void onSuccess(List result) { + assertNotNull("Search results should not be null", result); + // 空关键字应返回空列表 + assertEquals("Search results should be empty", 0, result.size()); + } + + @Override + public void onError(Exception error) { + fail("Should not throw error: " + error.getMessage()); + } + }); + } + + /** + * 测试获取笔记统计 + */ + @Test + public void testCountNotes() { + // Arrange + long folderId = Notes.ID_ROOT_FOLDER; + + // Act + repository.countNotes(folderId, new NotesRepository.Callback() { + @Override + public void onSuccess(Integer count) { + assertNotNull("Count should not be null", count); + assertTrue("Count should be >= 0", count >= 0); + } + + @Override + public void onError(Exception error) { + fail("Should not throw error: " + error.getMessage()); + } + }); + } + + /** + * 测试获取文件夹列表 + */ + @Test + public void testGetFolders() { + // Act + repository.getFolders(new NotesRepository.Callback>() { + @Override + public void onSuccess(List result) { + assertNotNull("Folders should not be null", result); + assertTrue("Folders count should be >= 0", result.size() >= 0); + } + + @Override + public void onError(Exception error) { + fail("Should not throw error: " + error.getMessage()); + } + }); + } +}