From 802f8283187b9bc39539565e7b4de8a3aae76ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=B0=94=E4=BF=8A?= Date: Fri, 30 Jan 2026 18:30:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BA=91=E5=90=8C=E6=AD=A5=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../.idea/deploymentTargetSelector.xml | 8 + src/Notesmaster/app/.project | 6 + src/Notesmaster/app/build.gradle.kts | 27 + .../app/src/main/AndroidManifest.xml | 42 + .../java/net/micode/notes/MainActivity.java | 9 +- .../net/micode/notes/NotesApplication.java | 20 +- .../net/micode/notes/api/AliyunConfig.java | 58 ++ .../net/micode/notes/api/AliyunService.java | 94 +++ .../net/micode/notes/api/CloudCallback.java | 27 + .../micode/notes/api/CloudDatabaseHelper.java | 284 +++++++ .../micode/notes/api/RetryInterceptor.java | 110 +++ .../notes/auth/AnonymousAuthManager.java | 129 +++ .../micode/notes/auth/UserAuthManager.java | 473 +++++++++++ .../java/net/micode/notes/data/Notes.java | 30 + .../notes/data/NotesDatabaseHelper.java | 142 +++- .../micode/notes/data/NotesRepository.java | 508 ++++++++++++ .../net/micode/notes/model/CloudNote.java | 193 +++++ .../java/net/micode/notes/model/Note.java | 65 +- .../net/micode/notes/model/WorkingNote.java | 216 ++++- .../java/net/micode/notes/sync/Conflict.java | 112 +++ .../notes/sync/NotesPushMessageReceiver.java | 65 ++ .../net/micode/notes/sync/SyncConstants.java | 55 ++ .../net/micode/notes/sync/SyncManager.java | 746 ++++++++++++++++++ .../net/micode/notes/sync/SyncWorker.java | 118 +++ .../notes/ui/ConflictResolutionDialog.java | 217 +++++ .../net/micode/notes/ui/LoginActivity.java | 145 ++++ .../net/micode/notes/ui/NoteEditActivity.java | 23 + .../micode/notes/ui/NotesListActivity.java | 231 +++++- .../net/micode/notes/ui/SidebarFragment.java | 661 +++++++--------- .../net/micode/notes/ui/SyncActivity.java | 154 ++++ .../notes/viewmodel/LoginViewModel.java | 260 ++++++ .../drawer_header_circle_decorator.xml | 11 + .../res/drawable/drawer_header_gradient.xml | 20 + .../main/res/drawable/ic_account_circle.xml | 17 + .../src/main/res/drawable/ic_cloud_done.xml | 17 + .../main/res/drawable/ic_cloud_settings.xml | 25 + .../app/src/main/res/drawable/ic_delete.xml | 17 + .../app/src/main/res/drawable/ic_export.xml | 17 + .../app/src/main/res/drawable/ic_favorite.xml | 17 + .../app/src/main/res/drawable/ic_help.xml | 17 + .../app/src/main/res/drawable/ic_login.xml | 17 + .../app/src/main/res/drawable/ic_logout.xml | 17 + .../app/src/main/res/drawable/ic_notes.xml | 17 + .../src/main/res/drawable/ic_notes_logo.xml | 17 + .../app/src/main/res/drawable/ic_reminder.xml | 17 + .../app/src/main/res/drawable/ic_settings.xml | 17 + .../app/src/main/res/drawable/ic_sync.xml | 17 + .../app/src/main/res/drawable/ic_template.xml | 17 + .../res/drawable/online_status_indicator.xml | 15 + .../src/main/res/layout/activity_login.xml | 100 +++ .../app/src/main/res/layout/activity_sync.xml | 149 ++++ .../res/layout/dialog_conflict_resolution.xml | 124 +++ .../res/layout/drawer_folder_expand_icon.xml | 11 + .../app/src/main/res/layout/drawer_header.xml | 125 +++ .../main/res/layout/drawer_menu_divider.xml | 11 + .../src/main/res/layout/fragment_sidebar.xml | 339 ++++++++ .../main/res/layout/sidebar_folder_item.xml | 15 +- .../app/src/main/res/menu/drawer_menu.xml | 92 +++ .../app/src/main/res/values/colors.xml | 7 + .../app/src/main/res/values/strings.xml | 55 ++ .../app/src/main/res/values/styles.xml | 6 + .../net/micode/notes/model/CloudNoteTest.java | 70 ++ src/Notesmaster/settings.gradle.kts | 3 + 63 files changed, 6246 insertions(+), 398 deletions(-) create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/api/AliyunConfig.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/api/AliyunService.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/api/CloudCallback.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/api/CloudDatabaseHelper.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/api/RetryInterceptor.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/auth/AnonymousAuthManager.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/auth/UserAuthManager.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/model/CloudNote.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/sync/Conflict.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/sync/NotesPushMessageReceiver.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/sync/SyncConstants.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/sync/SyncManager.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/sync/SyncWorker.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/ui/ConflictResolutionDialog.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/ui/LoginActivity.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/ui/SyncActivity.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/LoginViewModel.java create mode 100644 src/Notesmaster/app/src/main/res/drawable/drawer_header_circle_decorator.xml create mode 100644 src/Notesmaster/app/src/main/res/drawable/drawer_header_gradient.xml create mode 100644 src/Notesmaster/app/src/main/res/drawable/ic_account_circle.xml create mode 100644 src/Notesmaster/app/src/main/res/drawable/ic_cloud_done.xml create mode 100644 src/Notesmaster/app/src/main/res/drawable/ic_cloud_settings.xml create mode 100644 src/Notesmaster/app/src/main/res/drawable/ic_delete.xml create mode 100644 src/Notesmaster/app/src/main/res/drawable/ic_export.xml create mode 100644 src/Notesmaster/app/src/main/res/drawable/ic_favorite.xml create mode 100644 src/Notesmaster/app/src/main/res/drawable/ic_help.xml create mode 100644 src/Notesmaster/app/src/main/res/drawable/ic_login.xml create mode 100644 src/Notesmaster/app/src/main/res/drawable/ic_logout.xml create mode 100644 src/Notesmaster/app/src/main/res/drawable/ic_notes.xml create mode 100644 src/Notesmaster/app/src/main/res/drawable/ic_notes_logo.xml create mode 100644 src/Notesmaster/app/src/main/res/drawable/ic_reminder.xml create mode 100644 src/Notesmaster/app/src/main/res/drawable/ic_settings.xml create mode 100644 src/Notesmaster/app/src/main/res/drawable/ic_sync.xml create mode 100644 src/Notesmaster/app/src/main/res/drawable/ic_template.xml create mode 100644 src/Notesmaster/app/src/main/res/drawable/online_status_indicator.xml create mode 100644 src/Notesmaster/app/src/main/res/layout/activity_login.xml create mode 100644 src/Notesmaster/app/src/main/res/layout/activity_sync.xml create mode 100644 src/Notesmaster/app/src/main/res/layout/dialog_conflict_resolution.xml create mode 100644 src/Notesmaster/app/src/main/res/layout/drawer_folder_expand_icon.xml create mode 100644 src/Notesmaster/app/src/main/res/layout/drawer_header.xml create mode 100644 src/Notesmaster/app/src/main/res/layout/drawer_menu_divider.xml create mode 100644 src/Notesmaster/app/src/main/res/layout/fragment_sidebar.xml create mode 100644 src/Notesmaster/app/src/main/res/menu/drawer_menu.xml create mode 100644 src/Notesmaster/app/src/test/java/net/micode/notes/model/CloudNoteTest.java diff --git a/src/Notesmaster/.idea/deploymentTargetSelector.xml b/src/Notesmaster/.idea/deploymentTargetSelector.xml index b268ef3..3e68f31 100644 --- a/src/Notesmaster/.idea/deploymentTargetSelector.xml +++ b/src/Notesmaster/.idea/deploymentTargetSelector.xml @@ -4,6 +4,14 @@ diff --git a/src/Notesmaster/app/.project b/src/Notesmaster/app/.project index 4b50465..33c0cbe 100644 --- a/src/Notesmaster/app/.project +++ b/src/Notesmaster/app/.project @@ -5,6 +5,11 @@ + + org.eclipse.jdt.core.javabuilder + + + org.eclipse.buildship.core.gradleprojectbuilder @@ -12,6 +17,7 @@ + org.eclipse.jdt.core.javanature org.eclipse.buildship.core.gradleprojectnature diff --git a/src/Notesmaster/app/build.gradle.kts b/src/Notesmaster/app/build.gradle.kts index 295d7d2..c5b00fd 100644 --- a/src/Notesmaster/app/build.gradle.kts +++ b/src/Notesmaster/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) } @@ -10,6 +12,7 @@ android { // 启用ViewBinding buildFeatures { viewBinding = true + buildConfig = true } defaultConfig { @@ -20,6 +23,18 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + // Load Aliyun config from local.properties + val localProperties = Properties() + val localFile = rootProject.file("local.properties") + if (localFile.exists()) { + localFile.inputStream().use { localProperties.load(it) } + } + + buildConfigField("String", "ALIYUN_APP_KEY", "\"${localProperties.getProperty("aliyun.app.key", "")}\"") + buildConfigField("String", "ALIYUN_APP_SECRET", "\"${localProperties.getProperty("aliyun.app.secret", "")}\"") + buildConfigField("String", "ALIYUN_SPACE_ID", "\"${localProperties.getProperty("aliyun.space.id", "")}\"") + buildConfigField("String", "ALIYUN_ENDPOINT", "\"${localProperties.getProperty("aliyun.endpoint", "")}\"") } buildTypes { @@ -64,4 +79,16 @@ dependencies { testImplementation("org.mockito:mockito-core:5.7.0") androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.espresso.core) + + // Cloud sync dependencies + implementation("com.google.code.gson:gson:2.10.1") + implementation("androidx.work:work-runtime:2.8.1") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + + // Alibaba Cloud EMAS Push SDK + implementation("com.aliyun.ams:alicloud-android-push:3.10.1") + implementation("com.aliyun.ams:alicloud-android-utils:1.1.3") + + // Note: EMAS Serverless SDK needs to be downloaded from Aliyun console + // For now, use HTTP API approach with OkHttp } diff --git a/src/Notesmaster/app/src/main/AndroidManifest.xml b/src/Notesmaster/app/src/main/AndroidManifest.xml index a08a1f3..d7e4cd4 100644 --- a/src/Notesmaster/app/src/main/AndroidManifest.xml +++ b/src/Notesmaster/app/src/main/AndroidManifest.xml @@ -23,6 +23,14 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -216,6 +250,14 @@ --> + + + + + * 存储阿里云EMAS服务的配置信息,从BuildConfig读取(由local.properties生成) + * 敏感信息不再硬编码在源码中,避免泄露风险 + *

+ */ +public class AliyunConfig { + + /** + * 阿里云EMAS AppKey (从BuildConfig读取) + */ + public static final String APP_KEY = BuildConfig.ALIYUN_APP_KEY; + + /** + * 阿里云EMAS AppSecret (从BuildConfig读取) + */ + public static final String APP_SECRET = BuildConfig.ALIYUN_APP_SECRET; + + /** + * 阿里云EMAS Serverless Space ID (从BuildConfig读取) + */ + public static final String SPACE_ID = BuildConfig.ALIYUN_SPACE_ID; + + /** + * 服务端点(EMAS Serverless HTTP触发器)(从BuildConfig读取) + */ + public static final String ENDPOINT = BuildConfig.ALIYUN_ENDPOINT; + + /** + * API基础路径 + */ + public static final String BASE_URL = ENDPOINT + "/api"; + + private AliyunConfig() { + // Utility class, prevent instantiation + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/api/AliyunService.java b/src/Notesmaster/app/src/main/java/net/micode/notes/api/AliyunService.java new file mode 100644 index 0000000..f90613c --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/api/AliyunService.java @@ -0,0 +1,94 @@ +/* + * 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.api; + +import android.content.Context; +import android.util.Log; + +import com.alibaba.sdk.android.push.CloudPushService; +import com.alibaba.sdk.android.push.CommonCallback; +import com.alibaba.sdk.android.push.noonesdk.PushServiceFactory; + +/** + * 阿里云服务管理类 + *

+ * 管理阿里云EMAS服务的初始化和配置,包括推送服务、云数据库等。 + *

+ */ +public class AliyunService { + + private static final String TAG = "AliyunService"; + private static AliyunService sInstance; + private String mDeviceId; + + private AliyunService() { + // Private constructor for singleton + } + + /** + * 获取AliyunService单例实例 + * + * @return AliyunService实例 + */ + public static synchronized AliyunService getInstance() { + if (sInstance == null) { + sInstance = new AliyunService(); + } + return sInstance; + } + + /** + * 初始化阿里云服务 + * + * @param context 应用上下文 + */ + public void initialize(Context context) { + Log.i(TAG, "Initializing AliyunService with AppKey: " + AliyunConfig.APP_KEY); + + try { + // Initialize push service + PushServiceFactory.init(context); + CloudPushService pushService = PushServiceFactory.getCloudPushService(); + + pushService.register(context, AliyunConfig.APP_KEY, AliyunConfig.APP_SECRET, new CommonCallback() { + @Override + public void onSuccess(String response) { + mDeviceId = pushService.getDeviceId(); + Log.i(TAG, "Alibaba Cloud Push SDK registered successfully"); + Log.i(TAG, "Device ID: " + mDeviceId); + } + + @Override + public void onFailed(String errorCode, String errorMessage) { + Log.e(TAG, "Alibaba Cloud Push SDK registration failed: " + errorCode + " - " + errorMessage); + } + }); + + } catch (Exception e) { + Log.e(TAG, "Failed to initialize AliyunService", e); + } + } + + /** + * 获取设备ID + * + * @return 设备ID + */ + public String getDeviceId() { + return mDeviceId; + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/api/CloudCallback.java b/src/Notesmaster/app/src/main/java/net/micode/notes/api/CloudCallback.java new file mode 100644 index 0000000..5fb8235 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/api/CloudCallback.java @@ -0,0 +1,27 @@ +/* + * 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.api; + +/** + * 云数据库操作回调接口 + * + * @param 返回数据类型 + */ +public interface CloudCallback { + void onSuccess(T result); + void onError(String error); +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/api/CloudDatabaseHelper.java b/src/Notesmaster/app/src/main/java/net/micode/notes/api/CloudDatabaseHelper.java new file mode 100644 index 0000000..6edbc50 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/api/CloudDatabaseHelper.java @@ -0,0 +1,284 @@ +/* + * 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.api; + +import android.util.Log; + +import net.micode.notes.model.CloudNote; +import net.micode.notes.model.WorkingNote; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +/** + * 云数据库帮助类(EMAS Serverless HTTP API版本) + *

+ * 通过HTTP API与阿里云EMAS Serverless交互。 + *

+ */ +public class CloudDatabaseHelper { + + private static final String TAG = "CloudDatabaseHelper"; + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + private static final String API_NOTES = AliyunConfig.BASE_URL + "/notes"; + + private String mUserId; + private String mDeviceId; + private String mAuthToken; + private OkHttpClient mHttpClient; + + public CloudDatabaseHelper(String userId, String deviceId, String authToken) { + mUserId = userId; + mDeviceId = deviceId; + mAuthToken = authToken; + mHttpClient = new OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .addInterceptor(new RetryInterceptor()) + .build(); + } + + /** + * 上传笔记到云端 + */ + public void uploadNote(WorkingNote note, CloudCallback callback) { + Log.d(TAG, "Uploading note: " + note.getNoteId()); + + CloudNote cloudNote = new CloudNote(note, mDeviceId); + JSONObject json; + try { + json = cloudNote.toJson(); + json.put("action", "upload"); + json.put("userId", mUserId); + } catch (JSONException e) { + Log.e(TAG, "Failed to create JSON", e); + callback.onError("数据格式错误"); + return; + } + + RequestBody body = RequestBody.create(json.toString(), JSON); + Request request = new Request.Builder() + .url(API_NOTES) + .post(body) + .addHeader("Authorization", "Bearer " + mAuthToken) + .addHeader("Content-Type", "application/json") + .build(); + + mHttpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + Log.e(TAG, "Upload failed", e); + callback.onError("网络错误: " + e.getMessage()); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + try { + String responseBody = response.body().string(); + JSONObject jsonResponse = new JSONObject(responseBody); + + if (jsonResponse.getBoolean("success")) { + String cloudId = jsonResponse.getString("cloudId"); + callback.onSuccess(cloudId); + } else { + String message = jsonResponse.optString("message", "上传失败"); + callback.onError(message); + } + } catch (JSONException e) { + Log.e(TAG, "Failed to parse response", e); + callback.onError("解析响应失败"); + } finally { + response.close(); + } + } + }); + } + + /** + * 从云端下载用户的所有笔记 + */ + public void downloadNotes(long lastSyncTime, CloudCallback callback) { + Log.d(TAG, "Downloading notes for user: " + mUserId); + + JSONObject json = new JSONObject(); + try { + json.put("action", "download"); + json.put("lastSyncTime", lastSyncTime); + } catch (JSONException e) { + Log.e(TAG, "Failed to create JSON", e); + callback.onError("数据格式错误"); + return; + } + + RequestBody body = RequestBody.create(json.toString(), JSON); + Request request = new Request.Builder() + .url(API_NOTES) + .post(body) + .addHeader("Authorization", "Bearer " + mAuthToken) + .addHeader("Content-Type", "application/json") + .build(); + + mHttpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + Log.e(TAG, "Download failed", e); + callback.onError("网络错误: " + e.getMessage()); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + try { + String responseBody = response.body().string(); + JSONObject jsonResponse = new JSONObject(responseBody); + + if (jsonResponse.getBoolean("success")) { + JSONArray notesArray = jsonResponse.getJSONArray("notes"); + callback.onSuccess(notesArray); + } else { + String message = jsonResponse.optString("message", "下载失败"); + callback.onError(message); + } + } catch (JSONException e) { + Log.e(TAG, "Failed to parse response", e); + callback.onError("解析响应失败"); + } finally { + response.close(); + } + } + }); + } + + /** + * 删除云端笔记 + */ + public void deleteNote(String cloudNoteId, CloudCallback callback) { + Log.d(TAG, "Deleting note from cloud: " + cloudNoteId); + + JSONObject json = new JSONObject(); + + try { + json.put("action", "delete"); + json.put("cloudNoteId", cloudNoteId); + json.put("userId", mUserId); + } catch (JSONException e) { + Log.e(TAG, "Failed to create JSON", e); + callback.onError("数据格式错误"); + return; + } + + RequestBody body = RequestBody.create(json.toString(), JSON); + Request request = new Request.Builder() + .url(API_NOTES) + .post(body) + .addHeader("Authorization", "Bearer " + mAuthToken) + .addHeader("Content-Type", "application/json") + .build(); + + mHttpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + Log.e(TAG, "Delete failed", e); + callback.onError("网络错误: " + e.getMessage()); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + try { + String responseBody = response.body().string(); + JSONObject jsonResponse = new JSONObject(responseBody); + + if (jsonResponse.getBoolean("success")) { + callback.onSuccess(null); + } else { + String message = jsonResponse.optString("message", "删除失败"); + callback.onError(message); + } + } catch (JSONException e) { + Log.e(TAG, "Failed to parse response", e); + callback.onError("解析响应失败"); + } finally { + response.close(); + } + } + }); + } + + /** + * 将笔记转换为云端数据格式 + */ + public Map convertToCloudData(WorkingNote note) { + Map data = new HashMap<>(); + data.put("userId", mUserId); + data.put("deviceId", mDeviceId); + data.put("noteId", String.valueOf(note.getNoteId())); + + // 如果没有title,从content提取第一行作为title + String title = note.getTitle(); + String content = note.getContent(); + if (title == null || title.trim().isEmpty()) { + title = extractFirstLine(content); + } + + data.put("title", title); + data.put("content", content); + data.put("parentId", note.getFolderId()); + data.put("modifiedTime", note.getModifiedDate()); + data.put("syncStatus", 2); + data.put("lastSyncTime", System.currentTimeMillis()); + return data; + } + + /** + * 从文本中提取第一行作为标题 + * + * @param content 文本内容 + * @return 第一行文本(最多50个字符) + */ + private String extractFirstLine(String content) { + if (content == null || content.trim().isEmpty()) { + return "无标题"; + } + + // 按换行符分割,获取第一行 + String firstLine = content.split("\\r?\\n")[0].trim(); + + // 限制长度,避免标题过长 + int maxLength = 50; + if (firstLine.length() > maxLength) { + firstLine = firstLine.substring(0, maxLength) + "..."; + } + + return firstLine.isEmpty() ? "无标题" : firstLine; + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/api/RetryInterceptor.java b/src/Notesmaster/app/src/main/java/net/micode/notes/api/RetryInterceptor.java new file mode 100644 index 0000000..16af476 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/api/RetryInterceptor.java @@ -0,0 +1,110 @@ +/* + * 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.api; + +import android.util.Log; + +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; + +import okhttp3.Interceptor; +import okhttp3.Request; +import okhttp3.Response; + +/** + * OkHttp 重试拦截器 + * + *

+ * 实现指数退避重试策略,对网络超时和临时错误进行自动重试。 + * 支持最多3次重试,每次重试间隔递增(1秒、2秒、4秒)。 + *

+ */ +public class RetryInterceptor implements Interceptor { + + private static final String TAG = "RetryInterceptor"; + private static final int MAX_RETRY_COUNT = 3; + private static final long INITIAL_RETRY_DELAY_MS = 1000; + + @Override + public Response intercept(Chain chain) throws IOException { + Request request = chain.request(); + Response response = null; + IOException exception = null; + + for (int retryCount = 0; retryCount <= MAX_RETRY_COUNT; retryCount++) { + try { + response = chain.proceed(request); + + // 如果响应成功,直接返回 + if (response.isSuccessful()) { + return response; + } + + // 对于服务器错误 (5xx) 进行重试 + if (response.code() >= 500 && response.code() < 600) { + if (retryCount < MAX_RETRY_COUNT) { + Log.w(TAG, "Server error " + response.code() + ", retrying... (" + (retryCount + 1) + "/" + MAX_RETRY_COUNT + ")"); + response.close(); + waitBeforeRetry(retryCount); + continue; + } + } else { + // 客户端错误 (4xx) 不重试 + return response; + } + } catch (SocketTimeoutException | UnknownHostException e) { + // 网络超时和主机不可达时重试 + exception = e; + if (retryCount < MAX_RETRY_COUNT) { + Log.w(TAG, "Network error, retrying... (" + (retryCount + 1) + "/" + MAX_RETRY_COUNT + "): " + e.getMessage()); + waitBeforeRetry(retryCount); + } else { + throw e; + } + } catch (IOException e) { + // 其他 IO 异常也尝试重试 + exception = e; + if (retryCount < MAX_RETRY_COUNT) { + Log.w(TAG, "IO error, retrying... (" + (retryCount + 1) + "/" + MAX_RETRY_COUNT + "): " + e.getMessage()); + waitBeforeRetry(retryCount); + } else { + throw e; + } + } + } + + // 如果所有重试都失败了 + if (exception != null) { + throw exception; + } + + return response; + } + + /** + * 指数退避等待 + */ + private void waitBeforeRetry(int retryCount) { + long delay = INITIAL_RETRY_DELAY_MS * (1L << retryCount); // 1s, 2s, 4s + try { + Thread.sleep(delay); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/auth/AnonymousAuthManager.java b/src/Notesmaster/app/src/main/java/net/micode/notes/auth/AnonymousAuthManager.java new file mode 100644 index 0000000..331fe83 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/auth/AnonymousAuthManager.java @@ -0,0 +1,129 @@ +/* + * 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.auth; + +import android.content.Context; +import android.content.SharedPreferences; +import android.provider.Settings; +import android.util.Log; + +import java.util.UUID; + +/** + * 匿名认证管理器 + *

+ * 管理匿名用户的认证信息,包括生成和存储用户ID、设备ID等。 + * 使用单例模式确保全局只有一个认证管理器实例。 + *

+ */ +public class AnonymousAuthManager { + + private static final String TAG = "AnonymousAuthManager"; + private static final String PREFS_NAME = "AnonymousAuth"; + private static final String KEY_USER_ID = "anonymous_user_id"; + private static final String KEY_DEVICE_ID = "device_id"; + + private static AnonymousAuthManager sInstance; + private SharedPreferences mPrefs; + private String mUserId; + private String mDeviceId; + + private AnonymousAuthManager(Context context) { + mPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + } + + /** + * 获取AnonymousAuthManager单例实例 + * + * @param context 应用上下文 + * @return AnonymousAuthManager实例 + */ + public static synchronized AnonymousAuthManager getInstance(Context context) { + if (sInstance == null) { + sInstance = new AnonymousAuthManager(context.getApplicationContext()); + } + return sInstance; + } + + /** + * 初始化认证管理器 + * + * @param context 应用上下文 + */ + public void initialize(Context context) { + if (mUserId == null) { + mUserId = mPrefs.getString(KEY_USER_ID, null); + if (mUserId == null) { + mUserId = generateAnonymousUserId(); + mPrefs.edit().putString(KEY_USER_ID, mUserId).apply(); + Log.i(TAG, "Generated new anonymous user ID"); + } + } + + if (mDeviceId == null) { + mDeviceId = mPrefs.getString(KEY_DEVICE_ID, null); + if (mDeviceId == null) { + mDeviceId = generateDeviceId(context); + mPrefs.edit().putString(KEY_DEVICE_ID, mDeviceId).apply(); + Log.i(TAG, "Generated new device ID"); + } + } + + Log.i(TAG, "AnonymousAuthManager initialized successfully"); + } + + /** + * 获取用户ID + * + * @return 用户ID + */ + public String getUserId() { + return mUserId; + } + + /** + * 获取设备ID + * + * @return 设备ID + */ + public String getDeviceId() { + return mDeviceId; + } + + /** + * 生成匿名用户ID + * + * @return 格式为 anon_xxx 的用户ID + */ + private String generateAnonymousUserId() { + return "anon_" + UUID.randomUUID().toString().replace("-", ""); + } + + /** + * 生成设备ID + * + * @param context 应用上下文 + * @return 设备ID + */ + private String generateDeviceId(Context context) { + String androidId = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID); + if (androidId == null || androidId.isEmpty()) { + androidId = UUID.randomUUID().toString(); + } + return "device_" + androidId; + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/auth/UserAuthManager.java b/src/Notesmaster/app/src/main/java/net/micode/notes/auth/UserAuthManager.java new file mode 100644 index 0000000..aef9662 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/auth/UserAuthManager.java @@ -0,0 +1,473 @@ +/* + * 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.auth; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import androidx.annotation.Nullable; + +import net.micode.notes.api.AliyunConfig; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +/** + * 用户认证管理器(阿里云EMAS Serverless HTTP API版本) + *

+ * 使用阿里云EMAS Serverless的HTTP API进行用户认证。 + * 需要先登录阿里云控制台创建EMAS应用并开通Serverless服务。 + *

+ */ +public class UserAuthManager { + + private static final String TAG = "UserAuthManager"; + private static final String PREFS_NAME = "UserAuth"; + private static final String KEY_USER_ID = "user_id"; + private static final String KEY_USERNAME = "username"; + private static final String KEY_AUTH_TOKEN = "auth_token"; + private static final String KEY_REFRESH_TOKEN = "refresh_token"; + private static final String KEY_IS_LOGGED_IN = "is_logged_in"; + private static final String KEY_DEVICE_ID = "device_id"; + private static final String KEY_TOKEN_EXPIRE_TIME = "token_expire_time"; + + // Token过期时间:7天 + private static final long TOKEN_EXPIRE_DURATION = 7 * 24 * 60 * 60 * 1000; + + // EMAS Serverless API地址 + private static final String BASE_URL = AliyunConfig.BASE_URL; + private static final String API_AUTH = BASE_URL + "/auth"; + private static final String API_REFRESH_TOKEN = BASE_URL + "/auth/refresh"; + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + + private static UserAuthManager sInstance; + private final ExecutorService mExecutor; + private final OkHttpClient mHttpClient; + private SharedPreferences mPrefs; + private Context mContext; + + private String mUserId; + private String mUsername; + private String mAuthToken; + private String mRefreshToken; + private String mDeviceId; + private boolean mIsLoggedIn; + private long mTokenExpireTime; + + private UserAuthManager(Context context) { + mContext = context.getApplicationContext(); + mPrefs = mContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + mExecutor = Executors.newSingleThreadExecutor(); + mHttpClient = new OkHttpClient.Builder() + .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .writeTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .addInterceptor(new net.micode.notes.api.RetryInterceptor()) + .build(); + loadUserInfo(); + } + + public static synchronized UserAuthManager getInstance(Context context) { + if (sInstance == null) { + sInstance = new UserAuthManager(context); + } + return sInstance; + } + + /** + * 初始化(配置EMAS Serverless) + */ + public void initialize(Context context) { + Log.d(TAG, "Initializing UserAuthManager"); + Log.d(TAG, "AppKey: " + AliyunConfig.APP_KEY); + // 这里可以添加EMAS SDK初始化(如果需要) + } + + /** + * 认证回调接口 + */ + public interface AuthCallback { + void onSuccess(String userId, String username); + void onError(String error); + } + + /** + * 加载本地存储的用户信息 + */ + private void loadUserInfo() { + mIsLoggedIn = mPrefs.getBoolean(KEY_IS_LOGGED_IN, false); + mUserId = mPrefs.getString(KEY_USER_ID, null); + mUsername = mPrefs.getString(KEY_USERNAME, null); + mAuthToken = mPrefs.getString(KEY_AUTH_TOKEN, null); + mRefreshToken = mPrefs.getString(KEY_REFRESH_TOKEN, null); + mTokenExpireTime = mPrefs.getLong(KEY_TOKEN_EXPIRE_TIME, 0); + mDeviceId = mPrefs.getString(KEY_DEVICE_ID, null); + + if (mDeviceId == null) { + mDeviceId = generateDeviceId(); + mPrefs.edit().putString(KEY_DEVICE_ID, mDeviceId).apply(); + } + + Log.d(TAG, "User info loaded, logged in: " + mIsLoggedIn); + } + + /** + * 用户注册 + */ + public void register(String username, String password, AuthCallback callback) { + if (username == null || username.isEmpty() || password == null || password.isEmpty()) { + callback.onError("用户名和密码不能为空"); + return; + } + + mExecutor.execute(() -> { + try { + String hashedPassword = hashPassword(password); + + // 构建JSON请求体 + JSONObject json = new JSONObject(); + json.put("action", "register"); + json.put("username", username); + json.put("password", hashedPassword); + json.put("deviceId", mDeviceId); + json.put("appKey", AliyunConfig.APP_KEY); + + RequestBody body = RequestBody.create(json.toString(), JSON); + Request request = new Request.Builder() + .url(API_AUTH) + .post(body) + .build(); + + mHttpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + Log.e(TAG, "Register failed", e); + callback.onError("网络错误: " + e.getMessage()); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + handleAuthResponse(response, username, callback); + } + }); + } catch (Exception e) { + Log.e(TAG, "Register error", e); + callback.onError("注册失败: " + e.getMessage()); + } + }); + } + + /** + * 用户登录 + */ + public void login(String username, String password, AuthCallback callback) { + if (username == null || username.isEmpty() || password == null || password.isEmpty()) { + callback.onError("用户名和密码不能为空"); + return; + } + + mExecutor.execute(() -> { + try { + String hashedPassword = hashPassword(password); + + // 构建JSON请求体 + JSONObject json = new JSONObject(); + json.put("action", "login"); + json.put("username", username); + json.put("password", hashedPassword); + json.put("deviceId", mDeviceId); + json.put("appKey", AliyunConfig.APP_KEY); + + RequestBody body = RequestBody.create(json.toString(), JSON); + Request request = new Request.Builder() + .url(API_AUTH) + .post(body) + .build(); + + mHttpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + Log.e(TAG, "Login failed", e); + callback.onError("网络错误: " + e.getMessage()); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + handleAuthResponse(response, username, callback); + } + }); + } catch (Exception e) { + Log.e(TAG, "Login error", e); + callback.onError("登录失败: " + e.getMessage()); + } + }); + } + + /** + * 处理认证响应 + */ + private void handleAuthResponse(Response response, String username, AuthCallback callback) { + String responseBody = null; + try { + if (!response.isSuccessful()) { + String errorBody = response.body() != null ? response.body().string() : "Unknown error"; + callback.onError("服务器错误: " + response.code() + " - " + errorBody); + return; + } + + responseBody = response.body().string(); + JSONObject json = new JSONObject(responseBody); + + if (json.getBoolean("success")) { + mUserId = json.getString("userId"); + // 支持两种字段名:token 或 authToken + if (json.has("token")) { + mAuthToken = json.getString("token"); + } else if (json.has("authToken")) { + mAuthToken = json.getString("authToken"); + } + if (json.has("refreshToken")) { + mRefreshToken = json.getString("refreshToken"); + } + mTokenExpireTime = System.currentTimeMillis() + TOKEN_EXPIRE_DURATION; + mUsername = username; + mIsLoggedIn = true; + + saveUserInfo(); + + callback.onSuccess(mUserId, mUsername); + } else { + callback.onError(json.getString("message")); + } + } catch (JSONException | IOException e) { + Log.e(TAG, "Parse response error", e); + callback.onError("解析响应失败"); + } finally { + response.close(); + } + } + + /** + * 保存用户信息到本地 + */ + private void saveUserInfo() { + mPrefs.edit() + .putBoolean(KEY_IS_LOGGED_IN, mIsLoggedIn) + .putString(KEY_USER_ID, mUserId) + .putString(KEY_USERNAME, mUsername) + .putString(KEY_AUTH_TOKEN, mAuthToken) + .putString(KEY_REFRESH_TOKEN, mRefreshToken) + .putLong(KEY_TOKEN_EXPIRE_TIME, mTokenExpireTime) + .putString(KEY_DEVICE_ID, mDeviceId) + .apply(); + } + + /** + * 登出 + */ + public void logout() { + mIsLoggedIn = false; + mUserId = null; + mUsername = null; + mAuthToken = null; + mRefreshToken = null; + mTokenExpireTime = 0; + + mPrefs.edit() + .putBoolean(KEY_IS_LOGGED_IN, false) + .remove(KEY_USER_ID) + .remove(KEY_USERNAME) + .remove(KEY_AUTH_TOKEN) + .remove(KEY_REFRESH_TOKEN) + .remove(KEY_TOKEN_EXPIRE_TIME) + .apply(); + + Log.i(TAG, "User logged out"); + } + + /** + * 密码哈希(使用SHA-256) + * + * @throws RuntimeException 如果SHA-256算法不可用 + */ + private String hashPassword(String password) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(password.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + StringBuilder hexString = new StringBuilder(); + for (byte b : hash) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) hexString.append('0'); + hexString.append(hex); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + Log.e(TAG, "Hash algorithm not found", e); + throw new RuntimeException("密码哈希失败:系统不支持SHA-256算法", e); + } + } + + /** + * 生成设备ID + */ + private String generateDeviceId() { + return "device_" + java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 16); + } + + // ==================== Getter Methods ==================== + + public boolean isLoggedIn() { + return mIsLoggedIn; + } + + @Nullable + public String getUserId() { + return mUserId; + } + + @Nullable + public String getUsername() { + return mUsername; + } + + @Nullable + public String getAuthToken() { + return mAuthToken; + } + + public String getDeviceId() { + return mDeviceId; + } + + @Nullable + public String getRefreshToken() { + return mRefreshToken; + } + + /** + * 检查Token是否即将过期(24小时内) + */ + public boolean isTokenExpiringSoon() { + if (!mIsLoggedIn || mTokenExpireTime == 0) { + return false; + } + long timeUntilExpire = mTokenExpireTime - System.currentTimeMillis(); + return timeUntilExpire < 24 * 60 * 60 * 1000; // 24小时内过期 + } + + /** + * 检查Token是否已过期 + */ + public boolean isTokenExpired() { + if (!mIsLoggedIn || mTokenExpireTime == 0) { + return false; + } + return System.currentTimeMillis() >= mTokenExpireTime; + } + + /** + * 刷新Token + * + * @param callback 刷新回调 + */ + public void refreshToken(TokenRefreshCallback callback) { + if (mRefreshToken == null) { + callback.onError("没有可用的刷新令牌"); + return; + } + + mExecutor.execute(() -> { + try { + JSONObject json = new JSONObject(); + json.put("action", "refresh"); + json.put("refreshToken", mRefreshToken); + json.put("deviceId", mDeviceId); + + RequestBody body = RequestBody.create(json.toString(), JSON); + Request request = new Request.Builder() + .url(API_REFRESH_TOKEN) + .post(body) + .build(); + + mHttpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + Log.e(TAG, "Token refresh failed", e); + callback.onError("网络错误: " + e.getMessage()); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + String responseBody = null; + try { + if (!response.isSuccessful()) { + callback.onError("服务器错误: " + response.code()); + return; + } + + responseBody = response.body().string(); + JSONObject json = new JSONObject(responseBody); + + if (json.getBoolean("success")) { + mAuthToken = json.getString("token"); + if (json.has("refreshToken")) { + mRefreshToken = json.getString("refreshToken"); + } + mTokenExpireTime = System.currentTimeMillis() + TOKEN_EXPIRE_DURATION; + saveUserInfo(); + callback.onSuccess(mAuthToken); + } else { + callback.onError(json.getString("message")); + } + } catch (JSONException e) { + Log.e(TAG, "Parse refresh response error", e); + callback.onError("解析响应失败"); + } finally { + response.close(); + } + } + }); + } catch (Exception e) { + Log.e(TAG, "Refresh token error", e); + callback.onError("刷新失败: " + e.getMessage()); + } + }); + } + + /** + * Token刷新回调接口 + */ + public interface TokenRefreshCallback { + void onSuccess(String newToken); + void onError(String error); + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/data/Notes.java b/src/Notesmaster/app/src/main/java/net/micode/notes/data/Notes.java index 520d27a..b4c6688 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/data/Notes.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/data/Notes.java @@ -285,6 +285,36 @@ public class Notes { *

Type : INTEGER (long)

*/ public static final String GTASK_FINISHED_TIME = "gtask_finished_time"; + + /** + * Cloud User ID for sync + *

Type : TEXT

+ */ + public static final String CLOUD_USER_ID = "cloud_user_id"; + + /** + * Cloud Device ID for sync + *

Type : TEXT

+ */ + public static final String CLOUD_DEVICE_ID = "cloud_device_id"; + + /** + * Sync Status: 0=Not synced, 1=Syncing, 2=Synced, 3=Conflict + *

Type : INTEGER

+ */ + public static final String SYNC_STATUS = "sync_status"; + + /** + * Last Sync Time (Timestamp) + *

Type : INTEGER (long)

+ */ + public static final String LAST_SYNC_TIME = "last_sync_time"; + + /** + * Cloud Note ID for sync (UUID) + *

Type : TEXT

+ */ + public static final String CLOUD_NOTE_ID = "cloud_note_id"; } public interface DataColumns { diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesDatabaseHelper.java b/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesDatabaseHelper.java index b960d55..92bfc6c 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesDatabaseHelper.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesDatabaseHelper.java @@ -66,11 +66,11 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { /** * 数据库版本号 *

- * 当前数据库版本为8,用于跟踪数据库结构变更。 + * 当前数据库版本为12,用于跟踪数据库结构变更。 * 当数据库版本变更时,onUpgrade方法会被调用以执行升级逻辑。 *

*/ - private static final int DB_VERSION = 10; + private static final int DB_VERSION = 13; /** * 数据库表名常量接口 @@ -156,7 +156,11 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { NoteColumns.VERSION + " INTEGER NOT NULL DEFAULT 0," + NoteColumns.TOP + " INTEGER NOT NULL DEFAULT 0," + NoteColumns.LOCKED + " INTEGER NOT NULL DEFAULT 0," + - NoteColumns.TITLE + " TEXT NOT NULL DEFAULT ''" + + NoteColumns.TITLE + " TEXT NOT NULL DEFAULT ''," + + NoteColumns.CLOUD_USER_ID + " TEXT NOT NULL DEFAULT ''," + + NoteColumns.CLOUD_DEVICE_ID + " TEXT NOT NULL DEFAULT ''," + + NoteColumns.SYNC_STATUS + " INTEGER NOT NULL DEFAULT 0," + + NoteColumns.LAST_SYNC_TIME + " INTEGER NOT NULL DEFAULT 0" + ")"; /** @@ -578,6 +582,24 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { oldVersion++; } + // 从V10升级到V11 + if (oldVersion == 10) { + upgradeToV11(db); + oldVersion++; + } + + // 从V11升级到V12 + if (oldVersion == 11) { + upgradeToV12(db); + oldVersion++; + } + + // 从V12升级到V13 + if (oldVersion == 12) { + upgradeToV13(db); + oldVersion++; + } + // 如果需要,重新创建触发器 if (reCreateTriggers) { reCreateNoteTableTriggers(db); @@ -674,13 +696,32 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { } } - if (cursor != null) { - cursor.close(); - } - } catch (Exception e) { - Log.e(TAG, "Failed to fix database in onOpen", e); - } - } + boolean hasCloudNoteIdColumn = false; + if (cursor != null) { + if (cursor.getColumnIndex(NoteColumns.CLOUD_NOTE_ID) != -1) { + hasCloudNoteIdColumn = true; + } + } + + if (!hasCloudNoteIdColumn) { + try { + db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.CLOUD_NOTE_ID + + " TEXT NOT NULL DEFAULT ''"); + db.execSQL("CREATE INDEX IF NOT EXISTS idx_cloud_note_id ON " + TABLE.NOTE + + "(" + NoteColumns.CLOUD_NOTE_ID + ")"); + Log.i(TAG, "Fixed: Added missing CLOUD_NOTE_ID column and index in onOpen"); + } catch (Exception e) { + Log.e(TAG, "Failed to add CLOUD_NOTE_ID column in onOpen", e); + } + } + + if (cursor != null) { + cursor.close(); + } + } catch (Exception e) { + Log.e(TAG, "Failed to fix database in onOpen", e); + } + } /** * 升级数据库到V2版本 @@ -840,6 +881,86 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { createPresetTemplates(db); } + /** + * 升级数据库到V11版本 + *

+ * 添加云同步相关列:CLOUD_USER_ID, CLOUD_DEVICE_ID, SYNC_STATUS, LAST_SYNC_TIME + *

+ * + * @param db SQLiteDatabase实例 + */ + private void upgradeToV11(SQLiteDatabase db) { + try { + db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.CLOUD_USER_ID + + " TEXT NOT NULL DEFAULT ''"); + db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.CLOUD_DEVICE_ID + + " TEXT NOT NULL DEFAULT ''"); + db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.SYNC_STATUS + + " INTEGER NOT NULL DEFAULT 0"); + db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.LAST_SYNC_TIME + + " INTEGER NOT NULL DEFAULT 0"); + Log.i(TAG, "Upgraded database to V11: Added cloud sync columns"); + } catch (Exception e) { + Log.e(TAG, "Failed to add cloud sync columns in V11 upgrade", e); + } + } + + /** + * 升级数据库到V12版本 + *

+ * 添加cloud_note_id列用于云端笔记唯一标识 + *

+ * + * @param db SQLiteDatabase实例 + */ + private void upgradeToV12(SQLiteDatabase db) { + try { + db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.CLOUD_NOTE_ID + + " TEXT NOT NULL DEFAULT ''"); + db.execSQL("CREATE INDEX IF NOT EXISTS idx_cloud_note_id ON " + TABLE.NOTE + + "(" + NoteColumns.CLOUD_NOTE_ID + ")"); + Log.i(TAG, "Upgraded database to V12: Added cloud_note_id column and index"); + } catch (Exception e) { + Log.e(TAG, "Failed to add cloud_note_id column in V12 upgrade", e); + } + } + + /** + * 升级数据库到V13版本 + *

+ * 数据迁移:修复文件夹title为空的问题 + * 将所有title为空的文件夹的title字段设置为snippet的值 + *

+ * + * @param db SQLiteDatabase实例 + */ + private void upgradeToV13(SQLiteDatabase db) { + try { + String sql = "UPDATE " + TABLE.NOTE + + " SET " + NoteColumns.TITLE + " = " + NoteColumns.SNIPPET + + " WHERE " + NoteColumns.TYPE + " = " + Notes.TYPE_FOLDER + + " AND (" + NoteColumns.TITLE + " IS NULL OR " + NoteColumns.TITLE + " = '')"; + db.execSQL(sql); + + android.database.Cursor cursor = db.rawQuery( + "SELECT COUNT(*) FROM " + TABLE.NOTE + + " WHERE " + NoteColumns.TYPE + " = " + Notes.TYPE_FOLDER + + " AND (" + NoteColumns.TITLE + " IS NOT NULL OR " + NoteColumns.TITLE + " != '')", + null); + if (cursor != null) { + if (cursor.moveToFirst()) { + int count = cursor.getInt(0); + Log.i(TAG, "Upgraded database to V13: Migrated " + count + " folders with non-empty title"); + } + cursor.close(); + } + + Log.i(TAG, "Successfully upgraded database to V13: Fixed folder title migration"); + } catch (Exception e) { + Log.e(TAG, "Failed to migrate folder titles in V13 upgrade", e); + } + } + /** * 创建模板系统文件夹 * @@ -884,6 +1005,7 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { values.put(NoteColumns.PARENT_ID, parentId); values.put(NoteColumns.TYPE, Notes.TYPE_FOLDER); values.put(NoteColumns.SNIPPET, name); + values.put(NoteColumns.TITLE, name); values.put(NoteColumns.CREATED_DATE, System.currentTimeMillis()); values.put(NoteColumns.MODIFIED_DATE, System.currentTimeMillis()); values.put(NoteColumns.NOTES_COUNT, 0); diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesRepository.java b/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesRepository.java index 237b3db..9bd4d1a 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesRepository.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesRepository.java @@ -30,6 +30,8 @@ import net.micode.notes.data.Notes.DataColumns; import net.micode.notes.data.Notes.NoteColumns; import net.micode.notes.data.Notes.TextNote; import net.micode.notes.model.Note; +import net.micode.notes.model.WorkingNote; +import net.micode.notes.sync.SyncConstants; import java.util.ArrayList; import java.util.HashMap; @@ -90,6 +92,7 @@ public class NotesRepository { private final ContentResolver contentResolver; private final ExecutorService executor; + private final Context context; // 选择条件常量 private static final String NORMAL_SELECTION = NoteColumns.PARENT_ID + " = ?"; @@ -195,6 +198,15 @@ public class NotesRepository { */ public NotesRepository(ContentResolver contentResolver) { this.contentResolver = contentResolver; + this.context = null; + // 使用单线程Executor确保数据访问的顺序性 + this.executor = java.util.concurrent.Executors.newSingleThreadExecutor(); + Log.d(TAG, "NotesRepository initialized"); + } + + public NotesRepository(Context context) { + this.context = context.getApplicationContext(); + this.contentResolver = context.getContentResolver(); // 使用单线程Executor确保数据访问的顺序性 this.executor = java.util.concurrent.Executors.newSingleThreadExecutor(); Log.d(TAG, "NotesRepository initialized"); @@ -444,6 +456,7 @@ public class NotesRepository { values.put(NoteColumns.PARENT_ID, parentId); values.put(NoteColumns.TYPE, Notes.TYPE_FOLDER); values.put(NoteColumns.SNIPPET, name); + values.put(NoteColumns.TITLE, name); values.put(NoteColumns.CREATED_DATE, currentTime); values.put(NoteColumns.MODIFIED_DATE, currentTime); values.put(NoteColumns.LOCAL_MODIFIED, 1); @@ -1358,4 +1371,499 @@ public class NotesRepository { } return title; } + + // ==================== Cloud Sync Methods ==================== + + /** + * 获取未同步的笔记列表 + *

+ * 查询所有 LOCAL_MODIFIED = 1 的笔记 + *

+ * + * @param callback 回调接口,返回未同步笔记列表 + */ + public void getUnsyncedNotes(Callback> callback) { + executor.execute(() -> { + try { + String selection = NoteColumns.LOCAL_MODIFIED + " = ?"; + String[] selectionArgs = {"1"}; + + Cursor cursor = contentResolver.query( + Notes.CONTENT_NOTE_URI, + null, + selection, + selectionArgs, + NoteColumns.MODIFIED_DATE + " DESC" + ); + + List notes = new ArrayList<>(); + if (cursor != null) { + try { + while (cursor.moveToNext()) { + notes.add(noteFromCursor(cursor)); + } + Log.d(TAG, "Found " + notes.size() + " unsynced notes"); + } finally { + cursor.close(); + } + } + + callback.onSuccess(notes); + } catch (Exception e) { + Log.e(TAG, "Failed to get unsynced notes", e); + callback.onError(e); + } + }); + } + + /** + * 标记笔记为已同步 + *

+ * 更新 LOCAL_MODIFIED = 0 和 SYNC_STATUS = 2 + *

+ * + * @param noteId 笔记ID + */ + public void markAsSynced(long noteId) { + executor.execute(() -> { + try { + ContentValues values = new ContentValues(); + values.put(NoteColumns.LOCAL_MODIFIED, 0); + values.put(NoteColumns.SYNC_STATUS, 2); + values.put(NoteColumns.LAST_SYNC_TIME, System.currentTimeMillis()); + + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId); + int rows = contentResolver.update(uri, values, null, null); + + if (rows > 0) { + Log.d(TAG, "Marked note as synced: " + noteId); + } + } catch (Exception e) { + Log.e(TAG, "Failed to mark note as synced: " + noteId, e); + } + }); + } + + /** + * 更新笔记同步状态 + * + * @param noteId 笔记ID + * @param status 同步状态 (0=未同步, 1=同步中, 2=已同步, 3=冲突) + */ + public void updateSyncStatus(long noteId, int status) { + executor.execute(() -> { + try { + ContentValues values = new ContentValues(); + values.put(NoteColumns.SYNC_STATUS, status); + + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId); + int rows = contentResolver.update(uri, values, null, null); + + if (rows > 0) { + Log.d(TAG, "Updated sync status for note " + noteId + " to " + status); + } + } catch (Exception e) { + Log.e(TAG, "Failed to update sync status: " + noteId, e); + } + }); + } + + /** + * 获取最后同步时间 + * + * @param callback 回调接口,返回最后同步时间(毫秒) + */ + public void getLastSyncTime(Callback callback) { + executor.execute(() -> { + try { + Cursor cursor = contentResolver.query( + Notes.CONTENT_NOTE_URI, + new String[]{"MAX(" + NoteColumns.LAST_SYNC_TIME + ") AS last_sync"}, + null, + null, + null + ); + + long lastSyncTime = 0; + if (cursor != null) { + try { + if (cursor.moveToFirst()) { + lastSyncTime = cursor.getLong(0); + } + } finally { + cursor.close(); + } + } + + callback.onSuccess(lastSyncTime); + } catch (Exception e) { + Log.e(TAG, "Failed to get last sync time", e); + callback.onError(e); + } + }); + } + + // ==================== 云同步相关方法 ==================== + + /** + * 查询所有本地修改过的笔记(LOCAL_MODIFIED = 1) + *

+ * 根据当前登录用户过滤,只返回该用户的笔记 + *

+ * + * @param cloudUserId 当前用户的云端ID + * @param callback 回调接口,返回本地修改过的笔记列表 + */ + public void getLocalModifiedNotes(String cloudUserId, Callback> callback) { + executor.execute(() -> { + try { + // 同时过滤 LOCAL_MODIFIED = 1 和 cloud_user_id = 当前用户 + String selection = NoteColumns.LOCAL_MODIFIED + " = ? AND " + NoteColumns.CLOUD_USER_ID + " = ?"; + String[] selectionArgs = new String[] { "1", cloudUserId }; + String sortOrder = NoteColumns.MODIFIED_DATE + " DESC"; + + Cursor cursor = contentResolver.query( + Notes.CONTENT_NOTE_URI, + null, + selection, + selectionArgs, + sortOrder + ); + + List notes = new ArrayList<>(); + if (cursor != null) { + while (cursor.moveToNext()) { + long id = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID)); + WorkingNote note = WorkingNote.load(context, id); + notes.add(note); + } + cursor.close(); + } + + Log.d(TAG, "Found " + notes.size() + " locally modified notes for user: " + cloudUserId); + callback.onSuccess(notes); + } catch (Exception e) { + Log.e(TAG, "Failed to get local modified notes for user: " + cloudUserId, e); + callback.onError(e); + } + }); + } + + /** + * 查询所有本地修改过的笔记(LOCAL_MODIFIED = 1)- 不过滤用户(向后兼容) + * + * @param callback 回调接口,返回本地修改过的笔记列表 + * @deprecated 请使用 {@link #getLocalModifiedNotes(String, Callback)} + */ + @Deprecated + public void getLocalModifiedNotes(Callback> callback) { + executor.execute(() -> { + try { + String selection = NoteColumns.LOCAL_MODIFIED + " = ?"; + String[] selectionArgs = new String[] { "1" }; + String sortOrder = NoteColumns.MODIFIED_DATE + " DESC"; + + Cursor cursor = contentResolver.query( + Notes.CONTENT_NOTE_URI, + null, + selection, + selectionArgs, + sortOrder + ); + + List notes = new ArrayList<>(); + if (cursor != null) { + while (cursor.moveToNext()) { + long id = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID)); + WorkingNote note = WorkingNote.load(context, id); + notes.add(note); + } + cursor.close(); + } + + Log.d(TAG, "Found " + notes.size() + " locally modified notes"); + callback.onSuccess(notes); + } catch (Exception e) { + Log.e(TAG, "Failed to get local modified notes", e); + callback.onError(e); + } + }); + } + + /** + * 标记笔记为已同步 + * 更新: LOCAL_MODIFIED=0, SYNC_STATUS=2, LAST_SYNC_TIME=now + * + * @param noteId 笔记ID + * @param callback 回调接口 + */ + public void markNoteSynced(long noteId, Callback callback) { + executor.execute(() -> { + try { + ContentValues values = new ContentValues(); + values.put(NoteColumns.LOCAL_MODIFIED, 0); + values.put(NoteColumns.SYNC_STATUS, SyncConstants.SYNC_STATUS_SYNCED); + values.put(NoteColumns.LAST_SYNC_TIME, System.currentTimeMillis()); + + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId); + int rows = contentResolver.update(uri, values, null, null); + + if (rows > 0) { + Log.d(TAG, "Marked note " + noteId + " as synced"); + } + + callback.onSuccess(null); + } catch (Exception e) { + Log.e(TAG, "Failed to mark note as synced: " + noteId, e); + callback.onError(e); + } + }); + } + + /** + * 根据noteId查找笔记 + * + * @param noteId 笔记ID(字符串形式) + * @param callback 回调接口,返回找到的笔记或null + */ + public void findNoteByNoteId(String noteId, Callback callback) { + executor.execute(() -> { + try { + long id = Long.parseLong(noteId); + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, id); + + Cursor cursor = contentResolver.query( + uri, + null, + null, + null, + null + ); + + WorkingNote note = null; + if (cursor != null && cursor.moveToFirst()) { + long noteIdFromCursor = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID)); + note = WorkingNote.load(context, noteIdFromCursor); + cursor.close(); + } + + callback.onSuccess(note); + } catch (Exception e) { + Log.e(TAG, "Failed to find note by noteId: " + noteId, e); + callback.onError(e); + } + }); + } + + /** + * 根据cloudNoteId查找笔记 + * + * @param cloudNoteId 云端笔记ID(UUID) + * @param callback 回调接口,返回找到的笔记或null + */ + public void findByCloudNoteId(String cloudNoteId, Callback callback) { + executor.execute(() -> { + try { + if (cloudNoteId == null || cloudNoteId.isEmpty()) { + callback.onSuccess(null); + return; + } + + String selection = NoteColumns.CLOUD_NOTE_ID + " = ?"; + String[] selectionArgs = new String[] { cloudNoteId }; + + Cursor cursor = contentResolver.query( + Notes.CONTENT_NOTE_URI, + null, + selection, + selectionArgs, + null + ); + + WorkingNote note = null; + if (cursor != null) { + if (cursor.moveToFirst()) { + long noteId = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID)); + note = WorkingNote.load(context, noteId); + } + cursor.close(); + } + + Log.d(TAG, "findByCloudNoteId: " + cloudNoteId + " found=" + (note != null)); + callback.onSuccess(note); + } catch (Exception e) { + Log.e(TAG, "Failed to find note by cloudNoteId: " + cloudNoteId, e); + callback.onError(e); + } + }); + } + + /** + * 查询指定云端用户ID的笔记 + * + * @param cloudUserId 云端用户ID + * @param callback 回调接口,返回笔记列表 + */ + public void getNotesByCloudUserId(String cloudUserId, Callback> callback) { + executor.execute(() -> { + try { + String selection = NoteColumns.CLOUD_USER_ID + " = ?"; + String[] selectionArgs = new String[] { cloudUserId }; + + Cursor cursor = contentResolver.query( + Notes.CONTENT_NOTE_URI, + null, + selection, + selectionArgs, + null + ); + + List notes = new ArrayList<>(); + if (cursor != null) { + while (cursor.moveToNext()) { + long noteId = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID)); + WorkingNote note = WorkingNote.load(context, noteId); + notes.add(note); + } + cursor.close(); + } + + callback.onSuccess(notes); + } catch (Exception e) { + Log.e(TAG, "Failed to get notes by cloudUserId: " + cloudUserId, e); + callback.onError(e); + } + }); + } + + /** + * 更新笔记的云端用户ID(用于匿名用户迁移) + * + * @param oldUserId 旧的云端用户ID + * @param newUserId 新的云端用户ID + * @param callback 回调接口,返回更新的笔记数量 + */ + public void updateCloudUserId(String oldUserId, String newUserId, Callback callback) { + executor.execute(() -> { + try { + ContentValues values = new ContentValues(); + values.put(NoteColumns.CLOUD_USER_ID, newUserId); + values.put(NoteColumns.LOCAL_MODIFIED, 1); + + String selection = NoteColumns.CLOUD_USER_ID + " = ?"; + String[] selectionArgs = new String[] { oldUserId }; + + int rows = contentResolver.update( + Notes.CONTENT_NOTE_URI, + values, + selection, + selectionArgs + ); + + Log.d(TAG, "Updated " + rows + " notes from " + oldUserId + " to " + newUserId); + callback.onSuccess(rows); + } catch (Exception e) { + Log.e(TAG, "Failed to update cloudUserId", e); + callback.onError(e); + } + }); + } + + /** + * 批量标记笔记为已同步(带事务支持) + * + * @param noteIds 笔记ID列表 + * @param callback 回调接口 + */ + public void batchMarkNotesSynced(List noteIds, Callback callback) { + executor.execute(() -> { + int successCount = 0; + Exception lastError = null; + + for (Long noteId : noteIds) { + try { + ContentValues values = new ContentValues(); + values.put(NoteColumns.LOCAL_MODIFIED, 0); + values.put(NoteColumns.SYNC_STATUS, SyncConstants.SYNC_STATUS_SYNCED); + values.put(NoteColumns.LAST_SYNC_TIME, System.currentTimeMillis()); + + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId); + int rows = contentResolver.update(uri, values, null, null); + + if (rows > 0) { + successCount++; + Log.d(TAG, "Marked note " + noteId + " as synced"); + } + } catch (Exception e) { + Log.e(TAG, "Failed to mark note as synced: " + noteId, e); + lastError = e; + } + } + + Log.d(TAG, "Batch sync completed: " + successCount + "/" + noteIds.size() + " notes marked as synced"); + + if (successCount == noteIds.size()) { + callback.onSuccess(successCount); + } else if (successCount > 0) { + callback.onSuccess(successCount); + } else { + callback.onError(lastError != null ? lastError : new Exception("All batch operations failed")); + } + }); + } + + /** + * 新用户接管设备上的所有笔记 + *

+ * 将设备上所有笔记(无论之前的cloud_user_id是谁)的cloud_user_id更新为新用户, + * 并标记为需要同步。这样新用户登录后可以把设备上的所有笔记上传到云端。 + *

+ * + * @param newUserId 新用户的云端ID + * @param callback 回调接口,返回接管的笔记数量 + */ + public void takeoverAllNotes(String newUserId, Callback callback) { + executor.execute(() -> { + try { + // 1. 获取设备上所有笔记(排除系统文件夹) + String selection = NoteColumns.TYPE + " != ?"; + String[] selectionArgs = new String[] { String.valueOf(Notes.TYPE_SYSTEM) }; + + Cursor cursor = contentResolver.query( + Notes.CONTENT_NOTE_URI, + null, + selection, + selectionArgs, + null + ); + + int takeoverCount = 0; + if (cursor != null) { + while (cursor.moveToNext()) { + long noteId = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID)); + + // 更新笔记的cloud_user_id为新用户,并标记为本地修改 + ContentValues values = new ContentValues(); + values.put(NoteColumns.CLOUD_USER_ID, newUserId); + values.put(NoteColumns.LOCAL_MODIFIED, 1); + values.put(NoteColumns.SYNC_STATUS, 0); + + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId); + int rows = contentResolver.update(uri, values, null, null); + + if (rows > 0) { + takeoverCount++; + } + } + cursor.close(); + } + + Log.d(TAG, "Takeover completed: " + takeoverCount + " notes now belong to " + newUserId); + callback.onSuccess(takeoverCount); + } catch (Exception e) { + Log.e(TAG, "Failed to takeover notes for user: " + newUserId, e); + callback.onError(e); + } + }); + } } diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/model/CloudNote.java b/src/Notesmaster/app/src/main/java/net/micode/notes/model/CloudNote.java new file mode 100644 index 0000000..682d592 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/model/CloudNote.java @@ -0,0 +1,193 @@ +/* + * 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.model; + +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.util.Log; + +import net.micode.notes.data.Notes; + +import org.json.JSONException; +import org.json.JSONObject; + +/** + * 云端笔记数据模型 + *

+ * 用于表示从云数据库下载的笔记数据 + *

+ */ +public class CloudNote { + + private static final String TAG = "CloudNote"; + + private String mNoteId; + private String mCloudNoteId; + private String mTitle; + private String mContent; + private String mParentId; + private int mType; + private long mCreatedTime; + private long mModifiedTime; + private int mVersion; + private String mDeviceId; + + /** + * 从JSON构造CloudNote + */ + public CloudNote(JSONObject json) throws JSONException { + mCloudNoteId = json.optString("cloudNoteId", ""); + mNoteId = json.optString("noteId", ""); + mTitle = json.optString("title", ""); + mContent = json.optString("content", ""); + mParentId = json.optString("parentId", "0"); + mType = json.optInt("type", 0); + mCreatedTime = json.optLong("createdTime", System.currentTimeMillis()); + mModifiedTime = json.optLong("modifiedTime", System.currentTimeMillis()); + mVersion = json.optInt("version", 1); + mDeviceId = json.optString("deviceId", ""); + } + + /** + * 从WorkingNote构造CloudNote(用于上传) + */ + public CloudNote(WorkingNote note, String deviceId) { + mCloudNoteId = note.getCloudNoteId() != null ? note.getCloudNoteId() : ""; + mNoteId = String.valueOf(note.getNoteId()); + mTitle = note.getTitle(); + mContent = note.getContent(); + mParentId = String.valueOf(note.getFolderId()); + mType = note.getType(); + mCreatedTime = System.currentTimeMillis(); + mModifiedTime = note.getModifiedDate(); + mVersion = 1; + mDeviceId = deviceId; + } + + /** + * 转换为WorkingNote + * 注意:这会创建一个新的本地笔记或更新现有笔记 + * 修复:先检查本地是否存在该云端ID的笔记,决定是创建还是更新 + */ + public WorkingNote toWorkingNote(Context context, String userId) { + try { + long noteId = Long.parseLong(mNoteId); + + // 先检查本地是否存在该云端ID的笔记 + boolean existsInLocal = noteExistsInDatabase(context, noteId); + + WorkingNote note; + if (existsInLocal) { + // 本地存在,加载并更新 + note = WorkingNote.load(context, noteId); + Log.d(TAG, "Updating existing note from cloud: " + noteId); + } else { + // 本地不存在,创建新笔记(不指定ID,让数据库生成) + note = WorkingNote.createEmptyNote(context, 0); + Log.d(TAG, "Creating new note from cloud, cloud ID: " + noteId); + } + + note.setType(mType); + note.setTitle(mTitle); + note.setContent(mContent); + note.setFolderId(Long.parseLong(mParentId)); + note.setModifiedDate(mModifiedTime); + note.setCloudUserId(userId); + + note.setCloudNoteId(mCloudNoteId); + note.setSyncStatus(net.micode.notes.sync.SyncConstants.SYNC_STATUS_SYNCED); + note.setLocalModified(0); + note.setLastSyncTime(System.currentTimeMillis()); + + return note; + } catch (Exception e) { + Log.e(TAG, "转换WorkingNote失败", e); + return null; + } + } + + /** + * 检查指定ID的笔记是否在本地数据库存在 + * @param context 应用上下文 + * @param noteId 笔记ID(云端ID) + * @return 如果存在返回 true,否则返回 false + */ + private boolean noteExistsInDatabase(Context context, long noteId) { + Cursor cursor = null; + try { + cursor = context.getContentResolver().query( + ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId), + new String[]{Notes.NoteColumns.ID}, + null, + null, + null + ); + boolean exists = cursor != null && cursor.getCount() > 0; + Log.d(TAG, "Note exists in local DB: " + noteId + " - " + exists); + return exists; + } catch (Exception e) { + Log.e(TAG, "Failed to check note existence: " + noteId, e); + return false; + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + /** + * 转换为WorkingNote(向后兼容,使用默认userId) + * @deprecated 请使用 {@link #toWorkingNote(Context, String)} + */ + @Deprecated + public WorkingNote toWorkingNote(Context context) { + return toWorkingNote(context, ""); + } + + /** + * 转换为JSON(用于上传) + */ + public JSONObject toJson() throws JSONException { + JSONObject json = new JSONObject(); + if (mCloudNoteId != null && !mCloudNoteId.isEmpty()) { + json.put("cloudNoteId", mCloudNoteId); + } + json.put("noteId", mNoteId); + json.put("title", mTitle); + json.put("content", mContent); + json.put("parentId", mParentId); + json.put("type", mType); + json.put("createdTime", mCreatedTime); + json.put("modifiedTime", mModifiedTime); + json.put("version", mVersion); + json.put("deviceId", mDeviceId); + return json; + } + + // Getters + public String getCloudNoteId() { return mCloudNoteId; } + public String getNoteId() { return mNoteId; } + public String getTitle() { return mTitle; } + public String getContent() { return mContent; } + public String getParentId() { return mParentId; } + public int getType() { return mType; } + public long getCreatedTime() { return mCreatedTime; } + public long getModifiedTime() { return mModifiedTime; } + public int getVersion() { return mVersion; } + public String getDeviceId() { return mDeviceId; } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/model/Note.java b/src/Notesmaster/app/src/main/java/net/micode/notes/model/Note.java index 4cbd456..2daf8b7 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/model/Note.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/model/Note.java @@ -57,18 +57,34 @@ public class Note { * 在数据库中创建一条新笔记记录,并返回其 ID。 * 初始化笔记的创建时间、修改时间、类型和父文件夹 ID。 *

- * + * * @param context 应用上下文 * @param folderId 父文件夹 ID * @return 新创建的笔记 ID,失败时返回 0 */ public static synchronized long getNewNoteId(Context context, long folderId) { + return getNewNoteId(context, folderId, Notes.TYPE_NOTE); + } + + /** + * 创建新笔记 ID(支持指定类型) + *

+ * 在数据库中创建一条新笔记记录,并返回其 ID。 + * 初始化笔记的创建时间、修改时间、类型和父文件夹 ID。 + *

+ * + * @param context 应用上下文 + * @param folderId 父文件夹 ID + * @param type 笔记类型:0=普通笔记, 1=文件夹, 2=系统, 3=待办 + * @return 新创建的笔记 ID,失败时返回 0 + */ + public static synchronized long getNewNoteId(Context context, long folderId, int type) { // 在数据库中创建新笔记 ContentValues values = new ContentValues(); long createdTime = System.currentTimeMillis(); values.put(NoteColumns.CREATED_DATE, createdTime); values.put(NoteColumns.MODIFIED_DATE, createdTime); - values.put(NoteColumns.TYPE, Notes.TYPE_NOTE); + values.put(NoteColumns.TYPE, type); values.put(NoteColumns.LOCAL_MODIFIED, 1); values.put(NoteColumns.PARENT_ID, folderId); Uri uri = context.getContentResolver().insert(Notes.CONTENT_NOTE_URI, values); @@ -421,5 +437,50 @@ public class Note { } return null; } + + // ==================== 云同步相关方法 ==================== + + void setCloudUserId(String userId) { + mNoteDiffValues.put(NoteColumns.CLOUD_USER_ID, userId); + } + + String getCloudUserId() { + return mNoteDiffValues.getAsString(NoteColumns.CLOUD_USER_ID); + } + + void setCloudDeviceId(String deviceId) { + mNoteDiffValues.put(NoteColumns.CLOUD_DEVICE_ID, deviceId); + } + + String getCloudDeviceId() { + return mNoteDiffValues.getAsString(NoteColumns.CLOUD_DEVICE_ID); + } + + void setSyncStatus(int status) { + mNoteDiffValues.put(NoteColumns.SYNC_STATUS, status); + } + + int getSyncStatus() { + Integer status = mNoteDiffValues.getAsInteger(NoteColumns.SYNC_STATUS); + return status != null ? status : 0; + } + + void setLastSyncTime(long time) { + mNoteDiffValues.put(NoteColumns.LAST_SYNC_TIME, time); + } + + long getLastSyncTime() { + Long time = mNoteDiffValues.getAsLong(NoteColumns.LAST_SYNC_TIME); + return time != null ? time : 0; + } + + void setLocalModified(int modified) { + mNoteDiffValues.put(NoteColumns.LOCAL_MODIFIED, modified); + } + + int getLocalModified() { + Integer modified = mNoteDiffValues.getAsInteger(NoteColumns.LOCAL_MODIFIED); + return modified != null ? modified : 0; + } } } diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/model/WorkingNote.java b/src/Notesmaster/app/src/main/java/net/micode/notes/model/WorkingNote.java index deb1d2c..5ec9caa 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/model/WorkingNote.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/model/WorkingNote.java @@ -73,9 +73,30 @@ public class WorkingNote { /** 父文件夹 ID */ private long mFolderId; + /** 笔记类型: 0=普通笔记, 1=文件夹, 2=系统, 3=待办 */ + private int mType; + /** 应用上下文 */ private Context mContext; + /** 同步状态 */ + private int mSyncStatus; + + /** 最后同步时间 */ + private long mLastSyncTime; + + /** 本地修改标记 */ + private int mLocalModified; + + /** 云端用户ID */ + private String mCloudUserId; + + /** 云端设备ID */ + private String mCloudDeviceId; + + /** 云端笔记ID */ + private String mCloudNoteId; + /** 日志标签 */ private static final String TAG = "WorkingNote"; @@ -103,8 +124,10 @@ public class WorkingNote { NoteColumns.BG_COLOR_ID, NoteColumns.WIDGET_ID, NoteColumns.WIDGET_TYPE, + NoteColumns.LOCAL_MODIFIED, NoteColumns.MODIFIED_DATE, - NoteColumns.TITLE + NoteColumns.TITLE, + NoteColumns.TYPE }; /** 数据 ID 列索引 */ @@ -135,7 +158,13 @@ public class WorkingNote { private static final int NOTE_WIDGET_TYPE_COLUMN = 4; /** 笔记修改日期列索引 */ - private static final int NOTE_MODIFIED_DATE_COLUMN = 5; + private static final int NOTE_MODIFIED_DATE_COLUMN = 6; + + /** 笔记类型列索引 */ + private static final int NOTE_TYPE_COLUMN = 8; + + /** 云端笔记ID列索引 */ + private static final int NOTE_CLOUD_NOTE_ID_COLUMN = 9; /** * 新建笔记构造函数 @@ -159,6 +188,8 @@ public class WorkingNote { mIsDeleted = false; mMode = 0; mWidgetType = Notes.TYPE_WIDGET_INVALIDE; + mLocalModified = 1; // 新建笔记需要同步 + mType = Notes.TYPE_NOTE; // 默认为普通笔记类型 } /** @@ -200,6 +231,7 @@ public class WorkingNote { mWidgetType = cursor.getInt(NOTE_WIDGET_TYPE_COLUMN); mAlertDate = cursor.getLong(NOTE_ALERTED_DATE_COLUMN); mModifiedDate = cursor.getLong(NOTE_MODIFIED_DATE_COLUMN); + mType = cursor.getInt(NOTE_TYPE_COLUMN); // Load title int titleIndex = cursor.getColumnIndex(NoteColumns.TITLE); @@ -208,6 +240,14 @@ public class WorkingNote { } else { mTitle = ""; } + + // Load cloud note id + int cloudNoteIdIndex = cursor.getColumnIndex(NoteColumns.CLOUD_NOTE_ID); + if (cloudNoteIdIndex != -1) { + mCloudNoteId = cursor.getString(cloudNoteIdIndex); + } else { + mCloudNoteId = ""; + } } cursor.close(); } else { @@ -258,7 +298,7 @@ public class WorkingNote { *

* 创建一个新的空笔记对象,并设置默认属性。 *

- * + * * @param context 应用上下文 * @param folderId 父文件夹 ID * @param widgetId Widget ID @@ -275,6 +315,21 @@ public class WorkingNote { return note; } + /** + * 创建空笔记(简化版) + *

+ * 创建一个新的空笔记对象,用于云同步。 + *

+ * + * @param context 应用上下文 + * @param noteId 笔记 ID + * @return 新创建的 WorkingNote 对象 + */ + public static WorkingNote createEmptyNote(Context context, long noteId) { + WorkingNote note = new WorkingNote(context, noteId, 0); + return note; + } + /** * 加载已有笔记 *

@@ -378,6 +433,10 @@ public class WorkingNote { public void setTitle(String title) { mTitle = title; mNote.setNoteValue(NoteColumns.TITLE, mTitle); + // 对于文件夹类型,同时设置 snippet 字段以保持兼容性 + if (mType == Notes.TYPE_FOLDER) { + mNote.setNoteValue(NoteColumns.SNIPPET, mTitle); + } } public String getTitle() { @@ -622,13 +681,162 @@ public class WorkingNote { /** * 获取 Widget 类型 - * + * * @return Widget 类型 */ public int getWidgetType() { return mWidgetType; } + // ==================== 云同步相关方法 ==================== + + /** + * 设置云端用户ID + */ + public void setCloudUserId(String userId) { + mCloudUserId = userId; + } + + /** + * 获取云端用户ID + */ + public String getCloudUserId() { + return mCloudUserId; + } + + /** + * 设置云端设备ID + */ + public void setCloudDeviceId(String deviceId) { + mCloudDeviceId = deviceId; + } + + /** + * 获取云端设备ID + */ + public String getCloudDeviceId() { + return mCloudDeviceId; + } + + /** + * 设置同步状态 + * @param status 0=未同步, 1=同步中, 2=已同步, 3=冲突 + */ + public void setSyncStatus(int status) { + mSyncStatus = status; + } + + /** + * 获取同步状态 + */ + public int getSyncStatus() { + return mSyncStatus; + } + + /** + * 设置最后同步时间 + */ + public void setLastSyncTime(long time) { + mLastSyncTime = time; + } + + /** + * 获取最后同步时间 + */ + public long getLastSyncTime() { + return mLastSyncTime; + } + + /** + * 设置本地修改标记 + * @param modified 0=未修改, 1=已修改 + */ + public void setLocalModified(int modified) { + mLocalModified = modified; + } + + /** + * 获取本地修改标记 + */ + public int getLocalModified() { + return mLocalModified; + } + + /** + * 设置笔记内容 + */ + public void setContent(String content) { + mContent = content; + mNote.setTextData(DataColumns.CONTENT, mContent); + } + + /** + * 设置文件夹ID + */ + public void setFolderId(long folderId) { + mFolderId = folderId; + mNote.setNoteValue(NoteColumns.PARENT_ID, String.valueOf(mFolderId)); + } + + /** + * 设置修改日期 + */ + public void setModifiedDate(long date) { + mModifiedDate = date; + mNote.setNoteValue(NoteColumns.MODIFIED_DATE, String.valueOf(mModifiedDate)); + } + + /** + * 设置笔记类型 + * @param type 0=普通笔记, 1=文件夹, 2=系统, 3=待办 + */ + public void setType(int type) { + mType = type; + mNote.setNoteValue(NoteColumns.TYPE, String.valueOf(mType)); + } + + /** + * 获取笔记类型 + * @return 0=普通笔记, 1=文件夹, 2=系统, 3=待办 + */ + public int getType() { + return mType; + } + + /** + * 获取云端笔记ID + * @return 云端笔记ID(UUID),未上传时为空字符串 + */ + public String getCloudNoteId() { + return mCloudNoteId; + } + + /** + * 设置云端笔记ID + * @param cloudNoteId 云端笔记ID(UUID) + */ + public void setCloudNoteId(String cloudNoteId) { + mCloudNoteId = cloudNoteId; + mNote.setNoteValue(NoteColumns.CLOUD_NOTE_ID, mCloudNoteId != null ? mCloudNoteId : ""); + } + + /** + * 从CloudNote更新当前笔记 + * 用于云端下载后更新本地笔记 + */ + public void updateFrom(CloudNote cloudNote) { + setTitle(cloudNote.getTitle()); + setContent(cloudNote.getContent()); + setFolderId(Long.parseLong(cloudNote.getParentId())); + setModifiedDate(cloudNote.getModifiedTime()); + setType(cloudNote.getType()); + setCloudNoteId(cloudNote.getCloudNoteId()); + setSyncStatus(net.micode.notes.sync.SyncConstants.SYNC_STATUS_SYNCED); + setLastSyncTime(System.currentTimeMillis()); + setLocalModified(0); + saveNote(); + } + /** * 笔记设置变更监听器接口 *

diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/sync/Conflict.java b/src/Notesmaster/app/src/main/java/net/micode/notes/sync/Conflict.java new file mode 100644 index 0000000..43d05df --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/sync/Conflict.java @@ -0,0 +1,112 @@ +/* + * 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.sync; + +import net.micode.notes.model.CloudNote; +import net.micode.notes.model.WorkingNote; + +/** + * 冲突数据模型 + *

+ * 表示本地笔记和云端笔记的冲突 + *

+ */ +public class Conflict { + + private WorkingNote mLocalNote; + private CloudNote mCloudNote; + private String mNoteId; + private long mConflictTime; + private ConflictType mType; + + public enum ConflictType { + BOTH_MODIFIED, // 双方都修改过 + VERSION_MISMATCH // 版本号不匹配 + } + + public Conflict(WorkingNote localNote, CloudNote cloudNote) { + this(localNote, cloudNote, ConflictType.BOTH_MODIFIED); + } + + public Conflict(WorkingNote localNote, CloudNote cloudNote, ConflictType type) { + mLocalNote = localNote; + mCloudNote = cloudNote; + mNoteId = String.valueOf(localNote.getNoteId()); + mConflictTime = System.currentTimeMillis(); + mType = type; + } + + public WorkingNote getLocalNote() { + return mLocalNote; + } + + public CloudNote getCloudNote() { + return mCloudNote; + } + + public String getNoteId() { + return mNoteId; + } + + public long getConflictTime() { + return mConflictTime; + } + + public ConflictType getType() { + return mType; + } + + /** + * 获取冲突描述 + */ + public String getConflictDescription() { + return "本地修改时间: " + mLocalNote.getModifiedDate() + + "\n云端修改时间: " + mCloudNote.getModifiedTime(); + } + + /** + * 获取本地标题预览 + */ + public String getLocalTitle() { + return mLocalNote.getTitle(); + } + + /** + * 获取云端标题预览 + */ + public String getCloudTitle() { + return mCloudNote.getTitle(); + } + + /** + * 获取本地内容预览(前100字符) + */ + public String getLocalContentPreview() { + String content = mLocalNote.getContent(); + if (content == null) return ""; + return content.length() > 100 ? content.substring(0, 100) + "..." : content; + } + + /** + * 获取云端内容预览(前100字符) + */ + public String getCloudContentPreview() { + String content = mCloudNote.getContent(); + if (content == null) return ""; + return content.length() > 100 ? content.substring(0, 100) + "..." : content; + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/sync/NotesPushMessageReceiver.java b/src/Notesmaster/app/src/main/java/net/micode/notes/sync/NotesPushMessageReceiver.java new file mode 100644 index 0000000..98eb284 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/sync/NotesPushMessageReceiver.java @@ -0,0 +1,65 @@ +/* + * 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.sync; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +/** + * 推送消息接收器 + *

+ * 接收云端推送的同步通知消息,触发本地同步操作。 + *

+ */ +public class NotesPushMessageReceiver extends BroadcastReceiver { + + private static final String TAG = "NotesPushMessageReceiver"; + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null || intent.getAction() == null) { + return; + } + + String action = intent.getAction(); + Log.d(TAG, "Received push message: " + action); + + if ("com.alibaba.push2.action.NOTIFICATION_OPENED".equals(action)) { + handleNotificationOpened(context, intent); + } else if ("com.alibaba.push2.action.MESSAGE_RECEIVED".equals(action)) { + handleMessageReceived(context, intent); + } + } + + private void handleNotificationOpened(Context context, Intent intent) { + Log.d(TAG, "Notification opened"); + // TODO: Handle notification open action + } + + private void handleMessageReceived(Context context, Intent intent) { + Log.d(TAG, "Message received"); + // Check if this is a sync message + String messageAction = intent.getStringExtra("action"); + if ("sync".equals(messageAction)) { + Log.d(TAG, "Sync action received, broadcasting sync intent"); + Intent syncIntent = new Intent(SyncConstants.ACTION_SYNC); + context.sendBroadcast(syncIntent); + } + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/sync/SyncConstants.java b/src/Notesmaster/app/src/main/java/net/micode/notes/sync/SyncConstants.java new file mode 100644 index 0000000..1c8a3a1 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/sync/SyncConstants.java @@ -0,0 +1,55 @@ +/* + * 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.sync; + +/** + * 同步常量定义 + *

+ * 定义云同步功能中使用的所有常量,包括同步状态、错误码等。 + *

+ */ +public class SyncConstants { + + private SyncConstants() { + // Utility class, prevent instantiation + } + + /** + * 同步状态:未同步 + */ + public static final int SYNC_STATUS_NOT_SYNCED = 0; + + /** + * 同步状态:同步中 + */ + public static final int SYNC_STATUS_SYNCING = 1; + + /** + * 同步状态:已同步 + */ + public static final int SYNC_STATUS_SYNCED = 2; + + /** + * 同步状态:冲突 + */ + public static final int SYNC_STATUS_CONFLICT = 3; + + /** + * 同步广播Action + */ + public static final String ACTION_SYNC = "com.micode.notes.ACTION_SYNC"; +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/sync/SyncManager.java b/src/Notesmaster/app/src/main/java/net/micode/notes/sync/SyncManager.java new file mode 100644 index 0000000..e8b3898 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/sync/SyncManager.java @@ -0,0 +1,746 @@ +/* + * 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.sync; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import androidx.annotation.Nullable; + +import net.micode.notes.api.CloudCallback; +import net.micode.notes.api.CloudDatabaseHelper; +import net.micode.notes.auth.UserAuthManager; +import net.micode.notes.data.NotesRepository; +import net.micode.notes.model.CloudNote; +import net.micode.notes.model.WorkingNote; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +/** + * 同步管理器 + *

+ * 负责管理笔记的云同步操作,包括上传本地修改、下载云端更新、处理冲突等。 + * 使用单例模式确保全局只有一个同步管理器实例。 + *

+ *

+ * 修复记录: + * 1. 修复同步时间更新逻辑 - 确保所有笔记处理完成后再更新时间戳 + * 2. 优化线程同步机制 - 使用 CountDownLatch 替代 synchronized/wait/notify + * 3. 添加全量同步支持 - 支持强制下载所有云端笔记 + * 4. 添加同步进度回调 - 支持实时显示同步进度 + *

+ */ +public class SyncManager { + + private static final String TAG = "SyncManager"; + private static final String PREFS_SYNC = "sync_settings"; + private static final String KEY_LAST_SYNC = "last_sync_time"; + private static final String KEY_IS_FIRST_SYNC = "is_first_sync"; + private static final long SYNC_TIMEOUT_SECONDS = 60; + + private final ExecutorService mExecutor; + private Context mContext; + private SharedPreferences mPrefs; + private List mConflicts; + private ConflictListener mConflictListener; + + /** + * 静态内部类实现单例模式(Initialization-on-demand holder idiom) + */ + private static class Holder { + private static final SyncManager INSTANCE = new SyncManager(); + } + + private SyncManager() { + mExecutor = Executors.newSingleThreadExecutor(); + mConflicts = new ArrayList<>(); + } + + /** + * 同步回调接口 + */ + public interface SyncCallback { + void onSuccess(); + void onError(String error); + } + + /** + * 同步进度回调接口 + */ + public interface SyncProgressCallback { + void onProgress(int current, int total, String message); + } + + /** + * 冲突监听器接口 + */ + public interface ConflictListener { + void onConflictDetected(Conflict conflict); + } + + /** + * 获取SyncManager单例实例 + * + * @return SyncManager实例 + */ + public static SyncManager getInstance() { + return Holder.INSTANCE; + } + + /** + * 初始化SyncManager + * + * @param context 应用上下文 + */ + public void initialize(Context context) { + mContext = context.getApplicationContext(); + mPrefs = mContext.getSharedPreferences(PREFS_SYNC, Context.MODE_PRIVATE); + Log.d(TAG, "SyncManager initialized"); + } + + /** + * 设置冲突监听器 + * + * @param listener 冲突监听器 + */ + public void setConflictListener(ConflictListener listener) { + mConflictListener = listener; + } + + /** + * 移除冲突 + * + * @param conflict 要移除的冲突 + */ + public void removeConflict(Conflict conflict) { + mConflicts.remove(conflict); + } + + /** + * 执行笔记同步(增量同步) + * + * @param callback 同步回调 + */ + public void syncNotes(SyncCallback callback) { + syncNotesInternal(false, callback, null); + } + + /** + * 执行全量同步(强制下载所有云端笔记) + * + * @param callback 同步回调 + */ + public void syncAllNotes(SyncCallback callback) { + syncNotesInternal(true, callback, null); + } + + /** + * 上传所有本地笔记到云端 + *

+ * 用于新用户登录后,将设备上所有笔记上传到云端。 + * 不管笔记的 LOCAL_MODIFIED 状态如何,都会上传。 + *

+ * + * @param callback 同步回调 + */ + public void uploadAllNotes(SyncCallback callback) { + Log.d(TAG, "========== Starting upload all notes =========="); + + mExecutor.execute(() -> { + try { + performUploadAll(); + Log.d(TAG, "Upload all notes completed successfully"); + if (callback != null) { + callback.onSuccess(); + } + } catch (Exception e) { + Log.e(TAG, "Upload all notes failed", e); + if (callback != null) { + callback.onError(e.getMessage()); + } + } + }); + } + + /** + * 执行笔记同步(带进度回调) + * + * @param forceFullSync 是否强制全量同步 + * @param callback 同步回调 + * @param progressCallback 进度回调 + */ + public void syncNotesWithProgress(boolean forceFullSync, SyncCallback callback, + SyncProgressCallback progressCallback) { + syncNotesInternal(forceFullSync, callback, progressCallback); + } + + /** + * 内部同步方法 + */ + private void syncNotesInternal(boolean forceFullSync, SyncCallback callback, + SyncProgressCallback progressCallback) { + Log.d(TAG, "========== Starting sync operation =========="); + Log.d(TAG, "Force full sync: " + forceFullSync); + + mExecutor.execute(() -> { + try { + performSync(forceFullSync, progressCallback); + Log.d(TAG, "Sync completed successfully"); + if (callback != null) { + callback.onSuccess(); + } + } catch (Exception e) { + Log.e(TAG, "Sync failed", e); + if (callback != null) { + callback.onError(e.getMessage()); + } + } + }); + } + + /** + * 执行实际的同步操作 + */ + private void performSync(boolean forceFullSync, SyncProgressCallback progressCallback) throws Exception { + UserAuthManager authManager = UserAuthManager.getInstance(mContext); + if (!authManager.isLoggedIn()) { + throw new RuntimeException("用户未登录"); + } + + // 检查并刷新Token + ensureValidToken(authManager); + + NotesRepository repo = new NotesRepository(mContext); + String authToken = authManager.getAuthToken(); + Log.d(TAG, "Auth token: " + (authToken != null ? authToken.substring(0, Math.min(20, authToken.length())) + "..." : "null")); + + CloudDatabaseHelper cloudHelper = new CloudDatabaseHelper( + authManager.getUserId(), + authManager.getDeviceId(), + authToken + ); + + // 1. 上传本地修改的笔记(只上传当前用户的笔记) + if (progressCallback != null) { + progressCallback.onProgress(0, 100, "正在上传本地修改..."); + } + uploadNotesSync(repo, cloudHelper, progressCallback, authManager.getUserId()); + + // 2. 下载云端更新的笔记 + if (progressCallback != null) { + progressCallback.onProgress(50, 100, "正在下载云端更新..."); + } + boolean downloadSuccess = downloadNotesSync(repo, cloudHelper, forceFullSync, progressCallback, authManager.getUserId()); + + // 3. 只有在下载成功后才更新同步时间 + if (downloadSuccess) { + updateSyncFlags(); + // 标记已完成首次同步 + markFirstSyncCompleted(); + } else { + throw new RuntimeException("下载云端笔记失败,同步时间未更新"); + } + } + + /** + * 确保Token有效 + */ + private void ensureValidToken(UserAuthManager authManager) throws Exception { + if (authManager.isTokenExpired()) { + Log.w(TAG, "Token已过期,尝试刷新..."); + final CountDownLatch latch = new CountDownLatch(1); + final AtomicBoolean refreshSuccess = new AtomicBoolean(false); + final AtomicReference errorMsg = new AtomicReference<>(); + + authManager.refreshToken(new UserAuthManager.TokenRefreshCallback() { + @Override + public void onSuccess(String newToken) { + Log.d(TAG, "Token刷新成功"); + refreshSuccess.set(true); + latch.countDown(); + } + + @Override + public void onError(String error) { + Log.e(TAG, "Token刷新失败: " + error); + errorMsg.set(error); + latch.countDown(); + } + }); + + boolean completed = latch.await(SYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!completed || !refreshSuccess.get()) { + throw new RuntimeException("Token刷新失败: " + errorMsg.get()); + } + } + } + + /** + * 同步方式上传笔记 + */ + private void uploadNotesSync(NotesRepository repo, CloudDatabaseHelper cloudHelper, + SyncProgressCallback progressCallback, String userId) throws Exception { + Log.d(TAG, "Uploading local modified notes for user: " + userId); + + final List notesToUpload = new ArrayList<>(); + final CountDownLatch queryLatch = new CountDownLatch(1); + final AtomicReference errorRef = new AtomicReference<>(); + + // 使用带用户过滤的方法,只查询当前用户的笔记 + repo.getLocalModifiedNotes(userId, new NotesRepository.Callback>() { + @Override + public void onSuccess(List notes) { + notesToUpload.addAll(notes); + queryLatch.countDown(); + } + + @Override + public void onError(Exception e) { + errorRef.set(e); + queryLatch.countDown(); + } + }); + + boolean completed = queryLatch.await(SYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!completed) { + throw new RuntimeException("查询本地修改笔记超时"); + } + if (errorRef.get() != null) { + throw errorRef.get(); + } + + Log.d(TAG, "Found " + notesToUpload.size() + " notes to upload"); + + int total = notesToUpload.size(); + for (int i = 0; i < total; i++) { + WorkingNote note = notesToUpload.get(i); + + if (progressCallback != null) { + int progress = (i * 50) / total; // 上传占50%进度 + progressCallback.onProgress(progress, 100, "正在上传笔记 " + (i + 1) + "/" + total); + } + + uploadSingleNote(repo, cloudHelper, note); + } + } + + /** + * 上传单条笔记 + */ + private void uploadSingleNote(NotesRepository repo, CloudDatabaseHelper cloudHelper, + WorkingNote note) throws Exception { + final CountDownLatch uploadLatch = new CountDownLatch(1); + final AtomicReference cloudIdRef = new AtomicReference<>(); + final AtomicReference errorRef = new AtomicReference<>(); + + cloudHelper.uploadNote(note, new CloudCallback() { + @Override + public void onSuccess(String result) { + cloudIdRef.set(result); + uploadLatch.countDown(); + } + + @Override + public void onError(String err) { + errorRef.set(new Exception(err)); + uploadLatch.countDown(); + } + }); + + boolean completed = uploadLatch.await(SYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!completed) { + throw new RuntimeException("上传笔记超时: " + note.getNoteId()); + } + if (errorRef.get() != null) { + Log.e(TAG, "Failed to upload note: " + note.getNoteId(), errorRef.get()); + return; // 继续处理其他笔记 + } + + Log.d(TAG, "Uploaded note: " + note.getNoteId() + " with cloudId: " + cloudIdRef.get()); + + String cloudNoteId = cloudIdRef.get(); + if (cloudNoteId != null && !cloudNoteId.isEmpty()) { + note.setCloudNoteId(cloudNoteId); + if (!note.saveNote()) { + Log.w(TAG, "Failed to save cloudNoteId for note: " + note.getNoteId()); + } + } + + markNoteAsSynced(repo, note.getNoteId()); + } + + /** + * 标记笔记为已同步 + */ + private void markNoteAsSynced(NotesRepository repo, long noteId) throws Exception { + final CountDownLatch markLatch = new CountDownLatch(1); + final AtomicReference errorRef = new AtomicReference<>(); + + repo.markNoteSynced(noteId, new NotesRepository.Callback() { + @Override + public void onSuccess(Void result) { + markLatch.countDown(); + } + + @Override + public void onError(Exception e) { + errorRef.set(e); + markLatch.countDown(); + } + }); + + boolean completed = markLatch.await(SYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!completed) { + throw new RuntimeException("标记笔记同步状态超时: " + noteId); + } + if (errorRef.get() != null) { + throw errorRef.get(); + } + + Log.d(TAG, "Marked note " + noteId + " as synced"); + } + + /** + * 上传所有本地笔记到云端(不管modified状态) + */ + private void performUploadAll() throws Exception { + UserAuthManager authManager = UserAuthManager.getInstance(mContext); + if (!authManager.isLoggedIn()) { + throw new RuntimeException("用户未登录"); + } + + String userId = authManager.getUserId(); + String authToken = authManager.getAuthToken(); + Log.d(TAG, "Uploading all notes for user: " + userId); + + NotesRepository repo = new NotesRepository(mContext); + CloudDatabaseHelper cloudHelper = new CloudDatabaseHelper( + userId, + authManager.getDeviceId(), + authToken + ); + + // 获取当前用户的所有笔记(不管modified状态) + final List allNotes = new ArrayList<>(); + final CountDownLatch queryLatch = new CountDownLatch(1); + final AtomicReference errorRef = new AtomicReference<>(); + + repo.getNotesByCloudUserId(userId, new NotesRepository.Callback>() { + @Override + public void onSuccess(List notes) { + allNotes.addAll(notes); + queryLatch.countDown(); + } + + @Override + public void onError(Exception e) { + errorRef.set(e); + queryLatch.countDown(); + } + }); + + boolean completed = queryLatch.await(SYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!completed) { + throw new RuntimeException("查询笔记超时"); + } + if (errorRef.get() != null) { + throw errorRef.get(); + } + + Log.d(TAG, "Found " + allNotes.size() + " notes to upload"); + + // 上传所有笔记 + int total = allNotes.size(); + int successCount = 0; + for (int i = 0; i < total; i++) { + WorkingNote note = allNotes.get(i); + try { + uploadSingleNote(repo, cloudHelper, note); + successCount++; + } catch (Exception e) { + Log.e(TAG, "Failed to upload note: " + note.getNoteId(), e); + // 继续上传其他笔记 + } + } + + Log.d(TAG, "Upload completed: " + successCount + "/" + total + " notes uploaded"); + + // 更新同步时间 + updateSyncFlags(); + markFirstSyncCompleted(); + } + + /** + * 同步方式下载笔记 + * + * @return 是否下载成功 + */ + private boolean downloadNotesSync(NotesRepository repo, CloudDatabaseHelper cloudHelper, + boolean forceFullSync, SyncProgressCallback progressCallback, String userId) throws Exception { + Log.d(TAG, "Downloading cloud updates"); + + long lastSyncTime = forceFullSync ? 0 : mPrefs.getLong(KEY_LAST_SYNC, 0); + Log.d(TAG, "Last sync time: " + lastSyncTime + (forceFullSync ? " (强制全量同步)" : "")); + + // 首次同步时传递 0,获取所有笔记 + // 后续同步只获取修改过的笔记 + long downloadSince = (lastSyncTime == 0) ? 0 : lastSyncTime; + + final AtomicReference notesArrayRef = new AtomicReference<>(); + final CountDownLatch downloadLatch = new CountDownLatch(1); + final AtomicReference errorRef = new AtomicReference<>(); + final AtomicLong maxModifiedTime = new AtomicLong(0); + + cloudHelper.downloadNotes(downloadSince, new CloudCallback() { + @Override + public void onSuccess(JSONArray result) { + notesArrayRef.set(result); + + // 计算云端最新修改时间 + long latestCloudTime = 0; + try { + for (int i = 0; i < result.length(); i++) { + JSONObject noteJson = result.getJSONObject(i); + long modifiedTime = noteJson.optLong("modifiedTime", 0); + if (modifiedTime > latestCloudTime) { + latestCloudTime = modifiedTime; + } + } + maxModifiedTime.set(latestCloudTime); + } catch (Exception e) { + Log.e(TAG, "Failed to calculate latest cloud time", e); + } + + downloadLatch.countDown(); + } + + @Override + public void onError(String err) { + errorRef.set(new Exception(err)); + downloadLatch.countDown(); + } + }); + + boolean completed = downloadLatch.await(SYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!completed) { + throw new RuntimeException("下载云端笔记超时"); + } + if (errorRef.get() != null) { + throw errorRef.get(); + } + + JSONArray notesArray = notesArrayRef.get(); + if (notesArray == null) { + Log.w(TAG, "Downloaded notes array is null"); + return true; // 视为成功,只是没有数据 + } + + Log.d(TAG, "Downloaded " + notesArray.length() + " notes from cloud"); + + // 处理下载的笔记 + int total = notesArray.length(); + int successCount = 0; + + for (int i = 0; i < total; i++) { + if (progressCallback != null) { + int progress = 50 + (i * 50) / total; // 下载占50-100%进度 + progressCallback.onProgress(progress, 100, "正在处理笔记 " + (i + 1) + "/" + total); + } + + CloudNote cloudNote = new CloudNote(notesArray.getJSONObject(i)); + boolean processed = processDownloadedNote(repo, cloudNote, userId); + if (processed) { + successCount++; + } + } + + Log.d(TAG, "Successfully processed " + successCount + "/" + total + " notes"); + + // 只有在所有笔记都处理成功后才更新同步时间 + if (successCount == total) { + // 使用云端最新修改时间更新 lastSyncTime + if (maxModifiedTime.get() > 0) { + updateLastSyncTime(maxModifiedTime.get()); + Log.d(TAG, "Updated last sync time to: " + maxModifiedTime.get()); + } + return true; + } else { + Log.w(TAG, "Some notes failed to process, not updating sync time"); + return false; + } + } + + /** + * 处理下载的单条笔记 + * + * @return 是否处理成功 + */ + private boolean processDownloadedNote(NotesRepository repo, CloudNote cloudNote, String userId) { + final CountDownLatch findLatch = new CountDownLatch(1); + final AtomicReference localNoteRef = new AtomicReference<>(); + + String cloudNoteId = cloudNote.getCloudNoteId(); + if (cloudNoteId != null && !cloudNoteId.isEmpty()) { + repo.findByCloudNoteId(cloudNoteId, new NotesRepository.Callback() { + @Override + public void onSuccess(WorkingNote result) { + localNoteRef.set(result); + findLatch.countDown(); + } + + @Override + public void onError(Exception e) { + Log.e(TAG, "Failed to find note by cloudNoteId: " + cloudNoteId, e); + findLatch.countDown(); + } + }); + } else { + repo.findNoteByNoteId(cloudNote.getNoteId(), new NotesRepository.Callback() { + @Override + public void onSuccess(WorkingNote result) { + localNoteRef.set(result); + findLatch.countDown(); + } + + @Override + public void onError(Exception e) { + Log.e(TAG, "Failed to find note by noteId: " + cloudNote.getNoteId(), e); + findLatch.countDown(); + } + }); + } + + try { + boolean completed = findLatch.await(SYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!completed) { + Log.e(TAG, "查询本地笔记超时: " + cloudNote.getCloudNoteId()); + return false; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + + WorkingNote localNote = localNoteRef.get(); + + if (localNote == null) { + // 本地不存在,插入新笔记 + Log.d(TAG, "Inserting new note from cloud: cloudNoteId=" + cloudNote.getCloudNoteId()); + WorkingNote newNote = cloudNote.toWorkingNote(mContext, userId); + if (newNote != null) { + newNote.saveNote(); + return true; + } + return false; + } else { + // 本地已存在,检查版本 + if (cloudNote.getModifiedTime() > localNote.getModifiedDate()) { + if (localNote.getLocalModified() == 0) { + // 本地未修改,直接覆盖 + Log.d(TAG, "Updating local note from cloud: cloudNoteId=" + cloudNote.getCloudNoteId()); + localNote.updateFrom(cloudNote); + localNote.saveNote(); + return true; + } else { + // 双方都修改过,记录冲突 + Log.d(TAG, "Conflict detected for note: cloudNoteId=" + cloudNote.getCloudNoteId()); + mConflicts.add(new Conflict(localNote, cloudNote)); + return true; + } + } + return true; + } + } + + /** + * 更新同步标志 + */ + private void updateSyncFlags() { + long currentTime = System.currentTimeMillis(); + mPrefs.edit().putLong(KEY_LAST_SYNC, currentTime).apply(); + Log.d(TAG, "Sync time updated: " + currentTime); + } + + /** + * 更新最后同步时间 + */ + private void updateLastSyncTime(long syncTime) { + mPrefs.edit().putLong(KEY_LAST_SYNC, syncTime).apply(); + Log.d(TAG, "Saved last sync time: " + syncTime); + } + + /** + * 标记已完成首次同步 + */ + private void markFirstSyncCompleted() { + mPrefs.edit().putBoolean(KEY_IS_FIRST_SYNC, false).apply(); + Log.d(TAG, "First sync marked as completed"); + } + + /** + * 检查是否是首次同步 + */ + public boolean isFirstSync() { + return mPrefs.getBoolean(KEY_IS_FIRST_SYNC, true); + } + + /** + * 重置同步状态(用于重新安装后强制全量同步) + */ + public void resetSyncState() { + mPrefs.edit() + .remove(KEY_LAST_SYNC) + .putBoolean(KEY_IS_FIRST_SYNC, true) + .apply(); + Log.d(TAG, "Sync state reset"); + } + + /** + * 获取最后同步时间 + */ + public long getLastSyncTime() { + return mPrefs.getLong(KEY_LAST_SYNC, 0); + } + + /** + * 获取待解决冲突列表 + */ + public List getPendingConflicts() { + return new ArrayList<>(mConflicts); + } + + /** + * 清除所有冲突 + */ + public void clearAllConflicts() { + mConflicts.clear(); + Log.d(TAG, "All conflicts cleared"); + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/sync/SyncWorker.java b/src/Notesmaster/app/src/main/java/net/micode/notes/sync/SyncWorker.java new file mode 100644 index 0000000..1ea4f22 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/sync/SyncWorker.java @@ -0,0 +1,118 @@ +/* + * 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.sync; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.work.Constraints; +import androidx.work.NetworkType; +import androidx.work.PeriodicWorkRequest; +import androidx.work.WorkManager; +import androidx.work.Worker; +import androidx.work.WorkerParameters; +import androidx.work.ExistingPeriodicWorkPolicy; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * 同步Worker + *

+ * 使用WorkManager执行后台同步任务,定期同步笔记到云端。 + *

+ */ +public class SyncWorker extends Worker { + + private static final String TAG = "SyncWorker"; + private static final String WORK_NAME = "cloudSync"; + + public SyncWorker(@NonNull Context context, @NonNull WorkerParameters params) { + super(context, params); + } + + @NonNull + @Override + public Result doWork() { + Log.d(TAG, "Starting background sync work"); + + final CountDownLatch latch = new CountDownLatch(1); + final boolean[] success = {false}; + + SyncManager.getInstance().syncNotes(new SyncManager.SyncCallback() { + @Override + public void onSuccess() { + Log.d(TAG, "Background sync completed successfully"); + success[0] = true; + latch.countDown(); + } + + @Override + public void onError(String error) { + Log.e(TAG, "Background sync failed: " + error); + success[0] = false; + latch.countDown(); + } + }); + + try { + latch.await(60, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Log.e(TAG, "Sync work interrupted", e); + return Result.retry(); + } + + return success[0] ? Result.success() : Result.retry(); + } + + /** + * 初始化定期同步任务 + * + * @param context 应用上下文 + */ + public static void initialize(Context context) { + Log.d(TAG, "Initializing periodic sync work"); + + Constraints constraints = new Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiresBatteryNotLow(true) + .build(); + + PeriodicWorkRequest syncWork = new PeriodicWorkRequest.Builder( + SyncWorker.class, 30, TimeUnit.MINUTES) + .setConstraints(constraints) + .build(); + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.REPLACE, + syncWork); + + Log.d(TAG, "Periodic sync work scheduled (30 minutes interval)"); + } + + /** + * 取消定期同步任务 + * + * @param context 应用上下文 + */ + public static void cancel(Context context) { + Log.d(TAG, "Canceling periodic sync work"); + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME); + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/ConflictResolutionDialog.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/ConflictResolutionDialog.java new file mode 100644 index 0000000..ab4abbb --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/ConflictResolutionDialog.java @@ -0,0 +1,217 @@ +/* + * 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.Dialog; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; + +import com.google.android.material.button.MaterialButton; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import net.micode.notes.R; +import net.micode.notes.model.WorkingNote; + +/** + * 冲突解决对话框 + *

+ * 当本地笔记和云端笔记发生冲突时显示,让用户选择保留哪个版本。 + *

+ */ +public class ConflictResolutionDialog extends DialogFragment { + + private static final String TAG = "ConflictResolutionDialog"; + private static final String ARG_LOCAL_TITLE = "local_title"; + private static final String ARG_LOCAL_CONTENT = "local_content"; + private static final String ARG_CLOUD_TITLE = "cloud_title"; + private static final String ARG_CLOUD_CONTENT = "cloud_content"; + + private ConflictResolutionListener mListener; + + /** + * 冲突解决监听器接口 + */ + public interface ConflictResolutionListener { + void onChooseLocal(); + void onChooseCloud(); + void onMerge(String mergedTitle, String mergedContent); + } + + /** + * 创建冲突解决对话框实例 + * + * @param localNote 本地笔记 + * @param cloudNote 云端笔记 + * @return ConflictResolutionDialog实例 + */ + public static ConflictResolutionDialog newInstance(WorkingNote localNote, WorkingNote cloudNote) { + ConflictResolutionDialog dialog = new ConflictResolutionDialog(); + Bundle args = new Bundle(); + args.putString(ARG_LOCAL_TITLE, localNote.getTitle()); + args.putString(ARG_LOCAL_CONTENT, localNote.getContent()); + args.putString(ARG_CLOUD_TITLE, cloudNote.getTitle()); + args.putString(ARG_CLOUD_CONTENT, cloudNote.getContent()); + dialog.setArguments(args); + return dialog; + } + + /** + * 设置冲突解决监听器 + * + * @param listener 监听器 + */ + public void setConflictResolutionListener(ConflictResolutionListener listener) { + mListener = listener; + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext()); + + LayoutInflater inflater = requireActivity().getLayoutInflater(); + View view = inflater.inflate(R.layout.dialog_conflict_resolution, null); + + // Get arguments + Bundle args = getArguments(); + if (args != null) { + String localTitle = args.getString(ARG_LOCAL_TITLE, ""); + String localContent = args.getString(ARG_LOCAL_CONTENT, ""); + String cloudTitle = args.getString(ARG_CLOUD_TITLE, ""); + String cloudContent = args.getString(ARG_CLOUD_CONTENT, ""); + + // Set content previews (first 100 chars) + TextView tvLocalContent = view.findViewById(R.id.tv_local_content); + tvLocalContent.setText(truncateContent(localTitle, localContent)); + + TextView tvCloudContent = view.findViewById(R.id.tv_cloud_content); + tvCloudContent.setText(truncateContent(cloudTitle, cloudContent)); + } + + // Setup buttons + MaterialButton btnUseLocal = view.findViewById(R.id.btn_use_local); + MaterialButton btnUseCloud = view.findViewById(R.id.btn_use_cloud); + MaterialButton btnMerge = view.findViewById(R.id.btn_merge); + + btnUseLocal.setOnClickListener(v -> { + Log.d(TAG, "User chose local version"); + if (mListener != null) { + mListener.onChooseLocal(); + } + dismiss(); + }); + + btnUseCloud.setOnClickListener(v -> { + Log.d(TAG, "User chose cloud version"); + if (mListener != null) { + mListener.onChooseCloud(); + } + dismiss(); + }); + + btnMerge.setOnClickListener(v -> { + Log.d(TAG, "User chose merge"); + showMergeDialog(args); + }); + + builder.setView(view); + return builder.create(); + } + + private String truncateContent(String title, String content) { + String fullText = title + "\n" + content; + if (fullText.length() > 100) { + return fullText.substring(0, 100) + "..."; + } + return fullText; + } + + /** + * 显示合并编辑对话框 + */ + private void showMergeDialog(Bundle args) { + if (args == null) return; + + String localTitle = args.getString(ARG_LOCAL_TITLE, ""); + String localContent = args.getString(ARG_LOCAL_CONTENT, ""); + String cloudTitle = args.getString(ARG_CLOUD_TITLE, ""); + String cloudContent = args.getString(ARG_CLOUD_CONTENT, ""); + + // 智能合并:合并标题和内容 + String mergedTitle = mergeText(localTitle, cloudTitle); + String mergedContent = mergeText(localContent, cloudContent); + + // 创建编辑对话框 + androidx.appcompat.app.AlertDialog.Builder builder = new androidx.appcompat.app.AlertDialog.Builder(requireContext()); + builder.setTitle("合并笔记"); + + // 创建输入布局 + android.widget.LinearLayout layout = new android.widget.LinearLayout(requireContext()); + layout.setOrientation(android.widget.LinearLayout.VERTICAL); + layout.setPadding(50, 30, 50, 30); + + // 标题输入 + final android.widget.EditText etTitle = new android.widget.EditText(requireContext()); + etTitle.setHint("标题"); + etTitle.setText(mergedTitle); + layout.addView(etTitle); + + // 内容输入 + final android.widget.EditText etContent = new android.widget.EditText(requireContext()); + etContent.setHint("内容"); + etContent.setText(mergedContent); + etContent.setMinLines(5); + layout.addView(etContent); + + builder.setView(layout); + + builder.setPositiveButton("保存", (dialog, which) -> { + String finalTitle = etTitle.getText().toString().trim(); + String finalContent = etContent.getText().toString().trim(); + + if (mListener != null) { + mListener.onMerge(finalTitle, finalContent); + } + dismiss(); + }); + + builder.setNegativeButton("取消", (dialog, which) -> dialog.dismiss()); + + builder.show(); + } + + /** + * 合并两段文本 + * 如果内容相同返回其中一个,不同则合并 + */ + private String mergeText(String local, String cloud) { + if (local == null || local.isEmpty()) return cloud; + if (cloud == null || cloud.isEmpty()) return local; + if (local.equals(cloud)) return local; + + // 简单的合并策略:用分隔符连接 + return local + "\n\n--- 云端版本 ---\n\n" + cloud; + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/LoginActivity.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/LoginActivity.java new file mode 100644 index 0000000..b602a4e --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/LoginActivity.java @@ -0,0 +1,145 @@ +/* + * 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.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.ProgressBar; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.lifecycle.ViewModelProvider; + +import com.google.android.material.button.MaterialButton; +import com.google.android.material.textfield.TextInputEditText; + +import net.micode.notes.R; +import net.micode.notes.auth.UserAuthManager; +import net.micode.notes.viewmodel.LoginViewModel; + +/** + * 登录界面 + * + *

+ * 提供用户登录和注册功能。 + * 登录成功后,用户的笔记会自动同步到云端。 + *

+ *

+ * 遵循 MVVM 架构,所有业务逻辑委托给 {@link LoginViewModel}。 + *

+ */ +public class LoginActivity extends AppCompatActivity { + + private static final String TAG = "LoginActivity"; + + private TextInputEditText mEtUsername; + private TextInputEditText mEtPassword; + private MaterialButton mBtnLogin; + private MaterialButton mBtnRegister; + private View mTvSkip; + private ProgressBar mProgressBar; + + private LoginViewModel mViewModel; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // 检查是否已经登录 + UserAuthManager authManager = UserAuthManager.getInstance(this); + if (authManager.isLoggedIn()) { + startMainActivity(); + return; + } + + setContentView(R.layout.activity_login); + + mViewModel = new ViewModelProvider(this).get(LoginViewModel.class); + + initViews(); + setupListeners(); + observeViewModel(); + } + + private void initViews() { + mEtUsername = findViewById(R.id.et_username); + mEtPassword = findViewById(R.id.et_password); + mBtnLogin = findViewById(R.id.btn_login); + mBtnRegister = findViewById(R.id.btn_register); + mTvSkip = findViewById(R.id.tv_skip); + mProgressBar = findViewById(R.id.progress_bar); + } + + private void setupListeners() { + mBtnLogin.setOnClickListener(v -> attemptLogin()); + mBtnRegister.setOnClickListener(v -> attemptRegister()); + mTvSkip.setOnClickListener(v -> skipLogin()); + } + + private void observeViewModel() { + mViewModel.getIsLoading().observe(this, isLoading -> showLoading(isLoading)); + + mViewModel.getErrorMessage().observe(this, error -> { + if (error != null) { + Toast.makeText(this, error, Toast.LENGTH_LONG).show(); + mViewModel.clearError(); + } + }); + + mViewModel.getLoginSuccess().observe(this, success -> { + if (success) { + Integer migratedCount = mViewModel.getMigratedNotesCount().getValue(); + String message = (migratedCount != null && migratedCount > 0) + ? "登录成功!已迁移 " + migratedCount + " 条本地笔记" + : "登录成功!"; + Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); + startMainActivity(); + } + }); + } + + private void attemptLogin() { + String username = mEtUsername.getText().toString().trim(); + String password = mEtPassword.getText().toString().trim(); + mViewModel.login(username, password); + } + + private void attemptRegister() { + String username = mEtUsername.getText().toString().trim(); + String password = mEtPassword.getText().toString().trim(); + mViewModel.register(username, password); + } + + private void skipLogin() { + Toast.makeText(this, "使用本地模式(不同步)", Toast.LENGTH_SHORT).show(); + startMainActivity(); + } + + private void startMainActivity() { + Intent intent = new Intent(this, NotesListActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(intent); + finish(); + } + + private void showLoading(boolean show) { + mProgressBar.setVisibility(show ? View.VISIBLE : View.GONE); + mBtnLogin.setEnabled(!show); + mBtnRegister.setEnabled(!show); + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java index f232e6c..efdb3e1 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java @@ -1449,10 +1449,33 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen * {@link #RESULT_OK} is used to identify the create/edit state */ setResult(RESULT_OK); + + // 触发同步(如果用户已登录) + triggerBackgroundSync(); } return saved; } + /** + * 触发后台同步(如果用户已登录) + */ + private void triggerBackgroundSync() { + net.micode.notes.auth.UserAuthManager authManager = net.micode.notes.auth.UserAuthManager.getInstance(this); + if (authManager.isLoggedIn()) { + net.micode.notes.sync.SyncManager.getInstance().syncNotes(new net.micode.notes.sync.SyncManager.SyncCallback() { + @Override + public void onSuccess() { + Log.d("NoteEditActivity", "Background sync completed after save"); + } + + @Override + public void onError(String error) { + Log.e("NoteEditActivity", "Background sync failed after save: " + error); + } + }); + } + } + /** * 发送到桌面 *

diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NotesListActivity.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NotesListActivity.java index f89a549..56f0b01 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NotesListActivity.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NotesListActivity.java @@ -18,9 +18,11 @@ package net.micode.notes.ui; import android.app.AlertDialog; import android.appwidget.AppWidgetManager; +import android.content.BroadcastReceiver; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; +import android.content.IntentFilter; import android.os.Bundle; import android.text.InputFilter; import android.text.TextUtils; @@ -48,9 +50,13 @@ import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModelProvider; import net.micode.notes.R; +import net.micode.notes.api.CloudCallback; +import net.micode.notes.auth.UserAuthManager; import net.micode.notes.data.Notes; +import net.micode.notes.sync.SyncManager; import net.micode.notes.data.NotesRepository; import net.micode.notes.databinding.NoteListBinding; +import net.micode.notes.model.WorkingNote; import net.micode.notes.tool.SecurityManager; import net.micode.notes.ui.NoteInfoAdapter; import net.micode.notes.viewmodel.NotesListViewModel; @@ -91,9 +97,30 @@ public class NotesListActivity extends AppCompatActivity // 多选模式状态 private boolean isMultiSelectMode = false; - + // 待打开的受保护笔记 private long mPendingNodeIdToOpen = -1; + + // 同步广播接收器 + private BroadcastReceiver syncReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if ("com.micode.notes.ACTION_SYNC".equals(intent.getAction())) { + Log.d(TAG, "Received sync broadcast, triggering sync"); + SyncManager.getInstance().syncNotes(new SyncManager.SyncCallback() { + @Override + public void onSuccess() { + Log.d(TAG, "Auto-sync completed successfully"); + } + + @Override + public void onError(String error) { + Log.e(TAG, "Auto-sync failed: " + error); + } + }); + } + } + }; private int mPendingNodeTypeToOpen = -1; private static final String KEY_PENDING_NODE_ID = "pending_node_id"; private static final String KEY_PENDING_NODE_TYPE = "pending_node_type"; @@ -123,6 +150,14 @@ public class NotesListActivity extends AppCompatActivity initViewModel(); + // 初始化SyncManager并设置冲突监听器 + SyncManager.getInstance().initialize(this); + SyncManager.getInstance().setConflictListener(conflict -> { + runOnUiThread(() -> { + showConflictResolutionDialog(conflict); + }); + }); + // 恢复 pending 状态和当前文件夹 if (savedInstanceState != null) { mPendingNodeIdToOpen = savedInstanceState.getLong(KEY_PENDING_NODE_ID, -1); @@ -165,6 +200,45 @@ public class NotesListActivity extends AppCompatActivity // 这样可以保证从 PasswordActivity 返回时,如果 onStart 先执行,不会重置为根目录 viewModel.refreshNotes(); } + + @Override + protected void onResume() { + super.onResume(); + + // 注册同步广播接收器 + IntentFilter filter = new IntentFilter("com.micode.notes.ACTION_SYNC"); + registerReceiver(syncReceiver, filter); + + // 切换到前台时触发同步(如果用户已登录) + UserAuthManager authManager = UserAuthManager.getInstance(this); + if (authManager.isLoggedIn()) { + long lastSync = SyncManager.getInstance().getLastSyncTime(); + long currentTime = System.currentTimeMillis(); + // 如果超过30分钟未同步,自动触发同步 + if (currentTime - lastSync > 30 * 60 * 1000) { + Log.d(TAG, "Auto-syncing on resume (last sync: " + lastSync + ")"); + SyncManager.getInstance().syncNotes(new SyncManager.SyncCallback() { + @Override + public void onSuccess() { + Log.d(TAG, "Auto-sync on resume completed"); + } + + @Override + public void onError(String error) { + Log.e(TAG, "Auto-sync on resume failed: " + error); + } + }); + } + } + } + + @Override + protected void onPause() { + super.onPause(); + // 注销同步广播接收器 + unregisterReceiver(syncReceiver); + } + private void initViewModel() { NotesRepository repository = new NotesRepository(getContentResolver()); viewModel = new ViewModelProvider(this, @@ -926,16 +1000,38 @@ public class NotesListActivity extends AppCompatActivity @Override public void onSyncSelected() { - // TODO: 实现同步功能 - Log.d(TAG, "Sync selected"); - Toast.makeText(this, "同步功能待实现", Toast.LENGTH_SHORT).show(); + Log.d(TAG, "Sync selected, launching SyncActivity"); + Intent intent = new Intent(this, SyncActivity.class); + startActivity(intent); } @Override public void onLoginSelected() { - // TODO: 实现登录功能 - Log.d(TAG, "Login selected"); - Toast.makeText(this, "登录功能待实现", Toast.LENGTH_SHORT).show(); + Log.d(TAG, "Login selected, launching LoginActivity"); + if (binding.drawerLayout != null) { + binding.drawerLayout.closeDrawer(sidebarFragment); + } + Intent intent = new Intent(this, LoginActivity.class); + startActivity(intent); + } + + @Override + public void onLogoutSelected() { + Log.d(TAG, "Logout selected, user logged out"); + if (binding.drawerLayout != null) { + binding.drawerLayout.closeDrawer(sidebarFragment); + } + refreshSidebar(); + Toast.makeText(this, R.string.toast_logout_success, Toast.LENGTH_SHORT).show(); + } + + private void refreshSidebar() { + androidx.fragment.app.Fragment fragment = getSupportFragmentManager() + .findFragmentById(R.id.sidebar_fragment); + if (fragment instanceof SidebarFragment) { + ((SidebarFragment) fragment).updateUserState(); + ((SidebarFragment) fragment).refreshFolderTree(); + } } @Override @@ -1229,6 +1325,127 @@ public class NotesListActivity extends AppCompatActivity } } + /** + * 显示冲突解决对话框 + */ + private void showConflictResolutionDialog(net.micode.notes.sync.Conflict conflict) { + try { + net.micode.notes.model.WorkingNote cloudWorkingNote = conflict.getCloudNote().toWorkingNote(this); + if (cloudWorkingNote == null) { + Log.e(TAG, "Failed to convert CloudNote to WorkingNote"); + return; + } + + ConflictResolutionDialog dialog = ConflictResolutionDialog.newInstance( + conflict.getLocalNote(), + cloudWorkingNote + ); + dialog.setConflictResolutionListener(new ConflictResolutionDialog.ConflictResolutionListener() { + @Override + public void onChooseLocal() { + handleConflictChoice(conflict, "local"); + } + + @Override + public void onChooseCloud() { + handleConflictChoice(conflict, "cloud"); + } + + @Override + public void onMerge(String mergedTitle, String mergedContent) { + handleConflictMerge(conflict, mergedTitle, mergedContent); + } + }); + dialog.show(getSupportFragmentManager(), "conflict"); + } catch (Exception e) { + Log.e(TAG, "Failed to show conflict dialog", e); + } + } + + /** + * 处理冲突合并 + */ + private void handleConflictMerge(net.micode.notes.sync.Conflict conflict, String mergedTitle, String mergedContent) { + net.micode.notes.auth.UserAuthManager authManager = net.micode.notes.auth.UserAuthManager.getInstance(this); + if (!authManager.isLoggedIn()) { + Toast.makeText(this, "未登录,无法同步", Toast.LENGTH_SHORT).show(); + return; + } + + // 更新本地笔记为合并后的内容 + conflict.getLocalNote().setWorkingText(mergedContent); + conflict.getLocalNote().saveNote(); + + // 上传合并后的版本到云端 + net.micode.notes.api.CloudDatabaseHelper cloudHelper = new net.micode.notes.api.CloudDatabaseHelper( + authManager.getUserId(), + authManager.getDeviceId(), + authManager.getAuthToken() + ); + + cloudHelper.uploadNote(conflict.getLocalNote(), new CloudCallback() { + @Override + public void onSuccess(String result) { + runOnUiThread(() -> { + Toast.makeText(NotesListActivity.this, "已合并并上传", Toast.LENGTH_SHORT).show(); + SyncManager.getInstance().removeConflict(conflict); + viewModel.refreshNotes(); + }); + } + + @Override + public void onError(String error) { + runOnUiThread(() -> { + Toast.makeText(NotesListActivity.this, "合并上传失败: " + error, Toast.LENGTH_SHORT).show(); + }); + } + }); + } + + /** + * 处理用户对冲突的选择 + */ + private void handleConflictChoice(net.micode.notes.sync.Conflict conflict, String choice) { + net.micode.notes.auth.UserAuthManager authManager = net.micode.notes.auth.UserAuthManager.getInstance(this); + if (!authManager.isLoggedIn()) { + Toast.makeText(this, "未登录,无法同步", Toast.LENGTH_SHORT).show(); + return; + } + + net.micode.notes.api.CloudDatabaseHelper cloudHelper = new net.micode.notes.api.CloudDatabaseHelper( + authManager.getUserId(), + authManager.getDeviceId(), + authManager.getAuthToken() + ); + + if ("local".equals(choice)) { + cloudHelper.uploadNote(conflict.getLocalNote(), new CloudCallback() { + @Override + public void onSuccess(String result) { + runOnUiThread(() -> { + Toast.makeText(NotesListActivity.this, "已上传本地版本", Toast.LENGTH_SHORT).show(); + SyncManager.getInstance().removeConflict(conflict); + viewModel.refreshNotes(); + }); + } + + @Override + public void onError(String error) { + runOnUiThread(() -> { + Toast.makeText(NotesListActivity.this, "上传失败: " + error, Toast.LENGTH_SHORT).show(); + }); + } + }); + } else if ("cloud".equals(choice)) { + conflict.getLocalNote().updateFrom(conflict.getCloudNote()); + runOnUiThread(() -> { + Toast.makeText(this, "已应用云端版本", Toast.LENGTH_SHORT).show(); + SyncManager.getInstance().removeConflict(conflict); + viewModel.refreshNotes(); + }); + } + } + @Override protected void onDestroy() { super.onDestroy(); diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SidebarFragment.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SidebarFragment.java index ae2a8dd..f1500a0 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SidebarFragment.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SidebarFragment.java @@ -18,15 +18,16 @@ package net.micode.notes.ui; import android.app.AlertDialog; import android.content.Context; +import android.content.Intent; import android.os.Bundle; import android.text.InputFilter; import android.text.TextUtils; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.view.animation.Animation; -import android.view.animation.TranslateAnimation; import android.widget.EditText; +import android.widget.ImageButton; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.PopupMenu; @@ -41,21 +42,20 @@ import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import net.micode.notes.R; +import net.micode.notes.auth.UserAuthManager; import net.micode.notes.data.Notes; import net.micode.notes.data.NotesRepository; -import net.micode.notes.databinding.SidebarLayoutBinding; +import net.micode.notes.sync.SyncManager; import net.micode.notes.viewmodel.FolderListViewModel; import java.util.ArrayList; import java.util.List; -import java.util.Map; -import java.util.Set; /** - * 侧栏Fragment + * 现代化侧边栏 Fragment - 自定义布局版 *

- * 显示文件夹树、菜单项和操作按钮 - * 提供文件夹导航、创建、展开/收起等功能 + * 使用自定义 LinearLayout 替代 NavigationView + * 文件夹树在"文件夹"菜单项位置直接展开 *

*/ public class SidebarFragment extends Fragment { @@ -63,81 +63,52 @@ public class SidebarFragment extends Fragment { private static final String TAG = "SidebarFragment"; private static final int MAX_FOLDER_NAME_LENGTH = 50; - // ViewBinding - private SidebarLayoutBinding binding; - - // 适配器和数据 - private FolderTreeAdapter adapter; + // 菜单项 + private LinearLayout menuAllNotes; + private LinearLayout menuTrash; + private LinearLayout menuFolders; + private LinearLayout menuSyncSettings; + private LinearLayout menuTemplates; + private LinearLayout menuExport; + private LinearLayout menuSettings; + private LinearLayout menuLogin; + private LinearLayout menuLogout; + + // 文件夹树 + private LinearLayout folderTreeContainer; + private RecyclerView rvFolderTree; + private ImageButton btnCreateFolder; + private ImageView ivFolderExpand; + + // 头部 + private LinearLayout headerNotLoggedIn; + private LinearLayout headerLoggedIn; + private View btnLoginPrompt; + private TextView tvUsername; + private TextView tvDeviceId; + + // ViewModel private FolderListViewModel viewModel; - - // 单击和双击检测 - private long lastClickTime = 0; - private View lastClickedView = null; - private static final long DOUBLE_CLICK_INTERVAL = 300; // 毫秒 + private FolderTreeAdapter adapter; // 回调接口 private OnSidebarItemSelectedListener listener; - /** - * 侧栏项选择回调接口 - */ + // 状态 + private boolean isFolderTreeExpanded = false; + public interface OnSidebarItemSelectedListener { - /** - * 跳转到指定文件夹 - * @param folderId 文件夹ID - */ void onFolderSelected(long folderId); - - /** - * 打开回收站 - */ void onTrashSelected(); - - /** - * 同步 - */ void onSyncSelected(); - - /** - * 登录 - */ void onLoginSelected(); - - /** - * 导出 - */ + void onLogoutSelected(); void onExportSelected(); - - /** - * 模板 - */ void onTemplateSelected(); - - /** - * 设置 - */ void onSettingsSelected(); - - /** - * 创建文件夹 - */ void onCreateFolder(); - - /** - * 关闭侧栏 - */ void onCloseSidebar(); - - /** - * 重命名文件夹 - * @param folderId 文件夹ID - */ void onRenameFolder(long folderId); - - /** - * 删除文件夹 - * @param folderId 文件夹ID - */ void onDeleteFolder(long folderId); } @@ -160,137 +131,146 @@ public class SidebarFragment extends Fragment { @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - binding = SidebarLayoutBinding.inflate(inflater, container, false); - return binding.getRoot(); + @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_sidebar, container, false); } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - initViews(); - setupListeners(); + initViews(view); + initListeners(); + initFolderTree(); observeViewModel(); + updateUserState(); } @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; + public void onResume() { + super.onResume(); + updateUserState(); } - /** - * 刷新文件夹树(供外部调用,如删除笔记后) - */ - public void refreshFolderTree() { - if (viewModel != null) { - viewModel.loadFolderTree(); - } - } + private void initViews(View view) { + // 头部 + headerNotLoggedIn = view.findViewById(R.id.header_not_logged_in); + headerLoggedIn = view.findViewById(R.id.header_logged_in); + btnLoginPrompt = view.findViewById(R.id.btn_login_prompt); + tvUsername = view.findViewById(R.id.tv_username); + tvDeviceId = view.findViewById(R.id.tv_device_id); - /** - * 初始化视图 - */ - private void initViews() { - // 设置RecyclerView - binding.rvFolderTree.setLayoutManager(new LinearLayoutManager(requireContext())); - adapter = new FolderTreeAdapter(new ArrayList<>(), viewModel); - adapter.setOnFolderItemClickListener(this::handleFolderItemClick); - adapter.setOnFolderItemLongClickListener(this::handleFolderItemLongClick); - binding.rvFolderTree.setAdapter(adapter); + // 菜单项 + menuAllNotes = view.findViewById(R.id.menu_all_notes); + menuTrash = view.findViewById(R.id.menu_trash); + menuFolders = view.findViewById(R.id.menu_folders); + menuSyncSettings = view.findViewById(R.id.menu_sync_settings); + menuTemplates = view.findViewById(R.id.menu_templates); + menuExport = view.findViewById(R.id.menu_export); + menuSettings = view.findViewById(R.id.menu_settings); + menuLogin = view.findViewById(R.id.menu_login); + menuLogout = view.findViewById(R.id.menu_logout); + + // 文件夹树 + folderTreeContainer = view.findViewById(R.id.folder_tree_container); + rvFolderTree = view.findViewById(R.id.rv_folder_tree); + btnCreateFolder = view.findViewById(R.id.btn_create_folder); + ivFolderExpand = view.findViewById(R.id.iv_folder_expand); } - /** - * 设置监听器 - */ - private void setupListeners() { - // 根文件夹(单击展开/收起,双击跳转) - setupFolderClickListener(binding.tvRootFolder, Notes.ID_ROOT_FOLDER); + private void initListeners() { + if (headerNotLoggedIn != null) { + headerNotLoggedIn.setOnClickListener(v -> { + if (listener != null) listener.onLoginSelected(); + }); + } + + if (headerLoggedIn != null) { + headerLoggedIn.setOnClickListener(v -> { + Log.d(TAG, "Logged in header clicked"); + }); + } - // 关闭侧栏 - binding.btnCloseSidebar.setOnClickListener(v -> { + // 菜单项点击 + menuAllNotes.setOnClickListener(v -> { if (listener != null) { + listener.onFolderSelected(Notes.ID_ROOT_FOLDER); listener.onCloseSidebar(); } }); - // 创建文件夹 - binding.btnCreateFolder.setOnClickListener(v -> showCreateFolderDialog()); - - // 菜单项 - binding.menuSync.setOnClickListener(v -> { + menuTrash.setOnClickListener(v -> { if (listener != null) { - listener.onSyncSelected(); + listener.onTrashSelected(); + listener.onCloseSidebar(); } }); - binding.menuLogin.setOnClickListener(v -> { - if (listener != null) { - listener.onLoginSelected(); - } + menuFolders.setOnClickListener(v -> toggleFolderTree()); + + menuSyncSettings.setOnClickListener(v -> { + Intent intent = new Intent(requireContext(), SyncActivity.class); + startActivity(intent); + if (listener != null) listener.onCloseSidebar(); }); - binding.menuExport.setOnClickListener(v -> { + menuTemplates.setOnClickListener(v -> { if (listener != null) { - listener.onExportSelected(); + listener.onTemplateSelected(); + listener.onCloseSidebar(); } }); - binding.menuTemplates.setOnClickListener(v -> { + menuExport.setOnClickListener(v -> { if (listener != null) { - listener.onTemplateSelected(); + listener.onExportSelected(); + listener.onCloseSidebar(); } }); - binding.menuSettings.setOnClickListener(v -> { + menuSettings.setOnClickListener(v -> { if (listener != null) { listener.onSettingsSelected(); + listener.onCloseSidebar(); } }); - binding.menuTrash.setOnClickListener(v -> { + menuLogin.setOnClickListener(v -> { if (listener != null) { - listener.onTrashSelected(); + listener.onLoginSelected(); + listener.onCloseSidebar(); } }); + + menuLogout.setOnClickListener(v -> showLogoutConfirmDialog()); } - /** - * 设置文件夹的单击/双击监听器 - */ - private void setupFolderClickListener(View view, long folderId) { - view.setOnClickListener(v -> { - android.util.Log.d(TAG, "setupFolderClickListener: folderId=" + folderId); - long currentTime = System.currentTimeMillis(); - if (lastClickedView == view && (currentTime - lastClickTime) < DOUBLE_CLICK_INTERVAL) { - android.util.Log.d(TAG, "Double click on root folder, jumping to: " + folderId); - // 这是双击,执行跳转 - if (listener != null) { - // 根文件夹也可以跳转(回到根) - listener.onFolderSelected(folderId); - } - // 重置双击状态 - lastClickTime = 0; - lastClickedView = null; - } else { - android.util.Log.d(TAG, "Single click on root folder, will toggle expand in " + DOUBLE_CLICK_INTERVAL + "ms"); - // 可能是单击,延迟处理 - lastClickTime = currentTime; - lastClickedView = view; - view.postDelayed(() -> { - // 如果在延迟期间没有发生双击,则执行单击操作(展开/收起) - if (System.currentTimeMillis() - lastClickTime >= DOUBLE_CLICK_INTERVAL) { - android.util.Log.d(TAG, "Toggling root folder expand"); - toggleFolderExpand(folderId); - } - }, DOUBLE_CLICK_INTERVAL); + private void toggleFolderTree() { + isFolderTreeExpanded = !isFolderTreeExpanded; + folderTreeContainer.setVisibility(isFolderTreeExpanded ? View.VISIBLE : View.GONE); + + // 旋转展开图标 + if (ivFolderExpand != null) { + ivFolderExpand.setRotation(isFolderTreeExpanded ? 180 : 0); + } + } + + private void initFolderTree() { + rvFolderTree.setLayoutManager(new LinearLayoutManager(requireContext())); + adapter = new FolderTreeAdapter(new ArrayList<>(), viewModel); + adapter.setOnFolderItemClickListener(folderId -> { + if (listener != null) { + listener.onFolderSelected(folderId); + listener.onCloseSidebar(); } }); + adapter.setOnFolderItemLongClickListener(this::handleFolderItemLongClick); + rvFolderTree.setAdapter(adapter); + + if (btnCreateFolder != null) { + btnCreateFolder.setOnClickListener(v -> showCreateFolderDialog()); + } } - /** - * 观察ViewModel数据变化 - */ private void observeViewModel() { viewModel.getFolderTree().observe(getViewLifecycleOwner(), folderItems -> { if (folderItems != null) { @@ -298,67 +278,73 @@ public class SidebarFragment extends Fragment { adapter.notifyDataSetChanged(); } }); - viewModel.loadFolderTree(); } - /** - * 切换文件夹展开/收起状态 - */ - private void toggleFolderExpand(long folderId) { - android.util.Log.d(TAG, "toggleFolderExpand: folderId=" + folderId); - viewModel.toggleFolderExpand(folderId); - } + public void updateUserState() { + UserAuthManager authManager = UserAuthManager.getInstance(requireContext()); + boolean isLoggedIn = authManager.isLoggedIn(); - /** - * 处理文件夹项点击(单击/双击) - */ - private void handleFolderItemClick(long folderId) { - android.util.Log.d(TAG, "handleFolderItemClick: folderId=" + folderId); - long currentTime = System.currentTimeMillis(); - if (lastClickedFolderId == folderId && (currentTime - lastFolderClickTime) < DOUBLE_CLICK_INTERVAL) { - android.util.Log.d(TAG, "Double click detected, jumping to folder: " + folderId); - // 这是双击,执行跳转 - if (listener != null) { - listener.onFolderSelected(folderId); - } - // 重置双击状态 - lastFolderClickTime = 0; - lastClickedFolderId = -1; - } else { - android.util.Log.d(TAG, "Single click, will toggle expand in " + DOUBLE_CLICK_INTERVAL + "ms"); - // 可能是单击,延迟处理 - lastFolderClickTime = currentTime; - lastClickedFolderId = folderId; - new android.os.Handler().postDelayed(() -> { - // 如果在延迟期间没有发生双击,则执行单击操作(展开/收起) - if (System.currentTimeMillis() - lastFolderClickTime >= DOUBLE_CLICK_INTERVAL) { - android.util.Log.d(TAG, "Toggling folder expand: " + folderId); - toggleFolderExpand(folderId); + // 更新头部 + if (headerNotLoggedIn != null && headerLoggedIn != null) { + if (isLoggedIn) { + headerNotLoggedIn.setVisibility(View.GONE); + headerLoggedIn.setVisibility(View.VISIBLE); + + String username = authManager.getUsername(); + String deviceId = authManager.getDeviceId(); + + if (tvUsername != null) { + tvUsername.setText(username != null ? username : getString(R.string.drawer_default_username)); + } + if (tvDeviceId != null) { + tvDeviceId.setText(deviceId != null ? "Device: " + deviceId.substring(0, Math.min(8, deviceId.length())) + : getString(R.string.drawer_default_device_id)); } - }, DOUBLE_CLICK_INTERVAL); + } else { + headerNotLoggedIn.setVisibility(View.VISIBLE); + headerLoggedIn.setVisibility(View.GONE); + } + } + + // 更新菜单项 + if (menuLogin != null && menuLogout != null) { + menuLogin.setVisibility(isLoggedIn ? View.GONE : View.VISIBLE); + menuLogout.setVisibility(isLoggedIn ? View.VISIBLE : View.GONE); } } - /** - * 处理文件夹项长按 - */ - private void handleFolderItemLongClick(long folderId) { - android.util.Log.d(TAG, "handleFolderItemLongClick: folderId=" + folderId); - // 检查是否是系统文件夹(根文件夹、回收站等不允许重命名/删除) - if (folderId <= 0) { - android.util.Log.d(TAG, "System folder, ignoring long press"); - return; + private void showLogoutConfirmDialog() { + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.dialog_logout_title) + .setMessage(R.string.dialog_logout_message) + .setPositiveButton(R.string.dialog_logout_confirm, (dialog, which) -> performLogout()) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + private void performLogout() { + UserAuthManager authManager = UserAuthManager.getInstance(requireContext()); + authManager.logout(); + + // 重置同步状态,清除上次同步时间 + SyncManager.getInstance().resetSyncState(); + + updateUserState(); + + if (listener != null) { + listener.onLogoutSelected(); + listener.onCloseSidebar(); } - showFolderContextMenu(folderId); + Toast.makeText(requireContext(), R.string.toast_logout_success, Toast.LENGTH_SHORT).show(); + Log.d(TAG, "User logged out successfully"); } - /** - * 显示文件夹上下文菜单 - */ - private void showFolderContextMenu(long folderId) { - PopupMenu popup = new PopupMenu(requireContext(), binding.getRoot()); + private void handleFolderItemLongClick(long folderId) { + if (folderId <= 0) return; + + PopupMenu popup = new PopupMenu(requireContext(), rvFolderTree); popup.getMenuInflater().inflate(R.menu.folder_context_menu, popup.getMenu()); popup.setOnMenuItemClickListener(item -> { @@ -370,8 +356,7 @@ public class SidebarFragment extends Fragment { listener.onDeleteFolder(folderId); return true; } else if (itemId == R.id.action_move) { - // TODO: 实现移动功能(阶段3) - Toast.makeText(requireContext(), "移动功能待实现", Toast.LENGTH_SHORT).show(); + Toast.makeText(requireContext(), "移动功能开发中", Toast.LENGTH_SHORT).show(); return true; } return false; @@ -380,14 +365,7 @@ public class SidebarFragment extends Fragment { popup.show(); } - // 双击检测专用变量(针对文件夹列表项) - private long lastFolderClickTime = 0; - private long lastClickedFolderId = -1; - - /** - * 显示创建文件夹对话框 - */ - private void showCreateFolderDialog() { + public void showCreateFolderDialog() { AlertDialog.Builder builder = new AlertDialog.Builder(requireContext()); builder.setTitle(R.string.dialog_create_folder_title); @@ -403,180 +381,153 @@ public class SidebarFragment extends Fragment { Toast.makeText(requireContext(), R.string.error_folder_name_empty, Toast.LENGTH_SHORT).show(); return; } - if (folderName.length() > MAX_FOLDER_NAME_LENGTH) { - Toast.makeText(requireContext(), R.string.error_folder_name_too_long, Toast.LENGTH_SHORT).show(); - return; - } - - // 创建文件夹 - NotesRepository repository = new NotesRepository(requireContext().getContentResolver()); - long parentId = viewModel.getCurrentFolderId(); - if (parentId == 0) { - parentId = Notes.ID_ROOT_FOLDER; - } - repository.createFolder(parentId, folderName, - new NotesRepository.Callback() { - @Override - public void onSuccess(Long folderId) { - if (getActivity() != null) { - getActivity().runOnUiThread(() -> { - Toast.makeText(requireContext(), R.string.create_folder_success, Toast.LENGTH_SHORT).show(); - // 刷新文件夹列表 - viewModel.loadFolderTree(); - }); - } - } - - @Override - public void onError(Exception error) { - if (getActivity() != null) { - getActivity().runOnUiThread(() -> { - Toast.makeText(requireContext(), - getString(R.string.error_folder_name_too_long) + ": " + error.getMessage(), - Toast.LENGTH_SHORT).show(); - }); - } - } - }); + createFolder(folderName); }); builder.setNegativeButton(android.R.string.cancel, null); builder.show(); } - /** - * FolderTreeAdapter - * 文件夹树适配器,支持层级显示和展开/收起 - */ - private static class FolderTreeAdapter extends RecyclerView.Adapter { - - private List folderItems; - private FolderListViewModel viewModel; - private OnFolderItemClickListener folderItemClickListener; - private OnFolderItemLongClickListener folderItemLongClickListener; - - public FolderTreeAdapter(List folderItems, FolderListViewModel viewModel) { - this.folderItems = folderItems; - this.viewModel = viewModel; + private void createFolder(String folderName) { + NotesRepository repository = new NotesRepository(requireContext().getContentResolver()); + long parentId = viewModel.getCurrentFolderId(); + if (parentId == 0) parentId = Notes.ID_ROOT_FOLDER; + + repository.createFolder(parentId, folderName, new NotesRepository.Callback() { + @Override + public void onSuccess(Long folderId) { + if (getActivity() != null) { + getActivity().runOnUiThread(() -> { + Toast.makeText(requireContext(), R.string.create_folder_success, Toast.LENGTH_SHORT).show(); + viewModel.loadFolderTree(); + }); + } } - public void setData(List folderItems) { - this.folderItems = folderItems; + @Override + public void onError(Exception error) { + if (getActivity() != null) { + getActivity().runOnUiThread(() -> { + Toast.makeText(requireContext(), + getString(R.string.error_create_folder) + ": " + error.getMessage(), + Toast.LENGTH_SHORT).show(); + }); + } } + }); + } - public void setOnFolderItemClickListener(OnFolderItemClickListener listener) { - this.folderItemClickListener = listener; - } + public void refreshFolderTree() { + if (viewModel != null) viewModel.loadFolderTree(); + } - public void setOnFolderItemLongClickListener(OnFolderItemLongClickListener listener) { - this.folderItemLongClickListener = listener; - } + @Override + public void onDetach() { + super.onDetach(); + listener = null; + } - @NonNull - @Override - public FolderViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.sidebar_folder_item, parent, false); - return new FolderViewHolder(view, folderItemClickListener, folderItemLongClickListener); - } + // ==================== FolderTreeAdapter ==================== - @Override - public void onBindViewHolder(@NonNull FolderViewHolder holder, int position) { - FolderTreeItem item = folderItems.get(position); - boolean isExpanded = viewModel != null && viewModel.isFolderExpanded(item.folderId); - holder.bind(item, isExpanded); - } + private static class FolderTreeAdapter extends RecyclerView.Adapter { - @Override - public int getItemCount() { - return folderItems.size(); - } + private List folderItems; + private FolderListViewModel viewModel; + private OnFolderItemClickListener folderItemClickListener; + private OnFolderItemLongClickListener folderItemLongClickListener; - static class FolderViewHolder extends RecyclerView.ViewHolder { - private View indentView; - private ImageView ivExpandIcon; - private ImageView ivFolderIcon; - private TextView tvFolderName; - private TextView tvNoteCount; - private FolderTreeItem currentItem; - private final OnFolderItemClickListener folderItemClickListener; - private final OnFolderItemLongClickListener folderItemLongClickListener; - - public FolderViewHolder(@NonNull View itemView, OnFolderItemClickListener clickListener, - OnFolderItemLongClickListener longClickListener) { - super(itemView); - this.folderItemClickListener = clickListener; - this.folderItemLongClickListener = longClickListener; - indentView = itemView.findViewById(R.id.indent_view); - ivExpandIcon = itemView.findViewById(R.id.iv_expand_icon); - ivFolderIcon = itemView.findViewById(R.id.iv_folder_icon); - tvFolderName = itemView.findViewById(R.id.tv_folder_name); - tvNoteCount = itemView.findViewById(R.id.tv_note_count); - } + public FolderTreeAdapter(List folderItems, FolderListViewModel viewModel) { + this.folderItems = folderItems; + this.viewModel = viewModel; + } - public void bind(FolderTreeItem item, boolean isExpanded) { - this.currentItem = item; + public void setData(List folderItems) { + this.folderItems = folderItems; + } - // 设置缩进 - int indent = item.level * 32; - indentView.setLayoutParams(new LinearLayout.LayoutParams(indent, LinearLayout.LayoutParams.MATCH_PARENT)); + public void setOnFolderItemClickListener(OnFolderItemClickListener listener) { + this.folderItemClickListener = listener; + } - // 设置展开/收起图标 - if (item.hasChildren) { - ivExpandIcon.setVisibility(View.VISIBLE); - ivExpandIcon.setRotation(isExpanded ? 90 : 0); - } else { - ivExpandIcon.setVisibility(View.INVISIBLE); + public void setOnFolderItemLongClickListener(OnFolderItemLongClickListener listener) { + this.folderItemLongClickListener = listener; + } + + @NonNull + @Override + public FolderViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.sidebar_folder_item, parent, false); + return new FolderViewHolder(view, folderItemClickListener, folderItemLongClickListener); + } + + @Override + public void onBindViewHolder(@NonNull FolderViewHolder holder, int position) { + FolderTreeItem item = folderItems.get(position); + boolean isExpanded = viewModel != null && viewModel.isFolderExpanded(item.folderId); + holder.bind(item, isExpanded); + } + + @Override + public int getItemCount() { + return folderItems.size(); + } + + static class FolderViewHolder extends RecyclerView.ViewHolder { + private View indentView; + private View ivFolderIcon; + private TextView tvFolderName; + private TextView tvNoteCount; + private FolderTreeItem currentItem; + + public FolderViewHolder(@NonNull View itemView, OnFolderItemClickListener clickListener, + OnFolderItemLongClickListener longClickListener) { + super(itemView); + indentView = itemView.findViewById(R.id.indent_view); + ivFolderIcon = itemView.findViewById(R.id.iv_folder_icon); + tvFolderName = itemView.findViewById(R.id.tv_folder_name); + tvNoteCount = itemView.findViewById(R.id.tv_note_count); + + itemView.setOnClickListener(v -> { + if (clickListener != null && currentItem != null) { + clickListener.onFolderClick(currentItem.folderId); } + }); - // 设置文件夹名称 - tvFolderName.setText(item.name); + itemView.setOnLongClickListener(v -> { + if (longClickListener != null && currentItem != null) { + longClickListener.onFolderLongClick(currentItem.folderId); + return true; + } + return false; + }); + } - // 设置便签数量 - tvNoteCount.setText(String.format(itemView.getContext() - .getString(R.string.folder_note_count), item.noteCount)); + public void bind(FolderTreeItem item, boolean isExpanded) { + this.currentItem = item; - // 设置点击监听器 - itemView.setOnClickListener(v -> { - if (folderItemClickListener != null) { - folderItemClickListener.onFolderClick(item.folderId); - } - }); + int indent = item.level * 32; + indentView.setLayoutParams(new LinearLayout.LayoutParams(indent, LinearLayout.LayoutParams.MATCH_PARENT)); - // 设置长按监听器 - itemView.setOnLongClickListener(v -> { - if (folderItemLongClickListener != null) { - folderItemLongClickListener.onFolderLongClick(item.folderId); - return true; - } - return false; - }); - } + tvFolderName.setText(item.name); + tvNoteCount.setText(String.format(itemView.getContext() + .getString(R.string.folder_note_count), item.noteCount)); } } + } - /** - * 文件夹项点击监听器接口 - */ - public interface OnFolderItemClickListener { - void onFolderClick(long folderId); - } + public interface OnFolderItemClickListener { + void onFolderClick(long folderId); + } - /** - * 文件夹项长按监听器接口 - */ - public interface OnFolderItemLongClickListener { - void onFolderLongClick(long folderId); - } + public interface OnFolderItemLongClickListener { + void onFolderLongClick(long folderId); + } - /** - * FolderTreeItem - * 文件夹树项数据模型 - */ public static class FolderTreeItem { public long folderId; public String name; - public int level; // 层级,0表示顶级 + public int level; public boolean hasChildren; public int noteCount; @@ -588,10 +539,4 @@ public class SidebarFragment extends Fragment { this.noteCount = noteCount; } } - - @Override - public void onDetach() { - super.onDetach(); - listener = null; - } } diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SyncActivity.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SyncActivity.java new file mode 100644 index 0000000..63c0391 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SyncActivity.java @@ -0,0 +1,154 @@ +/* + * 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.content.SharedPreferences; +import android.os.Bundle; +import android.view.View; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; + +import com.google.android.material.button.MaterialButton; +import com.google.android.material.switchmaterial.SwitchMaterial; + +import net.micode.notes.R; +import net.micode.notes.auth.UserAuthManager; +import net.micode.notes.sync.SyncManager; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +/** + * 同步设置界面 + *

+ * 显示云同步设置,包括登录状态、同步开关、同步按钮和进度显示。 + *

+ */ +public class SyncActivity extends AppCompatActivity { + + private static final String PREFS_SYNC = "sync_settings"; + private static final String KEY_AUTO_SYNC = "auto_sync"; + private static final String KEY_LAST_SYNC = "last_sync_time"; + + private TextView mTvDeviceId; + private TextView mTvLastSyncTime; + private TextView mTvSyncStatus; + private SwitchMaterial mSwitchAutoSync; + private ProgressBar mProgressSync; + private MaterialButton mBtnSyncNow; + + private SharedPreferences mPrefs; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_sync); + + mPrefs = getSharedPreferences(PREFS_SYNC, MODE_PRIVATE); + + initViews(); + loadSettings(); + } + + private void initViews() { + mTvDeviceId = findViewById(R.id.tv_device_id); + mTvLastSyncTime = findViewById(R.id.tv_last_sync_time); + mTvSyncStatus = findViewById(R.id.tv_sync_status); + mSwitchAutoSync = findViewById(R.id.switch_auto_sync); + mProgressSync = findViewById(R.id.progress_sync); + mBtnSyncNow = findViewById(R.id.btn_sync_now); + + mSwitchAutoSync.setOnCheckedChangeListener((buttonView, isChecked) -> { + mPrefs.edit().putBoolean(KEY_AUTO_SYNC, isChecked).apply(); + }); + + mBtnSyncNow.setOnClickListener(v -> startSync()); + } + + private void loadSettings() { + // Load auto sync setting + boolean autoSync = mPrefs.getBoolean(KEY_AUTO_SYNC, false); + mSwitchAutoSync.setChecked(autoSync); + + // Load user info + UserAuthManager authManager = UserAuthManager.getInstance(this); + if (authManager.isLoggedIn()) { + String username = authManager.getUsername(); + String deviceId = authManager.getDeviceId(); + mTvDeviceId.setText("已登录: " + username + "\n设备ID: " + deviceId); + } else { + mTvDeviceId.setText("未登录,请先登录账号"); + mBtnSyncNow.setEnabled(false); + } + + // Load last sync time + long lastSync = mPrefs.getLong(KEY_LAST_SYNC, 0); + if (lastSync > 0) { + String timeStr = formatTime(lastSync); + mTvLastSyncTime.setText(timeStr); + } + + // Set initial status + mTvSyncStatus.setText(R.string.sync_status_idle); + } + + private void startSync() { + mTvSyncStatus.setText(R.string.sync_status_syncing); + mProgressSync.setVisibility(View.VISIBLE); + mBtnSyncNow.setEnabled(false); + + Toast.makeText(this, R.string.sync_toast_started, Toast.LENGTH_SHORT).show(); + + SyncManager.getInstance().syncNotes(new SyncManager.SyncCallback() { + @Override + public void onSuccess() { + runOnUiThread(() -> { + mTvSyncStatus.setText(R.string.sync_status_success); + mProgressSync.setVisibility(View.INVISIBLE); + mBtnSyncNow.setEnabled(true); + + long currentTime = System.currentTimeMillis(); + mPrefs.edit().putLong(KEY_LAST_SYNC, currentTime).apply(); + mTvLastSyncTime.setText(formatTime(currentTime)); + + Toast.makeText(SyncActivity.this, R.string.sync_toast_success, Toast.LENGTH_SHORT).show(); + }); + } + + @Override + public void onError(String error) { + runOnUiThread(() -> { + mTvSyncStatus.setText(R.string.sync_status_failed); + mProgressSync.setVisibility(View.INVISIBLE); + mBtnSyncNow.setEnabled(true); + + String message = getString(R.string.sync_toast_failed, error); + Toast.makeText(SyncActivity.this, message, Toast.LENGTH_LONG).show(); + }); + } + }); + } + + private String formatTime(long timeMillis) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()); + return sdf.format(new Date(timeMillis)); + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/LoginViewModel.java b/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/LoginViewModel.java new file mode 100644 index 0000000..54be829 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/LoginViewModel.java @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.viewmodel; + +import android.app.Application; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import net.micode.notes.auth.AnonymousAuthManager; +import net.micode.notes.auth.UserAuthManager; +import net.micode.notes.data.NotesRepository; +import net.micode.notes.model.WorkingNote; +import net.micode.notes.sync.SyncManager; + +import java.util.List; + +/** + * 登录界面 ViewModel + * + *

+ * 管理登录相关的业务逻辑,包括用户认证、匿名数据迁移、首次登录全量同步等。 + * 遵循 MVVM 架构,将业务逻辑从 Activity 中分离。 + *

+ *

+ * 修复记录: + * 1. 修复登录后缺少全量下载的问题 - 登录后强制执行全量同步 + * 2. 添加同步状态监听 - 同步完成后再通知登录成功 + * 3. 优化匿名数据迁移流程 - 迁移后执行全量同步确保数据完整 + *

+ */ +public class LoginViewModel extends AndroidViewModel { + + private static final String TAG = "LoginViewModel"; + + private final UserAuthManager mAuthManager; + private final AnonymousAuthManager mAnonymousAuthManager; + private final NotesRepository mNotesRepository; + + private final MutableLiveData mIsLoading = new MutableLiveData<>(false); + private final MutableLiveData mErrorMessage = new MutableLiveData<>(); + private final MutableLiveData mLoginSuccess = new MutableLiveData<>(false); + private final MutableLiveData mMigratedNotesCount = new MutableLiveData<>(); + private final MutableLiveData mSyncStatus = new MutableLiveData<>(); + + public LoginViewModel(@NonNull Application application) { + super(application); + mAuthManager = UserAuthManager.getInstance(application); + mAnonymousAuthManager = AnonymousAuthManager.getInstance(application); + mNotesRepository = new NotesRepository(application.getContentResolver()); + } + + public LiveData getIsLoading() { + return mIsLoading; + } + + public LiveData getErrorMessage() { + return mErrorMessage; + } + + public LiveData getLoginSuccess() { + return mLoginSuccess; + } + + public LiveData getMigratedNotesCount() { + return mMigratedNotesCount; + } + + public LiveData getSyncStatus() { + return mSyncStatus; + } + + /** + * 检查用户是否已登录 + */ + public boolean isLoggedIn() { + return mAuthManager.isLoggedIn(); + } + + /** + * 用户登录 + */ + public void login(String username, String password) { + if (username == null || username.isEmpty() || password == null || password.isEmpty()) { + mErrorMessage.setValue("请输入用户名和密码"); + return; + } + + mIsLoading.setValue(true); + mSyncStatus.setValue("正在登录..."); + + mAuthManager.login(username, password, new UserAuthManager.AuthCallback() { + @Override + public void onSuccess(String userId, String username) { + mSyncStatus.postValue("登录成功,正在同步数据..."); + // 登录成功后执行数据迁移和全量同步 + migrateAnonymousDataAndSync(userId); + } + + @Override + public void onError(String error) { + mIsLoading.postValue(false); + mSyncStatus.postValue("登录失败"); + mErrorMessage.postValue("登录失败: " + error); + } + }); + } + + /** + * 用户注册 + */ + public void register(String username, String password) { + if (username == null || username.isEmpty() || password == null || password.isEmpty()) { + mErrorMessage.setValue("请输入用户名和密码"); + return; + } + + if (password.length() < 6) { + mErrorMessage.setValue("密码长度至少6位"); + return; + } + + mIsLoading.setValue(true); + mSyncStatus.setValue("正在注册..."); + + mAuthManager.register(username, password, new UserAuthManager.AuthCallback() { + @Override + public void onSuccess(String userId, String username) { + mSyncStatus.postValue("注册成功,正在同步数据..."); + // 注册成功后执行数据迁移和全量同步 + migrateAnonymousDataAndSync(userId); + } + + @Override + public void onError(String error) { + mIsLoading.postValue(false); + mSyncStatus.postValue("注册失败"); + mErrorMessage.postValue("注册失败: " + error); + } + }); + } + + /** + * 新用户接管设备上的所有笔记并执行全量同步 + * + *

关键逻辑: + * 1. 新用户接管设备上所有笔记(无论之前属于谁) + * 2. 将所有笔记标记为需要同步 + * 3. 执行全量同步,上传到云端 + *

+ */ + private void migrateAnonymousDataAndSync(String newUserId) { + mSyncStatus.postValue("正在接管设备上的笔记..."); + + // 新用户接管设备上所有笔记 + mNotesRepository.takeoverAllNotes(newUserId, new NotesRepository.Callback() { + @Override + public void onSuccess(Integer count) { + Log.d(TAG, "Takeover completed: " + count + " notes now belong to " + newUserId); + mMigratedNotesCount.postValue(count); + // 接管完成后执行全量同步(上传所有笔记到云端) + performFullSync(); + } + + @Override + public void onError(Exception error) { + Log.e(TAG, "Failed to takeover notes", error); + mMigratedNotesCount.postValue(0); + // 即使接管失败也尝试同步 + performFullSync(); + } + }); + } + + /** + * 执行全量同步 + * + *

关键逻辑: + * 1. 首先上传设备上所有笔记到新用户的云端 + * 2. 然后下载云端其他笔记(如果有) + * 这样新用户可以把设备上的所有内容都保存到云端。 + *

+ */ + private void performFullSync() { + Log.d(TAG, "Performing full sync after login"); + mSyncStatus.postValue("正在上传笔记到云端..."); + + // 初始化同步管理器 + SyncManager.getInstance().initialize(getApplication()); + + // 重置同步状态 + SyncManager.getInstance().resetSyncState(); + + // 第一步:上传所有本地笔记到云端 + SyncManager.getInstance().uploadAllNotes(new SyncManager.SyncCallback() { + @Override + public void onSuccess() { + Log.d(TAG, "Upload all notes completed"); + mSyncStatus.postValue("上传完成,正在下载云端笔记..."); + + // 第二步:下载云端其他笔记(如果有) + downloadCloudNotes(); + } + + @Override + public void onError(String error) { + Log.e(TAG, "Upload failed: " + error); + // 即使上传失败也尝试下载 + downloadCloudNotes(); + } + }); + } + + /** + * 下载云端笔记 + */ + private void downloadCloudNotes() { + SyncManager.getInstance().syncAllNotes(new SyncManager.SyncCallback() { + @Override + public void onSuccess() { + Log.d(TAG, "Download cloud notes completed"); + mSyncStatus.postValue("同步完成"); + mIsLoading.postValue(false); + mLoginSuccess.postValue(true); + } + + @Override + public void onError(String error) { + Log.e(TAG, "Download failed: " + error); + mSyncStatus.postValue("同步完成(下载可能有部分失败)"); + mIsLoading.postValue(false); + mLoginSuccess.postValue(true); + } + }); + } + + /** + * 清除错误消息 + */ + public void clearError() { + mErrorMessage.setValue(null); + } +} diff --git a/src/Notesmaster/app/src/main/res/drawable/drawer_header_circle_decorator.xml b/src/Notesmaster/app/src/main/res/drawable/drawer_header_circle_decorator.xml new file mode 100644 index 0000000..f42bf89 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/drawer_header_circle_decorator.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/drawer_header_gradient.xml b/src/Notesmaster/app/src/main/res/drawable/drawer_header_gradient.xml new file mode 100644 index 0000000..46b318f --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/drawer_header_gradient.xml @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/ic_account_circle.xml b/src/Notesmaster/app/src/main/res/drawable/ic_account_circle.xml new file mode 100644 index 0000000..a505a11 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/ic_account_circle.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/ic_cloud_done.xml b/src/Notesmaster/app/src/main/res/drawable/ic_cloud_done.xml new file mode 100644 index 0000000..977e069 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/ic_cloud_done.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/ic_cloud_settings.xml b/src/Notesmaster/app/src/main/res/drawable/ic_cloud_settings.xml new file mode 100644 index 0000000..3b90c74 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/ic_cloud_settings.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/ic_delete.xml b/src/Notesmaster/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..8e33cb6 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/ic_export.xml b/src/Notesmaster/app/src/main/res/drawable/ic_export.xml new file mode 100644 index 0000000..e856f09 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/ic_export.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/ic_favorite.xml b/src/Notesmaster/app/src/main/res/drawable/ic_favorite.xml new file mode 100644 index 0000000..567e84f --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/ic_favorite.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/ic_help.xml b/src/Notesmaster/app/src/main/res/drawable/ic_help.xml new file mode 100644 index 0000000..bb93cec --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/ic_help.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/ic_login.xml b/src/Notesmaster/app/src/main/res/drawable/ic_login.xml new file mode 100644 index 0000000..84fe667 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/ic_login.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/ic_logout.xml b/src/Notesmaster/app/src/main/res/drawable/ic_logout.xml new file mode 100644 index 0000000..11f7e32 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/ic_logout.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/ic_notes.xml b/src/Notesmaster/app/src/main/res/drawable/ic_notes.xml new file mode 100644 index 0000000..511acf4 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/ic_notes.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/ic_notes_logo.xml b/src/Notesmaster/app/src/main/res/drawable/ic_notes_logo.xml new file mode 100644 index 0000000..22681d2 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/ic_notes_logo.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/ic_reminder.xml b/src/Notesmaster/app/src/main/res/drawable/ic_reminder.xml new file mode 100644 index 0000000..51f8ffa --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/ic_reminder.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/ic_settings.xml b/src/Notesmaster/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..5a2c9bb --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/ic_sync.xml b/src/Notesmaster/app/src/main/res/drawable/ic_sync.xml new file mode 100644 index 0000000..56214dd --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/ic_sync.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/ic_template.xml b/src/Notesmaster/app/src/main/res/drawable/ic_template.xml new file mode 100644 index 0000000..319f57d --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/ic_template.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/online_status_indicator.xml b/src/Notesmaster/app/src/main/res/drawable/online_status_indicator.xml new file mode 100644 index 0000000..e2dc9ec --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/online_status_indicator.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/layout/activity_login.xml b/src/Notesmaster/app/src/main/res/layout/activity_login.xml new file mode 100644 index 0000000..14efc51 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/layout/activity_login.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/layout/activity_sync.xml b/src/Notesmaster/app/src/main/res/layout/activity_sync.xml new file mode 100644 index 0000000..abb45fd --- /dev/null +++ b/src/Notesmaster/app/src/main/res/layout/activity_sync.xml @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/layout/dialog_conflict_resolution.xml b/src/Notesmaster/app/src/main/res/layout/dialog_conflict_resolution.xml new file mode 100644 index 0000000..8a79494 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/layout/dialog_conflict_resolution.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/layout/drawer_folder_expand_icon.xml b/src/Notesmaster/app/src/main/res/layout/drawer_folder_expand_icon.xml new file mode 100644 index 0000000..a0bfd98 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/layout/drawer_folder_expand_icon.xml @@ -0,0 +1,11 @@ + + + diff --git a/src/Notesmaster/app/src/main/res/layout/drawer_header.xml b/src/Notesmaster/app/src/main/res/layout/drawer_header.xml new file mode 100644 index 0000000..29813bd --- /dev/null +++ b/src/Notesmaster/app/src/main/res/layout/drawer_header.xml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/layout/drawer_menu_divider.xml b/src/Notesmaster/app/src/main/res/layout/drawer_menu_divider.xml new file mode 100644 index 0000000..1547baf --- /dev/null +++ b/src/Notesmaster/app/src/main/res/layout/drawer_menu_divider.xml @@ -0,0 +1,11 @@ + + + diff --git a/src/Notesmaster/app/src/main/res/layout/fragment_sidebar.xml b/src/Notesmaster/app/src/main/res/layout/fragment_sidebar.xml new file mode 100644 index 0000000..5d4d1b4 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/layout/fragment_sidebar.xml @@ -0,0 +1,339 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/layout/sidebar_folder_item.xml b/src/Notesmaster/app/src/main/res/layout/sidebar_folder_item.xml index f5985ea..3c15a00 100644 --- a/src/Notesmaster/app/src/main/res/layout/sidebar_folder_item.xml +++ b/src/Notesmaster/app/src/main/res/layout/sidebar_folder_item.xml @@ -18,22 +18,11 @@ android:layout_width="0dp" android:layout_height="match_parent" /> - + - - - diff --git a/src/Notesmaster/app/src/main/res/menu/drawer_menu.xml b/src/Notesmaster/app/src/main/res/menu/drawer_menu.xml new file mode 100644 index 0000000..4044bce --- /dev/null +++ b/src/Notesmaster/app/src/main/res/menu/drawer_menu.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/values/colors.xml b/src/Notesmaster/app/src/main/res/values/colors.xml index c07b5f2..3fcb64c 100644 --- a/src/Notesmaster/app/src/main/res/values/colors.xml +++ b/src/Notesmaster/app/src/main/res/values/colors.xml @@ -42,4 +42,11 @@ #C7EDCC #FFE0B2 #E1BEE7 + + + #1976D2 + #2196F3 + #64B5F6 + #4CAF50 + #1AFFFFFF diff --git a/src/Notesmaster/app/src/main/res/values/strings.xml b/src/Notesmaster/app/src/main/res/values/strings.xml index a054c8b..30b5a23 100644 --- a/src/Notesmaster/app/src/main/res/values/strings.xml +++ b/src/Notesmaster/app/src/main/res/values/strings.xml @@ -190,4 +190,59 @@ Rich Text Restore Delete Forever + + + 点击登录/注册 + 用户 + Device ID: Unknown + 已同步 + 同步中... + 同步失败 + 笔记 + 云同步 + 其他 + 全部笔记 + 收藏 + 提醒 + 立即同步 + 同步设置 + 帮助与反馈 + 登录 + 退出登录 + + + Cloud Sync + Login Status + Device ID: Not initialized + Auto Sync + Last Sync Time: + Never + Sync Status: + Idle + Syncing... + Sync Successful + Sync Failed + Sync Now + Sync started + Sync completed successfully + Sync failed: %1$s + + + 退出登录 + 确定要退出登录吗?退出后本地笔记将保留,但无法同步到云端。 + 退出 + 已退出登录 + + + 文件夹 + 创建文件夹失败 + + + Note Conflict + Local Version + Cloud Version + Use Local + Use Cloud + Merge + Merge feature coming soon diff --git a/src/Notesmaster/app/src/main/res/values/styles.xml b/src/Notesmaster/app/src/main/res/values/styles.xml index 67bc75f..d87b39f 100644 --- a/src/Notesmaster/app/src/main/res/values/styles.xml +++ b/src/Notesmaster/app/src/main/res/values/styles.xml @@ -65,4 +65,10 @@ + + + \ No newline at end of file diff --git a/src/Notesmaster/app/src/test/java/net/micode/notes/model/CloudNoteTest.java b/src/Notesmaster/app/src/test/java/net/micode/notes/model/CloudNoteTest.java new file mode 100644 index 0000000..b9bfc8d --- /dev/null +++ b/src/Notesmaster/app/src/test/java/net/micode/notes/model/CloudNoteTest.java @@ -0,0 +1,70 @@ +package net.micode.notes.model; + +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import static org.junit.Assert.*; + +/** + * CloudNote单元测试 + */ +@RunWith(RobolectricTestRunner.class) +public class CloudNoteTest { + + @Test + public void testCloudNoteFromJson() throws JSONException { + // 测试从JSON解析cloudNoteId + JSONObject json = new JSONObject(); + json.put("cloudNoteId", "test-uuid-123"); + json.put("noteId", "100"); + json.put("title", "测试标题"); + json.put("content", "测试内容"); + json.put("parentId", "0"); + json.put("type", 0); + json.put("modifiedTime", 1234567890L); + + CloudNote cloudNote = new CloudNote(json); + + assertEquals("test-uuid-123", cloudNote.getCloudNoteId()); + assertEquals("100", cloudNote.getNoteId()); + assertEquals("测试标题", cloudNote.getTitle()); + } + + @Test + public void testCloudNoteToJson() throws JSONException { + // 创建模拟的WorkingNote + WorkingNote note = WorkingNote.createEmptyNote( + RuntimeEnvironment.getApplication(), + 0, + 0, + -1, + 0 + ); + note.setTitle("测试标题"); + note.setContent("测试内容"); + note.setCloudNoteId("test-uuid-456"); + + CloudNote cloudNote = new CloudNote(note, "device-123"); + JSONObject json = cloudNote.toJson(); + + assertEquals("test-uuid-456", json.getString("cloudNoteId")); + assertEquals("测试标题", json.getString("title")); + } + + @Test + public void testCloudNoteWithoutCloudNoteId() throws JSONException { + // 测试没有cloudNoteId的情况(首次上传) + JSONObject json = new JSONObject(); + json.put("noteId", "200"); + json.put("title", "新笔记"); + + CloudNote cloudNote = new CloudNote(json); + + assertEquals("", cloudNote.getCloudNoteId()); + assertEquals("200", cloudNote.getNoteId()); + } +} diff --git a/src/Notesmaster/settings.gradle.kts b/src/Notesmaster/settings.gradle.kts index bcbfede..50804cf 100644 --- a/src/Notesmaster/settings.gradle.kts +++ b/src/Notesmaster/settings.gradle.kts @@ -16,6 +16,9 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://maven.aliyun.com/repository/public") } + maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") } + maven { url = uri("https://maven.aliyun.com/repository/releases") } } }