diff --git a/src/Notesmaster/.idea/deploymentTargetSelector.xml b/src/Notesmaster/.idea/deploymentTargetSelector.xml index b268ef3..a6ef832 100644 --- a/src/Notesmaster/.idea/deploymentTargetSelector.xml +++ b/src/Notesmaster/.idea/deploymentTargetSelector.xml @@ -4,6 +4,14 @@ diff --git a/src/Notesmaster/app/build.gradle.kts b/src/Notesmaster/app/build.gradle.kts index 677eb79..70c77ec 100644 --- a/src/Notesmaster/app/build.gradle.kts +++ b/src/Notesmaster/app/build.gradle.kts @@ -4,9 +4,7 @@ plugins { android { namespace = "net.micode.notes" - compileSdk { - version = release(36) - } + compileSdk = 36 buildFeatures { viewBinding = true } @@ -30,6 +28,13 @@ android { ) } } + + testOptions { + unitTests { + isIncludeAndroidResources = false + isReturnDefaultValues = true + } + } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 @@ -53,6 +58,8 @@ dependencies { implementation(files("D:\\ke\\software_enginering\\httpcomponents-client-4.5.14-bin\\lib\\httpclient-win-4.5.14.jar")) implementation(files("D:\\ke\\software_enginering\\httpcomponents-client-4.5.14-bin\\lib\\httpcore-4.4.16.jar")) testImplementation(libs.junit) + testImplementation(libs.mockito.core) + testImplementation(libs.mockito.junit) androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.espresso.core) } \ No newline at end of file diff --git a/src/Notesmaster/app/src/main/AndroidManifest.xml b/src/Notesmaster/app/src/main/AndroidManifest.xml index 5762f7d..773066d 100644 --- a/src/Notesmaster/app/src/main/AndroidManifest.xml +++ b/src/Notesmaster/app/src/main/AndroidManifest.xml @@ -60,7 +60,7 @@ android:name=".ui.NoteEditActivity" android:configChanges="keyboardHidden|orientation|screenSize" android:launchMode="singleTop" - android:theme="@style/NoteTheme" + android:theme="@style/Theme.Notesmaster.Edit" android:exported="true"> diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/MainActivity.java b/src/Notesmaster/app/src/main/java/net/micode/notes/MainActivity.java index 3716891..df300de 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/MainActivity.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/MainActivity.java @@ -8,6 +8,8 @@ import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; +import net.micode.notes.ui.SidebarFragment; + /** * 主活动类 *

@@ -15,14 +17,14 @@ import androidx.core.view.WindowInsetsCompat; * 支持边到边显示模式,自动适配系统栏的边距。 *

*/ -public class MainActivity extends AppCompatActivity { +public class MainActivity extends AppCompatActivity implements SidebarFragment.OnSidebarItemSelectedListener { /** * 创建活动 *

* 初始化活动界面,启用边到边显示模式,并设置窗口边距监听器。 *

- * + * * @param savedInstanceState 保存的实例状态,用于恢复活动状态 */ @Override @@ -32,7 +34,7 @@ public class MainActivity extends AppCompatActivity { EdgeToEdge.enable(this); setContentView(R.layout.activity_main); // 设置窗口边距监听器,自动适配系统栏 - ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> { + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main_content), (v, insets) -> { // 获取系统栏边距 Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); // 设置视图内边距以适配系统栏 @@ -40,4 +42,59 @@ public class MainActivity extends AppCompatActivity { return insets; }); } + + // ==================== SidebarFragment.OnSidebarItemSelectedListener 实现 ==================== + + @Override + public void onFolderSelected(long folderId) { + // TODO: 实现跳转到指定文件夹 + // 关闭侧栏 + closeSidebar(); + } + + @Override + public void onTrashSelected() { + // TODO: 实现跳转到回收站 + // 关闭侧栏 + closeSidebar(); + } + + @Override + public void onSyncSelected() { + // TODO: 实现同步功能 + } + + @Override + public void onLoginSelected() { + // TODO: 实现登录功能 + } + + @Override + public void onExportSelected() { + // TODO: 实现导出功能 + } + + @Override + public void onSettingsSelected() { + // TODO: 实现设置功能 + } + + @Override + public void onCreateFolder() { + // TODO: 实现创建文件夹功能 + } + + @Override + public void onCloseSidebar() { + closeSidebar(); + } + + // ==================== 私有方法 ==================== + + /** + * 关闭侧栏 + */ + private void closeSidebar() { + // TODO: 实现侧栏关闭功能 + } } \ No newline at end of file diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesDatabaseHelper.java b/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesDatabaseHelper.java index 558caf7..7614641 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesDatabaseHelper.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesDatabaseHelper.java @@ -471,7 +471,7 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { * @param context 应用上下文 * @return NotesDatabaseHelper单例实例 */ - static synchronized NotesDatabaseHelper getInstance(Context context) { + public static synchronized NotesDatabaseHelper getInstance(Context context) { if (mInstance == null) { mInstance = new NotesDatabaseHelper(context); } 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 index 739dfae..e7efd40 100644 --- 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 @@ -186,22 +186,29 @@ public class NotesRepository { /** * 查询笔记列表(内部方法) + *

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

* * @param folderId 文件夹ID - * @return 笔记列表 + * @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 = ROOT_FOLDER_SELECTION; + // 根文件夹:显示所有文件夹和便签 + 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 = NORMAL_SELECTION; + // 子文件夹:显示该文件夹下的文件夹和便签 + selection = NoteColumns.PARENT_ID + "=? AND " + NoteColumns.TYPE + "<>" + Notes.TYPE_SYSTEM; selectionArgs = new String[]{String.valueOf(folderId)}; } @@ -216,17 +223,198 @@ public class NotesRepository { if (cursor != null) { try { while (cursor.moveToNext()) { - notes.add(noteFromCursor(cursor)); + 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 " + cursor.getCount() + " notes"); + 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) -> 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); + } + }); + } + /** * 创建新笔记 *

diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java index 9544f6c..d4cecfe 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java @@ -71,8 +71,11 @@ import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; +import androidx.appcompat.app.AppCompatActivity; +import com.google.android.material.appbar.MaterialToolbar; -public class NoteEditActivity extends Activity implements OnClickListener, + +public class NoteEditActivity extends AppCompatActivity implements OnClickListener, NoteSettingChangedListener, OnTextViewChangeListener { /** * 笔记头部视图持有者 @@ -143,6 +146,8 @@ public class NoteEditActivity extends Activity implements OnClickListener, private SharedPreferences mSharedPrefs; private int mFontSizeId; + private MaterialToolbar toolbar; + private static final String PREFERENCE_FONT_SIZE = "pref_font_size"; private static final int SHORTCUT_ICON_TITLE_MAX_LEN = 10; @@ -160,6 +165,15 @@ public class NoteEditActivity extends Activity implements OnClickListener, super.onCreate(savedInstanceState); this.setContentView(R.layout.note_edit); + // 初始化Toolbar(使用MaterialToolbar,与列表页面一致) + MaterialToolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setDisplayShowHomeEnabled(true); + } + toolbar.setNavigationOnClickListener(v -> finish()); + if (savedInstanceState == null && !initActivityState(getIntent())) { finish(); return; @@ -284,6 +298,49 @@ public class NoteEditActivity extends Activity implements OnClickListener, return true; } + /** + * 初始化资源 + *

+ * 初始化笔记编辑界面的所有UI组件引用和点击监听器 + *

+ */ + private void initResources() { + mHeadViewPanel = findViewById(R.id.note_title); + mNoteHeaderHolder = new HeadViewHolder(); + mNoteHeaderHolder.tvModified = findViewById(R.id.tv_modified_date); + mNoteHeaderHolder.ivAlertIcon = findViewById(R.id.iv_alert_icon); + mNoteHeaderHolder.tvAlertDate = findViewById(R.id.tv_alert_date); + mNoteHeaderHolder.ibSetBgColor = findViewById(R.id.btn_set_bg_color); + mNoteHeaderHolder.ibSetBgColor.setOnClickListener(this); + mNoteEditor = findViewById(R.id.note_edit_view); + mNoteEditorPanel = findViewById(R.id.sv_note_edit); + mNoteBgColorSelector = findViewById(R.id.note_bg_color_selector); + + // 设置背景颜色选择器的点击事件 + for (int id : sBgSelectorBtnsMap.keySet()) { + ImageView iv = findViewById(id); + iv.setOnClickListener(this); + } + + mFontSizeSelector = findViewById(R.id.font_size_selector); + for (int id : sFontSizeBtnsMap.keySet()) { + View view = findViewById(id); + view.setOnClickListener(this); + } + + mSharedPrefs = PreferenceManager.getDefaultSharedPreferences(this); + mFontSizeId = mSharedPrefs.getInt(PREFERENCE_FONT_SIZE, ResourceParser.BG_DEFAULT_FONT_SIZE); + /** + * HACKME: Fix bug of store the resource id in shared preference. + * The id may larger than the length of resources, in this case, + * return the {@link ResourceParser#BG_DEFAULT_FONT_SIZE} + */ + if (mFontSizeId >= TextAppearanceResources.getResourcesSize()) { + mFontSizeId = ResourceParser.BG_DEFAULT_FONT_SIZE; + } + mEditTextList = findViewById(R.id.note_edit_list); + } + @Override protected void onResume() { super.onResume(); @@ -430,47 +487,6 @@ public class NoteEditActivity extends Activity implements OnClickListener, return true; } - /** - * 初始化资源 - *

- * 初始化所有UI组件的引用,设置点击监听器, - * 并从SharedPreferences中读取字体大小设置。 - *

- */ - private void initResources() { - mHeadViewPanel = findViewById(R.id.note_title); - mNoteHeaderHolder = new HeadViewHolder(); - mNoteHeaderHolder.tvModified = (TextView) findViewById(R.id.tv_modified_date); - mNoteHeaderHolder.ivAlertIcon = (ImageView) findViewById(R.id.iv_alert_icon); - mNoteHeaderHolder.tvAlertDate = (TextView) findViewById(R.id.tv_alert_date); - mNoteHeaderHolder.ibSetBgColor = (ImageView) findViewById(R.id.btn_set_bg_color); - mNoteHeaderHolder.ibSetBgColor.setOnClickListener(this); - mNoteEditor = (EditText) findViewById(R.id.note_edit_view); - mNoteEditorPanel = findViewById(R.id.sv_note_edit); - mNoteBgColorSelector = findViewById(R.id.note_bg_color_selector); - for (int id : sBgSelectorBtnsMap.keySet()) { - ImageView iv = (ImageView) findViewById(id); - iv.setOnClickListener(this); - } - - mFontSizeSelector = findViewById(R.id.font_size_selector); - for (int id : sFontSizeBtnsMap.keySet()) { - View view = findViewById(id); - view.setOnClickListener(this); - }; - mSharedPrefs = PreferenceManager.getDefaultSharedPreferences(this); - mFontSizeId = mSharedPrefs.getInt(PREFERENCE_FONT_SIZE, ResourceParser.BG_DEFAULT_FONT_SIZE); - /** - * HACKME: Fix bug of store the resource id in shared preference. - * The id may larger than the length of resources, in this case, - * return the {@link ResourceParser#BG_DEFAULT_FONT_SIZE} - */ - if(mFontSizeId >= TextAppearanceResources.getResourcesSize()) { - mFontSizeId = ResourceParser.BG_DEFAULT_FONT_SIZE; - } - mEditTextList = (LinearLayout) findViewById(R.id.note_edit_list); - } - /** * 活动暂停时保存笔记 *

@@ -528,7 +544,7 @@ public class NoteEditActivity extends Activity implements OnClickListener, if (id == R.id.btn_set_bg_color) { mNoteBgColorSelector.setVisibility(View.VISIBLE); findViewById(sBgSelectorSelectionMap.get(mWorkingNote.getBgColorId())).setVisibility( - - View.VISIBLE); + View.VISIBLE); } else if (sBgSelectorBtnsMap.containsKey(id)) { findViewById(sBgSelectorSelectionMap.get(mWorkingNote.getBgColorId())).setVisibility( View.GONE); 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 d4d961f..c237a90 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 @@ -22,18 +22,31 @@ import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.os.Bundle; +import android.text.InputFilter; +import android.text.TextUtils; import android.util.Log; import androidx.appcompat.view.ActionMode; import android.view.Menu; import android.view.MenuItem; import android.view.View; +import android.view.WindowInsets; +import android.view.WindowInsetsController; +import android.view.WindowManager; import android.widget.AdapterView; import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; import android.widget.ListView; import android.widget.PopupMenu; +import android.widget.TextView; import android.widget.Toast; import androidx.appcompat.app.AppCompatActivity; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.drawerlayout.widget.DrawerLayout; import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModelProvider; @@ -64,7 +77,8 @@ import java.util.List; public class NotesListActivity extends AppCompatActivity implements NoteInfoAdapter.OnNoteButtonClickListener, NoteInfoAdapter.OnNoteItemClickListener, - NoteInfoAdapter.OnNoteItemLongClickListener { + NoteInfoAdapter.OnNoteItemLongClickListener, + SidebarFragment.OnSidebarItemSelectedListener { private static final String TAG = "NotesListActivity"; private static final int REQUEST_CODE_OPEN_NODE = 102; private static final int REQUEST_CODE_NEW_NODE = 103; @@ -73,7 +87,13 @@ public class NotesListActivity extends AppCompatActivity private ListView notesListView; private androidx.appcompat.widget.Toolbar toolbar; private NoteInfoAdapter adapter; - private androidx.appcompat.view.ActionMode actionMode; + private DrawerLayout drawerLayout; + private FloatingActionButton fabNewNote; + private LinearLayout breadcrumbContainer; + private LinearLayout breadcrumbItems; + + // 多选模式状态 + private boolean isMultiSelectMode = false; /** * 活动创建时的初始化方法 @@ -86,8 +106,21 @@ public class NotesListActivity extends AppCompatActivity @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + + // 启用边缘到边缘显示 + WindowCompat.setDecorFitsSystemWindows(getWindow(), false); + setContentView(R.layout.note_list); + // 处理窗口insets(状态栏和导航栏) + View mainView = findViewById(android.R.id.content); + ViewCompat.setOnApplyWindowInsetsListener(mainView, (v, windowInsets) -> { + Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); + // 设置内容区域的padding以避免被状态栏遮挡 + v.setPadding(insets.left, insets.top, insets.right, insets.bottom); + return WindowInsetsCompat.CONSUMED; + }); + initViewModel(); initViews(); observeViewModel(); @@ -129,6 +162,11 @@ public class NotesListActivity extends AppCompatActivity private void initViews() { notesListView = findViewById(R.id.notes_list); toolbar = findViewById(R.id.toolbar); + drawerLayout = findViewById(R.id.drawer_layout); + + // 初始化面包屑导航 + breadcrumbContainer = findViewById(R.id.breadcrumb_container); + breadcrumbItems = findViewById(R.id.breadcrumb_items); // 设置适配器 adapter = new NoteInfoAdapter(this); @@ -144,7 +182,7 @@ public class NotesListActivity extends AppCompatActivity Object item = parent.getItemAtPosition(position); if (item instanceof NotesRepository.NoteInfo) { NotesRepository.NoteInfo note = (NotesRepository.NoteInfo) item; - openNoteEditor(note); + handleItemClick(note, position); } } }); @@ -156,18 +194,58 @@ public class NotesListActivity extends AppCompatActivity getSupportActionBar().setTitle(R.string.app_name); } + // 初始化为普通模式 + updateToolbarForNormalMode(); + + // 设置 Toolbar 的汉堡菜单按钮点击监听器(打开侧栏) + toolbar.setNavigationOnClickListener(v -> { + if (drawerLayout != null) { + drawerLayout.openDrawer(findViewById(R.id.sidebar_fragment)); + } + }); + // Set FAB click event - FloatingActionButton fabNewNote = findViewById(R.id.btn_new_note); + fabNewNote = findViewById(R.id.btn_new_note); if (fabNewNote != null) { fabNewNote.setOnClickListener(v -> { Intent intent = new Intent(NotesListActivity.this, NoteEditActivity.class); intent.setAction(Intent.ACTION_INSERT_OR_EDIT); - intent.putExtra(Notes.INTENT_EXTRA_FOLDER_ID, Notes.ID_ROOT_FOLDER); + intent.putExtra(Notes.INTENT_EXTRA_FOLDER_ID, viewModel.getCurrentFolderId()); startActivityForResult(intent, REQUEST_CODE_NEW_NODE); }); } } + /** + * 处理列表项点击 + *

+ * 如果是便签,打开编辑器;如果是文件夹,进入该文件夹 + *

+ * + * @param note 项 + * @param position 位置 + */ + private void handleItemClick(NotesRepository.NoteInfo note, int position) { + if (isMultiSelectMode) { + // 多选模式:切换选中状态 + boolean isSelected = viewModel.getSelectedNoteIds().contains(note.getId()); + viewModel.toggleNoteSelection(note.getId(), !isSelected); + if (adapter != null) { + adapter.setSelectedIds(viewModel.getSelectedNoteIds()); + } + updateToolbarForMultiSelectMode(); + } else { + // 普通模式 + if (note.type == Notes.TYPE_FOLDER) { + // 文件夹:进入该文件夹 + viewModel.enterFolder(note.getId()); + } else { + // 便签:打开编辑器 + openNoteEditor(note); + } + } + } + /** * 观察ViewModel的LiveData */ @@ -197,6 +275,74 @@ public class NotesListActivity extends AppCompatActivity } } }); + + // 观察文件夹路径(用于面包屑导航) + viewModel.getFolderPathLiveData().observe(this, new Observer>() { + @Override + public void onChanged(List path) { + updateBreadcrumb(path); + } + }); + + // 观察侧栏刷新通知 + viewModel.getSidebarRefreshNeeded().observe(this, new Observer() { + @Override + public void onChanged(Boolean refreshNeeded) { + if (refreshNeeded != null && refreshNeeded) { + // 通知侧栏刷新 + SidebarFragment sidebarFragment = (SidebarFragment) getSupportFragmentManager() + .findFragmentById(R.id.sidebar_fragment); + if (sidebarFragment != null) { + sidebarFragment.refreshFolderTree(); + } + // 重置刷新状态 + viewModel.getSidebarRefreshNeeded().setValue(false); + } + } + }); + } + + /** + * 更新面包屑导航 + * + * @param path 文件夹路径 + */ + private void updateBreadcrumb(List path) { + if (breadcrumbItems == null || path == null) { + return; + } + + breadcrumbItems.removeAllViews(); + + for (int i = 0; i < path.size(); i++) { + NotesRepository.NoteInfo folder = path.get(i); + + // 如果不是第一个,添加分隔符 " > " + if (i > 0) { + TextView separator = new TextView(this); + separator.setText(" > "); + separator.setTextSize(14); + separator.setTextColor(android.R.color.darker_gray); + breadcrumbItems.addView(separator); + } + + // 创建面包屑项 + TextView breadcrumbItem = (TextView) getLayoutInflater() + .inflate(R.layout.breadcrumb_item, breadcrumbItems, false); + breadcrumbItem.setText(folder.title); + + // 如果是当前文件夹(最后一个),高亮显示且不可点击 + if (i == path.size() - 1) { + breadcrumbItem.setTextColor(getColor(R.color.primary_color)); + breadcrumbItem.setEnabled(false); + } else { + // 其他层级可以点击跳转 + final long targetFolderId = folder.id; + breadcrumbItem.setOnClickListener(v -> viewModel.enterFolder(targetFolderId)); + } + + breadcrumbItems.addView(breadcrumbItem); + } } /** @@ -252,26 +398,36 @@ public class NotesListActivity extends AppCompatActivity public void onNoteItemClick(int position, long noteId) { Log.d(TAG, "===== onNoteItemClick CALLED ====="); Log.d(TAG, "position: " + position + ", noteId: " + noteId); - - if (actionMode != null) { - Log.d(TAG, "ActionMode is active, toggling selection"); + + if (isMultiSelectMode) { + Log.d(TAG, "Multi-select mode active, toggling selection"); NotesRepository.NoteInfo note = (NotesRepository.NoteInfo) adapter.getItem(position); if (note != null) { boolean isSelected = viewModel.getSelectedNoteIds().contains(note.getId()); viewModel.toggleNoteSelection(note.getId(), !isSelected); - + if (adapter != null) { adapter.setSelectedIds(viewModel.getSelectedNoteIds()); } + // 更新toolbar标题 + updateToolbarForMultiSelectMode(); } Log.d(TAG, "===== onNoteItemClick END (multi-select mode) ====="); } else { - Log.d(TAG, "ActionMode is not active, opening editor"); + Log.d(TAG, "Normal mode, checking item type"); NotesRepository.NoteInfo note = (NotesRepository.NoteInfo) adapter.getItem(position); if (note != null) { - openNoteEditor(note); + if (note.type == Notes.TYPE_FOLDER) { + // 文件夹:进入该文件夹 + Log.d(TAG, "Folder clicked, entering folder: " + note.getId()); + viewModel.enterFolder(note.getId()); + } else { + // 便签:打开编辑器 + Log.d(TAG, "Note clicked, opening editor"); + openNoteEditor(note); + } } - Log.d(TAG, "===== onNoteItemClick END (editor mode) ====="); + Log.d(TAG, "===== onNoteItemClick END ====="); } } @@ -279,91 +435,114 @@ public class NotesListActivity extends AppCompatActivity public void onNoteItemLongClick(int position, long noteId) { Log.d(TAG, "===== onNoteItemLongClick CALLED ====="); Log.d(TAG, "position: " + position + ", noteId: " + noteId); - - if (actionMode == null) { - Log.d(TAG, "Starting ActionMode manually"); - actionMode = startSupportActionMode(new androidx.appcompat.view.ActionMode.Callback() { - @Override - public boolean onCreateActionMode(androidx.appcompat.view.ActionMode mode, Menu menu) { - Log.d(TAG, "onCreateActionMode called"); - mode.getMenuInflater().inflate(R.menu.note_list_options, menu); - return true; - } - - @Override - public boolean onPrepareActionMode(androidx.appcompat.view.ActionMode mode, Menu menu) { - return false; - } - - @Override - public boolean onActionItemClicked(androidx.appcompat.view.ActionMode mode, MenuItem item) { - Log.d(TAG, "onActionItemClicked: " + item.getTitle()); - int itemId = item.getItemId(); - - if (itemId == R.id.delete) { - showDeleteDialog(); - } else if (itemId == R.id.move) { - showMoveMenu(); - } - - return true; - } - @Override - public void onDestroyActionMode(androidx.appcompat.view.ActionMode mode) { - Log.d(TAG, "onDestroyActionMode called"); - actionMode = null; - viewModel.clearSelection(); - - if (adapter != null) { - adapter.setSelectedIds(new java.util.HashSet<>()); - } - adapter.notifyDataSetChanged(); - } - }); - + if (!isMultiSelectMode) { + Log.d(TAG, "Entering multi-select mode"); + enterMultiSelectMode(); viewModel.toggleNoteSelection(noteId, true); - + if (adapter != null) { adapter.setSelectedIds(viewModel.getSelectedNoteIds()); } - + updateSelectionState(position, true); - + Log.d(TAG, "===== onNoteItemLongClick END ====="); } else { - Log.d(TAG, "ActionMode already active, ignoring long click"); + Log.d(TAG, "Multi-select mode already active, ignoring long click"); } } /** - * 更新ActionMode标题 + * 进入多选模式 */ - private void updateActionModeTitle(androidx.appcompat.view.ActionMode mode) { - int selectedCount = viewModel.getSelectedCount(); - String title = getString(R.string.menu_select_title, selectedCount); - mode.setTitle(title); + private void enterMultiSelectMode() { + isMultiSelectMode = true; + // 隐藏FAB按钮 + if (fabNewNote != null) { + fabNewNote.setVisibility(View.GONE); + } + // 更新toolbar为多选模式 + updateToolbarForMultiSelectMode(); } /** - * 选中状态变化回调 - * - * @param mode ActionMode 实例 - * @param position 位置 - * @param id 便签 ID - * @param checked 是否选中 + * 退出多选模式 */ - public void onItemCheckedStateChanged(ActionMode mode, int position, long id, boolean checked) { - Log.d(TAG, "onItemCheckedStateChanged: id=" + id + ", checked=" + checked); - viewModel.toggleNoteSelection(id, checked); - + private void exitMultiSelectMode() { + isMultiSelectMode = false; + // 显示FAB按钮 + if (fabNewNote != null) { + fabNewNote.setVisibility(View.VISIBLE); + } + // 清除选中状态 + viewModel.clearSelection(); if (adapter != null) { - adapter.setSelectedIds(viewModel.getSelectedNoteIds()); + adapter.setSelectedIds(new java.util.HashSet<>()); + adapter.notifyDataSetChanged(); } - - updateActionModeTitle(mode); + // 更新toolbar为普通模式 + updateToolbarForNormalMode(); } + /** + * 更新Toolbar为多选模式 + */ + private void updateToolbarForMultiSelectMode() { + if (toolbar == null) return; + + // 设置标题为选中数量 + int selectedCount = viewModel.getSelectedCount(); + String title = getString(R.string.menu_select_title, selectedCount); + toolbar.setTitle(title); + + // 设置导航图标为返回(取消多选) + toolbar.setNavigationIcon(androidx.appcompat.R.drawable.abc_ic_ab_back_material); + toolbar.setNavigationOnClickListener(v -> exitMultiSelectMode()); + + // 移除普通模式的菜单(如果有) + toolbar.getMenu().clear(); + + // 直接在toolbar上添加操作按钮(不在三点菜单中) + Menu menu = toolbar.getMenu(); + + // 删除按钮 + MenuItem deleteItem = menu.add(Menu.NONE, R.id.multi_select_delete, 1, getString(R.string.menu_delete)); + deleteItem.setIcon(android.R.drawable.ic_menu_delete); + deleteItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + + // 移动按钮 + MenuItem moveItem = menu.add(Menu.NONE, R.id.multi_select_move, 2, getString(R.string.menu_move)); + moveItem.setIcon(android.R.drawable.ic_menu_sort_by_size); + moveItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + } + + /** + * 更新Toolbar为普通模式 + */ + private void updateToolbarForNormalMode() { + if (toolbar == null) return; + + // 设置标题为应用名称 + toolbar.setTitle(R.string.app_name); + + // 设置导航图标为汉堡菜单 + toolbar.setNavigationIcon(android.R.drawable.ic_menu_sort_by_size); + toolbar.setNavigationOnClickListener(v -> { + if (drawerLayout != null) { + drawerLayout.openDrawer(findViewById(R.id.sidebar_fragment)); + } + }); + + // 清除多选模式菜单 + toolbar.getMenu().clear(); + + // 添加普通模式菜单(如果需要) + // getMenuInflater().inflate(R.menu.note_list_options, menu); + } + + + /** * 显示删除确认对话框 */ @@ -427,8 +606,8 @@ public class NotesListActivity extends AppCompatActivity Toast.makeText(this, "搜索功能开发中", Toast.LENGTH_SHORT).show(); return true; case R.id.menu_new_folder: - // TODO: 创建新文件夹 - Toast.makeText(this, "创建文件夹功能开发中", Toast.LENGTH_SHORT).show(); + // 创建新文件夹 + showCreateFolderDialog(); return true; case R.id.menu_export_text: // TODO: 导出笔记 @@ -442,6 +621,13 @@ public class NotesListActivity extends AppCompatActivity // TODO: 设置功能 Toast.makeText(this, "设置功能开发中", Toast.LENGTH_SHORT).show(); return true; + // 多选模式菜单项 + case R.id.multi_select_delete: + showDeleteDialog(); + return true; + case R.id.multi_select_move: + showMoveMenu(); + return true; default: return super.onOptionsItemSelected(item); } @@ -500,4 +686,152 @@ public class NotesListActivity extends AppCompatActivity } Log.d("NotesListActivity", "===== updateSelectionState END ====="); } + + // ==================== SidebarFragment.OnSidebarItemSelectedListener 实现 ==================== + + @Override + public void onFolderSelected(long folderId) { + // 跳转到指定文件夹 + viewModel.enterFolder(folderId); + // 关闭侧栏 + if (drawerLayout != null) { + drawerLayout.closeDrawer(findViewById(R.id.sidebar_fragment)); + } + } + + @Override + public void onTrashSelected() { + // TODO: 实现跳转到回收站 + Log.d(TAG, "Trash selected"); + // 关闭侧栏 + if (drawerLayout != null) { + drawerLayout.closeDrawer(findViewById(R.id.sidebar_fragment)); + } + } + + @Override + public void onSyncSelected() { + // TODO: 实现同步功能 + Log.d(TAG, "Sync selected"); + Toast.makeText(this, "同步功能待实现", Toast.LENGTH_SHORT).show(); + } + + @Override + public void onLoginSelected() { + // TODO: 实现登录功能 + Log.d(TAG, "Login selected"); + Toast.makeText(this, "登录功能待实现", Toast.LENGTH_SHORT).show(); + } + + @Override + public void onExportSelected() { + // TODO: 实现导出功能 + Log.d(TAG, "Export selected"); + Toast.makeText(this, "导出功能待实现", Toast.LENGTH_SHORT).show(); + } + + @Override + public void onSettingsSelected() { + // TODO: 实现设置功能 + Log.d(TAG, "Settings selected"); + Toast.makeText(this, "设置功能待实现", Toast.LENGTH_SHORT).show(); + } + + @Override + public void onCreateFolder() { + // 显示创建文件夹对话框 + showCreateFolderDialog(); + } + + /** + * 显示创建文件夹对话框 + */ + private void showCreateFolderDialog() { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.dialog_create_folder_title); + + final EditText input = new EditText(this); + input.setHint(R.string.dialog_create_folder_hint); + input.setFilters(new InputFilter[]{new InputFilter.LengthFilter(50)}); + + builder.setView(input); + + builder.setPositiveButton(R.string.menu_create_folder, (dialog, which) -> { + String folderName = input.getText().toString().trim(); + if (TextUtils.isEmpty(folderName)) { + Toast.makeText(this, R.string.error_folder_name_empty, Toast.LENGTH_SHORT).show(); + return; + } + if (folderName.length() > 50) { + Toast.makeText(this, R.string.error_folder_name_too_long, Toast.LENGTH_SHORT).show(); + return; + } + + // 创建文件夹 + NotesRepository repository = new NotesRepository(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) { + runOnUiThread(() -> { + Toast.makeText(NotesListActivity.this, R.string.create_folder_success, Toast.LENGTH_SHORT).show(); + // 刷新笔记列表 + viewModel.loadNotes(viewModel.getCurrentFolderId()); + }); + } + + @Override + public void onError(Exception error) { + runOnUiThread(() -> { + Toast.makeText(NotesListActivity.this, "创建文件夹失败: " + error.getMessage(), Toast.LENGTH_SHORT).show(); + }); + } + }); + }); + + builder.setNegativeButton(android.R.string.cancel, null); + builder.show(); + } + + @Override + public void onCloseSidebar() { + // 关闭侧栏 + if (drawerLayout != null) { + drawerLayout.closeDrawer(findViewById(R.id.sidebar_fragment)); + } + } + + /** + * 返回键按下事件处理 + * + *

+ * 多选模式:退出多选模式 + * 子文件夹:返回上一级文件夹 + * 根文件夹:最小化应用 + *

+ */ + @Override + public void onBackPressed() { + if (isMultiSelectMode) { + // 多选模式:退出多选模式 + exitMultiSelectMode(); + } else if (drawerLayout != null && drawerLayout.isDrawerOpen(findViewById(R.id.sidebar_fragment))) { + // 侧栏打开:关闭侧栏 + drawerLayout.closeDrawer(findViewById(R.id.sidebar_fragment)); + } else if (viewModel.getCurrentFolderId() != Notes.ID_ROOT_FOLDER && + viewModel.getCurrentFolderId() != Notes.ID_CALL_RECORD_FOLDER) { + // 子文件夹:返回上一级 + if (!viewModel.navigateUp()) { + // 如果没有导航历史,返回根文件夹 + viewModel.loadNotes(Notes.ID_ROOT_FOLDER); + } + } else { + // 根文件夹:最小化应用 + moveTaskToBack(true); + } + } } 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..2eec39b --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SidebarFragment.java @@ -0,0 +1,450 @@ +/* + * 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); + 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 -> { + long currentTime = System.currentTimeMillis(); + if (lastClickedView == view && (currentTime - lastClickTime) < DOUBLE_CLICK_INTERVAL) { + // 这是双击,执行跳转 + if (listener != null && folderId != Notes.ID_ROOT_FOLDER) { + listener.onFolderSelected(folderId); + } + // 重置双击状态 + lastClickTime = 0; + lastClickedView = null; + } else { + // 可能是单击,延迟处理 + lastClickTime = currentTime; + lastClickedView = view; + view.postDelayed(() -> { + // 如果在延迟期间没有发生双击,则执行单击操作(展开/收起) + if (System.currentTimeMillis() - lastClickTime >= DOUBLE_CLICK_INTERVAL) { + 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) { + viewModel.toggleFolderExpand(folderId); + } + + /** + * 显示创建文件夹对话框 + */ + 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(), "创建文件夹成功", Toast.LENGTH_SHORT).show(); + // 刷新文件夹列表 + viewModel.loadFolderTree(); + }); + } + } + + @Override + public void onError(Exception error) { + if (getActivity() != null) { + getActivity().runOnUiThread(() -> { + Toast.makeText(requireContext(), "创建文件夹失败: " + 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; + + public FolderTreeAdapter(List folderItems, FolderListViewModel viewModel) { + this.folderItems = folderItems; + this.viewModel = viewModel; + } + + public void setData(List folderItems) { + this.folderItems = folderItems; + } + + @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); + } + + @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; + + public FolderViewHolder(@NonNull View itemView) { + super(itemView); + 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) { + // 设置缩进 + 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)); + } + } + } + + /** + * 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..683a128 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/FolderListViewModel.java @@ -0,0 +1,269 @@ +/* + * 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 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) { + if (expandedFolderIds.contains(folderId)) { + expandedFolderIds.remove(folderId); + } else { + expandedFolderIds.add(folderId); + } + // 重新加载文件夹树 + 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(); + + // 构建文件夹映射表(方便查找父文件夹) + 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(); + + FolderNode node = new FolderNode(id, name, parentId, noteCount); + folderMap.put(id, node); + + // 如果是顶级文件夹(父文件夹为根),添加到根列表 + if (parentId == Notes.ID_ROOT_FOLDER) { + rootFolders.add(node); + } + } + + // 构建父子关系 + 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); + buildFolderTreeList(rootFolders, folderTree, 0, rootExpanded); + + return folderTree; + } + + /** + * 递归构建文件夹树列表 + * 只显示已展开文件夹的子文件夹 + * 顶层文件夹(level=0)默认收起,需要点击"我的便签"后展开 + * @param nodes 文件夹节点列表 + * @param folderTree 文件夹树列表(输出) + * @param level 当前层级 + * @param forceExpandChildren 是否强制展开子文件夹(用于顶层) + */ + private void buildFolderTreeList(List nodes, List folderTree, int level, boolean forceExpandChildren) { + for (FolderNode node : nodes) { + // 对于顶层文件夹,需要"我的便签"展开后才显示 + if (level == 0 && !expandedFolderIds.contains(Notes.ID_ROOT_FOLDER) && !forceExpandChildren) { + continue; // 跳过顶层文件夹,因为根未展开 + } + + folderTree.add(new FolderTreeItem( + node.id, + node.name, + level, + !node.children.isEmpty(), + node.noteCount + )); + + // 只有当父文件夹在 expandedFolderIds 中时,才递归处理子文件夹 + 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" + ); + + if (cursor != null) { + while (cursor.moveToNext()) { + Map folder = new HashMap<>(); + long id = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID)); + String name = cursor.getString(cursor.getColumnIndexOrThrow(NoteColumns.SNIPPET)); + long parentId = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.PARENT_ID)); + int noteCount = cursor.getInt(cursor.getColumnIndexOrThrow(NoteColumns.NOTES_COUNT)); + + 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) { + 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 index 3acacd4..5123f4b 100644 --- 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 @@ -58,6 +58,15 @@ public class NotesListViewModel extends ViewModel { // 当前文件夹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<>(); + /** * 构造函数 * @@ -98,7 +107,7 @@ public class NotesListViewModel extends ViewModel { /** * 加载笔记列表 *

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

* * @param folderId 文件夹ID,{@link Notes#ID_ROOT_FOLDER} 表示根文件夹 @@ -108,6 +117,20 @@ public class NotesListViewModel extends ViewModel { 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) { @@ -349,6 +372,58 @@ public class NotesListViewModel extends ViewModel { 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; + } + /** * 清除选择状态 *

diff --git a/src/Notesmaster/app/src/main/res/layout/activity_main.xml b/src/Notesmaster/app/src/main/res/layout/activity_main.xml index 86a5d97..80c956c 100644 --- a/src/Notesmaster/app/src/main/res/layout/activity_main.xml +++ b/src/Notesmaster/app/src/main/res/layout/activity_main.xml @@ -1,19 +1,37 @@ - - + + - \ No newline at end of file + + + + + + + + \ No newline at end of file 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_edit.xml b/src/Notesmaster/app/src/main/res/layout/note_edit.xml index 10b2aa7..8c449c4 100644 --- a/src/Notesmaster/app/src/main/res/layout/note_edit.xml +++ b/src/Notesmaster/app/src/main/res/layout/note_edit.xml @@ -1,43 +1,56 @@ - - - - + + + + + + + + + + android:fadingEdgeLength="0dp"> + android:layout_width="match_parent" + android:layout_height="match_parent"> - - + - - - + - + - - - - + - + + + + - - - - + - + + + + - - - - + - + + + + - - + - + + + + - + - - - + + + - - - + android:background="@drawable/font_size_selector_bg" + android:layout_gravity="bottom" + android:visibility="gone"> - + android:layout_weight="1"> - - - - + android:orientation="vertical" + android:layout_gravity="center" + android:gravity="center"> - - - - + - + + + android:layout_gravity="bottom|right" + android:layout_marginRight="6dp" + android:layout_marginBottom="-7dp" + android:focusable="false" + android:visibility="gone" + android:src="@drawable/selected" /> + + + - - + android:orientation="vertical" + android:layout_gravity="center" + android:gravity="center"> - - - - + - + + + android:layout_gravity="bottom|right" + android:focusable="false" + android:visibility="gone" + android:layout_marginRight="6dp" + android:layout_marginBottom="-7dp" + android:src="@drawable/selected" /> + + + - - + android:orientation="vertical" + android:layout_gravity="center" + android:gravity="center"> - - - - + - + + + android:layout_gravity="bottom|right" + android:focusable="false" + android:visibility="gone" + android:layout_marginRight="6dp" + android:layout_marginBottom="-7dp" + android:src="@drawable/selected" /> + + + - - + android:orientation="vertical" + android:layout_gravity="center" + android:gravity="center"> - - + + + + + + + + - + diff --git a/src/Notesmaster/app/src/main/res/layout/note_list.xml b/src/Notesmaster/app/src/main/res/layout/note_list.xml index 4be330a..c157627 100644 --- a/src/Notesmaster/app/src/main/res/layout/note_list.xml +++ b/src/Notesmaster/app/src/main/res/layout/note_list.xml @@ -1,80 +1,101 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/layout/sidebar_folder_item.xml b/src/Notesmaster/app/src/main/res/layout/sidebar_folder_item.xml new file mode 100644 index 0000000..f5985ea --- /dev/null +++ b/src/Notesmaster/app/src/main/res/layout/sidebar_folder_item.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + 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..43deb90 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/menu/note_list_multi_select.xml @@ -0,0 +1,20 @@ + + +

+ + + + + + 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/main/res/values/strings.xml b/src/Notesmaster/app/src/main/res/values/strings.xml index 7472d70..3300972 100644 --- a/src/Notesmaster/app/src/main/res/values/strings.xml +++ b/src/Notesmaster/app/src/main/res/values/strings.xml @@ -137,4 +137,19 @@ 暂无便签,点击右下角按钮创建 空便签图标 Edit note + + 我的便签 + 关闭侧栏 + 创建文件夹 + 登录 + 导出 + 设置 + 回收站 + %1$d个便签 + 创建文件夹 + 请输入文件夹名称 + 文件夹名称不能为空 + 文件夹名称不能超过50个字符 + 该文件夹已存在 + 创建文件夹成功 diff --git a/src/Notesmaster/app/src/main/res/values/themes.xml b/src/Notesmaster/app/src/main/res/values/themes.xml index 1668fcd..ca2f0be 100644 --- a/src/Notesmaster/app/src/main/res/values/themes.xml +++ b/src/Notesmaster/app/src/main/res/values/themes.xml @@ -7,4 +7,11 @@ \ No newline at end of file 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 index a40d2cf..53c977c 100644 --- 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 @@ -26,6 +26,7 @@ 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; @@ -77,9 +78,9 @@ public class NotesRepositoryTest { long folderId = Notes.ID_ROOT_FOLDER; // Act - repository.getNotes(folderId, new NotesRepository.Callback>() { + repository.getNotes(folderId, new NotesRepository.Callback>() { @Override - public void onSuccess(List result) { + public void onSuccess(List result) { assertNotNull("Notes list should not be null", result); // Mock返回空列表,实际应用中会有数据 assertTrue("Notes count should be >= 0", result.size() >= 0); @@ -171,9 +172,9 @@ public class NotesRepositoryTest { String keyword = "测试"; // Act - repository.searchNotes(keyword, new NotesRepository.Callback>() { + repository.searchNotes(keyword, new NotesRepository.Callback>() { @Override - public void onSuccess(List result) { + public void onSuccess(List result) { assertNotNull("Search results should not be null", result); assertTrue("Search results count should be >= 0", result.size() >= 0); } @@ -194,9 +195,9 @@ public class NotesRepositoryTest { String keyword = ""; // Act - repository.searchNotes(keyword, new NotesRepository.Callback>() { + repository.searchNotes(keyword, new NotesRepository.Callback>() { @Override - public void onSuccess(List result) { + public void onSuccess(List result) { assertNotNull("Search results should not be null", result); // 空关键字应返回空列表 assertEquals("Search results should be empty", 0, result.size()); @@ -238,9 +239,9 @@ public class NotesRepositoryTest { @Test public void testGetFolders() { // Act - repository.getFolders(new NotesRepository.Callback>() { + repository.getFolders(new NotesRepository.Callback>() { @Override - public void onSuccess(List result) { + public void onSuccess(List result) { assertNotNull("Folders should not be null", result); assertTrue("Folders count should be >= 0", result.size() >= 0); } diff --git a/src/Notesmaster/gradle/libs.versions.toml b/src/Notesmaster/gradle/libs.versions.toml index 91e0cd5..468f3be 100644 --- a/src/Notesmaster/gradle/libs.versions.toml +++ b/src/Notesmaster/gradle/libs.versions.toml @@ -7,6 +7,7 @@ appcompat = "1.6.1" material = "1.10.0" activity = "1.8.0" constraintlayout = "2.1.4" +mockito = "4.11.0" [libraries] junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -16,6 +17,8 @@ appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "a material = { group = "com.google.android.material", name = "material", version.ref = "material" } activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } +mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" } +mockito-junit = { group = "org.mockito", name = "mockito-junit-jupiter", version.ref = "mockito" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }