新增AI助手管家

pull/4/head
XUYE23 1 month ago
parent a49c0ae374
commit 5ed57280df

@ -1,27 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="net.micode.notes"
android:versionCode="1"
android:versionName="0.1" >
<uses-sdk android:minSdkVersion="14" />
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
<uses-permission android:name="android.permission.INTERNET" />
@ -31,31 +10,60 @@
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
<uses-permission android:name="android.permission.USE_CREDENTIALS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- 原有的权限可能已经存在,请确保补充以下权限 -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- 针对 Android 13+ (API 33+) 的图片读取权限 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- 声明相机硬件特性(非强制,但推荐) -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:icon="@drawable/icon_app"
android:label="@string/app_name" >
<activity
android:name=".ui.NotesListActivity"
android:configChanges="keyboardHidden|orientation|screenSize"
android:label="@string/app_name"
android:launchMode="singleTop"
android:theme="@style/NoteTheme"
android:uiOptions="splitActionBarWhenNarrow"
android:windowSoftInputMode="adjustPan" >
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/NoteTheme"
tools:targetApi="31">
<!-- 新的入口 -->
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:theme="@style/Theme.AppCompat.Light.NoActionBar"> <!-- 注意 Theme -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- 旧的 Activity保留声明去掉 intent-filter -->
<activity
android:name=".ui.NotesListActivity"
android:configChanges="keyboardHidden|orientation|screenSize"
android:launchMode="singleTop"
android:theme="@style/NoteTheme"
android:uiOptions="splitActionBarWhenNarrow"
android:windowSoftInputMode="adjustPan" />
<!-- 放在 application 标签内部 -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="net.micode.notes.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".ui.NoteEditActivity"
android:configChanges="keyboardHidden|orientation|screenSize"
android:launchMode="singleTop"
android:theme="@style/NoteTheme" >
android:theme="@style/NoteTheme"
android:exported="true">
<!-- 修复了这里的语法错误,去掉了多余的 > -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@ -63,7 +71,7 @@
<data android:mimeType="vnd.android.cursor.item/call_note" />
</intent-filter>
<intent-filter>
<intent-filter >
<action android:name="android.intent.action.INSERT_OR_EDIT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.item/text_note" />
@ -80,6 +88,7 @@
android:resource="@xml/searchable" />
</activity>
<provider
android:name="net.micode.notes.data.NotesProvider"
android:authorities="micode_notes"
@ -87,10 +96,13 @@
<receiver
android:name=".widget.NoteWidgetProvider_2x"
android:label="@string/app_widget2x2" >
android:label="@string/app_widget2x2"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="android.appwidget.action.APPWIDGET_DELETED" />
<action android:name="android.intent.action.PRIVACY_MODE_CHANGED" />
</intent-filter>
@ -100,7 +112,8 @@
</receiver>
<receiver
android:name=".widget.NoteWidgetProvider_4x"
android:label="@string/app_widget4x4" >
android:label="@string/app_widget4x4"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@ -113,7 +126,8 @@
android:resource="@xml/widget_4x_info" />
</receiver>
<receiver android:name=".ui.AlarmInitReceiver" >
<receiver android:name=".ui.AlarmInitReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
@ -146,5 +160,7 @@
<meta-data
android:name="android.app.default_searchable"
android:value=".ui.NoteEditActivity" />
</application>
</manifest>
</manifest>

