From 5ed57280df7ee31cc5f72cb33207c850105d8405 Mon Sep 17 00:00:00 2001 From: XUYE23 Date: Mon, 19 Jan 2026 08:36:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9EAI=E5=8A=A9=E6=89=8B=E7=AE=A1?= =?UTF-8?q?=E5=AE=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/AndroidManifest.xml | 92 +++-- src/res/layout/note_edit.xml | 30 +- src/src/net/micode/notes/data/Contact.java | 52 +-- src/src/net/micode/notes/data/Notes.java | 33 +- .../notes/data/NotesDatabaseHelper.java | 306 ++++++-------- .../net/micode/notes/data/NotesProvider.java | 89 +---- .../net/micode/notes/gtask/data/MetaData.java | 32 +- src/src/net/micode/notes/gtask/data/Node.java | 79 +--- .../net/micode/notes/gtask/data/SqlData.java | 75 ++-- .../net/micode/notes/gtask/data/SqlNote.java | 236 +++++------ src/src/net/micode/notes/gtask/data/Task.java | 130 +++--- .../net/micode/notes/gtask/data/TaskList.java | 92 ++--- .../exception/ActionFailureException.java | 17 +- .../exception/NetworkFailureException.java | 15 +- .../notes/gtask/remote/GTaskASyncTask.java | 68 +--- .../notes/gtask/remote/GTaskClient.java | 295 +++++--------- .../notes/gtask/remote/GTaskManager.java | 372 ++++++++---------- .../notes/gtask/remote/GTaskSyncService.java | 91 +---- .../net/micode/notes/ui/AlarmReceiver.java | 80 +--- .../net/micode/notes/ui/NoteEditActivity.java | 119 +++++- src/src/net/micode/notes/ui/NoteEditText.java | 66 ++++ .../notes/ui/NotesPreferenceActivity.java | 170 +++++--- 22 files changed, 1085 insertions(+), 1454 deletions(-) diff --git a/src/AndroidManifest.xml b/src/AndroidManifest.xml index e5c7d47..3096559 100644 --- a/src/AndroidManifest.xml +++ b/src/AndroidManifest.xml @@ -1,27 +1,6 @@ - - - - - - + xmlns:tools="http://schemas.android.com/tools"> @@ -31,31 +10,60 @@ + + + + + + + + - + android:allowBackup="true" + android:label="@string/app_name" + android:supportsRtl="true" + android:theme="@style/NoteTheme" + tools:targetApi="31"> + + + + + + + + + + android:theme="@style/NoteTheme" + android:exported="true"> + @@ -63,7 +71,7 @@ - + @@ -80,6 +88,7 @@ android:resource="@xml/searchable" /> + + android:label="@string/app_widget2x2" + android:exported="true"> + + @@ -100,7 +112,8 @@ + android:label="@string/app_widget4x4" + android:exported="true"> @@ -113,7 +126,8 @@ android:resource="@xml/widget_4x_info" /> - + @@ -146,5 +160,7 @@ + - + + \ No newline at end of file diff --git a/src/res/layout/note_edit.xml b/src/res/layout/note_edit.xml index 10b2aa7..0b5bcdc 100644 --- a/src/res/layout/note_edit.xml +++ b/src/res/layout/note_edit.xml @@ -93,11 +93,8 @@ android:layout_height="wrap_content" android:gravity="left|top" android:background="@null" - android:autoLink="all" - android:linksClickable="false" android:minLines="12" - android:textAppearance="@style/TextAppearancePrimaryItem" - android:lineSpacingMultiplier="1.2" /> + android:textAppearance="@style/TextAppearancePrimaryItem" /> - + + + + + + + + 联系人查询工具类 - *

- * 主要负责根据电话号码从Android数据库中检索联系人姓名。 - *

- * - *

核心功能:

- *
    - *
  • 基于电话号码查找姓名。
  • - *
  • 维护 {@code HashMap} 缓存,减少 IPC 调用次数。
  • - *
  • 处理国际化号码格式匹配问题。
  • - *