@ -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" />
<LinearLayout
android:id="@+id/note_edit_list"
@ -115,7 +112,30 @@
android:background="@drawable/bg_color_btn_mask" />
</LinearLayout>
</LinearLayout>
<!-- ================== 新增:插入图片按钮 ================== -->
<!-- layout_marginRight="100dip" 是为了不挡住 AI 按钮(50) 和 底色按钮(0) -->
<ImageView
android:id="@+id/btn_insert_image"
android:layout_height="43dip"
android:layout_width="43dip"
android:src="@android:drawable/ic_menu_gallery"
android:scaleType="centerInside"
android:layout_gravity="top|right"
android:layout_marginRight="100dip"
android:padding="8dip" />
<!-- ====================================================== -->
<!-- ================== 新增的 AI 按钮 ================== -->
<!-- layout_marginRight="50dip" 是为了把它向左推,不遮挡原来的按钮 -->
<ImageView
android:id="@+id/btn_ai_polish"
android:layout_height="43dip"
android:layout_width="43dip"
android:src="@android:drawable/ic_menu_search"
android:scaleType="centerInside"
android:layout_gravity="top|right"
android:layout_marginRight="50dip"
android:padding="8dip" />
<!-- ==================================================== -->
<ImageView
android:id="@+id/btn_set_bg_color"
android:layout_height="43dip"

@ -25,70 +25,48 @@ import android.util.Log;
import java.util.HashMap;
/**
* <b></b>
* <p>
* Android
* </p>
*
* <h3></h3>
* <ul>
* <li></li>
* <li> {@code HashMap} IPC </li>
* <li></li>
* </ul>
*
* @author
* @version 1.0
* @see android.provider.ContactsContract
*/
public class Contact {
//键值对,用于缓存<姓名,文本>,提高查找速度。
private static HashMap<String, String> 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<String, String>();
} //初次调用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;
}
}

@ -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_notescall_notes
* <P> Type: Text </P>
*/
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");

@ -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;
/**
*
* <p>
* SQLite
* 使 SQLite <b>Trigger</b>
*
* Java
* </p>
*
* @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");
}
}
}

@ -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;
/**
*
* <p>
* Android {@code ContentProvider}
* SQLite
* UI
*
* </p>
*
* @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.
* <p>
* SQL Android SearchManager
* x'0A'
* ICON Intent
* </p>
*/
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;
}
}
}

@ -24,62 +24,37 @@ import net.micode.notes.tool.GTaskStringUtils;
import org.json.JSONException;
import org.json.JSONObject;
/**
* Google Task
* <p>
* {@code Task}
* ID JSON GTask Notes
*
* </p>
*
* @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");
}
}
}

@ -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);
/**
* GoogleJSONNode
* @param js JSON
*/
public abstract void setContentByRemoteJSON(JSONObject js);
/**
* JSONNode
* @param js JSON
*/
public abstract void setContentByLocalJSON(JSONObject js);
/**
* NodeJSON便
* @return JSON
*/
public abstract JSONObject getLocalJSONFromContent();
/**
* NodeCursor
*
* @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;
}
}
}

@ -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;
}
}
}

@ -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<SqlData> mDataList; // 一条笔记可能包含多条数据比如文字、录音用list存起来
/**
* @Author:
* @Updator:
* @Date: 2025/12/16 17:30
* @Description:
*/
private ArrayList<SqlData> 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<SqlData>();
}
/**
* @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<SqlData>();
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<SqlData>();
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;
}
}
}

@ -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 TasksJSONJSON
*/
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;
}
/**
GoogleJSONTask
*/
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 {
}
}
/**
JSONTask
*/
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: TaskJSON
*/
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;
}
}
}

@ -30,46 +30,37 @@ import org.json.JSONObject;
import java.util.ArrayList;
/**
* @Author:
* @Updator:
* @Date: 2025/12/23 17:15
* @Description: Google TasksAppTask
*/
public class TaskList extends Node {
// 日志TAG
private static final String TAG = TaskList.class.getSimpleName();
private int mIndex; // 任务列表的索引位置
private int mIndex;
private ArrayList<Task> mChildren; // 用一个List来存放这个列表下的所有任务
private ArrayList<Task> mChildren;
public TaskList() {
super(); // 调用父类的构造函数
mChildren = new ArrayList<Task>(); // new一个list出来准备装东西
super();
mChildren = new ArrayList<Task>();
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;
}
}
}

@ -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);
}
}
}

@ -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);
}
}
}

@ -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<Void, String, Integer> {
// 给同步时弹出的通知栏消息一个唯一的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<Void, String, Integer> {
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<Void, String, Integer> {
@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<Void, String, Integer> {
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();
}
}
}
}

@ -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 ActivityAccountManager
* @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;
}
/**
* AndroidGoogleAuthToken
* @param activity
* @param invalidateToken token
* @return AuthTokennull
*/
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<Bundle> 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;
}
/**
* tokenGtasktoken
* @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;
}
/**
* HttpClientGETGoogleCookieclientVersion
* @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<Cookie> 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 = ")}</script>";
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<BasicNameValuePair> list = new LinkedList<BasicNameValuePair>();
// 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服务器给这个新任务分配的唯一IDgid拿出来存回我们自己的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 = ")}</script>";
@ -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;
}

@ -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<String, TaskList> mGTaskListHashMap;
private static GTaskManager mInstance = null; // 单例模式
private HashMap<String, Node> mGTaskHashMap;
private Activity mActivity; // Activity的上下文主要给GTaskClient登录用
private Context mContext; // 全局的上下文
private ContentResolver mContentResolver; // 内容提供者可以理解成咱们App访问便签数据库的“管家”
private HashMap<String, MetaData> mMetaHashMap;
private boolean mSyncing; // 同步状态的标记
private boolean mCancelled; // 取消同步的标记
private TaskList mMetaList;
// HashMap是整个同步算法的核心用来在内存里缓存和映射数据
private HashMap<String, TaskList> mGTaskListHashMap; // Google云端任务清单的缓存key是清单的gid
private HashMap<String, Node> mGTaskHashMap; // Google云端所有节点的缓存包括清单和任务key是gid
private HashMap<String, MetaData> mMetaHashMap; // Google云端元数据的缓存key是被关联任务的gid
private TaskList mMetaList; // 专门存meta数据的那个清单对象
private HashSet<Long> mLocalDeleteIdMap;
private HashSet<Long> mLocalDeleteIdMap; // 存放在本地被删除了的笔记ID
private HashMap<String, Long> mGidToNid;
// 这两个是关键用来建立Google ID和本地数据库ID之间的对应关系
private HashMap<String, Long> mGidToNid; // Google ID到本地笔记ID的映射
private HashMap<Long, String> mNidToGid; // 本地笔记ID到Google ID的映射
private HashMap<Long, String> mNidToGid;
private GTaskManager() {
// 构造函数里做一些初始化,防止空指针
mSyncing = false;
mCancelled = false;
mGTaskListHashMap = new HashMap<String, TaskList>();
@ -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 TasksHashMap
* @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<Map.Entry<String, Node>> iter = mGTaskHashMap.entrySet().iterator();
while (iter.hasNext()) {
Map.Entry<String, Node> 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<Map.Entry<String, TaskList>> iter = mGTaskListHashMap.entrySet().iterator();
while (iter.hasNext()) {
Map.Entry<String, TaskList> 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;
}

@ -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 GoogleIntent广
*/
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;
}
}
}

@ -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)
* <p>
*
* 1. 广 (BOOT_COMPLETED)
* 2. 便
* 3. Android AlarmManager
* <p>
*
* 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);
}
}

@ -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<Integer, Integer> sFontSelectorSelectionMap = new HashMap<Integer, Integer>();
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 {
// 这一步至关重要!让它解析 <img...> 标签
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);
}
}

@ -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 = "<img src=\"" + imagePath + "\"/>";
// 插入文本
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. 寻找所有的 <img src="..."> 标签
android.text.Editable editable = getText();
String pattern = "<img src=\"(.*?)\"/>"; // 正则表达式抓取路径
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;
}

@ -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)
* <p>
*
* 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;
}
}
}
}

Loading…
Cancel
Save