- * - * @author 林迪文 - * @version 1.0 - * @see android.provider.ContactsContract - */ public class Contact { - //键值对,用于缓存<姓名,文本>,提高查找速度。 private static HashMap sContactCache; - //fianl意思是常量,TAG为Contact的“别名”。 private static final String TAG = "Contact"; - //这是个SQL语句,CALLER_ID_SELECTION是个字符串,作用是构建匹配模式,用and连接了3个部分:电话匹配、数据类型需是电话、号码必须存在于快速查找表中 private static final String CALLER_ID_SELECTION = "PHONE_NUMBERS_EQUAL(" + Phone.NUMBER - + ",?) AND " + Data.MIMETYPE + "='" + Phone.CONTENT_ITEM_TYPE + "'" - + " AND " + Data.RAW_CONTACT_ID + " IN " //Data哪来的 + + ",?) AND " + Data.MIMETYPE + "='" + Phone.CONTENT_ITEM_TYPE + "'" + + " AND " + Data.RAW_CONTACT_ID + " IN " + "(SELECT raw_contact_id " + " FROM phone_lookup" + " WHERE min_match = '+')"; - //主函数,输入电话,根据context,返回人名 public static String getContact(Context context, String phoneNumber) { if(sContactCache == null) { sContactCache = new HashMap(); - } //初次调用getContact时,创建缓存 + } if(sContactCache.containsKey(phoneNumber)) { return sContactCache.get(phoneNumber); - } //缓存命中 + } String selection = CALLER_ID_SELECTION.replace("+", - PhoneNumberUtils.toCallerIDMinMatch(phoneNumber)); //用处理过的电话号码,替代CALLER_ID_SELECTION的+。 + PhoneNumberUtils.toCallerIDMinMatch(phoneNumber)); Cursor cursor = context.getContentResolver().query( - Data.CONTENT_URI, //查找对象 - new String [] { Phone.DISPLAY_NAME }, //需要返回的是名字这一列 - selection, //查找条件 - new String[] { phoneNumber }, //query可以将phoneNumber填到selection的'?'中 - null); //不需排序 + Data.CONTENT_URI, + new String [] { Phone.DISPLAY_NAME }, + selection, + new String[] { phoneNumber }, + null); - if (cursor != null && cursor.moveToFirst()) { //cursor.moveToFirst()为true代表至少有一条记录 + if (cursor != null && cursor.moveToFirst()) { try { String name = cursor.getString(0); sContactCache.put(phoneNumber, name); return name; - } catch (IndexOutOfBoundsException e) { //越界报错 + } catch (IndexOutOfBoundsException e) { Log.e(TAG, " Cursor get string error " + e.toString()); return null; } finally { - cursor.close(); //关闭进程,防止内存泄露 + cursor.close(); } - } - else { - Log.d(TAG, "No contact matched with number:" + phoneNumber); //没找到 + } else { + Log.d(TAG, "No contact matched with number:" + phoneNumber); return null; } } diff --git a/src/src/net/micode/notes/data/Notes.java b/src/src/net/micode/notes/data/Notes.java index 7e3769f..f240604 100644 --- a/src/src/net/micode/notes/data/Notes.java +++ b/src/src/net/micode/notes/data/Notes.java @@ -17,16 +17,7 @@ package net.micode.notes.data; import android.net.Uri; - -/** - * Notes类定义了许多常量,是整个项目的常量定义规范集 - * - * @Author: 林迪文 - * @Updator: 林迪文 - * @Date 2025/12/11 11:03 - */ public class Notes { - //定义项目名、TAG、文件类型 public static final String AUTHORITY = "micode_notes"; public static final String TAG = "Notes"; public static final int TYPE_NOTE = 0; @@ -35,16 +26,15 @@ public class Notes { /** * Following IDs are system folders' identifiers - * {@link Notes#ID_ROOT_FOLDER } is default folder,根目录 - * {@link Notes#ID_TEMPARAY_FOLDER } is for notes belonging no folder,临时文件夹 - * {@link Notes#ID_CALL_RECORD_FOLDER} is to store call records,通话记录文件夹 + * {@link Notes#ID_ROOT_FOLDER } is default folder + * {@link Notes#ID_TEMPARAY_FOLDER } is for notes belonging no folder + * {@link Notes#ID_CALL_RECORD_FOLDER} is to store call records */ - public static final int ID_ROOT_FOLDER = 0; //也是0,一样吗 + public static final int ID_ROOT_FOLDER = 0; public static final int ID_TEMPARAY_FOLDER = -1; public static final int ID_CALL_RECORD_FOLDER = -2; - public static final int ID_TRASH_FOLER = -3;//垃圾文件夹 + public static final int ID_TRASH_FOLER = -3; - //这些可以作为intent.putExtra("键名", 值)的键名 public static final String INTENT_EXTRA_ALERT_DATE = "net.micode.notes.alert_date"; public static final String INTENT_EXTRA_BACKGROUND_ID = "net.micode.notes.background_color_id"; public static final String INTENT_EXTRA_WIDGET_ID = "net.micode.notes.widget_id"; @@ -56,24 +46,21 @@ public class Notes { public static final int TYPE_WIDGET_2X = 0; public static final int TYPE_WIDGET_4X = 1; - //DataConstants存储两个类型常量:note和call_note,但是这个量不好 - //改名成MimeTypes或者DataTypes更好 public static class DataConstants { public static final String NOTE = TextNote.CONTENT_ITEM_TYPE; public static final String CALL_NOTE = CallNote.CONTENT_ITEM_TYPE; } /** - * Uri to query all notes and folders,访问 NOTE 表的 Uri + * Uri to query all notes and folders */ public static final Uri CONTENT_NOTE_URI = Uri.parse("content://" + AUTHORITY + "/note"); /** - * Uri to query data,访问 DATA 表的 Uri + * Uri to query data */ public static final Uri CONTENT_DATA_URI = Uri.parse("content://" + AUTHORITY + "/data"); - //与便签有关的基本信息 public interface NoteColumns { /** * The unique ID for a row @@ -180,7 +167,6 @@ public class Notes { public static final String VERSION = "version"; } - //便签的详细数据信息,Data1-Data5为通用数据栏,由mimetype决定意义 public interface DataColumns { /** * The unique ID for a row @@ -190,7 +176,6 @@ public class Notes { /** * The MIME type of the item represented by this row. - * 这个值拿来区分text_notes和call_notes *

Type: Text

*/ public static final String MIME_TYPE = "mime_type"; @@ -256,7 +241,6 @@ public class Notes { public static final String DATA5 = "data5"; } - //TextNote和CallNote为DataColumns的两个实现 public static final class TextNote implements DataColumns { /** * Mode to indicate the text in check list mode or not @@ -264,7 +248,6 @@ public class Notes { */ public static final String MODE = DATA1; - //清单模式 public static final int MODE_CHECK_LIST = 1; public static final String CONTENT_TYPE = "vnd.android.cursor.dir/text_note"; @@ -287,10 +270,8 @@ public class Notes { */ public static final String PHONE_NUMBER = DATA3; - //列表dir类型,用于通过Uri查找类型时返回 public static final String CONTENT_TYPE = "vnd.android.cursor.dir/call_note"; - //单条数据item类型,用于通过Uri查找类型时返回 public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/call_note"; public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/call_note"); diff --git a/src/src/net/micode/notes/data/NotesDatabaseHelper.java b/src/src/net/micode/notes/data/NotesDatabaseHelper.java index be0f476..ffe5d57 100644 --- a/src/src/net/micode/notes/data/NotesDatabaseHelper.java +++ b/src/src/net/micode/notes/data/NotesDatabaseHelper.java @@ -26,216 +26,185 @@ import net.micode.notes.data.Notes.DataColumns; import net.micode.notes.data.Notes.DataConstants; import net.micode.notes.data.Notes.NoteColumns; -/** - * 数据库操作核心类 - *

- * 负责 SQLite 数据库的创建、表结构初始化以及版本升级逻辑。 - * 这个类大量使用了 SQLite 的 Trigger(触发器) 特性, - * 将“更新文件夹内笔记数量”、“同步笔记摘要”等业务逻辑下沉到数据库层自动处理, - * 避免了上层 Java 代码频繁手动同步数据,提高了数据一致性。 - *

- * - * @Author: 林迪文 - * @Updator: 林迪文 - * @Date 2025/12/10 15:10 - */ + public class NotesDatabaseHelper extends SQLiteOpenHelper { - // 数据库文件名,位于系统内部存储 private static final String DB_NAME = "note.db"; - // 数据库版本号,升级数据库结构时需修改此值 private static final int DB_VERSION = 4; - // 定义表名常量 public interface TABLE { - public static final String NOTE = "note"; // 存储笔记的元数据(如ID、创建时间、父文件夹) + public static final String NOTE = "note"; - public static final String DATA = "data"; // 存储笔记的具体内容(文本、关联数据) + public static final String DATA = "data"; } private static final String TAG = "NotesDatabaseHelper"; private static NotesDatabaseHelper mInstance; - // 创建 NOTE 表的 SQL 语句 - // strftime('%s','now') * 1000 用于获取当前的毫秒级时间戳,默认填充创建时间和修改时间 private static final String CREATE_NOTE_TABLE_SQL = - "CREATE TABLE " + TABLE.NOTE + "(" + - NoteColumns.ID + " INTEGER PRIMARY KEY," + - NoteColumns.PARENT_ID + " INTEGER NOT NULL DEFAULT 0," + - NoteColumns.ALERTED_DATE + " INTEGER NOT NULL DEFAULT 0," + - NoteColumns.BG_COLOR_ID + " INTEGER NOT NULL DEFAULT 0," + - NoteColumns.CREATED_DATE + " INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)," + - NoteColumns.HAS_ATTACHMENT + " INTEGER NOT NULL DEFAULT 0," + - NoteColumns.MODIFIED_DATE + " INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)," + - NoteColumns.NOTES_COUNT + " INTEGER NOT NULL DEFAULT 0," + - NoteColumns.SNIPPET + " TEXT NOT NULL DEFAULT ''," + // 笔记摘要,用于列表展示 - NoteColumns.TYPE + " INTEGER NOT NULL DEFAULT 0," + - NoteColumns.WIDGET_ID + " INTEGER NOT NULL DEFAULT 0," + - NoteColumns.WIDGET_TYPE + " INTEGER NOT NULL DEFAULT -1," + - NoteColumns.SYNC_ID + " INTEGER NOT NULL DEFAULT 0," + - NoteColumns.LOCAL_MODIFIED + " INTEGER NOT NULL DEFAULT 0," + - NoteColumns.ORIGIN_PARENT_ID + " INTEGER NOT NULL DEFAULT 0," + - NoteColumns.GTASK_ID + " TEXT NOT NULL DEFAULT ''," + - NoteColumns.VERSION + " INTEGER NOT NULL DEFAULT 0" + - ")"; - - // 创建 DATA 表的 SQL 语句 - // data 表通过 note_id 关联到 note 表 + "CREATE TABLE " + TABLE.NOTE + "(" + + NoteColumns.ID + " INTEGER PRIMARY KEY," + + NoteColumns.PARENT_ID + " INTEGER NOT NULL DEFAULT 0," + + NoteColumns.ALERTED_DATE + " INTEGER NOT NULL DEFAULT 0," + + NoteColumns.BG_COLOR_ID + " INTEGER NOT NULL DEFAULT 0," + + NoteColumns.CREATED_DATE + " INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)," + + NoteColumns.HAS_ATTACHMENT + " INTEGER NOT NULL DEFAULT 0," + + NoteColumns.MODIFIED_DATE + " INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)," + + NoteColumns.NOTES_COUNT + " INTEGER NOT NULL DEFAULT 0," + + NoteColumns.SNIPPET + " TEXT NOT NULL DEFAULT ''," + + NoteColumns.TYPE + " INTEGER NOT NULL DEFAULT 0," + + NoteColumns.WIDGET_ID + " INTEGER NOT NULL DEFAULT 0," + + NoteColumns.WIDGET_TYPE + " INTEGER NOT NULL DEFAULT -1," + + NoteColumns.SYNC_ID + " INTEGER NOT NULL DEFAULT 0," + + NoteColumns.LOCAL_MODIFIED + " INTEGER NOT NULL DEFAULT 0," + + NoteColumns.ORIGIN_PARENT_ID + " INTEGER NOT NULL DEFAULT 0," + + NoteColumns.GTASK_ID + " TEXT NOT NULL DEFAULT ''," + + NoteColumns.VERSION + " INTEGER NOT NULL DEFAULT 0" + + ")"; + private static final String CREATE_DATA_TABLE_SQL = - "CREATE TABLE " + TABLE.DATA + "(" + - DataColumns.ID + " INTEGER PRIMARY KEY," + - DataColumns.MIME_TYPE + " TEXT NOT NULL," + // 数据类型,区分文本还是电话记录 - DataColumns.NOTE_ID + " INTEGER NOT NULL DEFAULT 0," + // 外键关联 - NoteColumns.CREATED_DATE + " INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)," + - NoteColumns.MODIFIED_DATE + " INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)," + - DataColumns.CONTENT + " TEXT NOT NULL DEFAULT ''," + // 实际存储的内容 - DataColumns.DATA1 + " INTEGER," + - DataColumns.DATA2 + " INTEGER," + - DataColumns.DATA3 + " TEXT NOT NULL DEFAULT ''," + - DataColumns.DATA4 + " TEXT NOT NULL DEFAULT ''," + - DataColumns.DATA5 + " TEXT NOT NULL DEFAULT ''" + - ")"; - - // 为 note_id 创建索引,加速根据笔记 ID 查询内容的效率 + "CREATE TABLE " + TABLE.DATA + "(" + + DataColumns.ID + " INTEGER PRIMARY KEY," + + DataColumns.MIME_TYPE + " TEXT NOT NULL," + + DataColumns.NOTE_ID + " INTEGER NOT NULL DEFAULT 0," + + NoteColumns.CREATED_DATE + " INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)," + + NoteColumns.MODIFIED_DATE + " INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)," + + DataColumns.CONTENT + " TEXT NOT NULL DEFAULT ''," + + DataColumns.DATA1 + " INTEGER," + + DataColumns.DATA2 + " INTEGER," + + DataColumns.DATA3 + " TEXT NOT NULL DEFAULT ''," + + DataColumns.DATA4 + " TEXT NOT NULL DEFAULT ''," + + DataColumns.DATA5 + " TEXT NOT NULL DEFAULT ''" + + ")"; + private static final String CREATE_DATA_NOTE_ID_INDEX_SQL = - "CREATE INDEX IF NOT EXISTS note_id_index ON " + - TABLE.DATA + "(" + DataColumns.NOTE_ID + ");"; + "CREATE INDEX IF NOT EXISTS note_id_index ON " + + TABLE.DATA + "(" + DataColumns.NOTE_ID + ");"; /** - * 触发器逻辑:当笔记被移动到新文件夹时(Update操作), - * 自动将新文件夹(new.parent_id)内的笔记数量 +1。 + * Increase folder's note count when move note to the folder */ private static final String NOTE_INCREASE_FOLDER_COUNT_ON_UPDATE_TRIGGER = - "CREATE TRIGGER increase_folder_count_on_update "+ - " AFTER UPDATE OF " + NoteColumns.PARENT_ID + " ON " + TABLE.NOTE + - " BEGIN " + - " UPDATE " + TABLE.NOTE + - " SET " + NoteColumns.NOTES_COUNT + "=" + NoteColumns.NOTES_COUNT + " + 1" + - " WHERE " + NoteColumns.ID + "=new." + NoteColumns.PARENT_ID + ";" + - " END"; + "CREATE TRIGGER increase_folder_count_on_update "+ + " AFTER UPDATE OF " + NoteColumns.PARENT_ID + " ON " + TABLE.NOTE + + " BEGIN " + + " UPDATE " + TABLE.NOTE + + " SET " + NoteColumns.NOTES_COUNT + "=" + NoteColumns.NOTES_COUNT + " + 1" + + " WHERE " + NoteColumns.ID + "=new." + NoteColumns.PARENT_ID + ";" + + " END"; /** - * 触发器逻辑:当笔记从旧文件夹移出时(Update操作), - * 自动将旧文件夹(old.parent_id)内的笔记数量 -1。 - * 增加了 >0 的判断,防止数据异常导致计数器变成负数。 + * Decrease folder's note count when move note from folder */ private static final String NOTE_DECREASE_FOLDER_COUNT_ON_UPDATE_TRIGGER = - "CREATE TRIGGER decrease_folder_count_on_update " + - " AFTER UPDATE OF " + NoteColumns.PARENT_ID + " ON " + TABLE.NOTE + - " BEGIN " + - " UPDATE " + TABLE.NOTE + - " SET " + NoteColumns.NOTES_COUNT + "=" + NoteColumns.NOTES_COUNT + "-1" + - " WHERE " + NoteColumns.ID + "=old." + NoteColumns.PARENT_ID + - " AND " + NoteColumns.NOTES_COUNT + ">0" + ";" + - " END"; + "CREATE TRIGGER decrease_folder_count_on_update " + + " AFTER UPDATE OF " + NoteColumns.PARENT_ID + " ON " + TABLE.NOTE + + " BEGIN " + + " UPDATE " + TABLE.NOTE + + " SET " + NoteColumns.NOTES_COUNT + "=" + NoteColumns.NOTES_COUNT + "-1" + + " WHERE " + NoteColumns.ID + "=old." + NoteColumns.PARENT_ID + + " AND " + NoteColumns.NOTES_COUNT + ">0" + ";" + + " END"; /** - * 触发器逻辑:当在文件夹下新建笔记时(Insert操作), - * 自动将该文件夹的笔记数量 +1。 + * Increase folder's note count when insert new note to the folder */ private static final String NOTE_INCREASE_FOLDER_COUNT_ON_INSERT_TRIGGER = - "CREATE TRIGGER increase_folder_count_on_insert " + - " AFTER INSERT ON " + TABLE.NOTE + - " BEGIN " + - " UPDATE " + TABLE.NOTE + - " SET " + NoteColumns.NOTES_COUNT + "=" + NoteColumns.NOTES_COUNT + " + 1" + - " WHERE " + NoteColumns.ID + "=new." + NoteColumns.PARENT_ID + ";" + - " END"; + "CREATE TRIGGER increase_folder_count_on_insert " + + " AFTER INSERT ON " + TABLE.NOTE + + " BEGIN " + + " UPDATE " + TABLE.NOTE + + " SET " + NoteColumns.NOTES_COUNT + "=" + NoteColumns.NOTES_COUNT + " + 1" + + " WHERE " + NoteColumns.ID + "=new." + NoteColumns.PARENT_ID + ";" + + " END"; /** - * 触发器逻辑:当删除笔记时(Delete操作), - * 自动将所在文件夹的笔记数量 -1。 + * Decrease folder's note count when delete note from the folder */ private static final String NOTE_DECREASE_FOLDER_COUNT_ON_DELETE_TRIGGER = - "CREATE TRIGGER decrease_folder_count_on_delete " + - " AFTER DELETE ON " + TABLE.NOTE + - " BEGIN " + - " UPDATE " + TABLE.NOTE + - " SET " + NoteColumns.NOTES_COUNT + "=" + NoteColumns.NOTES_COUNT + "-1" + - " WHERE " + NoteColumns.ID + "=old." + NoteColumns.PARENT_ID + - " AND " + NoteColumns.NOTES_COUNT + ">0;" + - " END"; + "CREATE TRIGGER decrease_folder_count_on_delete " + + " AFTER DELETE ON " + TABLE.NOTE + + " BEGIN " + + " UPDATE " + TABLE.NOTE + + " SET " + NoteColumns.NOTES_COUNT + "=" + NoteColumns.NOTES_COUNT + "-1" + + " WHERE " + NoteColumns.ID + "=old." + NoteColumns.PARENT_ID + + " AND " + NoteColumns.NOTES_COUNT + ">0;" + + " END"; /** - * 触发器逻辑:当 DATA 表插入新内容且类型为 Note 时, - * 自动把内容同步更新到 NOTE 表的 snippet(摘要)字段。 - * 这样列表页可以直接读取 NOTE 表显示预览,无需联表查询。 + * Update note's content when insert data with type {@link DataConstants#NOTE} */ private static final String DATA_UPDATE_NOTE_CONTENT_ON_INSERT_TRIGGER = - "CREATE TRIGGER update_note_content_on_insert " + - " AFTER INSERT ON " + TABLE.DATA + - " WHEN new." + DataColumns.MIME_TYPE + "='" + DataConstants.NOTE + "'" + - " BEGIN" + - " UPDATE " + TABLE.NOTE + - " SET " + NoteColumns.SNIPPET + "=new." + DataColumns.CONTENT + - " WHERE " + NoteColumns.ID + "=new." + DataColumns.NOTE_ID + ";" + - " END"; + "CREATE TRIGGER update_note_content_on_insert " + + " AFTER INSERT ON " + TABLE.DATA + + " WHEN new." + DataColumns.MIME_TYPE + "='" + DataConstants.NOTE + "'" + + " BEGIN" + + " UPDATE " + TABLE.NOTE + + " SET " + NoteColumns.SNIPPET + "=new." + DataColumns.CONTENT + + " WHERE " + NoteColumns.ID + "=new." + DataColumns.NOTE_ID + ";" + + " END"; /** - * 触发器逻辑:当 DATA 表内容发生变更时,同步更新 NOTE 表的摘要。 + * Update note's content when data with {@link DataConstants#NOTE} type has changed */ private static final String DATA_UPDATE_NOTE_CONTENT_ON_UPDATE_TRIGGER = - "CREATE TRIGGER update_note_content_on_update " + - " AFTER UPDATE ON " + TABLE.DATA + - " WHEN old." + DataColumns.MIME_TYPE + "='" + DataConstants.NOTE + "'" + - " BEGIN" + - " UPDATE " + TABLE.NOTE + - " SET " + NoteColumns.SNIPPET + "=new." + DataColumns.CONTENT + - " WHERE " + NoteColumns.ID + "=new." + DataColumns.NOTE_ID + ";" + - " END"; + "CREATE TRIGGER update_note_content_on_update " + + " AFTER UPDATE ON " + TABLE.DATA + + " WHEN old." + DataColumns.MIME_TYPE + "='" + DataConstants.NOTE + "'" + + " BEGIN" + + " UPDATE " + TABLE.NOTE + + " SET " + NoteColumns.SNIPPET + "=new." + DataColumns.CONTENT + + " WHERE " + NoteColumns.ID + "=new." + DataColumns.NOTE_ID + ";" + + " END"; /** - * 触发器逻辑:当 DATA 表内容被删除时,清空 NOTE 表对应的摘要。 + * Update note's content when data with {@link DataConstants#NOTE} type has deleted */ private static final String DATA_UPDATE_NOTE_CONTENT_ON_DELETE_TRIGGER = - "CREATE TRIGGER update_note_content_on_delete " + - " AFTER delete ON " + TABLE.DATA + - " WHEN old." + DataColumns.MIME_TYPE + "='" + DataConstants.NOTE + "'" + - " BEGIN" + - " UPDATE " + TABLE.NOTE + - " SET " + NoteColumns.SNIPPET + "=''" + - " WHERE " + NoteColumns.ID + "=old." + DataColumns.NOTE_ID + ";" + - " END"; + "CREATE TRIGGER update_note_content_on_delete " + + " AFTER delete ON " + TABLE.DATA + + " WHEN old." + DataColumns.MIME_TYPE + "='" + DataConstants.NOTE + "'" + + " BEGIN" + + " UPDATE " + TABLE.NOTE + + " SET " + NoteColumns.SNIPPET + "=''" + + " WHERE " + NoteColumns.ID + "=old." + DataColumns.NOTE_ID + ";" + + " END"; /** - * 触发器逻辑:级联删除。 - * 当 NOTE 表的记录被删除时,自动删除 DATA 表中对应的详细内容,防止产生脏数据。 + * Delete datas belong to note which has been deleted */ private static final String NOTE_DELETE_DATA_ON_DELETE_TRIGGER = - "CREATE TRIGGER delete_data_on_delete " + - " AFTER DELETE ON " + TABLE.NOTE + - " BEGIN" + - " DELETE FROM " + TABLE.DATA + - " WHERE " + DataColumns.NOTE_ID + "=old." + NoteColumns.ID + ";" + - " END"; + "CREATE TRIGGER delete_data_on_delete " + + " AFTER DELETE ON " + TABLE.NOTE + + " BEGIN" + + " DELETE FROM " + TABLE.DATA + + " WHERE " + DataColumns.NOTE_ID + "=old." + NoteColumns.ID + ";" + + " END"; /** - * 触发器逻辑:级联删除文件夹内容。 - * 当一个文件夹被物理删除时,自动删除该文件夹下的所有笔记。 + * Delete notes belong to folder which has been deleted */ private static final String FOLDER_DELETE_NOTES_ON_DELETE_TRIGGER = - "CREATE TRIGGER folder_delete_notes_on_delete " + - " AFTER DELETE ON " + TABLE.NOTE + - " BEGIN" + - " DELETE FROM " + TABLE.NOTE + - " WHERE " + NoteColumns.PARENT_ID + "=old." + NoteColumns.ID + ";" + - " END"; + "CREATE TRIGGER folder_delete_notes_on_delete " + + " AFTER DELETE ON " + TABLE.NOTE + + " BEGIN" + + " DELETE FROM " + TABLE.NOTE + + " WHERE " + NoteColumns.PARENT_ID + "=old." + NoteColumns.ID + ";" + + " END"; /** - * 触发器逻辑:级联移动到废纸篓。 - * 当文件夹被移入废纸篓(ID_TRASH_FOLER)时, - * 自动将该文件夹下的所有笔记也修改为废纸篓状态。 + * Move notes belong to folder which has been moved to trash folder */ private static final String FOLDER_MOVE_NOTES_ON_TRASH_TRIGGER = - "CREATE TRIGGER folder_move_notes_on_trash " + - " AFTER UPDATE ON " + TABLE.NOTE + - " WHEN new." + NoteColumns.PARENT_ID + "=" + Notes.ID_TRASH_FOLER + - " BEGIN" + - " UPDATE " + TABLE.NOTE + - " SET " + NoteColumns.PARENT_ID + "=" + Notes.ID_TRASH_FOLER + - " WHERE " + NoteColumns.PARENT_ID + "=old." + NoteColumns.ID + ";" + - " END"; + "CREATE TRIGGER folder_move_notes_on_trash " + + " AFTER UPDATE ON " + TABLE.NOTE + + " WHEN new." + NoteColumns.PARENT_ID + "=" + Notes.ID_TRASH_FOLER + + " BEGIN" + + " UPDATE " + TABLE.NOTE + + " SET " + NoteColumns.PARENT_ID + "=" + Notes.ID_TRASH_FOLER + + " WHERE " + NoteColumns.PARENT_ID + "=old." + NoteColumns.ID + ";" + + " END"; public NotesDatabaseHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); @@ -243,12 +212,11 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { public void createNoteTable(SQLiteDatabase db) { db.execSQL(CREATE_NOTE_TABLE_SQL); - reCreateNoteTableTriggers(db); // 创建表后立即初始化相关的触发器 - createSystemFolder(db); // 初始化系统默认文件夹 + reCreateNoteTableTriggers(db); + createSystemFolder(db); Log.d(TAG, "note table has been created"); } - // 重置 Note 表的所有触发器,先删除旧的再重新创建,确保逻辑最新 private void reCreateNoteTableTriggers(SQLiteDatabase db) { db.execSQL("DROP TRIGGER IF EXISTS increase_folder_count_on_update"); db.execSQL("DROP TRIGGER IF EXISTS decrease_folder_count_on_update"); @@ -267,14 +235,12 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { db.execSQL(FOLDER_MOVE_NOTES_ON_TRASH_TRIGGER); } - // 在数据库中插入默认的系统文件夹 private void createSystemFolder(SQLiteDatabase db) { ContentValues values = new ContentValues(); /** * call record foler for call notes */ - // 插入通话记录文件夹 values.put(NoteColumns.ID, Notes.ID_CALL_RECORD_FOLDER); values.put(NoteColumns.TYPE, Notes.TYPE_SYSTEM); db.insert(TABLE.NOTE, null, values); @@ -282,7 +248,6 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { /** * root folder which is default folder */ - // 插入默认根目录,复用 values 对象前先清空 values.clear(); values.put(NoteColumns.ID, Notes.ID_ROOT_FOLDER); values.put(NoteColumns.TYPE, Notes.TYPE_SYSTEM); @@ -291,7 +256,6 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { /** * temporary folder which is used for moving note */ - // 插入临时文件夹 values.clear(); values.put(NoteColumns.ID, Notes.ID_TEMPARAY_FOLDER); values.put(NoteColumns.TYPE, Notes.TYPE_SYSTEM); @@ -300,7 +264,6 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { /** * create trash folder */ - // 插入废纸篓文件夹 values.clear(); values.put(NoteColumns.ID, Notes.ID_TRASH_FOLER); values.put(NoteColumns.TYPE, Notes.TYPE_SYSTEM); @@ -310,7 +273,7 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { public void createDataTable(SQLiteDatabase db) { db.execSQL(CREATE_DATA_TABLE_SQL); reCreateDataTableTriggers(db); - db.execSQL(CREATE_DATA_NOTE_ID_INDEX_SQL); // 建表后创建索引 + db.execSQL(CREATE_DATA_NOTE_ID_INDEX_SQL); Log.d(TAG, "data table has been created"); } @@ -324,7 +287,6 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { db.execSQL(DATA_UPDATE_NOTE_CONTENT_ON_DELETE_TRIGGER); } - // 单例模式获取实例,使用 synchronized 保证多线程安全 static synchronized NotesDatabaseHelper getInstance(Context context) { if (mInstance == null) { mInstance = new NotesDatabaseHelper(context); @@ -338,46 +300,39 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { createDataTable(db); } - // 处理数据库版本升级逻辑 @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { boolean reCreateTriggers = false; boolean skipV2 = false; - // 升级逻辑:v1 -> v2 if (oldVersion == 1) { upgradeToV2(db); - skipV2 = true; // 标记跳过 v2 的独立判断逻辑 + skipV2 = true; // this upgrade including the upgrade from v2 to v3 oldVersion++; } - // 升级逻辑:v2 -> v3 (或从 v1 升上来后继续执行) if (oldVersion == 2 && !skipV2) { upgradeToV3(db); - reCreateTriggers = true; // v3 变更需要重置触发器 + reCreateTriggers = true; oldVersion++; } - // 升级逻辑:v3 -> v4 if (oldVersion == 3) { upgradeToV4(db); oldVersion++; } - // 如果升级过程中涉及触发器逻辑变更,统一重新创建 if (reCreateTriggers) { reCreateNoteTableTriggers(db); reCreateDataTableTriggers(db); } - // 升级后校验版本号,不匹配则抛出异常 if (oldVersion != newVersion) { throw new IllegalStateException("Upgrade notes database to version " + newVersion + "fails"); } } - // v1 升 v2:删除旧表并重建,重置整个数据库结构 private void upgradeToV2(SQLiteDatabase db) { db.execSQL("DROP TABLE IF EXISTS " + TABLE.NOTE); db.execSQL("DROP TABLE IF EXISTS " + TABLE.DATA); @@ -385,14 +340,12 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { createDataTable(db); } - // v2 升 v3:增加 GTask ID 字段和废纸篓文件夹,移除旧的触发器 private void upgradeToV3(SQLiteDatabase db) { // drop unused triggers db.execSQL("DROP TRIGGER IF EXISTS update_note_modified_date_on_insert"); db.execSQL("DROP TRIGGER IF EXISTS update_note_modified_date_on_delete"); db.execSQL("DROP TRIGGER IF EXISTS update_note_modified_date_on_update"); // add a column for gtask id - // 使用 ALTER TABLE 追加列,SQLite 不支持直接删除列 db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.GTASK_ID + " TEXT NOT NULL DEFAULT ''"); // add a trash system folder @@ -402,9 +355,8 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { db.insert(TABLE.NOTE, null, values); } - // v3 升 v4:增加版本号字段 private void upgradeToV4(SQLiteDatabase db) { db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.VERSION + " INTEGER NOT NULL DEFAULT 0"); } -} \ No newline at end of file +} diff --git a/src/src/net/micode/notes/data/NotesProvider.java b/src/src/net/micode/notes/data/NotesProvider.java index 97c73bd..edb0a60 100644 --- a/src/src/net/micode/notes/data/NotesProvider.java +++ b/src/src/net/micode/notes/data/NotesProvider.java @@ -32,45 +32,27 @@ import android.util.Log; import net.micode.notes.R; import net.micode.notes.data.Notes.DataColumns; import net.micode.notes.data.Notes.NoteColumns; -import net.micode.notes.data.Notes.TextNote; import net.micode.notes.data.NotesDatabaseHelper.TABLE; -/** - * 内容提供者核心类 - *

- * 这是 Android 四大组件之一 {@code ContentProvider} 的实现。 - * 它的作用就像是一个“管家”,把底层的 SQLite 数据库包装起来, - * 外部(比如 UI 界面或者桌面搜索栏)想要查数据、改数据,都得通过这个管家。 - * 这样既能保证数据的安全(比如防止误删系统文件夹),也能统一管理数据变化的通知。 - *

- * - * @Author: 林迪文 - * @Updator: 林迪文 - * @Date 2025/12/23 16:20 - */ + public class NotesProvider extends ContentProvider { - // URI匹配器,用来判断外边传进来的 URI 具体是想操作哪张表,还是想做搜索 private static final UriMatcher mMatcher; private NotesDatabaseHelper mHelper; private static final String TAG = "NotesProvider"; - // 定义 URI 对应的匹配码,方便 switch-case 使用 - private static final int URI_NOTE = 1; // 操作整个 Note 表 - private static final int URI_NOTE_ITEM = 2; // 操作 Note 表里的单条记录 - private static final int URI_DATA = 3; // 操作 Data 表 - private static final int URI_DATA_ITEM = 4; // 操作 Data 表里的单条记录 + private static final int URI_NOTE = 1; + private static final int URI_NOTE_ITEM = 2; + private static final int URI_DATA = 3; + private static final int URI_DATA_ITEM = 4; - private static final int URI_SEARCH = 5; // 搜索 - private static final int URI_SEARCH_SUGGEST = 6; // 搜索建议(给搜索框用的) + private static final int URI_SEARCH = 5; + private static final int URI_SEARCH_SUGGEST = 6; - // 静态代码块,类加载时先把规则定好 static { mMatcher = new UriMatcher(UriMatcher.NO_MATCH); - // 比如 content://net.micode.notes/note 对应 URI_NOTE mMatcher.addURI(Notes.AUTHORITY, "note", URI_NOTE); - // # 代表数字通配符,匹配具体 ID mMatcher.addURI(Notes.AUTHORITY, "note/#", URI_NOTE_ITEM); mMatcher.addURI(Notes.AUTHORITY, "data", URI_DATA); mMatcher.addURI(Notes.AUTHORITY, "data/#", URI_DATA_ITEM); @@ -82,49 +64,39 @@ public class NotesProvider extends ContentProvider { /** * x'0A' represents the '\n' character in sqlite. For title and content in the search result, * we will trim '\n' and white space in order to show more information. - *

- * 这个 SQL 投影比较复杂,主要是为了配合 Android 的全局搜索(SearchManager)。 - * 它把数据库里的换行符(x'0A')全都去掉了,不然搜索结果列表里显示会乱。 - * 还配置了 ICON 和点击后的 Intent 动作,这样用户点搜索建议就能直接跳进笔记里。 - *

*/ private static final String NOTES_SEARCH_PROJECTION = NoteColumns.ID + "," - + NoteColumns.ID + " AS " + SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA + "," - + "TRIM(REPLACE(" + NoteColumns.SNIPPET + ", x'0A','')) AS " + SearchManager.SUGGEST_COLUMN_TEXT_1 + "," - + "TRIM(REPLACE(" + NoteColumns.SNIPPET + ", x'0A','')) AS " + SearchManager.SUGGEST_COLUMN_TEXT_2 + "," - + R.drawable.search_result + " AS " + SearchManager.SUGGEST_COLUMN_ICON_1 + "," - + "'" + Intent.ACTION_VIEW + "' AS " + SearchManager.SUGGEST_COLUMN_INTENT_ACTION + "," - + "'" + TextNote.CONTENT_TYPE + "' AS " + SearchManager.SUGGEST_COLUMN_INTENT_DATA; + + NoteColumns.ID + " AS " + SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA + "," + + "TRIM(REPLACE(" + NoteColumns.SNIPPET + ", x'0A','')) AS " + SearchManager.SUGGEST_COLUMN_TEXT_1 + "," + + "TRIM(REPLACE(" + NoteColumns.SNIPPET + ", x'0A','')) AS " + SearchManager.SUGGEST_COLUMN_TEXT_2 + "," + + R.drawable.search_result + " AS " + SearchManager.SUGGEST_COLUMN_ICON_1 + "," + + "'" + Intent.ACTION_VIEW + "' AS " + SearchManager.SUGGEST_COLUMN_INTENT_ACTION + "," + + "'" + Notes.TextNote.CONTENT_TYPE + "' AS " + SearchManager.SUGGEST_COLUMN_INTENT_DATA; - // 预定义的搜索 SQL:查 Note 表,匹配 snippet 内容,排除垃圾桶里的和非笔记类型的 private static String NOTES_SNIPPET_SEARCH_QUERY = "SELECT " + NOTES_SEARCH_PROJECTION - + " FROM " + TABLE.NOTE - + " WHERE " + NoteColumns.SNIPPET + " LIKE ?" - + " AND " + NoteColumns.PARENT_ID + "<>" + Notes.ID_TRASH_FOLER - + " AND " + NoteColumns.TYPE + "=" + Notes.TYPE_NOTE; + + " FROM " + TABLE.NOTE + + " WHERE " + NoteColumns.SNIPPET + " LIKE ?" + + " AND " + NoteColumns.PARENT_ID + "<>" + Notes.ID_TRASH_FOLER + + " AND " + NoteColumns.TYPE + "=" + Notes.TYPE_NOTE; @Override public boolean onCreate() { - // 初始化数据库助手 mHelper = NotesDatabaseHelper.getInstance(getContext()); return true; } - // 查数据接口 @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, - String sortOrder) { + String sortOrder) { Cursor c = null; SQLiteDatabase db = mHelper.getReadableDatabase(); String id = null; switch (mMatcher.match(uri)) { case URI_NOTE: - // 查整个 Note 表 c = db.query(TABLE.NOTE, projection, selection, selectionArgs, null, null, sortOrder); break; case URI_NOTE_ITEM: - // 查单个 Note,解析出 ID 拼到查询条件里 id = uri.getPathSegments().get(1); c = db.query(TABLE.NOTE, projection, NoteColumns.ID + "=" + id + parseSelection(selection), selectionArgs, null, null, sortOrder); @@ -140,14 +112,12 @@ public class NotesProvider extends ContentProvider { break; case URI_SEARCH: case URI_SEARCH_SUGGEST: - // 搜索建议模式,不支持自定义排序和筛选 if (sortOrder != null || projection != null) { throw new IllegalArgumentException( "do not specify sortOrder, selection, selectionArgs, or projection" + "with this query"); } String searchString = null; - // 解析搜索关键词,有两种传递方式:路径里或者参数里 if (mMatcher.match(uri) == URI_SEARCH_SUGGEST) { if (uri.getPathSegments().size() > 1) { searchString = uri.getPathSegments().get(1); @@ -161,7 +131,6 @@ public class NotesProvider extends ContentProvider { } try { - // 加上 % 拼成模糊查询 searchString = String.format("%%%s%%", searchString); c = db.rawQuery(NOTES_SNIPPET_SEARCH_QUERY, new String[] { searchString }); @@ -173,13 +142,11 @@ public class NotesProvider extends ContentProvider { throw new IllegalArgumentException("Unknown URI " + uri); } if (c != null) { - // 绑定通知 URI。这一步很重要,如果数据变了,监听这个 URI 的界面(比如列表)就会自动刷新。 c.setNotificationUri(getContext().getContentResolver(), uri); } return c; } - // 增数据接口 @Override public Uri insert(Uri uri, ContentValues values) { SQLiteDatabase db = mHelper.getWritableDatabase(); @@ -189,7 +156,6 @@ public class NotesProvider extends ContentProvider { insertedId = noteId = db.insert(TABLE.NOTE, null, values); break; case URI_DATA: - // 插入 Data 表必须要有对应的 Note ID if (values.containsKey(DataColumns.NOTE_ID)) { noteId = values.getAsLong(DataColumns.NOTE_ID); } else { @@ -200,23 +166,21 @@ public class NotesProvider extends ContentProvider { default: throw new IllegalArgumentException("Unknown URI " + uri); } - // 通知 Note 表有变化,UI 该刷新了 + // Notify the note uri if (noteId > 0) { getContext().getContentResolver().notifyChange( ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId), null); } - // 通知 Data 表有变化 + // Notify the data uri if (dataId > 0) { getContext().getContentResolver().notifyChange( ContentUris.withAppendedId(Notes.CONTENT_DATA_URI, dataId), null); } - // 算出刚刚插入的数据 ID,拼成新的 URI 返回给调用者 return ContentUris.withAppendedId(uri, insertedId); } - // 删数据接口 @Override public int delete(Uri uri, String selection, String[] selectionArgs) { int count = 0; @@ -225,7 +189,6 @@ public class NotesProvider extends ContentProvider { boolean deleteData = false; switch (mMatcher.match(uri)) { case URI_NOTE: - // 只能删 ID > 0 的,防止误删系统文件夹 selection = "(" + selection + ") AND " + NoteColumns.ID + ">0 "; count = db.delete(TABLE.NOTE, selection, selectionArgs); break; @@ -235,7 +198,6 @@ public class NotesProvider extends ContentProvider { * ID that smaller than 0 is system folder which is not allowed to * trash */ - // 防御性代码:系统文件夹(ID <= 0,如根目录)绝对不能删 long noteId = Long.valueOf(id); if (noteId <= 0) { break; @@ -256,7 +218,6 @@ public class NotesProvider extends ContentProvider { default: throw new IllegalArgumentException("Unknown URI " + uri); } - // 如果删除了数据,记得发通知刷新界面 if (count > 0) { if (deleteData) { getContext().getContentResolver().notifyChange(Notes.CONTENT_NOTE_URI, null); @@ -266,7 +227,6 @@ public class NotesProvider extends ContentProvider { return count; } - // 改数据接口 @Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { int count = 0; @@ -275,13 +235,11 @@ public class NotesProvider extends ContentProvider { boolean updateData = false; switch (mMatcher.match(uri)) { case URI_NOTE: - // 批量更新 Note,先把涉及到的 Note 版本号 +1 increaseNoteVersion(-1, selection, selectionArgs); count = db.update(TABLE.NOTE, values, selection, selectionArgs); break; case URI_NOTE_ITEM: id = uri.getPathSegments().get(1); - // 更新单个 Note,先更新版本号 increaseNoteVersion(Long.valueOf(id), selection, selectionArgs); count = db.update(TABLE.NOTE, values, NoteColumns.ID + "=" + id + parseSelection(selection), selectionArgs); @@ -309,12 +267,10 @@ public class NotesProvider extends ContentProvider { return count; } - // 辅助方法:拼接 selection 查询条件,防止 SQL 注入 private String parseSelection(String selection) { return (!TextUtils.isEmpty(selection) ? " AND (" + selection + ')' : ""); } - // 增加笔记的版本号,这应该是为了配合云同步功能,判断本地数据是否是最新的 private void increaseNoteVersion(long id, String selection, String[] selectionArgs) { StringBuilder sql = new StringBuilder(120); sql.append("UPDATE "); @@ -323,7 +279,6 @@ public class NotesProvider extends ContentProvider { sql.append(NoteColumns.VERSION); sql.append("=" + NoteColumns.VERSION + "+1 "); - // 拼接 WHERE 条件 if (id > 0 || !TextUtils.isEmpty(selection)) { sql.append(" WHERE "); } @@ -332,8 +287,6 @@ public class NotesProvider extends ContentProvider { } if (!TextUtils.isEmpty(selection)) { String selectString = id > 0 ? parseSelection(selection) : selection; - // 因为 rawQuery 不能自动处理 update 语句里的 ? 占位符 - // 所以这里要手动把参数(selectionArgs)填进去,替换掉 ? for (String args : selectionArgs) { selectString = selectString.replaceFirst("\\?", args); } @@ -349,4 +302,4 @@ public class NotesProvider extends ContentProvider { return null; } -} \ No newline at end of file +} diff --git a/src/src/net/micode/notes/gtask/data/MetaData.java b/src/src/net/micode/notes/gtask/data/MetaData.java index b04ac70..3a2050b 100644 --- a/src/src/net/micode/notes/gtask/data/MetaData.java +++ b/src/src/net/micode/notes/gtask/data/MetaData.java @@ -24,62 +24,37 @@ import net.micode.notes.tool.GTaskStringUtils; import org.json.JSONException; import org.json.JSONObject; -/** - * 元数据类,属于 Google Task 同步模块的一部分。 - *

- * 虽然它继承了 {@code Task},但它不存用户的实际笔记。 - * 它的主要作用是把本地的关联 ID 包装成 JSON,藏在 GTask 的“备注(Notes)”字段里传到服务器, - * 或者从服务器拉取这些信息。 - *

- * - * @Author: 林迪文 - * @Updator: 林迪文 - * @Date 2025/12/16 16:35 - */ + public class MetaData extends Task { - // 获取类名做 TAG,方便打 Log private final static String TAG = MetaData.class.getSimpleName(); - // 关联的 Google Task ID private String mRelatedGid = null; - // 设置元数据信息:把 GID 和其他信息打包塞进 notes 字段 public void setMeta(String gid, JSONObject metaInfo) { try { - // 往 json 里塞一个键值对:KEY是关联ID的头,VALUE是具体的 gid metaInfo.put(GTaskStringUtils.META_HEAD_GTASK_ID, gid); } catch (JSONException e) { Log.e(TAG, "failed to put related gid"); } - // 关键操作:把 JSON 转成字符串,存到 Task 的 notes 属性里 - // 在 Google Tasks 网页版上,这会显示在任务的“备注”栏里 setNotes(metaInfo.toString()); - // 给这个特殊的 Task 起个固定的名字,方便识别 setName(GTaskStringUtils.META_NOTE_NAME); } - // 取出关联 ID public String getRelatedGid() { return mRelatedGid; } - // 如果 notes 里有东西,才值得保存 @Override public boolean isWorthSaving() { return getNotes() != null; } - // 从服务器下发的 JSON 数据里恢复内容 @Override public void setContentByRemoteJSON(JSONObject js) { - // 先让父类干完常规的初始化 super.setContentByRemoteJSON(js); - // 如果备注不为空,说明里面可能藏着我们要的 GID if (getNotes() != null) { try { - // 把字符串形式的备注还原成 JSON 对象 JSONObject metaInfo = new JSONObject(getNotes().trim()); - // 提取出关联的 GID mRelatedGid = metaInfo.getString(GTaskStringUtils.META_HEAD_GTASK_ID); } catch (JSONException e) { Log.w(TAG, "failed to get related gid"); @@ -88,9 +63,6 @@ public class MetaData extends Task { } } - // 下面这三个方法直接抛 Error,说明 MetaData 这个类很特殊 - // 它不需要走本地数据库(Local JSON/Cursor)那一套流程 - @Override public void setContentByLocalJSON(JSONObject js) { // this function should not be called @@ -107,4 +79,4 @@ public class MetaData extends Task { throw new IllegalAccessError("MetaData:getSyncAction should not be called"); } -} \ No newline at end of file +} diff --git a/src/src/net/micode/notes/gtask/data/Node.java b/src/src/net/micode/notes/gtask/data/Node.java index 01f6fbb..63950e0 100644 --- a/src/src/net/micode/notes/gtask/data/Node.java +++ b/src/src/net/micode/notes/gtask/data/Node.java @@ -3,7 +3,6 @@ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with 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 @@ -21,103 +20,52 @@ import android.database.Cursor; import org.json.JSONObject; -/** - * @Author: 林迪文 - * @Updator: 林迪文 - * @Date 2025/12/23 16:20 - * - * 同步节点类的基类 - * - * 我觉得这个 Node 类就是个抽象模板,管你是便签还是文件夹, - * 只要想跟 Google Tasks 同步,都得继承它。 - * 它把同步对象共有的属性和行为都抽出来了。 - */ public abstract class Node { - /* - * 定义了本地和云端数据对比后的几种同步状态。 - * 后面 getSyncAction 方法就是根据两边数据的差异,返回这些状态码。 - * - * 0: 无需操作 (数据一致) - * 1: 云端新增 (本地有,云端没有) - * 2: 本地新增 (云端有,本地没有) - * 3: 云端删除 (本地已删,云端还有) - * 4: 本地删除 (云端已删,本地还有) - * 5: 更新云端 (本地版本新) - * 6: 更新本地 (云端版本新) - * 7: 冲突 (两边都改了) - * 8: 出错 - */ public static final int SYNC_ACTION_NONE = 0; + public static final int SYNC_ACTION_ADD_REMOTE = 1; + public static final int SYNC_ACTION_ADD_LOCAL = 2; + public static final int SYNC_ACTION_DEL_REMOTE = 3; + public static final int SYNC_ACTION_DEL_LOCAL = 4; + public static final int SYNC_ACTION_UPDATE_REMOTE = 5; + public static final int SYNC_ACTION_UPDATE_LOCAL = 6; + public static final int SYNC_ACTION_UPDATE_CONFLICT = 7; + public static final int SYNC_ACTION_ERROR = 8; - private String mGid; // Google那边给的唯一ID + private String mGid; - private String mName; // 笔记标题或者文件夹名 + private String mName; - private long mLastModified; // 最后修改时间,同步的时候就靠它比对新旧了 + private long mLastModified; - private boolean mDeleted; // 删除标记,本地删了就标一下 + private boolean mDeleted; public Node() { - // 构造函数,创建一个空的Node,把所有属性都初始化一下 mGid = null; mName = ""; mLastModified = 0; mDeleted = false; } - // 下面这几个是抽象方法,也就是说,这个类只是个架子 - // 具体的便签(Task)和文件夹(TaskList)要自己去实现这些方法 - // 告诉程序到底怎么处理JSON数据,怎么判断同步状态 - - /** - * 把本地数据打包成JSON,用来在云端创建新条目 - * @param actionId 操作ID - * @return 打包好的JSON对象 - */ public abstract JSONObject getCreateAction(int actionId); - /** - * 把本地数据打包成JSON,用来更新云端已有条目 - * @param actionId 操作ID - * @return 打包好的JSON对象 - */ public abstract JSONObject getUpdateAction(int actionId); - /** - * 从Google服务器拉下来的JSON,解析完更新到这个Node对象里 - * @param js 从云端接收的JSON对象 - */ public abstract void setContentByRemoteJSON(JSONObject js); - /** - * 从本地数据库(通过JSON格式)恢复数据到这个Node对象里 - * @param js 从本地数据库读取的JSON对象 - */ public abstract void setContentByLocalJSON(JSONObject js); - /** - * 把Node对象里的内容,转换成JSON格式,方便存到本地数据库 - * @return 代表当前内容的JSON对象 - */ public abstract JSONObject getLocalJSONFromContent(); - /** - * 核心方法,比较当前Node对象和数据库里的Cursor - * 看看谁新谁旧,然后返回一个上面定义好的同步状态码 - * @param c 数据库查询结果的游标 - * @return 同步状态码 (SYNC_ACTION_*) - */ public abstract int getSyncAction(Cursor c); - // 下面这一堆就是常规的 getter 和 setter,用来读写私有变量 public void setGid(String gid) { this.mGid = gid; } @@ -149,4 +97,5 @@ public abstract class Node { public boolean getDeleted() { return this.mDeleted; } -} \ No newline at end of file + +} diff --git a/src/src/net/micode/notes/gtask/data/SqlData.java b/src/src/net/micode/notes/gtask/data/SqlData.java index 04b128e..d3ec3be 100644 --- a/src/src/net/micode/notes/gtask/data/SqlData.java +++ b/src/src/net/micode/notes/gtask/data/SqlData.java @@ -9,7 +9,6 @@ * * 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. @@ -35,70 +34,61 @@ import net.micode.notes.gtask.exception.ActionFailureException; import org.json.JSONException; import org.json.JSONObject; -/** - * @Author: 林迪文 - * @Updator: 林迪文 - * @Date 2025/12/19 16:45 - * - * 封装了 data 表的一行数据 - * - * 这个类的作用,就是把数据库里的一行 data 数据,或者一个 JSON 对象, - * 包装成一个 SqlData 对象。方便在同步的时候,进行比较和提交。 - * 它还记录了数据的变更,最后统一 commit 到数据库。 - */ + public class SqlData { - private static final String TAG = SqlData.class.getSimpleName(); // TAG,打日志用 + private static final String TAG = SqlData.class.getSimpleName(); - private static final int INVALID_ID = -99999; // 无效ID,-99999 这个值选得有点随意啊? + private static final int INVALID_ID = -99999; - // PROJECTION_DATA,查询投影,就是指定我们从 data 表里只要这几列数据 public static final String[] PROJECTION_DATA = new String[] { DataColumns.ID, DataColumns.MIME_TYPE, DataColumns.CONTENT, DataColumns.DATA1, DataColumns.DATA3 }; - // 下面这几个是上面投影列对应的索引,方便从 Cursor 里取值 public static final int DATA_ID_COLUMN = 0; + public static final int DATA_MIME_TYPE_COLUMN = 1; + public static final int DATA_CONTENT_COLUMN = 2; + public static final int DATA_CONTENT_DATA_1_COLUMN = 3; + public static final int DATA_CONTENT_DATA_3_COLUMN = 4; - private ContentResolver mContentResolver; // 内容解析器,用来跟数据库打交道 + private ContentResolver mContentResolver; - private boolean mIsCreate; // 是不是新建模式的标志 + private boolean mIsCreate; - // 这几个字段,就是 data 表里一行数据的内容 private long mDataId; + private String mDataMimeType; + private String mDataContent; + private long mDataContentData1; + private String mDataContentData3; - // ContentValues,就是个键值对集合,专门用来存放有变化的数据,等会儿一次性提交 private ContentValues mDiffDataValues; - // 构造函数1: 创建一个全新的、空的 SqlData 对象 public SqlData(Context context) { - mContentResolver = context.getContentResolver(); // 从 context 里拿到 ContentResolver - mIsCreate = true; // 标记为“新建” + mContentResolver = context.getContentResolver(); + mIsCreate = true; mDataId = INVALID_ID; - mDataMimeType = DataConstants.NOTE; // 默认是普通笔记类型 + mDataMimeType = DataConstants.NOTE; mDataContent = ""; mDataContentData1 = 0; mDataContentData3 = ""; mDiffDataValues = new ContentValues(); } - // 构造函数2: 从数据库游标 Cursor 里加载数据,创建一个已存在的 SqlData 对象 public SqlData(Context context, Cursor c) { mContentResolver = context.getContentResolver(); - mIsCreate = false; // 标记为“已存在” - loadFromCursor(c); // 用游标里的数据,把这个对象的各个字段都填上 + mIsCreate = false; + loadFromCursor(c); mDiffDataValues = new ContentValues(); } - // 从游标 Cursor 里读取数据,初始化对象的各个字段 private void loadFromCursor(Cursor c) { mDataId = c.getLong(DATA_ID_COLUMN); mDataMimeType = c.getString(DATA_MIME_TYPE_COLUMN); @@ -107,14 +97,8 @@ public class SqlData { mDataContentData3 = c.getString(DATA_CONTENT_DATA_3_COLUMN); } - /** - * 用 JSON 对象来更新 SqlData 的内容。 - * 它会比较 JSON 里的值和当前对象里的值,把不一样的地方记在 mDiffDataValues 里。 - */ public void setContent(JSONObject js) throws JSONException { - // 先用 has 判断一下 JSON 里有没有这个字段,免得直接 get 报错 long dataId = js.has(DataColumns.ID) ? js.getLong(DataColumns.ID) : INVALID_ID; - // 如果是新建模式,或者ID变了,就把新ID加到差异集合里 if (mIsCreate || mDataId != dataId) { mDiffDataValues.put(DataColumns.ID, dataId); } @@ -146,12 +130,8 @@ public class SqlData { mDataContentData3 = dataContentData3; } - /** - * 把当前 SqlData 对象的内容,打包成一个 JSON 对象返回。 - */ public JSONObject getContent() throws JSONException { if (mIsCreate) { - // 如果还是新建状态,说明还没往数据库里存,这时候转成JSON没啥意义 Log.e(TAG, "it seems that we haven't created this in database yet"); return null; } @@ -164,52 +144,41 @@ public class SqlData { return js; } - /** - * 把之前记录的所有变更(mDiffDataValues),一次性提交到数据库。 - */ public void commit(long noteId, boolean validateVersion, long version) { if (mIsCreate) { - // 新建模式,就执行 insert if (mDataId == INVALID_ID && mDiffDataValues.containsKey(DataColumns.ID)) { - // 如果ID是无效的,就把它从要插入的数据里去掉,让数据库自己生成 mDiffDataValues.remove(DataColumns.ID); } - mDiffDataValues.put(DataColumns.NOTE_ID, noteId); // 别忘了把所属的 noteId 加上 + mDiffDataValues.put(DataColumns.NOTE_ID, noteId); Uri uri = mContentResolver.insert(Notes.CONTENT_DATA_URI, mDiffDataValues); try { - // 从返回的uri里,解析出新生成的ID mDataId = Long.valueOf(uri.getPathSegments().get(1)); } catch (NumberFormatException e) { - // 万一出错了,记个日志,然后抛个自定义的异常出去,告诉上层同步失败了 Log.e(TAG, "Get note id error :" + e.toString()); throw new ActionFailureException("create note failed"); } } else { - // 非新建模式,就执行 update if (mDiffDataValues.size() > 0) { int result = 0; if (!validateVersion) { - // 不需要版本验证,直接更新 result = mContentResolver.update(ContentUris.withAppendedId( Notes.CONTENT_DATA_URI, mDataId), mDiffDataValues, null, null); } else { - // 需要版本验证,这是一种乐观锁,防止同步的时候本地数据被意外修改 result = mContentResolver.update(ContentUris.withAppendedId( - Notes.CONTENT_DATA_URI, mDataId), mDiffDataValues, + Notes.CONTENT_DATA_URI, mDataId), mDiffDataValues, " ? in (SELECT " + NoteColumns.ID + " FROM " + TABLE.NOTE + " WHERE " + NoteColumns.VERSION + "=?)", new String[] { String.valueOf(noteId), String.valueOf(version) }); } if (result == 0) { - // 没更新成功,可能是版本冲突了 Log.w(TAG, "there is no update. maybe user updates note when syncing"); } } } - // 提交完了,就把差异集合清空,再把状态改成“已存在” + mDiffDataValues.clear(); mIsCreate = false; } @@ -217,4 +186,4 @@ public class SqlData { public long getId() { return mDataId; } -} \ No newline at end of file +} diff --git a/src/src/net/micode/notes/gtask/data/SqlNote.java b/src/src/net/micode/notes/gtask/data/SqlNote.java index bf22f49..79a4095 100644 --- a/src/src/net/micode/notes/gtask/data/SqlNote.java +++ b/src/src/net/micode/notes/gtask/data/SqlNote.java @@ -3,7 +3,7 @@ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. - * You may not use a copy of the License at + * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * @@ -38,20 +38,11 @@ import org.json.JSONObject; import java.util.ArrayList; -/** - * @Author: 林迪文 - * @Updator: 林迪文 - * @Date: 2025/12/16 16:30 - * @Description: 这个类是笔记在本地数据库的体现,负责把数据库里的数据读出来变成一个对象,或者把对象的改动写回数据库。 - */ public class SqlNote { - // 这个TAG是用来打日志的,方便在Logcat里筛选特定类的输出 private static final String TAG = SqlNote.class.getSimpleName(); - // 定义一个特殊的负数值来表示无效ID,避免和数据库自增的ID(从0或1开始)混淆 private static final int INVALID_ID = -99999; - // 把所有笔记相关的字段预先定义成一个数组,查询数据库时直接用它,就不用每次都手写一遍了 public static final String[] PROJECTION_NOTE = new String[] { NoteColumns.ID, NoteColumns.ALERTED_DATE, NoteColumns.BG_COLOR_ID, NoteColumns.CREATED_DATE, NoteColumns.HAS_ATTACHMENT, NoteColumns.MODIFIED_DATE, @@ -61,63 +52,83 @@ public class SqlNote { NoteColumns.VERSION }; - // 下面这一堆常量,是上面 PROJECTION_NOTE 数组里每个字段对应的下标。 - // 这样从Cursor里取数据的时候,直接用 c.getLong(ID_COLUMN) 就行,比用 c.getColumnIndex("id") 效率高。 public static final int ID_COLUMN = 0; + public static final int ALERTED_DATE_COLUMN = 1; + public static final int BG_COLOR_ID_COLUMN = 2; + public static final int CREATED_DATE_COLUMN = 3; + public static final int HAS_ATTACHMENT_COLUMN = 4; + public static final int MODIFIED_DATE_COLUMN = 5; + public static final int NOTES_COUNT_COLUMN = 6; + public static final int PARENT_ID_COLUMN = 7; + public static final int SNIPPET_COLUMN = 8; + public static final int TYPE_COLUMN = 9; + public static final int WIDGET_ID_COLUMN = 10; + public static final int WIDGET_TYPE_COLUMN = 11; + public static final int SYNC_ID_COLUMN = 12; + public static final int LOCAL_MODIFIED_COLUMN = 13; + public static final int ORIGIN_PARENT_ID_COLUMN = 14; + public static final int GTASK_ID_COLUMN = 15; + public static final int VERSION_COLUMN = 16; private Context mContext; - private ContentResolver mContentResolver; // 安卓系统里专门用来跟ContentProvider打交道的东西,增删改查都靠它 - - private boolean mIsCreate; // 一个标志位,用来区分这个对象是新创建的,还是从数据库里读出来的 - - // 底下这些就是笔记的各种属性,跟数据库表的字段一一对应 - private long mId; // ID - private long mAlertDate; // 提醒日期 - private int mBgColorId; // 背景颜色ID - private long mCreatedDate; // 创建日期 - private int mHasAttachment; // 是否有附件 - private long mModifiedDate; // 修改日期 - private long mParentId; // 父文件夹ID - private String mSnippet; // 摘要 - private int mType; // 类型 - private int mWidgetId; // 桌面小部件ID - private int mWidgetType; // 桌面小部件类型 - private long mOriginParent; // 原始父文件夹ID - private long mVersion; // 版本号 - - // 这个ContentValues专门存放发生变化的字段,commit的时候直接把它丢给数据库更新,很方便 + + private ContentResolver mContentResolver; + + private boolean mIsCreate; + + private long mId; + + private long mAlertDate; + + private int mBgColorId; + + private long mCreatedDate; + + private int mHasAttachment; + + private long mModifiedDate; + + private long mParentId; + + private String mSnippet; + + private int mType; + + private int mWidgetId; + + private int mWidgetType; + + private long mOriginParent; + + private long mVersion; + private ContentValues mDiffNoteValues; - private ArrayList mDataList; // 一条笔记可能包含多条数据(比如文字、录音),用list存起来 - - /** - * @Author: 林迪文 - * @Updator: 林迪文 - * @Date: 2025/12/16 17:30 - * @Description: 这是给新建笔记用的构造函数。 - */ + + private ArrayList mDataList; + public SqlNote(Context context) { mContext = context; mContentResolver = context.getContentResolver(); - mIsCreate = true; // 明确这是个新笔记 + mIsCreate = true; mId = INVALID_ID; mAlertDate = 0; - mBgColorId = ResourceParser.getDefaultBgId(context); // 连颜色都有默认的 + mBgColorId = ResourceParser.getDefaultBgId(context); mCreatedDate = System.currentTimeMillis(); mHasAttachment = 0; mModifiedDate = System.currentTimeMillis(); @@ -132,63 +143,49 @@ public class SqlNote { mDataList = new ArrayList(); } - /** - * @Author: 林迪文 - * @Updator: 林迪文 - * @Date: 2025/12/16 16:46 - * @Description: 从一个已经查好的数据库Cursor里直接加载数据,这样就不用再查一次数据库了,效率高。 - */ public SqlNote(Context context, Cursor c) { mContext = context; mContentResolver = context.getContentResolver(); - mIsCreate = false; // 从数据库来的,不是新建 - loadFromCursor(c); // 用传进来的cursor填充数据 + mIsCreate = false; + loadFromCursor(c); mDataList = new ArrayList(); if (mType == Notes.TYPE_NOTE) - loadDataContent(); // 如果是笔记类型,还得把它的具体内容也加载进来 + loadDataContent(); mDiffNoteValues = new ContentValues(); } - /** - * @Author: 林迪文 - * @Updator: 林迪文 - * @Date: 2025/12/16 16:54 - * @Description: 根据一个笔记ID来构造对象,会触发一次数据库查询。 - */ public SqlNote(Context context, long id) { mContext = context; mContentResolver = context.getContentResolver(); mIsCreate = false; - loadFromCursor(id); // 这个会去查数据库 + loadFromCursor(id); mDataList = new ArrayList(); if (mType == Notes.TYPE_NOTE) loadDataContent(); mDiffNoteValues = new ContentValues(); } + private void loadFromCursor(long id) { Cursor c = null; try { - // 用ContentResolver去查询指定ID的笔记 c = mContentResolver.query(Notes.CONTENT_NOTE_URI, PROJECTION_NOTE, "(_id=?)", new String[] { - String.valueOf(id) + String.valueOf(id) }, null); if (c != null) { c.moveToNext(); - loadFromCursor(c); // 把查到的cursor交给另一个重载方法处理 + loadFromCursor(c); } else { Log.w(TAG, "loadFromCursor: cursor = null"); } } finally { - // 这个很重要,不管前面有没有出错,只要cursor不是null,就必须关掉,防止内存泄漏 if (c != null) c.close(); } } private void loadFromCursor(Cursor c) { - // 把cursor当前行的数据,一个个取出来,塞到这个对象的成员变量里 mId = c.getLong(ID_COLUMN); mAlertDate = c.getLong(ALERTED_DATE_COLUMN); mBgColorId = c.getInt(BG_COLOR_ID_COLUMN); @@ -205,20 +202,18 @@ public class SqlNote { private void loadDataContent() { Cursor c = null; - mDataList.clear(); // 先清空,再加载 + mDataList.clear(); try { - // 查的是另一个URI,说明笔记内容是存在另一张表里的 c = mContentResolver.query(Notes.CONTENT_DATA_URI, SqlData.PROJECTION_DATA, "(note_id=?)", new String[] { - String.valueOf(mId) // 用当前笔记的ID作为查询条件 + String.valueOf(mId) }, null); if (c != null) { if (c.getCount() == 0) { - // 笔记没有内容数据,也正常 + Log.w(TAG, "it seems that the note has not data"); return; } while (c.moveToNext()) { - // 把每一行数据都包装成一个SqlData对象,然后加到List里 SqlData data = new SqlData(mContext, c); mDataList.add(data); } @@ -226,25 +221,18 @@ public class SqlNote { Log.w(TAG, "loadDataContent: cursor = null"); } } finally { - // 同样,用完cursor要及时关掉 if (c != null) c.close(); } } - /** - * @Author: 林迪文 - * @Updator: 林迪文 - * @Date: 2025/12/16 16:44 - * @Description: 把从云端同步下来的JSON数据解析,然后更新到这个笔记对象里。 - */ public boolean setContent(JSONObject js) { try { JSONObject note = js.getJSONObject(GTaskStringUtils.META_HEAD_NOTE); if (note.getInt(NoteColumns.TYPE) == Notes.TYPE_SYSTEM) { Log.w(TAG, "cannot set system folder"); } else if (note.getInt(NoteColumns.TYPE) == Notes.TYPE_FOLDER) { - // 文件夹只更新摘要和类型 + // for folder we can only update the snnipet and type String snippet = note.has(NoteColumns.SNIPPET) ? note .getString(NoteColumns.SNIPPET) : ""; if (mIsCreate || !mSnippet.equals(snippet)) { @@ -259,103 +247,108 @@ public class SqlNote { } mType = type; } else if (note.getInt(NoteColumns.TYPE) == Notes.TYPE_NOTE) { - // 如果是笔记类型,就把所有字段都更新一遍 JSONArray dataArray = js.getJSONArray(GTaskStringUtils.META_HEAD_DATA); - - // note.has() 是个好习惯,先判断有没有这个key,防止JSONException long id = note.has(NoteColumns.ID) ? note.getLong(NoteColumns.ID) : INVALID_ID; - if (mIsCreate || mId != id) { // 如果是新数据,或者数据有变化 - mDiffNoteValues.put(NoteColumns.ID, id); // 就把这个变化记录到mDiffNoteValues里 + if (mIsCreate || mId != id) { + mDiffNoteValues.put(NoteColumns.ID, id); } - mId = id; // 同时更新内存里的值 + mId = id; - long alertDate = note.has(NoteColumns.ALERTED_DATE) ? note.getLong(NoteColumns.ALERTED_DATE) : 0; + long alertDate = note.has(NoteColumns.ALERTED_DATE) ? note + .getLong(NoteColumns.ALERTED_DATE) : 0; if (mIsCreate || mAlertDate != alertDate) { mDiffNoteValues.put(NoteColumns.ALERTED_DATE, alertDate); } mAlertDate = alertDate; - int bgColorId = note.has(NoteColumns.BG_COLOR_ID) ? note.getInt(NoteColumns.BG_COLOR_ID) : ResourceParser.getDefaultBgId(mContext); + int bgColorId = note.has(NoteColumns.BG_COLOR_ID) ? note + .getInt(NoteColumns.BG_COLOR_ID) : ResourceParser.getDefaultBgId(mContext); if (mIsCreate || mBgColorId != bgColorId) { mDiffNoteValues.put(NoteColumns.BG_COLOR_ID, bgColorId); } mBgColorId = bgColorId; - long createDate = note.has(NoteColumns.CREATED_DATE) ? note.getLong(NoteColumns.CREATED_DATE) : System.currentTimeMillis(); + long createDate = note.has(NoteColumns.CREATED_DATE) ? note + .getLong(NoteColumns.CREATED_DATE) : System.currentTimeMillis(); if (mIsCreate || mCreatedDate != createDate) { mDiffNoteValues.put(NoteColumns.CREATED_DATE, createDate); } mCreatedDate = createDate; - int hasAttachment = note.has(NoteColumns.HAS_ATTACHMENT) ? note.getInt(NoteColumns.HAS_ATTACHMENT) : 0; + int hasAttachment = note.has(NoteColumns.HAS_ATTACHMENT) ? note + .getInt(NoteColumns.HAS_ATTACHMENT) : 0; if (mIsCreate || mHasAttachment != hasAttachment) { mDiffNoteValues.put(NoteColumns.HAS_ATTACHMENT, hasAttachment); } mHasAttachment = hasAttachment; - long modifiedDate = note.has(NoteColumns.MODIFIED_DATE) ? note.getLong(NoteColumns.MODIFIED_DATE) : System.currentTimeMillis(); + long modifiedDate = note.has(NoteColumns.MODIFIED_DATE) ? note + .getLong(NoteColumns.MODIFIED_DATE) : System.currentTimeMillis(); if (mIsCreate || mModifiedDate != modifiedDate) { mDiffNoteValues.put(NoteColumns.MODIFIED_DATE, modifiedDate); } mModifiedDate = modifiedDate; - long parentId = note.has(NoteColumns.PARENT_ID) ? note.getLong(NoteColumns.PARENT_ID) : 0; + long parentId = note.has(NoteColumns.PARENT_ID) ? note + .getLong(NoteColumns.PARENT_ID) : 0; if (mIsCreate || mParentId != parentId) { mDiffNoteValues.put(NoteColumns.PARENT_ID, parentId); } mParentId = parentId; - String snippet = note.has(NoteColumns.SNIPPET) ? note.getString(NoteColumns.SNIPPET) : ""; + String snippet = note.has(NoteColumns.SNIPPET) ? note + .getString(NoteColumns.SNIPPET) : ""; if (mIsCreate || !mSnippet.equals(snippet)) { mDiffNoteValues.put(NoteColumns.SNIPPET, snippet); } mSnippet = snippet; - int type = note.has(NoteColumns.TYPE) ? note.getInt(NoteColumns.TYPE) : Notes.TYPE_NOTE; + int type = note.has(NoteColumns.TYPE) ? note.getInt(NoteColumns.TYPE) + : Notes.TYPE_NOTE; if (mIsCreate || mType != type) { mDiffNoteValues.put(NoteColumns.TYPE, type); } mType = type; - int widgetId = note.has(NoteColumns.WIDGET_ID) ? note.getInt(NoteColumns.WIDGET_ID) : AppWidgetManager.INVALID_APPWIDGET_ID; + int widgetId = note.has(NoteColumns.WIDGET_ID) ? note.getInt(NoteColumns.WIDGET_ID) + : AppWidgetManager.INVALID_APPWIDGET_ID; if (mIsCreate || mWidgetId != widgetId) { mDiffNoteValues.put(NoteColumns.WIDGET_ID, widgetId); } mWidgetId = widgetId; - int widgetType = note.has(NoteColumns.WIDGET_TYPE) ? note.getInt(NoteColumns.WIDGET_TYPE) : Notes.TYPE_WIDGET_INVALIDE; + int widgetType = note.has(NoteColumns.WIDGET_TYPE) ? note + .getInt(NoteColumns.WIDGET_TYPE) : Notes.TYPE_WIDGET_INVALIDE; if (mIsCreate || mWidgetType != widgetType) { mDiffNoteValues.put(NoteColumns.WIDGET_TYPE, widgetType); } mWidgetType = widgetType; - long originParent = note.has(NoteColumns.ORIGIN_PARENT_ID) ? note.getLong(NoteColumns.ORIGIN_PARENT_ID) : 0; + long originParent = note.has(NoteColumns.ORIGIN_PARENT_ID) ? note + .getLong(NoteColumns.ORIGIN_PARENT_ID) : 0; if (mIsCreate || mOriginParent != originParent) { mDiffNoteValues.put(NoteColumns.ORIGIN_PARENT_ID, originParent); } mOriginParent = originParent; - // 处理笔记的具体内容数据 for (int i = 0; i < dataArray.length(); i++) { JSONObject data = dataArray.getJSONObject(i); SqlData sqlData = null; if (data.has(DataColumns.ID)) { long dataId = data.getLong(DataColumns.ID); - // 看看本地这条笔记是否已经有这个ID的内容了 for (SqlData temp : mDataList) { if (dataId == temp.getId()) { - sqlData = temp; // 找到了就用它,准备更新 + sqlData = temp; } } } if (sqlData == null) { - // 没找到,说明是新增的内容 sqlData = new SqlData(mContext); mDataList.add(sqlData); } - sqlData.setContent(data); // 把JSON数据设置到SqlData对象里 + sqlData.setContent(data); } } } catch (JSONException e) { @@ -366,25 +359,17 @@ public class SqlNote { return true; } - /** - * @Author: 林迪文 - * @Updator: 林迪文 - * @Date: 2025/12/16 16:04 - * @Description: 把这个笔记对象里的数据,打包成一个JSONObject,方便上传到云端。 - */ public JSONObject getContent() { try { JSONObject js = new JSONObject(); if (mIsCreate) { - // 如果还没在数据库里创建,就不能生成内容 Log.e(TAG, "it seems that we haven't created this in database yet"); return null; } JSONObject note = new JSONObject(); if (mType == Notes.TYPE_NOTE) { - // 把对象的各个属性,一个个put到JSONObject里 note.put(NoteColumns.ID, mId); note.put(NoteColumns.ALERTED_DATE, mAlertDate); note.put(NoteColumns.BG_COLOR_ID, mBgColorId); @@ -399,17 +384,15 @@ public class SqlNote { note.put(NoteColumns.ORIGIN_PARENT_ID, mOriginParent); js.put(GTaskStringUtils.META_HEAD_NOTE, note); - // 把笔记的具体内容也打包 JSONArray dataArray = new JSONArray(); for (SqlData sqlData : mDataList) { - JSONObject data = sqlData.getContent(); // 让SqlData自己去打包 + JSONObject data = sqlData.getContent(); if (data != null) { dataArray.put(data); } } js.put(GTaskStringUtils.META_HEAD_DATA, dataArray); } else if (mType == Notes.TYPE_FOLDER || mType == Notes.TYPE_SYSTEM) { - // 文件夹类型的数据就简单多了 note.put(NoteColumns.ID, mId); note.put(NoteColumns.TYPE, mType); note.put(NoteColumns.SNIPPET, mSnippet); @@ -423,23 +406,20 @@ public class SqlNote { } return null; } - // 更新父文件夹ID,同时记录到mDiffNoteValues里 + public void setParentId(long id) { mParentId = id; mDiffNoteValues.put(NoteColumns.PARENT_ID, id); } - // 更新Google Task的ID public void setGtaskId(String gid) { mDiffNoteValues.put(NoteColumns.GTASK_ID, gid); } - // 更新同步ID public void setSyncId(long syncId) { mDiffNoteValues.put(NoteColumns.SYNC_ID, syncId); } - // 重置本地修改标志位,通常在同步成功后调用 public void resetLocalModified() { mDiffNoteValues.put(NoteColumns.LOCAL_MODIFIED, 0); } @@ -456,28 +436,18 @@ public class SqlNote { return mSnippet; } - // 判断当前对象是不是一个笔记(而不是文件夹) public boolean isNoteType() { return mType == Notes.TYPE_NOTE; } - /** - * @Author: 林迪文 - * @Updator: 林迪文 - * @Date: 2025/12/17 8:05 - * @Description: 提交所有修改。这个是核心方法,负责把内存里的数据变动写入数据库。 - */ public void commit(boolean validateVersion) { if (mIsCreate) { - // 如果是新笔记,就执行插入操作 if (mId == INVALID_ID && mDiffNoteValues.containsKey(NoteColumns.ID)) { - // 如果ID是自己设的无效ID,就从待插入的数据里移除,让数据库自己生成 mDiffNoteValues.remove(NoteColumns.ID); } Uri uri = mContentResolver.insert(Notes.CONTENT_NOTE_URI, mDiffNoteValues); try { - // 从返回的uri里解析出新生成的笔记ID,这是ContentProvider的标准操作 mId = Long.valueOf(uri.getPathSegments().get(1)); } catch (NumberFormatException e) { Log.e(TAG, "Get note id error :" + e.toString()); @@ -487,33 +457,27 @@ public class SqlNote { throw new IllegalStateException("Create thread id failed"); } - // 把笔记的详细内容也跟着存进去 if (mType == Notes.TYPE_NOTE) { for (SqlData sqlData : mDataList) { sqlData.commit(mId, false, -1); } } } else { - // 如果是已存在的笔记,就执行更新操作 if (mId <= 0 && mId != Notes.ID_ROOT_FOLDER && mId != Notes.ID_CALL_RECORD_FOLDER) { - // 防御性编程,更新一个ID不合法的笔记肯定有问题 Log.e(TAG, "No such note"); throw new IllegalStateException("Try to update note with invalid id"); } if (mDiffNoteValues.size() > 0) { - mVersion ++; // 每次更新,版本号加1 + mVersion ++; int result = 0; if (!validateVersion) { - // 普通更新,直接拿ID去更新 result = mContentResolver.update(Notes.CONTENT_NOTE_URI, mDiffNoteValues, "(" + NoteColumns.ID + "=?)", new String[] { - String.valueOf(mId) + String.valueOf(mId) }); } else { - // 带版本校验的更新,只有当数据库里的版本号小于等于当前版本号时才更新成功 - // 这是为了防止本地的旧数据覆盖掉服务器上已经更新的数据,处理同步冲突用的 result = mContentResolver.update(Notes.CONTENT_NOTE_URI, mDiffNoteValues, "(" - + NoteColumns.ID + "=?) AND (" + NoteColumns.VERSION + "<=?)", + + NoteColumns.ID + "=?) AND (" + NoteColumns.VERSION + "<=?)", new String[] { String.valueOf(mId), String.valueOf(mVersion) }); @@ -530,12 +494,12 @@ public class SqlNote { } } - // 提交完之后,重新从数据库加载一次数据,保证内存里的对象和数据库完全同步 + // refresh local info loadFromCursor(mId); if (mType == Notes.TYPE_NOTE) loadDataContent(); - mDiffNoteValues.clear(); // 清空已变更的记录,为下次修改做准备 - mIsCreate = false; // 不管之前是不是新创建的,现在都已经存在于数据库里了 + mDiffNoteValues.clear(); + mIsCreate = false; } -} \ No newline at end of file +} diff --git a/src/src/net/micode/notes/gtask/data/Task.java b/src/src/net/micode/notes/gtask/data/Task.java index da1f06a..6a19454 100644 --- a/src/src/net/micode/notes/gtask/data/Task.java +++ b/src/src/net/micode/notes/gtask/data/Task.java @@ -9,7 +9,6 @@ * * 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. @@ -33,23 +32,21 @@ import org.json.JSONException; import org.json.JSONObject; -/** - * @Author: 林迪文 - * @Updator: 林迪文 - * @Date: 2025/12/20 16:45 - * @Description: 这个类代表Google Tasks里的一个具体任务。它负责把任务信息打包成JSON发给服务器,或者解析服务器返回的JSON。 - */ public class Task extends Node { private static final String TAG = Task.class.getSimpleName(); - private boolean mCompleted; // 任务是否完成 - private String mNotes; // 任务的备注信息 - private JSONObject mMetaInfo; // 从本地数据库读出来的元数据,JSON格式,同步时很有用 - private Task mPriorSibling; // 指向上一个任务,Google Tasks用这个来排序 - private TaskList mParent; // 这个任务属于哪个TaskList + private boolean mCompleted; + + private String mNotes; + + private JSONObject mMetaInfo; + + private Task mPriorSibling; + + private TaskList mParent; public Task() { - super(); // 用父类的构造函数完成一些基础初始化 + super(); mCompleted = false; mNotes = null; mPriorSibling = null; @@ -57,44 +54,42 @@ public class Task extends Node { mMetaInfo = null; } - /** - 当要创建一个新任务时,用这个方法来生成发给服务器的JSON数据包。 - */ public JSONObject getCreateAction(int actionId) { JSONObject js = new JSONObject(); try { - // action_type: 告诉服务器,咱们这次是要“创建” + // action_type js.put(GTaskStringUtils.GTASK_JSON_ACTION_TYPE, GTaskStringUtils.GTASK_JSON_ACTION_TYPE_CREATE); - // action_id: 本次操作的唯一ID + // action_id js.put(GTaskStringUtils.GTASK_JSON_ACTION_ID, actionId); - // index: 这个新任务在列表里的位置 + // index js.put(GTaskStringUtils.GTASK_JSON_INDEX, mParent.getChildTaskIndex(this)); - // entity_delta: 这里面放的是任务本身的核心数据 + // entity_delta JSONObject entity = new JSONObject(); - entity.put(GTaskStringUtils.GTASK_JSON_NAME, getName()); // 任务名 + entity.put(GTaskStringUtils.GTASK_JSON_NAME, getName()); entity.put(GTaskStringUtils.GTASK_JSON_CREATOR_ID, "null"); entity.put(GTaskStringUtils.GTASK_JSON_ENTITY_TYPE, GTaskStringUtils.GTASK_JSON_TYPE_TASK); if (getNotes() != null) { - entity.put(GTaskStringUtils.GTASK_JSON_NOTES, getNotes()); // 任务备注 + entity.put(GTaskStringUtils.GTASK_JSON_NOTES, getNotes()); } js.put(GTaskStringUtils.GTASK_JSON_ENTITY_DELTA, entity); - // parent_id: 告诉服务器,这个任务要创建在哪个任务列表下面 + // parent_id js.put(GTaskStringUtils.GTASK_JSON_PARENT_ID, mParent.getGid()); + // dest_parent_type js.put(GTaskStringUtils.GTASK_JSON_DEST_PARENT_TYPE, GTaskStringUtils.GTASK_JSON_TYPE_GROUP); - // list_id: 任务列表的ID + // list_id js.put(GTaskStringUtils.GTASK_JSON_LIST_ID, mParent.getGid()); - // prior_sibling_id: 它的前一个任务是谁,服务器靠这个来排序 + // prior_sibling_id if (mPriorSibling != null) { js.put(GTaskStringUtils.GTASK_JSON_PRIOR_SIBLING_ID, mPriorSibling.getGid()); } @@ -108,24 +103,21 @@ public class Task extends Node { return js; } - /** - 和上面那个对应,这个是用来生成“更新”任务的JSON数据包。 - */ public JSONObject getUpdateAction(int actionId) { JSONObject js = new JSONObject(); try { - // 这次是“更新”操作 + // action_type js.put(GTaskStringUtils.GTASK_JSON_ACTION_TYPE, GTaskStringUtils.GTASK_JSON_ACTION_TYPE_UPDATE); - // 本次操作的ID + // action_id js.put(GTaskStringUtils.GTASK_JSON_ACTION_ID, actionId); - // id: 必须告诉服务器要更新的是哪个任务 + // id js.put(GTaskStringUtils.GTASK_JSON_ID, getGid()); - // entity_delta: 存放变化了的数据 + // entity_delta JSONObject entity = new JSONObject(); entity.put(GTaskStringUtils.GTASK_JSON_NAME, getName()); if (getNotes() != null) { @@ -142,38 +134,36 @@ public class Task extends Node { return js; } - /** - 用从Google服务器返回的JSON数据,来设置Task对象的属性。 - */ + public void setContentByRemoteJSON(JSONObject js) { if (js != null) { try { - // id: 服务器分配的唯一ID + // id if (js.has(GTaskStringUtils.GTASK_JSON_ID)) { setGid(js.getString(GTaskStringUtils.GTASK_JSON_ID)); } - // last_modified: 最后修改时间,这个是同步的关键 + // last_modified if (js.has(GTaskStringUtils.GTASK_JSON_LAST_MODIFIED)) { setLastModified(js.getLong(GTaskStringUtils.GTASK_JSON_LAST_MODIFIED)); } - // 任务名 + // name if (js.has(GTaskStringUtils.GTASK_JSON_NAME)) { setName(js.getString(GTaskStringUtils.GTASK_JSON_NAME)); } - // 任务备注 + // notes if (js.has(GTaskStringUtils.GTASK_JSON_NOTES)) { setNotes(js.getString(GTaskStringUtils.GTASK_JSON_NOTES)); } - // 是否被删除 + // deleted if (js.has(GTaskStringUtils.GTASK_JSON_DELETED)) { setDeleted(js.getBoolean(GTaskStringUtils.GTASK_JSON_DELETED)); } - // 是否已完成 + // completed if (js.has(GTaskStringUtils.GTASK_JSON_COMPLETED)) { setCompleted(js.getBoolean(GTaskStringUtils.GTASK_JSON_COMPLETED)); } @@ -185,18 +175,13 @@ public class Task extends Node { } } - /** - 这个是把我们自己本地数据库存的JSON,解析了来设置Task的属性。 - */ public void setContentByLocalJSON(JSONObject js) { if (js == null || !js.has(GTaskStringUtils.META_HEAD_NOTE) || !js.has(GTaskStringUtils.META_HEAD_DATA)) { Log.w(TAG, "setContentByLocalJSON: nothing is avaiable"); - return; } try { - // 本地JSON的结构和服务器返回的不一样,有note头和data头 JSONObject note = js.getJSONObject(GTaskStringUtils.META_HEAD_NOTE); JSONArray dataArray = js.getJSONArray(GTaskStringUtils.META_HEAD_DATA); @@ -205,12 +190,11 @@ public class Task extends Node { return; } - // 遍历data数组,找到真正存笔记内容的那一项,把它的内容作为任务名 for (int i = 0; i < dataArray.length(); i++) { JSONObject data = dataArray.getJSONObject(i); if (TextUtils.equals(data.getString(DataColumns.MIME_TYPE), DataConstants.NOTE)) { setName(data.getString(DataColumns.CONTENT)); - break; // 找到了就不用再找了 + break; } } @@ -219,41 +203,34 @@ public class Task extends Node { e.printStackTrace(); } } - /** - * @Author: 林迪文 - * @Updator: 林迪文 - * @Date: 2025/12/23 16:45 - * @Description: 把Task对象里的数据,转换成要存到本地数据库的那种JSON格式。 - */ + public JSONObject getLocalJSONFromContent() { String name = getName(); try { if (mMetaInfo == null) { - // 如果mMetaInfo是空的,说明这是一个从服务器上新拉下来的任务,本地还没有它的记录 + // new task created from web if (name == null) { Log.w(TAG, "the note seems to be an empty one"); return null; } - // 手动创建一个符合本地格式的JSON对象 JSONObject js = new JSONObject(); JSONObject note = new JSONObject(); JSONArray dataArray = new JSONArray(); JSONObject data = new JSONObject(); - data.put(DataColumns.CONTENT, name); // 把任务名存到content字段 + data.put(DataColumns.CONTENT, name); dataArray.put(data); js.put(GTaskStringUtils.META_HEAD_DATA, dataArray); note.put(NoteColumns.TYPE, Notes.TYPE_NOTE); js.put(GTaskStringUtils.META_HEAD_NOTE, note); return js; } else { - // 如果mMetaInfo不是空,说明这个任务之前就在本地存过,我们直接在旧数据上修改就行 + // synced task JSONObject note = mMetaInfo.getJSONObject(GTaskStringUtils.META_HEAD_NOTE); JSONArray dataArray = mMetaInfo.getJSONArray(GTaskStringUtils.META_HEAD_DATA); for (int i = 0; i < dataArray.length(); i++) { JSONObject data = dataArray.getJSONObject(i); - // 找到存内容的地方,用最新的任务名把它覆盖掉 if (TextUtils.equals(data.getString(DataColumns.MIME_TYPE), DataConstants.NOTE)) { data.put(DataColumns.CONTENT, getName()); break; @@ -270,7 +247,6 @@ public class Task extends Node { } } - // 把从数据库查出来的原始JSON存到mMetaInfo里 public void setMetaInfo(MetaData metaData) { if (metaData != null && metaData.getNotes() != null) { try { @@ -282,61 +258,48 @@ public class Task extends Node { } } - /** - 这是同步的核心!比较本地数据和远程数据,决定下一步该干啥。 - */ public int getSyncAction(Cursor c) { try { JSONObject noteInfo = null; - // mMetaInfo 是从本地数据库的笔记内容里解析出来的JSON if (mMetaInfo != null && mMetaInfo.has(GTaskStringUtils.META_HEAD_NOTE)) { noteInfo = mMetaInfo.getJSONObject(GTaskStringUtils.META_HEAD_NOTE); } - // 第一道检查:看元数据还在不在 + if (noteInfo == null) { Log.w(TAG, "it seems that note meta has been deleted"); - // 如果元数据没了,说明本地记录可能不完整了,干脆把当前任务的信息更新到服务器上,防止数据丢失。 return SYNC_ACTION_UPDATE_REMOTE; } - // 第二道检查:看元数据里有没有ID if (!noteInfo.has(NoteColumns.ID)) { Log.w(TAG, "remote note id seems to be deleted"); - // 元数据里连ID都没有,这数据肯定坏了。那就用服务器上的版本把它覆盖掉,修复一下。 return SYNC_ACTION_UPDATE_LOCAL; } - // 第三道检查:核对ID是否一致 - // 确认一下元数据里的ID和数据库游标(Cursor)里的ID是不是同一个。 + // validate the note id now if (c.getLong(SqlNote.ID_COLUMN) != noteInfo.getLong(NoteColumns.ID)) { Log.w(TAG, "note id doesn't match"); - // 如果对不上,说明数据乱了,本地记录不可信,也得用服务器的来覆盖。 return SYNC_ACTION_UPDATE_LOCAL; } - // --- 核心逻辑开始 --- - // 如果上面的检查都通过了,说明本地数据是基本健康的,可以开始比较时间戳了 if (c.getInt(SqlNote.LOCAL_MODIFIED_COLUMN) == 0) { - // Case 1: 本地数据从上次同步以来,没被修改过 + // there is no local update if (c.getLong(SqlNote.SYNC_ID_COLUMN) == getLastModified()) { - // 两边的时间戳一样,说明都没变,啥也不用干 + // no update both side return SYNC_ACTION_NONE; } else { - // 服务器上的时间戳更新,说明服务器上有新版本,应该用服务器的数据覆盖本地 + // apply remote to local return SYNC_ACTION_UPDATE_LOCAL; } } else { - // Case 2: 本地数据被修改了 - // 在这里再确认下gtask id,双重保险 + // validate gtask id if (!c.getString(SqlNote.GTASK_ID_COLUMN).equals(getGid())) { Log.e(TAG, "gtask id doesn't match"); return SYNC_ACTION_ERROR; } if (c.getLong(SqlNote.SYNC_ID_COLUMN) == getLastModified()) { - // 只有本地改了,服务器没动,那就把本地的修改推到服务器 + // local modification only return SYNC_ACTION_UPDATE_REMOTE; } else { - // 两边都改了!这就是冲突,需要特殊处理 return SYNC_ACTION_UPDATE_CONFLICT; } } @@ -345,16 +308,14 @@ public class Task extends Node { e.printStackTrace(); } - // 如果中间出了任何岔子,就返回错误状态 return SYNC_ACTION_ERROR; } - // 判断这个任务有没有实际内容,免得存一堆空任务 + public boolean isWorthSaving() { return mMetaInfo != null || (getName() != null && getName().trim().length() > 0) || (getNotes() != null && getNotes().trim().length() > 0); } - // 下面都是些简单的get和set方法 public void setCompleted(boolean completed) { this.mCompleted = completed; } @@ -386,4 +347,5 @@ public class Task extends Node { public TaskList getParent() { return this.mParent; } -} \ No newline at end of file + +} diff --git a/src/src/net/micode/notes/gtask/data/TaskList.java b/src/src/net/micode/notes/gtask/data/TaskList.java index 5c11a72..4ea21c5 100644 --- a/src/src/net/micode/notes/gtask/data/TaskList.java +++ b/src/src/net/micode/notes/gtask/data/TaskList.java @@ -30,46 +30,37 @@ import org.json.JSONObject; import java.util.ArrayList; -/** - * @Author: 林迪文 - * @Updator: 林迪文 - * @Date: 2025/12/23 17:15 - * @Description: 这个类代表Google Tasks里的一个“任务列表”,在咱们App里对应的就是“文件夹”。它管理着一堆Task对象。 - */ public class TaskList extends Node { - // 日志TAG private static final String TAG = TaskList.class.getSimpleName(); - private int mIndex; // 任务列表的索引位置 + private int mIndex; - private ArrayList mChildren; // 用一个List来存放这个列表下的所有任务 + private ArrayList mChildren; public TaskList() { - super(); // 调用父类的构造函数 - mChildren = new ArrayList(); // new一个list出来,准备装东西 + super(); + mChildren = new ArrayList(); mIndex = 1; } - // 生成'创建'任务列表的JSON数据包,发给服务器用 public JSONObject getCreateAction(int actionId) { JSONObject js = new JSONObject(); try { - // action_type: 告诉服务器,操作类型是“创建” + // action_type js.put(GTaskStringUtils.GTASK_JSON_ACTION_TYPE, GTaskStringUtils.GTASK_JSON_ACTION_TYPE_CREATE); - // action_id: 本次操作的唯一ID + // action_id js.put(GTaskStringUtils.GTASK_JSON_ACTION_ID, actionId); - // index: 任务列表的位置 + // index js.put(GTaskStringUtils.GTASK_JSON_INDEX, mIndex); - // entity_delta: 存放这个任务列表的核心信息 + // entity_delta JSONObject entity = new JSONObject(); entity.put(GTaskStringUtils.GTASK_JSON_NAME, getName()); entity.put(GTaskStringUtils.GTASK_JSON_CREATOR_ID, "null"); - // 注意,这里的类型是GROUP,和Task的TASK不一样 entity.put(GTaskStringUtils.GTASK_JSON_ENTITY_TYPE, GTaskStringUtils.GTASK_JSON_TYPE_GROUP); js.put(GTaskStringUtils.GTASK_JSON_ENTITY_DELTA, entity); @@ -83,22 +74,21 @@ public class TaskList extends Node { return js; } - // 生成'更新'任务列表的JSON数据包 public JSONObject getUpdateAction(int actionId) { JSONObject js = new JSONObject(); try { - // action_type: 这次是“更新” + // action_type js.put(GTaskStringUtils.GTASK_JSON_ACTION_TYPE, GTaskStringUtils.GTASK_JSON_ACTION_TYPE_UPDATE); - // action_id: 本次操作的ID + // action_id js.put(GTaskStringUtils.GTASK_JSON_ACTION_ID, actionId); - // id: 告诉服务器要更新的是哪一个 + // id js.put(GTaskStringUtils.GTASK_JSON_ID, getGid()); - // entity_delta: 存放变化了的数据,比如名字改了,或者被删了 + // entity_delta JSONObject entity = new JSONObject(); entity.put(GTaskStringUtils.GTASK_JSON_NAME, getName()); entity.put(GTaskStringUtils.GTASK_JSON_DELETED, getDeleted()); @@ -112,21 +102,21 @@ public class TaskList extends Node { return js; } - // 用服务器返回的JSON数据,填充任务列表的属性 + public void setContentByRemoteJSON(JSONObject js) { if (js != null) { try { - // id: 服务器分配的唯一ID + // id if (js.has(GTaskStringUtils.GTASK_JSON_ID)) { setGid(js.getString(GTaskStringUtils.GTASK_JSON_ID)); } - // last_modified: 最后修改时间,同步的关键 + // last_modified if (js.has(GTaskStringUtils.GTASK_JSON_LAST_MODIFIED)) { setLastModified(js.getLong(GTaskStringUtils.GTASK_JSON_LAST_MODIFIED)); } - // name: 任务列表的名字 + // name if (js.has(GTaskStringUtils.GTASK_JSON_NAME)) { setName(js.getString(GTaskStringUtils.GTASK_JSON_NAME)); } @@ -139,9 +129,7 @@ public class TaskList extends Node { } } - // 用本地数据库里的JSON数据,填充任务列表的属性 public void setContentByLocalJSON(JSONObject js) { - // 先做个防御性编程,检查传入的js是否有效 if (js == null || !js.has(GTaskStringUtils.META_HEAD_NOTE)) { Log.w(TAG, "setContentByLocalJSON: nothing is avaiable"); } @@ -149,12 +137,10 @@ public class TaskList extends Node { try { JSONObject folder = js.getJSONObject(GTaskStringUtils.META_HEAD_NOTE); - // 根据本地文件夹的类型来设置任务列表的名字,这里有个特殊的"MIUI_"前缀,应该是为了区分 if (folder.getInt(NoteColumns.TYPE) == Notes.TYPE_FOLDER) { String name = folder.getString(NoteColumns.SNIPPET); setName(GTaskStringUtils.MIUI_FOLDER_PREFFIX + name); } else if (folder.getInt(NoteColumns.TYPE) == Notes.TYPE_SYSTEM) { - // 系统文件夹要做特殊判断,根据ID来区分是哪个 if (folder.getLong(NoteColumns.ID) == Notes.ID_ROOT_FOLDER) setName(GTaskStringUtils.MIUI_FOLDER_PREFFIX + GTaskStringUtils.FOLDER_DEFAULT); else if (folder.getLong(NoteColumns.ID) == Notes.ID_CALL_RECORD_FOLDER) @@ -171,19 +157,16 @@ public class TaskList extends Node { } } - // 反过来,把任务列表对象转换成本地数据库要存的JSON格式 public JSONObject getLocalJSONFromContent() { try { JSONObject js = new JSONObject(); JSONObject folder = new JSONObject(); String folderName = getName(); - // 如果名字里有约定的前缀,就把它去掉再存,保证本地数据是干净的 if (getName().startsWith(GTaskStringUtils.MIUI_FOLDER_PREFFIX)) folderName = folderName.substring(GTaskStringUtils.MIUI_FOLDER_PREFFIX.length(), folderName.length()); folder.put(NoteColumns.SNIPPET, folderName); - // 根据名字判断是系统文件夹还是普通文件夹,然后设置正确的类型 if (folderName.equals(GTaskStringUtils.FOLDER_DEFAULT) || folderName.equals(GTaskStringUtils.FOLDER_CALL_NOTE)) folder.put(NoteColumns.TYPE, Notes.TYPE_SYSTEM); @@ -199,30 +182,29 @@ public class TaskList extends Node { return null; } } - // 比较本地和远程数据,决定同步策略 + public int getSyncAction(Cursor c) { try { if (c.getInt(SqlNote.LOCAL_MODIFIED_COLUMN) == 0) { - // Case 1: 本地数据从上次同步以来,没被修改过 + // there is no local update if (c.getLong(SqlNote.SYNC_ID_COLUMN) == getLastModified()) { - // 两边的时间戳一样,说明都没动,啥也不用干 + // no update both side return SYNC_ACTION_NONE; } else { - // 服务器数据更新了,用服务器的覆盖本地 + // apply remote to local return SYNC_ACTION_UPDATE_LOCAL; } } else { - // Case 2: 本地数据被修改了 - // 先做个安全检查,看gid对不对得上 + // validate gtask id if (!c.getString(SqlNote.GTASK_ID_COLUMN).equals(getGid())) { Log.e(TAG, "gtask id doesn't match"); return SYNC_ACTION_ERROR; } if (c.getLong(SqlNote.SYNC_ID_COLUMN) == getLastModified()) { - // 只有本地改了,服务器没动,那就把本地的修改推到服务器 + // local modification only return SYNC_ACTION_UPDATE_REMOTE; } else { - // 发生冲突,两边都改了。这里的策略是直接用本地的覆盖服务器,简单粗暴但有效 + // for folder conflicts, just apply local modification return SYNC_ACTION_UPDATE_REMOTE; } } @@ -238,13 +220,12 @@ public class TaskList extends Node { return mChildren.size(); } - // 添加一个子任务到列表末尾 public boolean addChildTask(Task task) { boolean ret = false; - if (task != null && !mChildren.contains(task)) { // 避免空指针和重复添加 + if (task != null && !mChildren.contains(task)) { ret = mChildren.add(task); if (ret) { - // 加到列表里只是第一步,关键是要维护好任务之间的前后关系(priorSibling)和父子关系(parent) + // need to set prior sibling and parent task.setPriorSibling(mChildren.isEmpty() ? null : mChildren .get(mChildren.size() - 1)); task.setParent(this); @@ -253,7 +234,6 @@ public class TaskList extends Node { return ret; } - // 在指定位置插入一个子任务 public boolean addChildTask(Task task, int index) { if (index < 0 || index > mChildren.size()) { Log.e(TAG, "add child task: invalid index"); @@ -261,10 +241,10 @@ public class TaskList extends Node { } int pos = mChildren.indexOf(task); - if (task != null && pos == -1) { // 同样,任务不能为null,也不能已存在 + if (task != null && pos == -1) { mChildren.add(index, task); - // 插入后,需要更新新任务和它后面任务的前后关系,把链条接好 + // update the task list Task preTask = null; Task afterTask = null; if (index != 0) @@ -280,19 +260,18 @@ public class TaskList extends Node { return true; } - // 移除一个子任务 public boolean removeChildTask(Task task) { boolean ret = false; int index = mChildren.indexOf(task); - if (index != -1) { // 必须先找到这个任务 + if (index != -1) { ret = mChildren.remove(task); if (ret) { - // 移除后,也要把它的父子关系和前后关系都断开,让它变成一个“孤儿” + // reset prior sibling and parent task.setPriorSibling(null); task.setParent(null); - // 并且把断开的链条重新接上,让它后面的任务指向它前面的任务 + // update the task list if (index != mChildren.size()) { mChildren.get(index).setPriorSibling( index == 0 ? null : mChildren.get(index - 1)); @@ -302,28 +281,24 @@ public class TaskList extends Node { return ret; } - // 移动一个子任务到新的位置 public boolean moveChildTask(Task task, int index) { - // 边界条件检查,防止数组越界 + if (index < 0 || index >= mChildren.size()) { Log.e(TAG, "move child task: invalid index"); return false; } int pos = mChildren.indexOf(task); - if (pos == -1) { // 要移动的任务必须得在列表里 + if (pos == -1) { Log.e(TAG, "move child task: the task should in the list"); return false; } - // 如果位置没变,就不用折腾了 if (pos == index) return true; - // 这个实现很巧妙,移动操作 = 先移除 + 再插入,代码复用得很好 return (removeChildTask(task) && addChildTask(task, index)); } - // 通过Gid找到一个子任务 public Task findChildTaskByGid(String gid) { for (int i = 0; i < mChildren.size(); i++) { Task t = mChildren.get(i); @@ -346,7 +321,6 @@ public class TaskList extends Node { return mChildren.get(index); } - // 这个命名不太规范,Chil应该是Child,建议改成findChildTaskByGid,和上面那个方法统一 public Task getChilTaskByGid(String gid) { for (Task task : mChildren) { if (task.getGid().equals(gid)) @@ -366,4 +340,4 @@ public class TaskList extends Node { public int getIndex() { return this.mIndex; } -} \ No newline at end of file +} diff --git a/src/src/net/micode/notes/gtask/exception/ActionFailureException.java b/src/src/net/micode/notes/gtask/exception/ActionFailureException.java index d3aed1b..15504be 100644 --- a/src/src/net/micode/notes/gtask/exception/ActionFailureException.java +++ b/src/src/net/micode/notes/gtask/exception/ActionFailureException.java @@ -16,27 +16,18 @@ package net.micode.notes.gtask.exception; -/** - * @Author: 林迪文 - * @Updator: 林迪文 - * @Date: 2025/12/20 16:10 - * @Description: 定义一个自定义的运行时异常,用来表示某个操作执行失败的情况。 - * 尤其是在处理Google Task相关的逻辑时,如果某个操作没有成功,就可以抛出这个异常。 - */ public class ActionFailureException extends RuntimeException { - // 这个序列化ID主要是为了在序列化和反序列化时,能够保持类版本的兼容性。 - // 虽然是运行时异常,但加了也更规范,预防以后万一需要序列化这个异常对象。 private static final long serialVersionUID = 4425249765923293627L; public ActionFailureException() { - super(); // 调用父类RuntimeException的无参构造方法 + super(); } public ActionFailureException(String paramString) { - super(paramString); // 调用父类RuntimeException的带消息参数的构造方法 + super(paramString); } public ActionFailureException(String paramString, Throwable paramThrowable) { - super(paramString, paramThrowable); // 与上面类似 + super(paramString, paramThrowable); } -} \ No newline at end of file +} diff --git a/src/src/net/micode/notes/gtask/exception/NetworkFailureException.java b/src/src/net/micode/notes/gtask/exception/NetworkFailureException.java index 7ba8ac6..b08cfb1 100644 --- a/src/src/net/micode/notes/gtask/exception/NetworkFailureException.java +++ b/src/src/net/micode/notes/gtask/exception/NetworkFailureException.java @@ -3,7 +3,6 @@ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with 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 @@ -17,28 +16,18 @@ package net.micode.notes.gtask.exception; -/** - * @Author: 林迪文 - * @Updator: 林迪文 - * @Date: 2025/12/19 20:10 - * @Description: 定义一个用于表示网络失败的异常。 - */ public class NetworkFailureException extends Exception { - // 序列化版本ID private static final long serialVersionUID = 2107610287180234136L; - // 下面是几个重载的构造方法,它们最终都是调用父类 Exception 的构造方法来完成初始化 public NetworkFailureException() { super(); } - // 只允许传入一个描述性的错误信息字符串 public NetworkFailureException(String paramString) { - super(paramString); // 把错误信息传给父类 + super(paramString); } - // 不仅能提供错误信息,还能把原始异常也包装进来。 public NetworkFailureException(String paramString, Throwable paramThrowable) { super(paramString, paramThrowable); } -} \ No newline at end of file +} diff --git a/src/src/net/micode/notes/gtask/remote/GTaskASyncTask.java b/src/src/net/micode/notes/gtask/remote/GTaskASyncTask.java index 19f6904..4454a1a 100644 --- a/src/src/net/micode/notes/gtask/remote/GTaskASyncTask.java +++ b/src/src/net/micode/notes/gtask/remote/GTaskASyncTask.java @@ -1,10 +1,10 @@ + /* * 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 + * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * @@ -28,60 +28,43 @@ import net.micode.notes.R; import net.micode.notes.ui.NotesListActivity; import net.micode.notes.ui.NotesPreferenceActivity; -/** - * @Author: 林迪文 - * @Updator: 林迪文 - * @Date: 2025/12/21 16:10 - * @Description: 这是一个专门用来和 Google Tasks 进行同步的后台任务类。 - * 继承了 AsyncTask,专门用来处理耗时操作。 - * 把网络请求放到一个单独的线程里去做,这样就不会卡住主界面(UI线程)。 - */ + public class GTaskASyncTask extends AsyncTask { - // 给同步时弹出的通知栏消息一个唯一的ID,这样后面可以根据这个ID来更新或者取消它 private static int GTASK_SYNC_NOTIFICATION_ID = 5234235; - // 定义一个回调接口,当整个同步任务完成时,会通过这个接口通知调用者 public interface OnCompleteListener { void onComplete(); } - private Context mContext; // 保存一个上下文引用,用来访问系统资源 - private NotificationManager mNotifiManager; // 用来发通知栏消息 - private GTaskManager mTaskManager; // 负责执行同步逻辑的管理类 - private OnCompleteListener mOnCompleteListener; // 任务完成后的回调监听器 + private Context mContext; + + private NotificationManager mNotifiManager; + + private GTaskManager mTaskManager; + + private OnCompleteListener mOnCompleteListener; public GTaskASyncTask(Context context, OnCompleteListener listener) { mContext = context; mOnCompleteListener = listener; - // 从系统服务里拿到 NotificationManager 的实例 mNotifiManager = (NotificationManager) mContext .getSystemService(Context.NOTIFICATION_SERVICE); - // 保证整个App里只有一个实例在工作 mTaskManager = GTaskManager.getInstance(); } - // 提供一个公开的方法,让外部可以取消正在进行的同步任务 public void cancelSync() { mTaskManager.cancelSync(); } - // 这是一个自定义的工具方法,方便在后台任务里调用,用来发布进度 public void publishProgess(String message) { - // 调用 AsyncTask 自带的 publishProgress 方法,它会触发 onProgressUpdate publishProgress(new String[] { - message + message }); } - /** - * @Description: 封装了创建和显示通知栏消息的逻辑。 - * @param tickerId 状态栏上滚动的提示文字的资源ID - * @param content 通知栏里显示的详细内容 - */ private void showNotification(int tickerId, String content) { PendingIntent pendingIntent; - // 这里有个逻辑判断:如果同步成功,点击通知就跳到笔记列表;如果失败了,就跳到设置页面,方便用户检查账户设置 if (tickerId != R.string.ticker_success) { pendingIntent = PendingIntent.getActivity(mContext, 0, new Intent(mContext, NotesPreferenceActivity.class), PendingIntent.FLAG_IMMUTABLE); @@ -90,36 +73,28 @@ public class GTaskASyncTask extends AsyncTask { NotesListActivity.class), PendingIntent.FLAG_IMMUTABLE); } - // 使用 Builder 模式来创建 Notification 对象 Notification.Builder builder = new Notification.Builder(mContext) - .setSmallIcon(R.drawable.notification) // 设置小图标 - .setTicker(mContext.getString(tickerId)) // 设置滚动提示 - .setContentTitle(mContext.getString(R.string.app_name)) // 设置标题 - .setContentText(content) // 设置内容 - .setContentIntent(pendingIntent) // 设置点击事件 + .setSmallIcon(R.drawable.notification) + .setTicker(mContext.getString(tickerId)) + .setContentTitle(mContext.getString(R.string.app_name)) + .setContentText(content) + .setContentIntent(pendingIntent) .setDefaults(Notification.DEFAULT_LIGHTS) - .setAutoCancel(true); // 点击后自动消失 + .setAutoCancel(true); - // 调用 notify 方法把通知发出去 mNotifiManager.notify(GTASK_SYNC_NOTIFICATION_ID, builder.build()); } @Override protected Integer doInBackground(Void... unused) { - // 这是 AsyncTask 的核心,这个方法里的代码会在后台线程执行 - // 先发布一个进度,告诉用户正在登录 publishProgess(mContext.getString(R.string.sync_progress_login, NotesPreferenceActivity .getSyncAccountName(mContext))); - // 调用 GTaskManager 的 sync 方法来执行真正的同步操作,并把结果返回 return mTaskManager.sync(mContext, this); } @Override protected void onProgressUpdate(String... progress) { - // 这个方法在UI线程执行,用来响应后台的 publishProgress 调用 - // 1. 用传过来的进度信息更新通知栏 showNotification(R.string.ticker_syncing, progress[0]); - // 2. 如果任务是从 GTaskSyncService 启动的,就发个广播出去,让其他地方也能收到进度更新 if (mContext instanceof GTaskSyncService) { ((GTaskSyncService) mContext).sendBroadcast(progress[0]); } @@ -127,12 +102,9 @@ public class GTaskASyncTask extends AsyncTask { @Override protected void onPostExecute(Integer result) { - // 当 doInBackground 执行完毕后,这个方法会在UI线程被调用 - // 根据返回的结果码,显示不同的通知,告诉用户同步是成功了还是失败了 if (result == GTaskManager.STATE_SUCCESS) { showNotification(R.string.ticker_success, mContext.getString( R.string.success_sync_account, mTaskManager.getSyncAccount())); - // 同步成功后,记录一下当前的同步时间 NotesPreferenceActivity.setLastSyncTime(mContext, System.currentTimeMillis()); } else if (result == GTaskManager.STATE_NETWORK_ERROR) { showNotification(R.string.ticker_fail, mContext.getString(R.string.error_sync_network)); @@ -142,15 +114,13 @@ public class GTaskASyncTask extends AsyncTask { showNotification(R.string.ticker_cancel, mContext .getString(R.string.error_sync_cancelled)); } - - // 不管同步结果如何,只要任务结束了,就调用onComplete 方法 if (mOnCompleteListener != null) { - // 开了一个新线程去执行 onComplete new Thread(new Runnable() { + public void run() { mOnCompleteListener.onComplete(); } }).start(); } } -} \ No newline at end of file +} diff --git a/src/src/net/micode/notes/gtask/remote/GTaskClient.java b/src/src/net/micode/notes/gtask/remote/GTaskClient.java index f843e10..c67dfdf 100644 --- a/src/src/net/micode/notes/gtask/remote/GTaskClient.java +++ b/src/src/net/micode/notes/gtask/remote/GTaskClient.java @@ -61,193 +61,145 @@ import java.util.zip.Inflater; import java.util.zip.InflaterInputStream; -/** - * @Author: 林迪文 - * @Updator: 林迪文 - * @Date 2025/12/21 19:20 - * @Description 这个类是专门跟Google Tasks服务器打交道的。登录、获取、修改便签数据,都靠它了。可以理解成一个封装好的网络请求工具。 - */ public class GTaskClient { - // 日志的标签,方便在Logcat里按这个名字过滤,只看这个类相关的日志 private static final String TAG = GTaskClient.class.getSimpleName(); - // Google Tasks的基础网址 private static final String GTASK_URL = "https://mail.google.com/tasks/"; - // 获取数据的GET请求地址 private static final String GTASK_GET_URL = "https://mail.google.com/tasks/ig"; - // 提交数据的POST请求地址 private static final String GTASK_POST_URL = "https://mail.google.com/tasks/r/ig"; - // 单例模式,保证整个App里只有一个GTaskClient实例 private static GTaskClient mInstance = null; - // 用来发HTTP请求的客户端,是Apache提供的库 private DefaultHttpClient mHttpClient; - // 实际使用的GET地址,可能会根据邮箱域名变化 private String mGetUrl; - // 实际使用的POST地址 private String mPostUrl; - // 客户端版本号。发请求时得带上,服务器要根据这个判断 private long mClientVersion; - // 登录状态的标记 private boolean mLoggedin; - // 上次登录成功的时间戳,用来判断登录状态是否过期 private long mLastLoginTime; - // 操作ID,每次操作递增,防止请求重复 private int mActionId; - // 当前登录的Google账户信息 private Account mAccount; - // 用来批量提交更新操作的JSON数组 private JSONArray mUpdateArray; - // 私有的构造方法,不让外面直接new,配合getInstance()实现单例 private GTaskClient() { mHttpClient = null; - mGetUrl = GTASK_GET_URL; // 默认用官方地址 + mGetUrl = GTASK_GET_URL; mPostUrl = GTASK_POST_URL; - mClientVersion = -1; // 默认-1,表示还没从服务器获取 - mLoggedin = false; // 默认未登录 + mClientVersion = -1; + mLoggedin = false; mLastLoginTime = 0; - mActionId = 1; // 从1开始 + mActionId = 1; mAccount = null; mUpdateArray = null; } - /** - * 获取GTaskClient的唯一实例 - */ public static synchronized GTaskClient getInstance() { - // synchronized关键字是用来防止多线程下重复创建实例的 if (mInstance == null) { mInstance = new GTaskClient(); } return mInstance; } - /** - * 核心的登录逻辑 - * @param activity 需要一个Activity作为上下文,给AccountManager用 - * @return 登录成功还是失败 - */ public boolean login(Activity activity) { - // 假设登录状态5分钟过期,超时了就需要重新登录,避免token失效 - final long interval = 1000 * 60 * 5; // 5分钟的毫秒数 + // we suppose that the cookie would expire after 5 minutes + // then we need to re-login + final long interval = 1000 * 60 * 5; if (mLastLoginTime + interval < System.currentTimeMillis()) { - mLoggedin = false; // 标记为未登录 + mLoggedin = false; } - // 如果用户在设置里切换了同步的Google账户,那之前的登录状态就作废了,也得重新登录 + // need to re-login after account switch if (mLoggedin && !TextUtils.equals(getSyncAccount().name, NotesPreferenceActivity - .getSyncAccountName(activity))) { + .getSyncAccountName(activity))) { mLoggedin = false; } - // 如果已经是登录状态,就没必要再走一遍登录流程了,直接返回成功 if (mLoggedin) { Log.d(TAG, "already logged in"); return true; } - mLastLoginTime = System.currentTimeMillis(); // 更新一下最后登录时间 - // 调用下面的方法去获取Google账户的认证令牌(token),这是第一步 + mLastLoginTime = System.currentTimeMillis(); String authToken = loginGoogleAccount(activity, false); if (authToken == null) { - Log.e(TAG, "login google account failed"); // 拿不到token,直接失败 + Log.e(TAG, "login google account failed"); return false; } - // 如果登录的邮箱不是标准的gmail.com或者googlemail.com,需要拼接一个特殊的URL + // login with custom domain if necessary if (!(mAccount.name.toLowerCase().endsWith("gmail.com") || mAccount.name.toLowerCase() .endsWith("googlemail.com"))) { StringBuilder url = new StringBuilder(GTASK_URL).append("a/"); int index = mAccount.name.indexOf('@') + 1; - String suffix = mAccount.name.substring(index); // 截取@后面的域名 + String suffix = mAccount.name.substring(index); url.append(suffix + "/"); - mGetUrl = url.toString() + "ig"; // 拼出新的请求地址 + mGetUrl = url.toString() + "ig"; mPostUrl = url.toString() + "r/ig"; - // 用这个定制的URL去尝试登录 if (tryToLoginGtask(activity, authToken)) { mLoggedin = true; } } - // 如果上面用定制URL没登录成功,或者压根就不是定制邮箱,就用官方标准URL再试一次 + // try to login with google official url if (!mLoggedin) { mGetUrl = GTASK_GET_URL; mPostUrl = GTASK_POST_URL; if (!tryToLoginGtask(activity, authToken)) { - return false; // 标准地址也失败了,那就是真的失败了 + return false; } } - mLoggedin = true; // 登录成功,打上标记 + mLoggedin = true; return true; } - /** - * 和Android系统的账户系统交互,获取指定Google账户的AuthToken - * @param activity 上下文 - * @param invalidateToken 是否需要让旧的token失效 - * @return 获取到的AuthToken字符串,失败则为null - */ private String loginGoogleAccount(Activity activity, boolean invalidateToken) { String authToken; - // 这是Android系统里专门管理各种账户的工具 AccountManager accountManager = AccountManager.get(activity); - // 按"com.google"这个类型,拿到手机上所有登录过的Google账户 Account[] accounts = accountManager.getAccountsByType("com.google"); if (accounts.length == 0) { - Log.e(TAG, "there is no available google account"); // 手机上一个Google账户都没有 + Log.e(TAG, "there is no available google account"); return null; } - // 从SharedPreferences里读取用户在设置页面选好的账户名 String accountName = NotesPreferenceActivity.getSyncAccountName(activity); Account account = null; - // 遍历手机里的所有Google账户 for (Account a : accounts) { - // 找到跟我们设置里存的账户名一样的那个 if (a.name.equals(accountName)) { account = a; break; } } if (account != null) { - mAccount = account; // 找到了就存到成员变量里 + mAccount = account; } else { - // 如果系统账户里找不到设置里存的那个名字,说明可能账户被移除了 Log.e(TAG, "unable to get an account with the same name in the settings"); return null; } - // 开始获取token,这是一个异步操作 + // get the token now AccountManagerFuture accountManagerFuture = accountManager.getAuthToken(account, - "goanna_mobile", null, activity, null, null); // "goanna_mobile"是Google Tasks服务的类型名 + "goanna_mobile", null, activity, null, null); try { - // getResult()会阻塞当前线程,直到异步操作有结果返回 Bundle authTokenBundle = accountManagerFuture.getResult(); authToken = authTokenBundle.getString(AccountManager.KEY_AUTHTOKEN); if (invalidateToken) { - // 如果需要,就让刚才拿到的这个token失效,然后递归调用自己,重新走一遍流程获取一个新的。 - // 这一般是在token过期认证失败后才需要做的。 accountManager.invalidateAuthToken("com.google", authToken); loginGoogleAccount(activity, false); } } catch (Exception e) { - // 获取token的过程中可能出现各种异常 Log.e(TAG, "get auth token failed"); authToken = null; } @@ -255,92 +207,72 @@ public class GTaskClient { return authToken; } - /** - * 用上一步拿到的token尝试登录Gtask。这里面有个重试机制,如果第一次失败了,会认为token过期了,然后让它失效再重新获取一次,再试。 - * @param activity 上下文 - * @param authToken 认证令牌 - * @return 登录成功返回true - */ private boolean tryToLoginGtask(Activity activity, String authToken) { - // 先直接用传进来的token试一次 if (!loginGtask(authToken)) { - // 如果失败了,很可能是token过期了。 - // 重新调用loginGoogleAccount,并且第二个参数传true,意思是让旧的token失效 + // maybe the auth token is out of date, now let's invalidate the + // token and try again authToken = loginGoogleAccount(activity, true); if (authToken == null) { - Log.e(TAG, "login google account failed"); // 重新获取token都失败了,那肯定不行 + Log.e(TAG, "login google account failed"); return false; } - // 用新拿到的token再试最后一次 if (!loginGtask(authToken)) { - Log.e(TAG, "login gtask failed"); // 换了新token还不行,那就是网络或者其他问题了 + Log.e(TAG, "login gtask failed"); return false; } } return true; } - /** - * 这是真正执行网络操作的地方。它会配置好HttpClient,发一个GET请求到Google服务器,拿到登录后的Cookie和后面操作要用的clientVersion。 - * @param authToken 认证令牌 - * @return 登录成功返回true - */ private boolean loginGtask(String authToken) { - int timeoutConnection = 10000; // 连接超时时间,10秒 - int timeoutSocket = 15000; // socket通信超时时间,15秒 + int timeoutConnection = 10000; + int timeoutSocket = 15000; HttpParams httpParameters = new BasicHttpParams(); - // 把超时设置放进参数里 HttpConnectionParams.setConnectionTimeout(httpParameters, timeoutConnection); HttpConnectionParams.setSoTimeout(httpParameters, timeoutSocket); - // DefaultHttpClient是发HTTP请求的主力军,把超时参数给它 mHttpClient = new DefaultHttpClient(httpParameters); - // 建一个存Cookie的地方。登录成功后服务器会给我们发Cookie,后面带着它访问就不用再登录了 BasicCookieStore localBasicCookieStore = new BasicCookieStore(); mHttpClient.setCookieStore(localBasicCookieStore); - // 这个ExpectContinue握手协议关掉,能提高点效率 HttpProtocolParams.setUseExpectContinue(mHttpClient.getParams(), false); - // 开始正式登录 + // login gtask try { - // 把token拼接到URL后面作为认证参数 String loginUrl = mGetUrl + "?auth=" + authToken; HttpGet httpGet = new HttpGet(loginUrl); HttpResponse response = null; - response = mHttpClient.execute(httpGet); // 发送请求 + response = mHttpClient.execute(httpGet); - // 从HttpClient里把服务器返回的Cookie拿出来 + // get the cookie now List cookies = mHttpClient.getCookieStore().getCookies(); boolean hasAuthCookie = false; - // 遍历所有Cookie,看看有没有名字里带"GTL"的 for (Cookie cookie : cookies) { if (cookie.getName().contains("GTL")) { - hasAuthCookie = true; // "GTL"是Google Tasks登录凭证的Cookie名 + hasAuthCookie = true; } } if (!hasAuthCookie) { - Log.w(TAG, "it seems that there is no auth cookie"); // 没拿到关键的Cookie,后面可能会有问题 + Log.w(TAG, "it seems that there is no auth cookie"); } - // 把响应内容读出来转成字符串,具体实现在下面的getResponseContent方法 + // get the client version String resString = getResponseContent(response.getEntity()); - // 返回的不是纯JSON,是一段网页代码,我们需要的数据在_setup()这个js函数里,得手动截取出来 String jsBegin = "_setup("; String jsEnd = ")}"; int begin = resString.indexOf(jsBegin); int end = resString.lastIndexOf(jsEnd); String jsString = null; if (begin != -1 && end != -1 && begin < end) { - jsString = resString.substring(begin + jsBegin.length(), end); // 截取中间的JSON部分 + jsString = resString.substring(begin + jsBegin.length(), end); } - JSONObject js = new JSONObject(jsString); // 把截出来的字符串转成JSON对象 - mClientVersion = js.getLong("v"); // 从JSON里把"v"这个key对应的值拿出来,这就是客户端版本号,后面发请求都要带上 + JSONObject js = new JSONObject(jsString); + mClientVersion = js.getLong("v"); } catch (JSONException e) { Log.e(TAG, e.toString()); e.printStackTrace(); return false; } catch (Exception e) { - // 网络异常 + // simply catch all exceptions Log.e(TAG, "httpget gtask_url failed"); return false; } @@ -348,132 +280,103 @@ public class GTaskClient { return true; } - /** - * 获取一个自增的操作ID - */ private int getActionId() { - return mActionId++; // 每次调用都加1,保证每个操作的ID不一样 + return mActionId++; } - /** - * 创建一个已经配置好请求头的HttpPost对象,方便复用 - */ private HttpPost createHttpPost() { HttpPost httpPost = new HttpPost(mPostUrl); - // 设置请求头,告诉服务器我们发的是表单数据,编码是UTF-8 httpPost.setHeader("Content-Type", "application/x-www-form-urlencoded;charset=utf-8"); - // AT: 1 这个头应该是Google Tasks API的特定要求 httpPost.setHeader("AT", "1"); return httpPost; } - /** - * @Description 从HTTP响应实体(HttpEntity)里把返回的内容读成一个字符串。这个方法比较关键,因为它还处理了服务器可能返回的gzip压缩数据。 - * @param entity http响应实体 - * @return 响应内容的字符串 - * @throws IOException - */ private String getResponseContent(HttpEntity entity) throws IOException { String contentEncoding = null; if (entity.getContentEncoding() != null) { - contentEncoding = entity.getContentEncoding().getValue(); // 先看看服务器返回的数据有没有被压缩 + contentEncoding = entity.getContentEncoding().getValue(); Log.d(TAG, "encoding: " + contentEncoding); } - InputStream input = entity.getContent(); // 拿到原始的输入流(字节流) - // 如果内容是gzip或者deflate压缩的,就要用对应的流来解压,不然读出来是乱码 + InputStream input = entity.getContent(); if (contentEncoding != null && contentEncoding.equalsIgnoreCase("gzip")) { - input = new GZIPInputStream(entity.getContent()); // GZIPInputStream是专门解压gzip格式的 + input = new GZIPInputStream(entity.getContent()); } else if (contentEncoding != null && contentEncoding.equalsIgnoreCase("deflate")) { Inflater inflater = new Inflater(true); input = new InflaterInputStream(entity.getContent(), inflater); } try { - InputStreamReader isr = new InputStreamReader(input); // 字节流转字符流,做编码翻译 - BufferedReader br = new BufferedReader(isr); // 加个缓冲,读起来快一点 - StringBuilder sb = new StringBuilder(); // 用StringBuilder来拼接字符串,效率高 + InputStreamReader isr = new InputStreamReader(input); + BufferedReader br = new BufferedReader(isr); + StringBuilder sb = new StringBuilder(); while (true) { - String buff = br.readLine(); // 一行一行地读 + String buff = br.readLine(); if (buff == null) { - return sb.toString(); // 读完了,把结果转成String返回 + return sb.toString(); } - sb = sb.append(buff); // 把读到的内容拼到sb里 + sb = sb.append(buff); } } finally { - // finally代码块里的内容,不管try里面有没有出异常,都一定会执行 - input.close(); // 确保输入流一定会被关闭,这是为了防止资源泄漏,好习惯 + input.close(); } } - /** - * 封装一个通用的POST请求方法。所有需要向服务器提交数据的操作(比如新建、修改)都走这里。 - * @param js 包含所有操作指令的JSON对象 - * @return 服务器返回的JSON对象 - * @throws NetworkFailureException - */ private JSONObject postRequest(JSONObject js) throws NetworkFailureException { - // 发请求前先看看登录了没,没登录就直接报错 if (!mLoggedin) { Log.e(TAG, "please login first"); throw new ActionFailureException("not logged in"); } - HttpPost httpPost = createHttpPost(); // 复用前面写好的方法,把请求头都设好 + HttpPost httpPost = createHttpPost(); try { - // 建个list,用来放要POST的参数 LinkedList list = new LinkedList(); - // Google的接口要求把整个JSON操作数据包转成字符串,然后放到一个名叫"r"的参数里 list.add(new BasicNameValuePair("r", js.toString())); - // 把参数列表打包成http请求能认识的实体格式,编码用UTF-8 UrlEncodedFormEntity entity = new UrlEncodedFormEntity(list, "UTF-8"); httpPost.setEntity(entity); - // 发送 + // execute the post HttpResponse response = mHttpClient.execute(httpPost); - // 用上面写好的方法把返回结果读出来 String jsString = getResponseContent(response.getEntity()); - return new JSONObject(jsString); // 把返回的字符串转成JSON对象,方便后面解析 + return new JSONObject(jsString); } catch (ClientProtocolException e) { Log.e(TAG, e.toString()); e.printStackTrace(); - throw new NetworkFailureException("postRequest failed"); // 协议错误,一般是客户端代码问题 + throw new NetworkFailureException("postRequest failed"); } catch (IOException e) { Log.e(TAG, e.toString()); e.printStackTrace(); - throw new NetworkFailureException("postRequest failed"); // IO异常,多半是网络不通 + throw new NetworkFailureException("postRequest failed"); } catch (JSONException e) { Log.e(TAG, e.toString()); e.printStackTrace(); - throw new ActionFailureException("unable to convert response content to jsonobject"); // 返回的数据不是标准的JSON,解析不了 + throw new ActionFailureException("unable to convert response content to jsonobject"); } catch (Exception e) { Log.e(TAG, e.toString()); e.printStackTrace(); - throw new ActionFailureException("error occurs when posting request"); // 兜底 + throw new ActionFailureException("error occurs when posting request"); } } - // 新建一个任务 public void createTask(Task task) throws NetworkFailureException { - commitUpdate(); // 发新请求之前,先把之前攒着的更新操作(比如改名字)都提交了,保证数据一致 + commitUpdate(); try { - JSONObject jsPost = new JSONObject(); // 创建一个JSON对象,作为整个请求的数据包 - JSONArray actionList = new JSONArray(); // 再创建一个JSON数组,专门放具体的操作指令 + JSONObject jsPost = new JSONObject(); + JSONArray actionList = new JSONArray(); - // 调用Task对象自己的方法,生成一个符合Gtask API规范的'创建'操作,然后塞到actionList里 + // action_list actionList.put(task.getCreateAction(getActionId())); - // 把actionList和客户端版本号都塞到请求数据包里 jsPost.put(GTaskStringUtils.GTASK_JSON_ACTION_LIST, actionList); + + // client_version jsPost.put(GTaskStringUtils.GTASK_JSON_CLIENT_VERSION, mClientVersion); - // 调用咱们封装好的post方法发出去 + // post JSONObject jsResponse = postRequest(jsPost); - // 解析服务器返回的结果 JSONObject jsResult = (JSONObject) jsResponse.getJSONArray( GTaskStringUtils.GTASK_JSON_RESULTS).get(0); - // 从返回结果里把Google服务器给这个新任务分配的唯一ID(gid)拿出来,存回我们自己的Task对象里。修改删除都靠这个ID task.setGid(jsResult.getString(GTaskStringUtils.GTASK_JSON_NEW_ID)); } catch (JSONException e) { @@ -483,23 +386,23 @@ public class GTaskClient { } } - // 新建一个任务清单(就是便签夹) public void createTaskList(TaskList tasklist) throws NetworkFailureException { - commitUpdate(); // 同样,先提交本地缓存的修改 + commitUpdate(); try { - // 下面的逻辑和createTask几乎一模一样,就是把Task对象换成了TaskList对象 JSONObject jsPost = new JSONObject(); JSONArray actionList = new JSONArray(); + // action_list actionList.put(tasklist.getCreateAction(getActionId())); jsPost.put(GTaskStringUtils.GTASK_JSON_ACTION_LIST, actionList); + // client version jsPost.put(GTaskStringUtils.GTASK_JSON_CLIENT_VERSION, mClientVersion); + // post JSONObject jsResponse = postRequest(jsPost); JSONObject jsResult = (JSONObject) jsResponse.getJSONArray( GTaskStringUtils.GTASK_JSON_RESULTS).get(0); - // 把服务器返回的新gid存到TaskList对象里 tasklist.setGid(jsResult.getString(GTaskStringUtils.GTASK_JSON_NEW_ID)); } catch (JSONException e) { @@ -509,19 +412,19 @@ public class GTaskClient { } } - // 提交攒着的一批更新 public void commitUpdate() throws NetworkFailureException { - if (mUpdateArray != null) { // 先检查下有没有攒着没提交的更新 + if (mUpdateArray != null) { try { - // 这个方法就是把mUpdateArray里攒的一堆更新操作一次性发给服务器,比一条一条发效率高 JSONObject jsPost = new JSONObject(); + // action_list jsPost.put(GTaskStringUtils.GTASK_JSON_ACTION_LIST, mUpdateArray); + // client_version jsPost.put(GTaskStringUtils.GTASK_JSON_CLIENT_VERSION, mClientVersion); postRequest(jsPost); - mUpdateArray = null; // 发完之后,把这个数组清空,免得下次重复提交 + mUpdateArray = null; } catch (JSONException e) { Log.e(TAG, e.toString()); e.printStackTrace(); @@ -530,49 +433,48 @@ public class GTaskClient { } } - // 把一个“更新”操作(比如修改标题、备注)加到待提交的数组里 public void addUpdateNode(Node node) throws NetworkFailureException { - if (node != null) { // 先判空 - // 这里做了个优化。如果攒的操作超过10个了,就先自动提交一次。估计是怕一次性提交太多数据,服务器那边会报错或者超时 + if (node != null) { + // too many update items may result in an error + // set max to 10 items if (mUpdateArray != null && mUpdateArray.length() > 10) { commitUpdate(); } - if (mUpdateArray == null) // 如果数组还是空的,就new一个出来 + if (mUpdateArray == null) mUpdateArray = new JSONArray(); - // 把当前的更新操作加到待提交的数组里 mUpdateArray.put(node.getUpdateAction(getActionId())); } } - // 移动一个任务 public void moveTask(Task task, TaskList preParent, TaskList curParent) throws NetworkFailureException { - commitUpdate(); // 先提交缓存的更新 + commitUpdate(); try { JSONObject jsPost = new JSONObject(); JSONArray actionList = new JSONArray(); JSONObject action = new JSONObject(); - // 告诉服务器,这次操作的类型是'move' + // action_list action.put(GTaskStringUtils.GTASK_JSON_ACTION_TYPE, GTaskStringUtils.GTASK_JSON_ACTION_TYPE_MOVE); action.put(GTaskStringUtils.GTASK_JSON_ACTION_ID, getActionId()); - action.put(GTaskStringUtils.GTASK_JSON_ID, task.getGid()); // 要移动的那个任务的ID - // 判断一下是不是在同一个列表里移动 + action.put(GTaskStringUtils.GTASK_JSON_ID, task.getGid()); if (preParent == curParent && task.getPriorSibling() != null) { - // 如果是,并且不是移动到第一个位置,就需要告诉服务器它前面的那个兄弟节点是谁,这样服务器才知道要把它插到哪儿 + // put prioring_sibing_id only if moving within the tasklist and + // it is not the first one action.put(GTaskStringUtils.GTASK_JSON_PRIOR_SIBLING_ID, task.getPriorSibling()); } - action.put(GTaskStringUtils.GTASK_JSON_SOURCE_LIST, preParent.getGid()); // 原来的父列表ID - action.put(GTaskStringUtils.GTASK_JSON_DEST_PARENT, curParent.getGid()); // 目标父列表ID + action.put(GTaskStringUtils.GTASK_JSON_SOURCE_LIST, preParent.getGid()); + action.put(GTaskStringUtils.GTASK_JSON_DEST_PARENT, curParent.getGid()); if (preParent != curParent) { - // 如果是跨列表移动,还得告诉服务器目标列表的ID + // put the dest_list only if moving between tasklists action.put(GTaskStringUtils.GTASK_JSON_DEST_LIST, curParent.getGid()); } actionList.put(action); jsPost.put(GTaskStringUtils.GTASK_JSON_ACTION_LIST, actionList); + // client_version jsPost.put(GTaskStringUtils.GTASK_JSON_CLIENT_VERSION, mClientVersion); postRequest(jsPost); @@ -584,23 +486,22 @@ public class GTaskClient { } } - // 删除一个节点(可以是任务,也可以是任务清单) public void deleteNode(Node node) throws NetworkFailureException { commitUpdate(); try { JSONObject jsPost = new JSONObject(); JSONArray actionList = new JSONArray(); - // 这里不是直接发一个'delete'指令,而是把节点的deleted状态改成true + // action_list node.setDeleted(true); - // 然后发一个'update'指令。这应该是Gtask API的设计,逻辑删除而不是物理删除 actionList.put(node.getUpdateAction(getActionId())); jsPost.put(GTaskStringUtils.GTASK_JSON_ACTION_LIST, actionList); + // client_version jsPost.put(GTaskStringUtils.GTASK_JSON_CLIENT_VERSION, mClientVersion); postRequest(jsPost); - mUpdateArray = null; // 这个命名不太规范,deleteNode里面还把mUpdateArray清空了,应该在commitUpdate里做才对 + mUpdateArray = null; } catch (JSONException e) { Log.e(TAG, e.toString()); e.printStackTrace(); @@ -608,19 +509,18 @@ public class GTaskClient { } } - // 获取所有的任务清单 public JSONArray getTaskLists() throws NetworkFailureException { - if (!mLoggedin) { // 还是先检查登录状态 + if (!mLoggedin) { Log.e(TAG, "please login first"); throw new ActionFailureException("not logged in"); } try { - HttpGet httpGet = new HttpGet(mGetUrl); // 获取列表是用GET请求 + HttpGet httpGet = new HttpGet(mGetUrl); HttpResponse response = null; response = mHttpClient.execute(httpGet); - // 和登录时一样,返回的是网页代码,需要从_setup()里把数据截取出来 + // get the task list String resString = getResponseContent(response.getEntity()); String jsBegin = "_setup("; String jsEnd = ")}"; @@ -631,7 +531,6 @@ public class GTaskClient { jsString = resString.substring(begin + jsBegin.length(), end); } JSONObject js = new JSONObject(jsString); - // 数据在't'对象的'lists'数组里 return js.getJSONObject("t").getJSONArray(GTaskStringUtils.GTASK_JSON_LISTS); } catch (ClientProtocolException e) { Log.e(TAG, e.toString()); @@ -648,26 +547,26 @@ public class GTaskClient { } } - // 获取某个清单下的所有任务 public JSONArray getTaskList(String listGid) throws NetworkFailureException { - commitUpdate(); // 获取某个列表的详细内容前,也先把本地的修改提交了,保证拿到的是最新的状态 + commitUpdate(); try { JSONObject jsPost = new JSONObject(); JSONArray actionList = new JSONArray(); JSONObject action = new JSONObject(); + // action_list action.put(GTaskStringUtils.GTASK_JSON_ACTION_TYPE, - GTaskStringUtils.GTASK_JSON_ACTION_TYPE_GETALL); // 操作类型是'get_all' + GTaskStringUtils.GTASK_JSON_ACTION_TYPE_GETALL); action.put(GTaskStringUtils.GTASK_JSON_ACTION_ID, getActionId()); - action.put(GTaskStringUtils.GTASK_JSON_LIST_ID, listGid); // 告诉服务器要哪个列表的数据 - action.put(GTaskStringUtils.GTASK_JSON_GET_DELETED, false); // 不获取已经删除的任务 + action.put(GTaskStringUtils.GTASK_JSON_LIST_ID, listGid); + action.put(GTaskStringUtils.GTASK_JSON_GET_DELETED, false); actionList.put(action); jsPost.put(GTaskStringUtils.GTASK_JSON_ACTION_LIST, actionList); + // client_version jsPost.put(GTaskStringUtils.GTASK_JSON_CLIENT_VERSION, mClientVersion); JSONObject jsResponse = postRequest(jsPost); - // 从返回结果里拿到'tasks'这个数组,里面就是这个列表下所有的任务了 return jsResponse.getJSONArray(GTaskStringUtils.GTASK_JSON_TASKS); } catch (JSONException e) { Log.e(TAG, e.toString()); @@ -676,12 +575,10 @@ public class GTaskClient { } } - // 一个简单的getter,给外面获取当前是哪个账户在同步 public Account getSyncAccount() { return mAccount; } - // 把待提交的更新数组清空,一般是同步出错了或者取消同步的时候调用 public void resetUpdateArray() { mUpdateArray = null; } diff --git a/src/src/net/micode/notes/gtask/remote/GTaskManager.java b/src/src/net/micode/notes/gtask/remote/GTaskManager.java index 1b7d832..d2b4082 100644 --- a/src/src/net/micode/notes/gtask/remote/GTaskManager.java +++ b/src/src/net/micode/notes/gtask/remote/GTaskManager.java @@ -48,46 +48,46 @@ import java.util.Iterator; import java.util.Map; -/** - * @Author: 林迪文 - * @Updator: 林迪文 - * @Date 2025/12/22 21:30 - * @Description 这个是同步功能的核心管理者。它负责整个同步流程的调度,比如登录、拉取云端数据、比对本地和云端的数据差异,然后决定哪些需要上传,哪些需要下载。 - */ public class GTaskManager { - // 日志标签 private static final String TAG = GTaskManager.class.getSimpleName(); - // 定义了一堆同步结果的状态码,方便给调用方(比如Service)返回结果 - public static final int STATE_SUCCESS = 0; // 0代表成功 - public static final int STATE_NETWORK_ERROR = 1; // 1是网络问题 - public static final int STATE_INTERNAL_ERROR = 2; // 2是程序内部错误 - public static final int STATE_SYNC_IN_PROGRESS = 3; // 3是正在同步中,防止重复启动 - public static final int STATE_SYNC_CANCELLED = 4; // 4是用户手动取消了 + public static final int STATE_SUCCESS = 0; + + public static final int STATE_NETWORK_ERROR = 1; + + public static final int STATE_INTERNAL_ERROR = 2; + + public static final int STATE_SYNC_IN_PROGRESS = 3; + + public static final int STATE_SYNC_CANCELLED = 4; + + private static GTaskManager mInstance = null; + + private Activity mActivity; + + private Context mContext; + + private ContentResolver mContentResolver; + + private boolean mSyncing; + + private boolean mCancelled; + + private HashMap mGTaskListHashMap; - private static GTaskManager mInstance = null; // 单例模式 + private HashMap mGTaskHashMap; - private Activity mActivity; // Activity的上下文,主要给GTaskClient登录用 - private Context mContext; // 全局的上下文 - private ContentResolver mContentResolver; // 内容提供者,可以理解成咱们App访问便签数据库的“管家” + private HashMap mMetaHashMap; - private boolean mSyncing; // 同步状态的标记 - private boolean mCancelled; // 取消同步的标记 + private TaskList mMetaList; - // HashMap是整个同步算法的核心,用来在内存里缓存和映射数据 - private HashMap mGTaskListHashMap; // Google云端任务清单的缓存,key是清单的gid - private HashMap mGTaskHashMap; // Google云端所有节点的缓存(包括清单和任务),key是gid - private HashMap mMetaHashMap; // Google云端元数据的缓存,key是被关联任务的gid - private TaskList mMetaList; // 专门存meta数据的那个清单对象 + private HashSet mLocalDeleteIdMap; - private HashSet mLocalDeleteIdMap; // 存放在本地被删除了的笔记ID + private HashMap mGidToNid; - // 这两个是关键,用来建立Google ID和本地数据库ID之间的对应关系 - private HashMap mGidToNid; // Google ID到本地笔记ID的映射 - private HashMap mNidToGid; // 本地笔记ID到Google ID的映射 + private HashMap mNidToGid; private GTaskManager() { - // 构造函数里做一些初始化,防止空指针 mSyncing = false; mCancelled = false; mGTaskListHashMap = new HashMap(); @@ -106,29 +106,20 @@ public class GTaskManager { return mInstance; } - // 设置Activity上下文,因为登录Google账户需要一个Activity public synchronized void setActivityContext(Activity activity) { + // used for getting authtoken mActivity = activity; } - /** - * 这是同步操作的总入口,所有同步逻辑都从这里开始。 - * @param context 上下文 - * @param asyncTask 异步任务的实例,用来在同步过程中更新界面上的进度提示 - * @return 返回上面定义的状态码,告诉调用者同步结果 - */ public int sync(Context context, GTaskASyncTask asyncTask) { - // 先检查是不是已经在同步了,是的话就直接返回,避免重复执行 if (mSyncing) { Log.d(TAG, "Sync is in progress"); return STATE_SYNC_IN_PROGRESS; } mContext = context; - mContentResolver = mContext.getContentResolver(); // 拿到数据库“管家” - mSyncing = true; // 标记开始同步 - mCancelled = false; // 重置取消标记 - - // 每次同步前,先把上次的缓存清空,保证拿到的是最新数据 + mContentResolver = mContext.getContentResolver(); + mSyncing = true; + mCancelled = false; mGTaskListHashMap.clear(); mGTaskHashMap.clear(); mMetaHashMap.clear(); @@ -138,35 +129,33 @@ public class GTaskManager { try { GTaskClient client = GTaskClient.getInstance(); - client.resetUpdateArray(); // 万一上次同步失败有残留,先把待提交队列清一下 + client.resetUpdateArray(); - // 登录 + // login google task if (!mCancelled) { if (!client.login(mActivity)) { throw new NetworkFailureException("login google task failed"); } } - // 从Google服务器上把所有任务清单和任务都拉下来,放到内存的HashMap里 - asyncTask.publishProgess(mContext.getString(R.string.sync_progress_init_list)); // 通知界面,“正在初始化列表...” + // get the task list from google + asyncTask.publishProgess(mContext.getString(R.string.sync_progress_init_list)); initGTaskList(); - // 开始比对和同步本地与云端的内容 - asyncTask.publishProgess(mContext.getString(R.string.sync_progress_syncing)); // 通知界面,“正在同步...” + // do content sync work + asyncTask.publishProgess(mContext.getString(R.string.sync_progress_syncing)); syncContent(); - } catch (NetworkFailureException e) { Log.e(TAG, e.toString()); - return STATE_NETWORK_ERROR; // 网络异常 + return STATE_NETWORK_ERROR; } catch (ActionFailureException e) { Log.e(TAG, e.toString()); - return STATE_INTERNAL_ERROR; // 操作失败,比如JSON解析错了 + return STATE_INTERNAL_ERROR; } catch (Exception e) { Log.e(TAG, e.toString()); e.printStackTrace(); - return STATE_INTERNAL_ERROR; // 其他未知错误 + return STATE_INTERNAL_ERROR; } finally { - // 关键的收尾工作,不管同步成功、失败还是取消,都要把这些缓存清掉,把状态改回去,保证下次能正常同步 mGTaskListHashMap.clear(); mGTaskHashMap.clear(); mMetaHashMap.clear(); @@ -176,43 +165,37 @@ public class GTaskManager { mSyncing = false; } - // 最后根据取消标志返回最终状态 return mCancelled ? STATE_SYNC_CANCELLED : STATE_SUCCESS; } - /** - * 这个方法负责从Google Tasks服务器拉取所有的数据,并初始化到内存中的各个HashMap里,为后续的数据比对做准备。 - * @throws NetworkFailureException - */ private void initGTaskList() throws NetworkFailureException { - if (mCancelled) // 同步过程中随时检查是否被取消了 + if (mCancelled) return; GTaskClient client = GTaskClient.getInstance(); try { - JSONArray jsTaskLists = client.getTaskLists(); // 从服务器拿到所有清单的JSON数组 + JSONArray jsTaskLists = client.getTaskLists(); - // 优先处理meta清单。这个清单是用来存一些便签的附加信息(比如颜色、提醒时间)的,在Google Tasks界面上看不到,是咱们App自己用的 + // init meta list first mMetaList = null; for (int i = 0; i < jsTaskLists.length(); i++) { JSONObject object = jsTaskLists.getJSONObject(i); - String gid = object.getString(GTaskStringUtils.GTASK_JSON_ID); // 清单的gid - String name = object.getString(GTaskStringUtils.GTASK_JSON_NAME); // 清单的名字 + String gid = object.getString(GTaskStringUtils.GTASK_JSON_ID); + String name = object.getString(GTaskStringUtils.GTASK_JSON_NAME); - // 通过一个特殊的前缀名来识别出哪个是meta清单 - if (name.equals(GTaskStringUtils.MIUI_FOLDER_PREFFIX + GTaskStringUtils.FOLDER_META)) { + if (name + .equals(GTaskStringUtils.MIUI_FOLDER_PREFFIX + GTaskStringUtils.FOLDER_META)) { mMetaList = new TaskList(); - mMetaList.setContentByRemoteJSON(object); // 把JSON数据填到TaskList对象里 + mMetaList.setContentByRemoteJSON(object); - // 找到meta清单后,再去拉这个清单下面所有的meta数据 + // load meta data JSONArray jsMetas = client.getTaskList(gid); for (int j = 0; j < jsMetas.length(); j++) { object = (JSONObject) jsMetas.getJSONObject(j); MetaData metaData = new MetaData(); metaData.setContentByRemoteJSON(object); - if (metaData.isWorthSaving()) { // 检查一下数据是不是有效 - mMetaList.addChildTask(metaData); // 加到meta清单的子任务列表里 + if (metaData.isWorthSaving()) { + mMetaList.addChildTask(metaData); if (metaData.getGid() != null) { - // 把meta数据存到缓存里,key是它关联的那个笔记的gid,方便后面查找 mMetaHashMap.put(metaData.getRelatedGid(), metaData); } } @@ -220,7 +203,7 @@ public class GTaskManager { } } - // 如果服务器上没有meta清单,说明是第一次同步,那咱们就在云端给它创建一个 + // create meta list if not existed if (mMetaList == null) { mMetaList = new TaskList(); mMetaList.setName(GTaskStringUtils.MIUI_FOLDER_PREFFIX @@ -228,23 +211,21 @@ public class GTaskManager { GTaskClient.getInstance().createTaskList(mMetaList); } - // 处理正常的便签夹(任务清单) + // init task list for (int i = 0; i < jsTaskLists.length(); i++) { JSONObject object = jsTaskLists.getJSONObject(i); String gid = object.getString(GTaskStringUtils.GTASK_JSON_ID); String name = object.getString(GTaskStringUtils.GTASK_JSON_NAME); - // 通过前缀名过滤出我们自己App创建的清单,忽略用户在Google Tasks上创建的其他清单 if (name.startsWith(GTaskStringUtils.MIUI_FOLDER_PREFFIX) && !name.equals(GTaskStringUtils.MIUI_FOLDER_PREFFIX - + GTaskStringUtils.FOLDER_META)) { + + GTaskStringUtils.FOLDER_META)) { TaskList tasklist = new TaskList(); tasklist.setContentByRemoteJSON(object); - // 放到清单缓存和总节点缓存里 mGTaskListHashMap.put(gid, tasklist); mGTaskHashMap.put(gid, tasklist); - // 拉取这个清单下的所有任务(便签) + // load tasks JSONArray jsTasks = client.getTaskList(gid); for (int j = 0; j < jsTasks.length(); j++) { object = (JSONObject) jsTasks.getJSONObject(j); @@ -252,10 +233,9 @@ public class GTaskManager { Task task = new Task(); task.setContentByRemoteJSON(object); if (task.isWorthSaving()) { - // 从meta缓存里找到这条便签对应的meta数据,然后关联起来 task.setMetaInfo(mMetaHashMap.get(gid)); - tasklist.addChildTask(task); // 把任务加到清单的子节点里 - mGTaskHashMap.put(gid, task); // 也放到总节点缓存里 + tasklist.addChildTask(task); + mGTaskHashMap.put(gid, task); } } } @@ -267,15 +247,11 @@ public class GTaskManager { } } - /** - * 这个方法是数据同步的核心。它会遍历本地数据库和从云端拉下来的数据,找出两边的差异,然后调用doContentSync来决定具体执行哪种同步操作。 - * @throws NetworkFailureException - */ private void syncContent() throws NetworkFailureException { - int syncType; // 用来存同步操作的类型,比如是本地新增、还是远程删除 - Cursor c = null; // 数据库查询用的游标 - String gid; // Google ID - Node node; // 云端节点对象 + int syncType; + Cursor c = null; + String gid; + Node node; mLocalDeleteIdMap.clear(); @@ -283,7 +259,7 @@ public class GTaskManager { return; } - // 处理本地已经删除的笔记。这些笔记在回收站里,需要通知云端也删除。 + // for local deleted note try { c = mContentResolver.query(Notes.CONTENT_NOTE_URI, SqlNote.PROJECTION_NOTE, "(type<>? AND parent_id=?)", new String[] { @@ -291,14 +267,13 @@ public class GTaskManager { }, null); if (c != null) { while (c.moveToNext()) { - gid = c.getString(SqlNote.GTASK_ID_COLUMN); // 拿到本地笔记对应的Google ID - node = mGTaskHashMap.get(gid); // 去云端数据缓存里找找,看云上还有没有 + gid = c.getString(SqlNote.GTASK_ID_COLUMN); + node = mGTaskHashMap.get(gid); if (node != null) { - // 如果云上还有,说明这是一个“本地删除,云端保留”的情况 - mGTaskHashMap.remove(gid); // 从待处理的云端数据里把它移除,因为它已经被处理了 - doContentSync(Node.SYNC_ACTION_DEL_REMOTE, node, c); // 告诉云端也要删除 + mGTaskHashMap.remove(gid); + doContentSync(Node.SYNC_ACTION_DEL_REMOTE, node, c); } - // 把这个本地ID记下来,最后统一从本地数据库的data表里删掉 + mLocalDeleteIdMap.add(c.getLong(SqlNote.ID_COLUMN)); } } else { @@ -306,17 +281,16 @@ public class GTaskManager { } } finally { if (c != null) { - c.close(); // 确保游标一定被关闭,防止内存泄漏 + c.close(); c = null; } } - // 同步文件夹。文件夹的层级关系比较重要,所以要优先同步 + // sync folder first syncFolder(); - // 处理本地数据库里还存在的笔记 + // for note existing in database try { - // 查询所有不在回收站里的普通笔记 c = mContentResolver.query(Notes.CONTENT_NOTE_URI, SqlNote.PROJECTION_NOTE, "(type=? AND parent_id<>?)", new String[] { String.valueOf(Notes.TYPE_NOTE), String.valueOf(Notes.ID_TRASH_FOLER) @@ -324,23 +298,22 @@ public class GTaskManager { if (c != null) { while (c.moveToNext()) { gid = c.getString(SqlNote.GTASK_ID_COLUMN); - node = mGTaskHashMap.get(gid); // 拿本地笔记的gid去云端缓存里找 - if (node != null) { // 在云端找到了对应的笔记 - mGTaskHashMap.remove(gid); // 从待处理的云端数据里移除 - mGidToNid.put(gid, c.getLong(SqlNote.ID_COLUMN)); // 再次确认一下ID映射关系 + node = mGTaskHashMap.get(gid); + if (node != null) { + mGTaskHashMap.remove(gid); + mGidToNid.put(gid, c.getLong(SqlNote.ID_COLUMN)); mNidToGid.put(c.getLong(SqlNote.ID_COLUMN), gid); - // 调用node自己的方法来比对内容,判断是谁更新了,还是都更新了(冲突) syncType = node.getSyncAction(c); - } else { // 在云端没找到对应的笔记 + } else { if (c.getString(SqlNote.GTASK_ID_COLUMN).trim().length() == 0) { - // 如果本地笔记的gid是空的,说明这是在本地新建的,还没同步过 + // local add syncType = Node.SYNC_ACTION_ADD_REMOTE; } else { - // 如果本地有gid,但云端没有,说明这个笔记在云端被删掉了 + // remote delete syncType = Node.SYNC_ACTION_DEL_LOCAL; } } - doContentSync(syncType, node, c); // 根据比对结果执行相应的同步操作 + doContentSync(syncType, node, c); } } else { Log.w(TAG, "failed to query existing note in database"); @@ -353,35 +326,31 @@ public class GTaskManager { } } - // 处理云端数据缓存(mGTaskHashMap)里剩下的东西 - // 经过上面几步,缓存里剩下的就是那些“云端有,本地没有”的笔记了,说明是需要下载到本地的 + // go through remaining items Iterator> iter = mGTaskHashMap.entrySet().iterator(); while (iter.hasNext()) { Map.Entry entry = iter.next(); node = entry.getValue(); - doContentSync(Node.SYNC_ACTION_ADD_LOCAL, node, null); // 执行本地新增操作 + doContentSync(Node.SYNC_ACTION_ADD_LOCAL, node, null); } - // 收尾工作 - // 检查是否被取消 + // mCancelled can be set by another thread, so we neet to check one by + // one + // clear local delete table if (!mCancelled) { - // 把之前记录的、在本地被删除的笔记,批量从数据库里彻底清理掉 if (!DataUtils.batchDeleteNotes(mContentResolver, mLocalDeleteIdMap)) { throw new ActionFailureException("failed to batch-delete local deleted notes"); } } + // refresh local sync id if (!mCancelled) { - GTaskClient.getInstance().commitUpdate(); // 提交所有攒着的更新操作 - refreshLocalSyncId(); // 最后再刷新一下本地笔记的sync_id,保证和云端一致 + GTaskClient.getInstance().commitUpdate(); + refreshLocalSyncId(); } } - /** - * 这个是专门同步文件夹的逻辑,和同步笔记的流程基本一样,只是查询条件和处理的对象不同。 - * @throws NetworkFailureException - */ private void syncFolder() throws NetworkFailureException { Cursor c = null; String gid; @@ -392,7 +361,7 @@ public class GTaskManager { return; } - // 先处理“默认便签夹”(根目录),这是个系统文件夹 + // for root folder try { c = mContentResolver.query(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, Notes.ID_ROOT_FOLDER), SqlNote.PROJECTION_NOTE, null, null, null); @@ -401,15 +370,14 @@ public class GTaskManager { gid = c.getString(SqlNote.GTASK_ID_COLUMN); node = mGTaskHashMap.get(gid); if (node != null) { - mGTaskHashMap.remove(gid); // 从待处理缓存中移除 - mGidToNid.put(gid, (long) Notes.ID_ROOT_FOLDER); // 建立ID映射 + mGTaskHashMap.remove(gid); + mGidToNid.put(gid, (long) Notes.ID_ROOT_FOLDER); mNidToGid.put((long) Notes.ID_ROOT_FOLDER, gid); - // 系统文件夹只检查名字对不对,不对的话就更新云端的名字 + // for system folder, only update remote name if necessary if (!node.getName().equals( GTaskStringUtils.MIUI_FOLDER_PREFFIX + GTaskStringUtils.FOLDER_DEFAULT)) doContentSync(Node.SYNC_ACTION_UPDATE_REMOTE, node, c); } else { - // 云端没有这个文件夹,就创建一个 doContentSync(Node.SYNC_ACTION_ADD_REMOTE, node, c); } } else { @@ -422,12 +390,11 @@ public class GTaskManager { } } - // 再处理“通话便签”文件夹,也是系统文件夹 + // for call-note folder try { - // 这块逻辑和上面处理根目录的完全一样,只是ID和名字换了 c = mContentResolver.query(Notes.CONTENT_NOTE_URI, SqlNote.PROJECTION_NOTE, "(_id=?)", new String[] { - String.valueOf(Notes.ID_CALL_RECORD_FOLDER) + String.valueOf(Notes.ID_CALL_RECORD_FOLDER) }, null); if (c != null) { if (c.moveToNext()) { @@ -437,7 +404,8 @@ public class GTaskManager { mGTaskHashMap.remove(gid); mGidToNid.put(gid, (long) Notes.ID_CALL_RECORD_FOLDER); mNidToGid.put((long) Notes.ID_CALL_RECORD_FOLDER, gid); - + // for system folder, only update remote name if + // necessary if (!node.getName().equals( GTaskStringUtils.MIUI_FOLDER_PREFFIX + GTaskStringUtils.FOLDER_CALL_NOTE)) @@ -456,9 +424,8 @@ public class GTaskManager { } } - // 处理本地存在的其他普通文件夹 + // for local existing folders try { - // 这里的比对逻辑和syncContent里处理普通笔记的逻辑是一样的 c = mContentResolver.query(Notes.CONTENT_NOTE_URI, SqlNote.PROJECTION_NOTE, "(type=? AND parent_id<>?)", new String[] { String.valueOf(Notes.TYPE_FOLDER), String.valueOf(Notes.ID_TRASH_FOLER) @@ -472,10 +439,12 @@ public class GTaskManager { mGidToNid.put(gid, c.getLong(SqlNote.ID_COLUMN)); mNidToGid.put(c.getLong(SqlNote.ID_COLUMN), gid); syncType = node.getSyncAction(c); - } else { + } else { if (c.getString(SqlNote.GTASK_ID_COLUMN).trim().length() == 0) { + // local add syncType = Node.SYNC_ACTION_ADD_REMOTE; } else { + // remote delete syncType = Node.SYNC_ACTION_DEL_LOCAL; } } @@ -491,77 +460,61 @@ public class GTaskManager { } } - // 处理云端新增的文件夹 - // mGTaskListHashMap里是所有云端文件夹,经过上面的处理,还在mGTaskHashMap里的就是“云端有,本地没有”的 + // for remote add folders Iterator> iter = mGTaskListHashMap.entrySet().iterator(); while (iter.hasNext()) { Map.Entry entry = iter.next(); gid = entry.getKey(); node = entry.getValue(); - if (mGTaskHashMap.containsKey(gid)) { // 再次确认一下 + if (mGTaskHashMap.containsKey(gid)) { mGTaskHashMap.remove(gid); doContentSync(Node.SYNC_ACTION_ADD_LOCAL, node, null); } } if (!mCancelled) - GTaskClient.getInstance().commitUpdate(); // 同步完文件夹就提交一次,免得操作太多 + GTaskClient.getInstance().commitUpdate(); } - /** - * 这是一个分发器。根据传入的同步类型,决定具体是调用本地新增、远程删除还是其他方法。 - * @param syncType 同步操作的类型 - * @param node 云端节点对象 - * @param c 本地数据库查询的游标 - * @throws NetworkFailureException - */ private void doContentSync(int syncType, Node node, Cursor c) throws NetworkFailureException { - if (mCancelled) { // 最后一层检查,确保取消指令能及时生效 + if (mCancelled) { return; } MetaData meta; switch (syncType) { case Node.SYNC_ACTION_ADD_LOCAL: - // 云端有,本地没有 -> 在本地数据库里新建 addLocalNode(node); break; case Node.SYNC_ACTION_ADD_REMOTE: - // 本地有,云端没有 -> 上传到Google服务器 addRemoteNode(node, c); break; case Node.SYNC_ACTION_DEL_LOCAL: - // 本地有,云端没有(但本地有gid,说明之前同步过) -> 在本地删除 - // 删之前,先看看它有没有关联的meta数据,有的话也要一起删掉 meta = mMetaHashMap.get(c.getString(SqlNote.GTASK_ID_COLUMN)); if (meta != null) { GTaskClient.getInstance().deleteNode(meta); } - mLocalDeleteIdMap.add(c.getLong(SqlNote.ID_COLUMN)); // 记下ID,最后统一删 + mLocalDeleteIdMap.add(c.getLong(SqlNote.ID_COLUMN)); break; case Node.SYNC_ACTION_DEL_REMOTE: - // 本地删了,云端还有 -> 通知云端删除 meta = mMetaHashMap.get(node.getGid()); if (meta != null) { - GTaskClient.getInstance().deleteNode(meta); // 同样,连meta数据一起删 + GTaskClient.getInstance().deleteNode(meta); } GTaskClient.getInstance().deleteNode(node); break; case Node.SYNC_ACTION_UPDATE_LOCAL: - // 云端更新了 -> 把云端的数据更新到本地数据库 updateLocalNode(node, c); break; case Node.SYNC_ACTION_UPDATE_REMOTE: - // 本地更新了 -> 把本地的数据上传到云端 updateRemoteNode(node, c); break; case Node.SYNC_ACTION_UPDATE_CONFLICT: - // 两边都更新了,冲突了 - // 直接用本地的覆盖云端的 + // merging both modifications maybe a good idea + // right now just use local update simply updateRemoteNode(node, c); break; case Node.SYNC_ACTION_NONE: - // 两边数据一样,啥也不用干 break; case Node.SYNC_ACTION_ERROR: default: @@ -569,30 +522,26 @@ public class GTaskManager { } } - // 在本地数据库里添加一个节点(文件夹或便签) private void addLocalNode(Node node) throws NetworkFailureException { if (mCancelled) { return; } SqlNote sqlNote; - if (node instanceof TaskList) { // 判断是文件夹还是便签 - // 对系统文件夹做特殊处理,直接关联到固定的ID上,而不是新建 + if (node instanceof TaskList) { if (node.getName().equals( GTaskStringUtils.MIUI_FOLDER_PREFFIX + GTaskStringUtils.FOLDER_DEFAULT)) { sqlNote = new SqlNote(mContext, Notes.ID_ROOT_FOLDER); } else if (node.getName().equals( GTaskStringUtils.MIUI_FOLDER_PREFFIX + GTaskStringUtils.FOLDER_CALL_NOTE)) { sqlNote = new SqlNote(mContext, Notes.ID_CALL_RECORD_FOLDER); - } else { // 普通文件夹 + } else { sqlNote = new SqlNote(mContext); - sqlNote.setContent(node.getLocalJSONFromContent()); // 把云端节点内容转成本地格式 - sqlNote.setParentId(Notes.ID_ROOT_FOLDER); // 普通文件夹都放在根目录下 + sqlNote.setContent(node.getLocalJSONFromContent()); + sqlNote.setParentId(Notes.ID_ROOT_FOLDER); } - } else { // 是便签 + } else { sqlNote = new SqlNote(mContext); - // 这里有一段防御性代码,检查下载下来的笔记ID和dataID在本地是不是已经被占用了 - // 如果被占用了,就把它ID去掉,让数据库自己生成新的,避免主键冲突 JSONObject js = node.getLocalJSONFromContent(); try { if (js.has(GTaskStringUtils.META_HEAD_NOTE)) { @@ -600,11 +549,12 @@ public class GTaskManager { if (note.has(NoteColumns.ID)) { long id = note.getLong(NoteColumns.ID); if (DataUtils.existInNoteDatabase(mContentResolver, id)) { + // the id is not available, have to create a new one note.remove(NoteColumns.ID); } } } - // data表里的ID也要检查 + if (js.has(GTaskStringUtils.META_HEAD_DATA)) { JSONArray dataArray = js.getJSONArray(GTaskStringUtils.META_HEAD_DATA); for (int i = 0; i < dataArray.length(); i++) { @@ -612,10 +562,13 @@ public class GTaskManager { if (data.has(DataColumns.ID)) { long dataId = data.getLong(DataColumns.ID); if (DataUtils.existInDataDatabase(mContentResolver, dataId)) { + // the data id is not available, have to create + // a new one data.remove(DataColumns.ID); } } } + } } catch (JSONException e) { Log.w(TAG, e.toString()); @@ -623,7 +576,6 @@ public class GTaskManager { } sqlNote.setContent(js); - // 从ID映射表里找到它的parent(父文件夹)在本地的ID Long parentId = mGidToNid.get(((Task) node).getParent().getGid()); if (parentId == null) { Log.e(TAG, "cannot find task's parent id locally"); @@ -632,30 +584,28 @@ public class GTaskManager { sqlNote.setParentId(parentId.longValue()); } - sqlNote.setGtaskId(node.getGid()); // 关联Google ID - sqlNote.commit(false); // 提交到数据库 + // create the local node + sqlNote.setGtaskId(node.getGid()); + sqlNote.commit(false); - // 关键一步:在ID映射表里把这个新节点的对应关系加上 + // update gid-nid mapping mGidToNid.put(node.getGid(), sqlNote.getId()); mNidToGid.put(sqlNote.getId(), node.getGid()); - // 顺便把meta数据也更新一下 + // update meta updateRemoteMeta(node.getGid(), sqlNote); } - // 更新本地数据库里的一个节点 private void updateLocalNode(Node node, Cursor c) throws NetworkFailureException { if (mCancelled) { return; } SqlNote sqlNote; - // 先用游标把数据库里现有的数据读出来 + // update the note locally sqlNote = new SqlNote(mContext, c); - // 然后把云端的数据盖上去 sqlNote.setContent(node.getLocalJSONFromContent()); - // 找到它的parent在本地的ID Long parentId = (node instanceof Task) ? mGidToNid.get(((Task) node).getParent().getGid()) : new Long(Notes.ID_ROOT_FOLDER); if (parentId == null) { @@ -663,39 +613,41 @@ public class GTaskManager { throw new ActionFailureException("cannot update local node"); } sqlNote.setParentId(parentId.longValue()); - sqlNote.commit(true); // 提交更新 + sqlNote.commit(true); - updateRemoteMeta(node.getGid(), sqlNote); // 更新meta + // update meta info + updateRemoteMeta(node.getGid(), sqlNote); } - // 把本地新建的节点上传到云端 private void addRemoteNode(Node node, Cursor c) throws NetworkFailureException { if (mCancelled) { return; } SqlNote sqlNote = new SqlNote(mContext, c); - Node n; // 用来存创建成功后的云端节点对象 + Node n; - if (sqlNote.isNoteType()) { // 是便签 + // update remotely + if (sqlNote.isNoteType()) { Task task = new Task(); - task.setContentByLocalJSON(sqlNote.getContent()); // 用本地数据填充Task对象 + task.setContentByLocalJSON(sqlNote.getContent()); - // 从映射表里找到父文件夹的gid String parentGid = mNidToGid.get(sqlNote.getParentId()); if (parentGid == null) { Log.e(TAG, "cannot find task's parent tasklist"); throw new ActionFailureException("cannot add remote task"); } - mGTaskListHashMap.get(parentGid).addChildTask(task); // 在内存里也维护一下父子关系 + mGTaskListHashMap.get(parentGid).addChildTask(task); - GTaskClient.getInstance().createTask(task); // 发请求创建 + GTaskClient.getInstance().createTask(task); n = (Node) task; + // add meta updateRemoteMeta(task.getGid(), sqlNote); - } else { // 是文件夹 + } else { TaskList tasklist = null; - // 上传前先检查一下,云端是不是已经有同名的文件夹了,有的话就直接复用,不再创建新的 + + // we need to skip folder if it has already existed String folderName = GTaskStringUtils.MIUI_FOLDER_PREFFIX; if (sqlNote.getId() == Notes.ID_ROOT_FOLDER) folderName += GTaskStringUtils.FOLDER_DEFAULT; @@ -711,7 +663,7 @@ public class GTaskManager { TaskList list = entry.getValue(); if (list.getName().equals(folderName)) { - tasklist = list; // 找到了同名的 + tasklist = list; if (mGTaskHashMap.containsKey(gid)) { mGTaskHashMap.remove(gid); } @@ -719,7 +671,7 @@ public class GTaskManager { } } - // 如果没找到同名的,才真的去创建 + // no match we can add now if (tasklist == null) { tasklist = new TaskList(); tasklist.setContentByLocalJSON(sqlNote.getContent()); @@ -729,18 +681,17 @@ public class GTaskManager { n = (Node) tasklist; } - // 上传成功后,把服务器返回的gid写回本地数据库 + // update local note sqlNote.setGtaskId(n.getGid()); sqlNote.commit(false); - sqlNote.resetLocalModified(); // 清除'本地已修改'的标记 + sqlNote.resetLocalModified(); sqlNote.commit(true); - // 更新ID映射表 + // gid-id mapping mGidToNid.put(n.getGid(), sqlNote.getId()); mNidToGid.put(sqlNote.getId(), n.getGid()); } - // 把本地修改过的节点上传到云端 private void updateRemoteNode(Node node, Cursor c) throws NetworkFailureException { if (mCancelled) { return; @@ -748,61 +699,59 @@ public class GTaskManager { SqlNote sqlNote = new SqlNote(mContext, c); - // 用本地数据更新云端节点对象的内容 + // update remotely node.setContentByLocalJSON(sqlNote.getContent()); - GTaskClient.getInstance().addUpdateNode(node); // 把这个更新操作加到待提交队列 + GTaskClient.getInstance().addUpdateNode(node); + // update meta updateRemoteMeta(node.getGid(), sqlNote); - // 如果是便签,还要检查一下它的父文件夹有没有变,变了的话就要发一个'move'指令 + // move task if necessary if (sqlNote.isNoteType()) { Task task = (Task) node; - TaskList preParentList = task.getParent(); // 之前的parent + TaskList preParentList = task.getParent(); String curParentGid = mNidToGid.get(sqlNote.getParentId()); if (curParentGid == null) { Log.e(TAG, "cannot find task's parent tasklist"); throw new ActionFailureException("cannot update remote task"); } - TaskList curParentList = mGTaskListHashMap.get(curParentGid);// parent变了 + TaskList curParentList = mGTaskListHashMap.get(curParentGid); if (preParentList != curParentList) { - // 在内存里维护父子关系 preParentList.removeChildTask(task); curParentList.addChildTask(task); - GTaskClient.getInstance().moveTask(task, preParentList, curParentList); // 发送移动请求 + GTaskClient.getInstance().moveTask(task, preParentList, curParentList); } } - // 上传成功后,清除'本地已修改'的标记 + // clear local modified flag sqlNote.resetLocalModified(); sqlNote.commit(true); } - // 更新云端的meta数据 private void updateRemoteMeta(String gid, SqlNote sqlNote) throws NetworkFailureException { if (sqlNote != null && sqlNote.isNoteType()) { - MetaData metaData = mMetaHashMap.get(gid); // 先看看这个便签之前有没有meta数据 - if (metaData != null) { // 有的话,直接更新 + MetaData metaData = mMetaHashMap.get(gid); + if (metaData != null) { metaData.setMeta(gid, sqlNote.getContent()); GTaskClient.getInstance().addUpdateNode(metaData); - } else { // 没有的话,就新建一个 + } else { metaData = new MetaData(); metaData.setMeta(gid, sqlNote.getContent()); - mMetaList.addChildTask(metaData); // 关联到meta清单下 - mMetaHashMap.put(gid, metaData); // 加到缓存 - GTaskClient.getInstance().createTask(metaData); // 发请求创建 + mMetaList.addChildTask(metaData); + mMetaHashMap.put(gid, metaData); + GTaskClient.getInstance().createTask(metaData); } } } - // 同步完成后,刷新本地数据库里所有笔记的sync_id,让它和云端的lastModified时间戳保持一致 private void refreshLocalSyncId() throws NetworkFailureException { if (mCancelled) { return; } - // 重新从服务器拉一遍最新的数据,因为commitUpdate之后,云端的lastModified可能变了 + // get the latest gtask list mGTaskHashMap.clear(); mGTaskListHashMap.clear(); mMetaHashMap.clear(); @@ -810,7 +759,6 @@ public class GTaskManager { Cursor c = null; try { - // 遍历本地所有笔记 c = mContentResolver.query(Notes.CONTENT_NOTE_URI, SqlNote.PROJECTION_NOTE, "(type<>? AND parent_id<>?)", new String[] { String.valueOf(Notes.TYPE_SYSTEM), String.valueOf(Notes.ID_TRASH_FOLER) @@ -819,15 +767,13 @@ public class GTaskManager { while (c.moveToNext()) { String gid = c.getString(SqlNote.GTASK_ID_COLUMN); Node node = mGTaskHashMap.get(gid); - if (node != null) { // 找到对应的云端节点 + if (node != null) { mGTaskHashMap.remove(gid); ContentValues values = new ContentValues(); - // 把云端节点的时间戳更新到本地的sync_id字段 values.put(NoteColumns.SYNC_ID, node.getLastModified()); mContentResolver.update(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, c.getLong(SqlNote.ID_COLUMN)), values, null, null); } else { - // 如果同步完,本地有的笔记在云端居然找不到了,说明出错了 Log.e(TAG, "something is missed"); throw new ActionFailureException( "some local items don't have gid after sync"); @@ -844,12 +790,10 @@ public class GTaskManager { } } - // 获取当前同步的账户名 public String getSyncAccount() { return GTaskClient.getInstance().getSyncAccount().name; } - // 从外部取消同步 public void cancelSync() { mCancelled = true; } diff --git a/src/src/net/micode/notes/gtask/remote/GTaskSyncService.java b/src/src/net/micode/notes/gtask/remote/GTaskSyncService.java index a03c96a..cca36f7 100644 --- a/src/src/net/micode/notes/gtask/remote/GTaskSyncService.java +++ b/src/src/net/micode/notes/gtask/remote/GTaskSyncService.java @@ -23,93 +23,64 @@ import android.content.Intent; import android.os.Bundle; import android.os.IBinder; -/** - * @Author: 林迪文 - * @Updator: 林迪文 - * @Date 2025/12/21 19:15 - * @Description 这个是后台同步Google任务的服务。别的页面通过发Intent来让它开始或者停止同步。它还会把同步的状态和进度广播出去,这样界面就能知道现在同步到哪一步了。 - */ public class GTaskSyncService extends Service { - // 用这个名字作为key,从Intent里拿具体操作类型 public final static String ACTION_STRING_NAME = "sync_action_type"; - public final static int ACTION_START_SYNC = 0; // 0代表开始同步 + public final static int ACTION_START_SYNC = 0; - public final static int ACTION_CANCEL_SYNC = 1; // 1代表取消同步 + public final static int ACTION_CANCEL_SYNC = 1; - public final static int ACTION_INVALID = 2; // 无效操作 + public final static int ACTION_INVALID = 2; - // 定义一个广播的名字,方便Activity接收 public final static String GTASK_SERVICE_BROADCAST_NAME = "net.micode.notes.gtask.remote.gtask_sync_service"; - // 广播里用这个key存bool值,表示在不在同步 public final static String GTASK_SERVICE_BROADCAST_IS_SYNCING = "isSyncing"; - // 广播里用这个key存进度信息 public final static String GTASK_SERVICE_BROADCAST_PROGRESS_MSG = "progressMsg"; - // 同步任务的实例。static保证了整个应用里只有一个同步任务在跑 private static GTaskASyncTask mSyncTask = null; - // 存当前的同步进度信息,也是static的,方便随时获取 private static String mSyncProgress = ""; - /** - * 接收到“开始同步”的指令后,执行这里的逻辑 - */ private void startSync() { - // 先判断一下是不是已经在同步了,防止重复启动 if (mSyncTask == null) { - // new一个异步任务出来,准备开始干活 mSyncTask = new GTaskASyncTask(this, new GTaskASyncTask.OnCompleteListener() { - /** - * 这里传了个回调,等任务干完之后会执行onComplete - */ public void onComplete() { - mSyncTask = null; // 任务结束了,把这个实例置空,这样下次才能再启动 - sendBroadcast(""); // 发广播通知界面任务结束 - stopSelf(); // 停 + mSyncTask = null; + sendBroadcast(""); + stopSelf(); } }); - sendBroadcast(""); // 任务刚开始,也发个广播通知一下 - mSyncTask.execute(); // 启动这个异步任务 + sendBroadcast(""); + mSyncTask.execute(); } } - /** - * 接收到“取消同步”的指令后,执行这里的逻辑 - */ private void cancelSync() { - // 确认一下任务是不是真的在跑,在跑才能取消 if (mSyncTask != null) { - mSyncTask.cancelSync(); // 调用任务自己的取消方法 + mSyncTask.cancelSync(); } } @Override public void onCreate() { - // 服务第一次创建的时候,确保mSyncTask是空的 mSyncTask = null; } @Override public int onStartCommand(Intent intent, int flags, int startId) { - // 从传过来的Intent里拿出数据包 Bundle bundle = intent.getExtras(); - // 看看数据包不为空,并且确实有我们需要的操作指令 if (bundle != null && bundle.containsKey(ACTION_STRING_NAME)) { - // 根据指令的类型,决定是开始还是取消 switch (bundle.getInt(ACTION_STRING_NAME, ACTION_INVALID)) { case ACTION_START_SYNC: - startSync(); // 开始同步 + startSync(); break; case ACTION_CANCEL_SYNC: - cancelSync(); // 取消同步 + cancelSync(); break; default: break; } - // 如果服务被系统杀了,系统会尝试重启服务 return START_STICKY; } return super.onStartCommand(intent, flags, startId); @@ -117,67 +88,41 @@ public class GTaskSyncService extends Service { @Override public void onLowMemory() { - // 系统内存不够时调用 if (mSyncTask != null) { - // 内存紧张,赶紧把正在跑的同步任务停了,别占资源 mSyncTask.cancelSync(); } } - @Override public IBinder onBind(Intent intent) { - // 这个服务不支持绑定,所以直接返回null return null; } - /** - * 把同步的状态广播出去 - * @param msg 要广播出去的进度消息 - */ public void sendBroadcast(String msg) { - mSyncProgress = msg; // 先把最新的进度信息存到静态变量里 - Intent intent = new Intent(GTASK_SERVICE_BROADCAST_NAME); // 创建一个广播Intent,用我们之前定义好的名字 - // 把“是否在同步”和“进度消息”这两个信息塞到Intent里 + mSyncProgress = msg; + Intent intent = new Intent(GTASK_SERVICE_BROADCAST_NAME); intent.putExtra(GTASK_SERVICE_BROADCAST_IS_SYNCING, mSyncTask != null); intent.putExtra(GTASK_SERVICE_BROADCAST_PROGRESS_MSG, msg); - sendBroadcast(intent); // 发射,这样关心这个广播的组件(比如Activity)就能收到了 + sendBroadcast(intent); } - /** - * 这是个静态方法,给外面的Activity用的,这样调用起来比较方便 - * @param activity 调用这个方法的Activity - */ public static void startSync(Activity activity) { - // 把Activity的上下文传给GTaskManager GTaskManager.getInstance().setActivityContext(activity); - Intent intent = new Intent(activity, GTaskSyncService.class); // 创建一个指向自己的Intent - intent.putExtra(GTaskSyncService.ACTION_STRING_NAME, GTaskSyncService.ACTION_START_SYNC); // 在Intent里放一个“开始同步”的指令 - activity.startService(intent); // 启动服务 + Intent intent = new Intent(activity, GTaskSyncService.class); + intent.putExtra(GTaskSyncService.ACTION_STRING_NAME, GTaskSyncService.ACTION_START_SYNC); + activity.startService(intent); } - /** - * 同样是给外面用的静态方法,用来取消同步 - * @param context 上下文环境 - */ public static void cancelSync(Context context) { Intent intent = new Intent(context, GTaskSyncService.class); - // 流程和startSync差不多,就是指令变成了“取消同步” intent.putExtra(GTaskSyncService.ACTION_STRING_NAME, GTaskSyncService.ACTION_CANCEL_SYNC); context.startService(intent); } - /** - * 提供一个静态方法,让外面能随时知道是不是正在同步 - */ public static boolean isSyncing() { - // 判断那个任务实例是不是null就行了 return mSyncTask != null; } - /** - * 获取当前进度的静态方法 - */ public static String getProgressString() { return mSyncProgress; } -} \ No newline at end of file +} diff --git a/src/src/net/micode/notes/ui/AlarmReceiver.java b/src/src/net/micode/notes/ui/AlarmReceiver.java index 4f667dd..54e503b 100644 --- a/src/src/net/micode/notes/ui/AlarmReceiver.java +++ b/src/src/net/micode/notes/ui/AlarmReceiver.java @@ -1,78 +1,30 @@ /* * 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.AlarmManager; -import android.app.PendingIntent; import android.content.BroadcastReceiver; -import android.content.ContentUris; import android.content.Context; import android.content.Intent; -import android.database.Cursor; - -import net.micode.notes.data.Notes; -import net.micode.notes.data.Notes.NoteColumns; - -/** - * 闹钟初始化广播接收器 (System Integration Layer) - *

- * 职责: - * 1. 监听系统启动完成广播 (BOOT_COMPLETED)。 - * 2. 扫描数据库中所有“未来需要提醒”的便签。 - * 3. 将这些提醒重新注册到 Android 系统 AlarmManager 中。 - *

- * 背景知识: - * Android 的 AlarmManager 注册的闹钟在设备重启后会丢失(非持久化)。 - * 因此必须通过此类在开机时重建闹钟队列,保证提醒功能不会因为重启而失效。 - */ -public class AlarmInitReceiver extends BroadcastReceiver { - - // 数据库查询投影:仅查询 ID 和 提醒时间,节省内存 - private static final String [] PROJECTION = new String [] { - NoteColumns.ID, - NoteColumns.ALERTED_DATE - }; - - private static final int COLUMN_ID = 0; - private static final int COLUMN_ALERTED_DATE = 1; +public class AlarmReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { - long currentDate = System.currentTimeMillis(); - - // 查询数据库:查找所有提醒时间晚于当前时间 (ALERTED_DATE > now) 的便签 - // 即只恢复那些“还没过期”的闹钟,过期的就不管了 - Cursor c = context.getContentResolver().query(Notes.CONTENT_NOTE_URI, - PROJECTION, - NoteColumns.ALERTED_DATE + ">? AND " + NoteColumns.TYPE + "=" + Notes.TYPE_NOTE, - new String[] { String.valueOf(currentDate) }, - null); - - if (c != null) { - if (c.moveToFirst()) { - do { - long alertDate = c.getLong(COLUMN_ALERTED_DATE); - - // 构建发送给 AlarmReceiver 的 Intent - // 这里必须与设置闹钟时的 Intent 结构完全一致,否则无法触发目标逻辑 - Intent sender = new Intent(context, AlarmReceiver.class); - sender.setData(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, c.getLong(COLUMN_ID))); - - PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, sender, 0); - - // 获取系统 AlarmManager 服务 - AlarmManager alermManager = (AlarmManager) context - .getSystemService(Context.ALARM_SERVICE); - - // 重新设定闹钟 - // RTC_WAKEUP: 使用绝对时间,并在触发时唤醒设备(如果设备处于休眠状态) - alermManager.set(AlarmManager.RTC_WAKEUP, alertDate, pendingIntent); - } while (c.moveToNext()); - } - c.close(); - } + intent.setClass(context, AlarmAlertActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); } } diff --git a/src/src/net/micode/notes/ui/NoteEditActivity.java b/src/src/net/micode/notes/ui/NoteEditActivity.java index 5a8bf80..e0df11b 100644 --- a/src/src/net/micode/notes/ui/NoteEditActivity.java +++ b/src/src/net/micode/notes/ui/NoteEditActivity.java @@ -119,7 +119,13 @@ public class NoteEditActivity extends Activity implements OnClickListener, // AI 菜单项的 ID (虽然主要逻辑已迁移至 XML 按钮,但保留此常量兼容旧代码) private static final int MENU_AI_OPT_ID = 999; + // ================= 图片功能相关常量与变量 ================= + private static final int REQUEST_CODE_TAKE_PHOTO = 1001; + private static final int REQUEST_CODE_OPEN_ALBUM = 1002; + // 用于临时存储相机拍摄照片的路径 + private String mCurrentPhotoPath; + // ======================================================== // 静态映射表:字体选中状态映射 private static final Map sFontSelectorSelectionMap = new HashMap(); static { @@ -136,7 +142,7 @@ public class NoteEditActivity extends Activity implements OnClickListener, private View mHeadViewPanel; private View mNoteBgColorSelector; // 背景色选择面板 private View mFontSizeSelector; // 字体大小选择面板 - private EditText mNoteEditor; // 核心编辑框 (普通模式) + private NoteEditText mNoteEditor; // 核心编辑框 (普通模式) private View mNoteEditorPanel; // 编辑区域容器 private LinearLayout mEditTextList; // 清单模式下的容器 (CheckList) @@ -174,6 +180,12 @@ public class NoteEditActivity extends Activity implements OnClickListener, // 这里手动绑定点击监听器,触发 AI 逻辑 findViewById(R.id.btn_ai_polish).setOnClickListener(this); // ============================ + // 绑定插入图片按钮 + findViewById(R.id.btn_insert_image).setOnClickListener(this); + // ... findViewById(R.id.btn_ai_polish).setOnClickListener(this); + + // 【绝杀】强制关闭编辑框的硬件加速,使用软件渲染 + mNoteEditor.setLayerType(View.LAYER_TYPE_SOFTWARE, null); } /** @@ -304,7 +316,15 @@ public class NoteEditActivity extends Activity implements OnClickListener, switchToListMode(mWorkingNote.getContent()); } else { // 普通模式:设置文本并处理搜索高亮 - mNoteEditor.setText(getHighlightQueryResult(mWorkingNote.getContent(), mUserQuery)); + // 原来是:mNoteEditor.setText(...) + // 修改为:使用支持图片的设置方法 + if (mWorkingNote.getCheckListMode() == TextNote.MODE_CHECK_LIST) { + switchToListMode(mWorkingNote.getContent()); + } else { + // 这一步至关重要!让它解析 标签 + mNoteEditor.setTextWithImages(getHighlightQueryResult(mWorkingNote.getContent(), mUserQuery).toString()); + mNoteEditor.setSelection(mNoteEditor.getText().length()); + } mNoteEditor.setSelection(mNoteEditor.getText().length()); } @@ -405,7 +425,7 @@ public class NoteEditActivity extends Activity implements OnClickListener, 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); + mNoteEditor = (NoteEditText) 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()) { @@ -465,6 +485,14 @@ public class NoteEditActivity extends Activity implements OnClickListener, */ public void onClick(View v) { int id = v.getId(); + // ... 在 onClick 方法内部 ... + + // === [新增] 处理插入图片按钮点击 === + if (id == R.id.btn_insert_image) { + showInsertImageDialog(); + return; + } + // ================================= // ============================================= // [新增] AI 智能助手入口 @@ -485,6 +513,7 @@ public class NoteEditActivity extends Activity implements OnClickListener, }) .show(); return; + } // 处理更改背景色按钮 @@ -985,6 +1014,7 @@ public class NoteEditActivity extends Activity implements OnClickListener, // [新功能] 启动 AI 智能分类流程 // 逻辑:获取文本 -> 调用AIService分类接口 -> 解析JSON -> 自动设置背景色和插入标签 // ================================================================================= + private void performAIClassification() { final String content = mNoteEditor.getText().toString(); if (content.trim().length() == 0) { @@ -1037,4 +1067,87 @@ public class NoteEditActivity extends Activity implements OnClickListener, } }); } + // 1. 显示选择对话框 + private void showInsertImageDialog() { + new AlertDialog.Builder(this) + .setTitle("插入图片") + .setItems(new String[]{"📷 拍摄照片", "🖼️ 从相册选择"}, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + if (which == 0) { + takePhoto(); + } else { + pickFromGallery(); + } + } + }) + .show(); + } + + // 2. 启动相机 + private void takePhoto() { + Intent takePictureIntent = new Intent(android.provider.MediaStore.ACTION_IMAGE_CAPTURE); + // 确保有相机应用能处理这个 Intent + if (takePictureIntent.resolveActivity(getPackageManager()) != null) { + try { + // 使用 MediaUtils 创建临时文件 (高内聚:Activity 不关心文件怎么创建的) + java.io.File photoFile = net.micode.notes.tool.MediaUtils.createImageFile(this); + mCurrentPhotoPath = photoFile.getAbsolutePath(); + + // 获取安全的 Content Uri (使用我们刚配置好的 FileProvider) + android.net.Uri photoURI = androidx.core.content.FileProvider.getUriForFile(this, + "net.micode.notes.fileprovider", + photoFile); + + takePictureIntent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, photoURI); + startActivityForResult(takePictureIntent, REQUEST_CODE_TAKE_PHOTO); + } catch (java.io.IOException ex) { + Toast.makeText(this, "创建图片文件失败", Toast.LENGTH_SHORT).show(); + } + } else { + Toast.makeText(this, "未找到相机应用", Toast.LENGTH_SHORT).show(); + } + } + + // 3. 打开相册 + private void pickFromGallery() { + Intent intent = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI); + startActivityForResult(intent, REQUEST_CODE_OPEN_ALBUM); + } + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (resultCode == RESULT_OK) { + switch (requestCode) { + case REQUEST_CODE_TAKE_PHOTO: + if (mCurrentPhotoPath != null) { + // === [修改] 调用 NoteEditText 的接口插入图片 === + mNoteEditor.insertImage(mCurrentPhotoPath); + // 【新增】立刻把含标签的新文本保存到 Model,防止 onResume 覆盖 + mWorkingNote.setWorkingText(mNoteEditor.getText().toString()); + } + break; + + case REQUEST_CODE_OPEN_ALBUM: + if (data != null && data.getData() != null) { + android.net.Uri selectedImage = data.getData(); + String localPath = net.micode.notes.tool.MediaUtils.copyUriToInternalStorage(this, selectedImage); + if (localPath != null) { + mNoteEditor.insertImage(localPath); + // 【新增】立刻保存到 Model + mWorkingNote.setWorkingText(mNoteEditor.getText().toString()); + } + + + // ============================================ + } + break; + + + } + } + super.onActivityResult(requestCode, resultCode, data); + } + + + } \ No newline at end of file diff --git a/src/src/net/micode/notes/ui/NoteEditText.java b/src/src/net/micode/notes/ui/NoteEditText.java index 8068537..217f887 100644 --- a/src/src/net/micode/notes/ui/NoteEditText.java +++ b/src/src/net/micode/notes/ui/NoteEditText.java @@ -20,6 +20,7 @@ import android.view.MenuItem; import android.view.MenuItem.OnMenuItemClickListener; import android.view.MotionEvent; import android.widget.EditText; +import android.widget.Toast; import net.micode.notes.R; @@ -89,6 +90,71 @@ public class NoteEditText extends EditText { mIndex = 0; } + // ================= [新增] 图片插入核心逻辑 ================= + + /** + * 插入图片到光标处 + * @param imagePath 图片的本地绝对路径 + */ + public void insertImage(String imagePath) { + try { + // 构造标签 + String tag = ""; + + // 插入文本 + int start = getSelectionStart(); + getText().insert(start, "\n" + tag + "\n"); + + // 注意:这里我们不再手动 setSpan,而是依赖下面的 setTextWithImages 统一处理 + // 这样逻辑更统一,不会出现“刚插进去有图,刷新后没图”的尴尬 + } catch (Exception e) { + e.printStackTrace(); + } + } + public void setTextWithImages(String text) { + // 1. 先设置纯文本 + setText(text); + + // 2. 寻找所有的 标签 + android.text.Editable editable = getText(); + String pattern = ""; // 正则表达式抓取路径 + java.util.regex.Pattern p = java.util.regex.Pattern.compile(pattern); + java.util.regex.Matcher m = p.matcher(text); + + while (m.find()) { + // 获取路径 + String imagePath = m.group(1); + int start = m.start(); + int end = m.end(); + + try { + // 3. 加载图片 (复用之前的逻辑) + int width = getWidth() - getPaddingLeft() - getPaddingRight(); + if (width <= 0) width = 1000; + int height = 1000; + + android.graphics.Bitmap bitmap = net.micode.notes.tool.MediaUtils.getCompressedBitmap(imagePath, width, height); + if (bitmap != null) { + android.graphics.drawable.Drawable drawable = new android.graphics.drawable.BitmapDrawable(getResources(), bitmap); + int imgWidth = drawable.getIntrinsicWidth(); + int imgHeight = drawable.getIntrinsicHeight(); + + if (imgWidth > width) { + float ratio = (float) width / imgWidth; + imgWidth = width; + imgHeight = (int) (imgHeight * ratio); + } + drawable.setBounds(0, 0, imgWidth, imgHeight); + + // 4. 使用我们修复版 CenterImageSpan + CenterImageSpan span = new CenterImageSpan(drawable, imagePath); + editable.setSpan(span, start, end, android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + } public void setIndex(int index) { mIndex = index; } diff --git a/src/src/net/micode/notes/ui/NotesPreferenceActivity.java b/src/src/net/micode/notes/ui/NotesPreferenceActivity.java index 3a0ba00..07c5f7e 100644 --- a/src/src/net/micode/notes/ui/NotesPreferenceActivity.java +++ b/src/src/net/micode/notes/ui/NotesPreferenceActivity.java @@ -1,6 +1,17 @@ /* * 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; @@ -36,41 +47,37 @@ import net.micode.notes.data.Notes; import net.micode.notes.data.Notes.NoteColumns; import net.micode.notes.gtask.remote.GTaskSyncService; -/** - * 偏好设置页面 (UI Layer - Settings) - *

- * 职责: - * 1. 管理应用配置,主要是 Google Task 同步账户的设置。 - * 2. 提供手动触发同步的按钮。 - * 3. 实时显示同步状态(正在同步、最后同步时间)。 - * 4. 监听同步服务广播,更新 UI。 - */ + public class NotesPreferenceActivity extends PreferenceActivity { public static final String PREFERENCE_NAME = "notes_preferences"; + public static final String PREFERENCE_SYNC_ACCOUNT_NAME = "pref_key_account_name"; + public static final String PREFERENCE_LAST_SYNC_TIME = "pref_last_sync_time"; + public static final String PREFERENCE_SET_BG_COLOR_KEY = "pref_key_bg_random_appear"; private static final String PREFERENCE_SYNC_ACCOUNT_KEY = "pref_sync_account_key"; + private static final String AUTHORITIES_FILTER_KEY = "authorities"; private PreferenceCategory mAccountCategory; - private GTaskReceiver mReceiver; // 广播接收器,监听同步服务状态 - private Account[] mOriAccounts; // 原始账户列表 + + private GTaskReceiver mReceiver; + + private Account[] mOriAccounts; + private boolean mHasAddedAccount; @Override protected void onCreate(Bundle icicle) { super.onCreate(icicle); - // 启用 ActionBar 的返回按钮 + /* using the app icon for navigation */ getActionBar().setDisplayHomeAsUpEnabled(true); - // 加载 XML 偏好设置布局 addPreferencesFromResource(R.xml.preferences); mAccountCategory = (PreferenceCategory) findPreference(PREFERENCE_SYNC_ACCOUNT_KEY); - - // 注册广播接收器,监听 GTaskSyncService 发出的状态广播 mReceiver = new GTaskReceiver(); IntentFilter filter = new IntentFilter(); filter.addAction(GTaskSyncService.GTASK_SERVICE_BROADCAST_NAME); @@ -85,8 +92,8 @@ public class NotesPreferenceActivity extends PreferenceActivity { protected void onResume() { super.onResume(); - // 自动检测是否添加了新账户 - // 如果用户刚才跳转到系统设置页添加了账户,回来后自动选中新账户 + // need to set sync account automatically if user has added a new + // account if (mHasAddedAccount) { Account[] accounts = getGoogleAccounts(); if (mOriAccounts != null && accounts.length > mOriAccounts.length) { @@ -117,7 +124,6 @@ public class NotesPreferenceActivity extends PreferenceActivity { super.onDestroy(); } - // 加载账户设置项 private void loadAccountPreference() { mAccountCategory.removeAll(); @@ -125,21 +131,20 @@ public class NotesPreferenceActivity extends PreferenceActivity { final String defaultAccount = getSyncAccountName(this); accountPref.setTitle(getString(R.string.preferences_account_title)); accountPref.setSummary(getString(R.string.preferences_account_summary)); - - // 设置点击事件:根据当前是否已设置账户,弹出不同的对话框 accountPref.setOnPreferenceClickListener(new OnPreferenceClickListener() { public boolean onPreferenceClick(Preference preference) { if (!GTaskSyncService.isSyncing()) { if (TextUtils.isEmpty(defaultAccount)) { - // 首次设置:显示账户选择列表 + // the first time to set account showSelectAccountAlertDialog(); } else { - // 已设置:显示更改/删除确认框 + // if the account has already been set, we need to promp + // user about the risk showChangeAccountConfirmAlertDialog(); } } else { Toast.makeText(NotesPreferenceActivity.this, - R.string.preferences_toast_cannot_change_account, Toast.LENGTH_SHORT) + R.string.preferences_toast_cannot_change_account, Toast.LENGTH_SHORT) .show(); } return true; @@ -149,12 +154,11 @@ public class NotesPreferenceActivity extends PreferenceActivity { mAccountCategory.addPreference(accountPref); } - // 加载同步按钮状态 private void loadSyncButton() { Button syncButton = (Button) findViewById(R.id.preference_sync_button); TextView lastSyncTimeView = (TextView) findViewById(R.id.prefenerece_sync_status_textview); - // 根据是否正在同步,切换按钮文字(立即同步 / 取消同步) + // set button state if (GTaskSyncService.isSyncing()) { syncButton.setText(getString(R.string.preferences_button_sync_cancel)); syncButton.setOnClickListener(new View.OnClickListener() { @@ -172,7 +176,7 @@ public class NotesPreferenceActivity extends PreferenceActivity { } syncButton.setEnabled(!TextUtils.isEmpty(getSyncAccountName(this))); - // 显示最后同步时间或当前进度 + // set last sync time if (GTaskSyncService.isSyncing()) { lastSyncTimeView.setText(GTaskSyncService.getProgressString()); lastSyncTimeView.setVisibility(View.VISIBLE); @@ -194,20 +198,55 @@ public class NotesPreferenceActivity extends PreferenceActivity { loadSyncButton(); } - // 弹出账户选择对话框 private void showSelectAccountAlertDialog() { AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(this); - // ... (省略 UI 构建代码) ... - // 获取系统账户列表并显示 + + View titleView = LayoutInflater.from(this).inflate(R.layout.account_dialog_title, null); + TextView titleTextView = (TextView) titleView.findViewById(R.id.account_dialog_title); + titleTextView.setText(getString(R.string.preferences_dialog_select_account_title)); + TextView subtitleTextView = (TextView) titleView.findViewById(R.id.account_dialog_subtitle); + subtitleTextView.setText(getString(R.string.preferences_dialog_select_account_tips)); + + dialogBuilder.setCustomTitle(titleView); + dialogBuilder.setPositiveButton(null, null); + Account[] accounts = getGoogleAccounts(); - // ... - // 提供“添加账户”按钮,跳转到系统设置 + String defAccount = getSyncAccountName(this); + + mOriAccounts = accounts; + mHasAddedAccount = false; + + if (accounts.length > 0) { + CharSequence[] items = new CharSequence[accounts.length]; + final CharSequence[] itemMapping = items; + int checkedItem = -1; + int index = 0; + for (Account account : accounts) { + if (TextUtils.equals(account.name, defAccount)) { + checkedItem = index; + } + items[index++] = account.name; + } + dialogBuilder.setSingleChoiceItems(items, checkedItem, + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + setSyncAccount(itemMapping[which].toString()); + dialog.dismiss(); + refreshUI(); + } + }); + } + + View addAccountView = LayoutInflater.from(this).inflate(R.layout.add_account_text, null); + dialogBuilder.setView(addAccountView); + + final AlertDialog dialog = dialogBuilder.show(); addAccountView.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { mHasAddedAccount = true; Intent intent = new Intent("android.settings.ADD_ACCOUNT_SETTINGS"); intent.putExtra(AUTHORITIES_FILTER_KEY, new String[] { - "gmail-ls" + "gmail-ls" }); startActivityForResult(intent, -1); dialog.dismiss(); @@ -215,21 +254,42 @@ public class NotesPreferenceActivity extends PreferenceActivity { }); } - // 弹出更改/移除账户确认框 private void showChangeAccountConfirmAlertDialog() { - // ... + AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(this); + + View titleView = LayoutInflater.from(this).inflate(R.layout.account_dialog_title, null); + TextView titleTextView = (TextView) titleView.findViewById(R.id.account_dialog_title); + titleTextView.setText(getString(R.string.preferences_dialog_change_account_title, + getSyncAccountName(this))); + TextView subtitleTextView = (TextView) titleView.findViewById(R.id.account_dialog_subtitle); + subtitleTextView.setText(getString(R.string.preferences_dialog_change_account_warn_msg)); + dialogBuilder.setCustomTitle(titleView); + + CharSequence[] menuItemArray = new CharSequence[] { + getString(R.string.preferences_menu_change_account), + getString(R.string.preferences_menu_remove_account), + getString(R.string.preferences_menu_cancel) + }; + dialogBuilder.setItems(menuItemArray, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + if (which == 0) { + showSelectAccountAlertDialog(); + } else if (which == 1) { + removeSyncAccount(); + refreshUI(); + } + } + }); + dialogBuilder.show(); } - // 获取系统中的 Google 账户 private Account[] getGoogleAccounts() { AccountManager accountManager = AccountManager.get(this); return accountManager.getAccountsByType("com.google"); } - // 设置选中的同步账户 private void setSyncAccount(String account) { if (!getSyncAccountName(this).equals(account)) { - // 保存账户名到 SharedPreferences SharedPreferences settings = getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE); SharedPreferences.Editor editor = settings.edit(); if (account != null) { @@ -239,11 +299,10 @@ public class NotesPreferenceActivity extends PreferenceActivity { } editor.commit(); - // 重置最后同步时间 + // clean up last sync time setLastSyncTime(this, 0); - // 关键步骤:清理本地数据库中的 GTask ID 映射 - // 因为切换账户后,本地便签与云端的对应关系失效,必须重置 + // clean up local gtask related info new Thread(new Runnable() { public void run() { ContentValues values = new ContentValues(); @@ -260,8 +319,25 @@ public class NotesPreferenceActivity extends PreferenceActivity { } private void removeSyncAccount() { - // 逻辑类似 setSyncAccount,只是移除配置 - // ... + SharedPreferences settings = getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE); + SharedPreferences.Editor editor = settings.edit(); + if (settings.contains(PREFERENCE_SYNC_ACCOUNT_NAME)) { + editor.remove(PREFERENCE_SYNC_ACCOUNT_NAME); + } + if (settings.contains(PREFERENCE_LAST_SYNC_TIME)) { + editor.remove(PREFERENCE_LAST_SYNC_TIME); + } + editor.commit(); + + // clean up local gtask related info + new Thread(new Runnable() { + public void run() { + ContentValues values = new ContentValues(); + values.put(NoteColumns.GTASK_ID, ""); + values.put(NoteColumns.SYNC_ID, 0); + getContentResolver().update(Notes.CONTENT_NOTE_URI, values, null, null); + } + }).start(); } public static String getSyncAccountName(Context context) { @@ -284,11 +360,8 @@ public class NotesPreferenceActivity extends PreferenceActivity { return settings.getLong(PREFERENCE_LAST_SYNC_TIME, 0); } - /** - * 内部广播接收器 - * 监听同步服务的状态变化(开始、进行中、结束),刷新 UI - */ private class GTaskReceiver extends BroadcastReceiver { + @Override public void onReceive(Context context, Intent intent) { refreshUI(); @@ -297,6 +370,7 @@ public class NotesPreferenceActivity extends PreferenceActivity { syncStatus.setText(intent .getStringExtra(GTaskSyncService.GTASK_SERVICE_BROADCAST_PROGRESS_MSG)); } + } } @@ -311,4 +385,4 @@ public class NotesPreferenceActivity extends PreferenceActivity { return false; } } -} \ No newline at end of file +}