云同步功能实现

pull/32/head
包尔俊 4 weeks ago
parent aff9ac63d1
commit 802f828318

@ -4,6 +4,14 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-01-30T10:15:59.196226600Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\啊?\.android\avd\Pixel_4a_API_31.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>

@ -5,6 +5,11 @@
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
<arguments>
@ -12,6 +17,7 @@
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.jdt.core.javanature</nature>
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
</natures>
<filteredResources>

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

@ -23,6 +23,14 @@
<!-- 允许接收系统启动完成广播,用于初始化闹钟 -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- 阿里云推送服务权限 -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- ==================== 应用配置 ==================== -->
<application
android:name=".NotesApplication"
@ -179,6 +187,18 @@
android:process=":remote" >
</receiver>
<!-- ==================== 推送消息接收器 ==================== -->
<!-- 接收云端同步通知消息 -->
<receiver
android:name=".sync.NotesPushMessageReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="com.alibaba.push2.action.NOTIFICATION_OPENED" />
<action android:name="com.alibaba.push2.action.MESSAGE_RECEIVED" />
</intent-filter>
</receiver>
<!-- ==================== 闹钟提醒活动 ==================== -->
<!-- 显示闹钟提醒界面 -->
<activity
@ -205,6 +225,20 @@
android:windowSoftInputMode="stateVisible|adjustResize">
</activity>
<!-- ==================== 登录/注册活动 ==================== -->
<activity
android:name=".ui.LoginActivity"
android:label="登录"
android:theme="@style/Theme.Notesmaster"
android:exported="false" />
<!-- ==================== 云同步设置活动 ==================== -->
<activity
android:name=".ui.SyncActivity"
android:label="@string/sync_title"
android:theme="@style/Theme.Notesmaster"
android:exported="false" />
<!-- ==================== 同步服务 ==================== -->
<!-- Google任务同步服务用于与Google Tasks同步数据 -->
<!-- 暂时禁用同步功能,为未来云同步开发暂留代码 -->
@ -216,6 +250,14 @@
</service>
-->
<!-- ==================== 阿里云EMAS配置 ==================== -->
<meta-data
android:name="com.alibaba.app.appkey"
android:value="335655908" />
<meta-data
android:name="com.alibaba.app.appsecret"
android:value="6d5086096f28435797413a48d932aff4" />
<!-- ==================== 搜索元数据 ==================== -->
<!-- 指定默认的搜索活动为NoteEditActivity -->
<meta-data

@ -116,7 +116,14 @@ public class MainActivity extends AppCompatActivity implements SidebarFragment.O
@Override
public void onLoginSelected() {
Log.d(TAG, "Login selected");
// TODO: 实现登录功能
Intent intent = new Intent(this, net.micode.notes.ui.LoginActivity.class);
startActivity(intent);
}
@Override
public void onLogoutSelected() {
Log.d(TAG, "Logout selected");
closeSidebar();
}
@Override

@ -1,18 +1,32 @@
package net.micode.notes;
import android.app.Application;
import android.util.Log;
import net.micode.notes.auth.UserAuthManager;
import net.micode.notes.data.ThemeRepository;
import net.micode.notes.sync.SyncWorker;
import com.google.android.material.color.DynamicColors;
public class NotesApplication extends Application {
private static final String TAG = "NotesApplication";
@Override
public void onCreate() {
super.onCreate();
// Apply Dynamic Colors (Material You) if available
DynamicColors.applyToActivitiesIfAvailable(this);
// Apply saved theme preference
DynamicColors.applyToActivitiesIfAvailable(this);
ThemeRepository repository = new ThemeRepository(this);
ThemeRepository.applyTheme(repository.getThemeMode());
UserAuthManager authManager = UserAuthManager.getInstance(this);
authManager.initialize(this);
Log.d(TAG, "EMAS Serverless initialized");
SyncWorker.initialize(this);
}
}

@ -0,0 +1,58 @@
/*
* 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 net.micode.notes.BuildConfig;
/**
* EMAS
* <p>
* EMASBuildConfiglocal.properties
*
* </p>
*/
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
}
}

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

@ -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 <T>
*/
public interface CloudCallback<T> {
void onSuccess(T result);
void onError(String error);
}

@ -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
* <p>
* HTTP APIEMAS Serverless
* </p>
*/
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<String> 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<JSONArray> 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<Void> 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<String, Object> convertToCloudData(WorkingNote note) {
Map<String, Object> 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;
}
}

@ -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
*
* <p>
* 退
* 3124
* </p>
*/
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();
}
}
}

@ -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;
/**
*
* <p>
* IDID
* 使
* </p>
*/
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;
}
}

@ -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
* <p>
* 使EMAS ServerlessHTTP API
* EMASServerless
* </p>
*/
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;
}
/**
* Token24
*/
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);
}
}

@ -285,6 +285,36 @@ public class Notes {
* <P> Type : INTEGER (long) </P>
*/
public static final String GTASK_FINISHED_TIME = "gtask_finished_time";
/**
* Cloud User ID for sync
* <P> Type : TEXT </P>
*/
public static final String CLOUD_USER_ID = "cloud_user_id";
/**
* Cloud Device ID for sync
* <P> Type : TEXT </P>
*/
public static final String CLOUD_DEVICE_ID = "cloud_device_id";
/**
* Sync Status: 0=Not synced, 1=Syncing, 2=Synced, 3=Conflict
* <P> Type : INTEGER </P>
*/
public static final String SYNC_STATUS = "sync_status";
/**
* Last Sync Time (Timestamp)
* <P> Type : INTEGER (long) </P>
*/
public static final String LAST_SYNC_TIME = "last_sync_time";
/**
* Cloud Note ID for sync (UUID)
* <P> Type : TEXT </P>
*/
public static final String CLOUD_NOTE_ID = "cloud_note_id";
}
public interface DataColumns {

@ -66,11 +66,11 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper {
/**
*
* <p>
* 8
* 12
* onUpgrade
* </p>
*/
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
* <p>
* CLOUD_USER_ID, CLOUD_DEVICE_ID, SYNC_STATUS, LAST_SYNC_TIME
* </p>
*
* @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
* <p>
* cloud_note_id
* </p>
*
* @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
* <p>
* title
* titletitlesnippet
* </p>
*
* @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);

@ -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 ====================
/**
*
* <p>
* LOCAL_MODIFIED = 1
* </p>
*
* @param callback
*/
public void getUnsyncedNotes(Callback<List<NoteInfo>> 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<NoteInfo> 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);
}
});
}
/**
*
* <p>
* LOCAL_MODIFIED = 0 SYNC_STATUS = 2
* </p>
*
* @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<Long> 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
* <p>
*
* </p>
*
* @param cloudUserId ID
* @param callback
*/
public void getLocalModifiedNotes(String cloudUserId, Callback<List<WorkingNote>> 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<WorkingNote> 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<List<WorkingNote>> 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<WorkingNote> 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<Void> 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<WorkingNote> 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 IDUUID
* @param callback null
*/
public void findByCloudNoteId(String cloudNoteId, Callback<WorkingNote> 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<List<WorkingNote>> 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<WorkingNote> 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<Integer> 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<Long> noteIds, Callback<Integer> 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"));
}
});
}
/**
*
* <p>
* cloud_user_idcloud_user_id
*
* </p>
*
* @param newUserId ID
* @param callback
*/
public void takeoverAllNotes(String newUserId, Callback<Integer> 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);
}
});
}
}

@ -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;
/**
*
* <p>
*
* </p>
*/
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;
/**
* JSONCloudNote
*/
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", "");
}
/**
* WorkingNoteCloudNote
*/
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 IDID
* @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; }
}

@ -57,18 +57,34 @@ public class Note {
* ID
* ID
* </p>
*
*
* @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
* <p>
* ID
* ID
* </p>
*
* @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;
}
}
}

@ -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 {
* <p>
*
* </p>
*
*
* @param context
* @param folderId ID
* @param widgetId Widget ID
@ -275,6 +315,21 @@ public class WorkingNote {
return note;
}
/**
*
* <p>
*
* </p>
*
* @param context
* @param noteId ID
* @return WorkingNote
*/
public static WorkingNote createEmptyNote(Context context, long noteId) {
WorkingNote note = new WorkingNote(context, noteId, 0);
return note;
}
/**
*
* <p>
@ -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 IDUUID
*/
public String getCloudNoteId() {
return mCloudNoteId;
}
/**
* ID
* @param cloudNoteId IDUUID
*/
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();
}
/**
*
* <p>

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

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

@ -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;
/**
*
* <p>
* 使
* </p>
*/
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";
}

@ -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;
/**
*
* <p>
*
* 使
* </p>
* <p>
*
* 1. -
* 2. 线 - 使 CountDownLatch synchronized/wait/notify
* 3. -
* 4. -
* </p>
*/
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<Conflict> 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);
}
/**
*
* <p>
*
* LOCAL_MODIFIED
* </p>
*
* @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<String> 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<WorkingNote> notesToUpload = new ArrayList<>();
final CountDownLatch queryLatch = new CountDownLatch(1);
final AtomicReference<Exception> errorRef = new AtomicReference<>();
// 使用带用户过滤的方法,只查询当前用户的笔记
repo.getLocalModifiedNotes(userId, new NotesRepository.Callback<List<WorkingNote>>() {
@Override
public void onSuccess(List<WorkingNote> 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<String> cloudIdRef = new AtomicReference<>();
final AtomicReference<Exception> errorRef = new AtomicReference<>();
cloudHelper.uploadNote(note, new CloudCallback<String>() {
@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<Exception> errorRef = new AtomicReference<>();
repo.markNoteSynced(noteId, new NotesRepository.Callback<Void>() {
@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<WorkingNote> allNotes = new ArrayList<>();
final CountDownLatch queryLatch = new CountDownLatch(1);
final AtomicReference<Exception> errorRef = new AtomicReference<>();
repo.getNotesByCloudUserId(userId, new NotesRepository.Callback<List<WorkingNote>>() {
@Override
public void onSuccess(List<WorkingNote> 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<JSONArray> notesArrayRef = new AtomicReference<>();
final CountDownLatch downloadLatch = new CountDownLatch(1);
final AtomicReference<Exception> errorRef = new AtomicReference<>();
final AtomicLong maxModifiedTime = new AtomicLong(0);
cloudHelper.downloadNotes(downloadSince, new CloudCallback<JSONArray>() {
@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<WorkingNote> localNoteRef = new AtomicReference<>();
String cloudNoteId = cloudNote.getCloudNoteId();
if (cloudNoteId != null && !cloudNoteId.isEmpty()) {
repo.findByCloudNoteId(cloudNoteId, new NotesRepository.Callback<WorkingNote>() {
@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<WorkingNote>() {
@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<Conflict> getPendingConflicts() {
return new ArrayList<>(mConflicts);
}
/**
*
*/
public void clearAllConflicts() {
mConflicts.clear();
Log.d(TAG, "All conflicts cleared");
}
}

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

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

@ -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;
/**
*
*
* <p>
*
*
* </p>
* <p>
* MVVM {@link LoginViewModel}
* </p>
*/
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);
}
}

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

@ -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<String>() {
@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<String>() {
@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();

@ -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 -
* <p>
*
* /
* 使 LinearLayout NavigationView
* "文件夹"
* </p>
*/
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<Long>() {
@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<FolderTreeAdapter.FolderViewHolder> {
private List<FolderTreeItem> folderItems;
private FolderListViewModel viewModel;
private OnFolderItemClickListener folderItemClickListener;
private OnFolderItemLongClickListener folderItemLongClickListener;
public FolderTreeAdapter(List<FolderTreeItem> 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<Long>() {
@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<FolderTreeItem> 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<FolderTreeAdapter.FolderViewHolder> {
@Override
public int getItemCount() {
return folderItems.size();
}
private List<FolderTreeItem> 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<FolderTreeItem> folderItems, FolderListViewModel viewModel) {
this.folderItems = folderItems;
this.viewModel = viewModel;
}
public void bind(FolderTreeItem item, boolean isExpanded) {
this.currentItem = item;
public void setData(List<FolderTreeItem> 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;
}
}

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

@ -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
*
* <p>
*
* MVVM Activity
* </p>
* <p>
*
* 1. -
* 2. -
* 3. -
* </p>
*/
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<Boolean> mIsLoading = new MutableLiveData<>(false);
private final MutableLiveData<String> mErrorMessage = new MutableLiveData<>();
private final MutableLiveData<Boolean> mLoginSuccess = new MutableLiveData<>(false);
private final MutableLiveData<Integer> mMigratedNotesCount = new MutableLiveData<>();
private final MutableLiveData<String> 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<Boolean> getIsLoading() {
return mIsLoading;
}
public LiveData<String> getErrorMessage() {
return mErrorMessage;
}
public LiveData<Boolean> getLoginSuccess() {
return mLoginSuccess;
}
public LiveData<Integer> getMigratedNotesCount() {
return mMigratedNotesCount;
}
public LiveData<String> 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);
}
});
}
/**
*
*
* <p>
* 1.
* 2.
* 3.
* </p>
*/
private void migrateAnonymousDataAndSync(String newUserId) {
mSyncStatus.postValue("正在接管设备上的笔记...");
// 新用户接管设备上所有笔记
mNotesRepository.takeoverAllNotes(newUserId, new NotesRepository.Callback<Integer>() {
@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();
}
});
}
/**
*
*
* <p>
* 1.
* 2.
*
* </p>
*/
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);
}
}

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Drawer Header Circle Decorator
装饰性圆形,用于增加层次感
-->
<shape xmlns:android="http://schemas.android.com/res/android"
android:shape="oval">
<solid android:color="@color/white" />
</shape>

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Drawer Header Gradient Background
蓝色系渐变背景Material Design 3 风格
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:type="linear"
android:angle="135"
android:startColor="@color/drawer_header_gradient_start"
android:centerColor="@color/drawer_header_gradient_center"
android:endColor="@color/drawer_header_gradient_end"
android:centerX="0.3"
android:centerY="0.7" />
<corners android:radius="16dp" />
</shape>

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Account Circle Icon - Material Design 3
用户头像占位符
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="64dp"
android:height="64dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorPrimary">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,5c1.66,0 3,1.34 3,3s-1.34,3 -3,3 -3,-1.34 -3,-3 1.34,-3 3,-3zM12,19.2c-2.5,0 -4.71,-1.28 -6,-3.22 0.03,-1.99 4,-3.08 6,-3.08 1.99,0 5.97,1.09 6,3.08 -1.29,1.94 -3.5,3.22 -6,3.22z" />
</vector>

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Cloud Done Icon - Material Design 3
云同步完成图标
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/white">
<path
android:fillColor="@android:color/white"
android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4C9.11,4 6.6,5.64 5.35,8.04C2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5C24,12.36 21.95,10.22 19.35,10.04zM10,17l-3.5,-3.5l1.41,-1.41L10,14.17l5.09,-5.09L16.5,10.5L10,17z" />
</vector>

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Cloud Settings Icon - Material Design 3
云设置图标
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurfaceVariant">
<path
android:fillColor="@android:color/white"
android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4C9.11,4 6.6,5.64 5.35,8.04C2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5C24,12.36 21.95,10.22 19.35,10.04zM19,18H6c-2.21,0 -4,-1.79 -4,-4c0,-2.05 1.53,-3.76 3.56,-3.97l1.07,-0.11l0.5,-0.95C8.08,7.14 9.94,6 12,6c2.62,0 4.88,1.86 5.39,4.43l0.3,1.5l1.53,0.11C20.78,12.14 22,13.45 22,15C22,16.65 20.65,18 19,18z" />
<path
android:fillColor="@android:color/white"
android:pathData="M10.59,9.17L5.41,14.34 6.83,15.76 12.01,10.59z" />
<path
android:fillColor="@android:color/white"
android:pathData="M13.41,9.17L12,10.59 17.18,15.76 18.59,14.34z" />
</vector>

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Delete Icon - Material Design 3
删除/回收站图标
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurfaceVariant">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z" />
</vector>

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Export Icon - Material Design 3
导出图标
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurfaceVariant">
<path
android:fillColor="@android:color/white"
android:pathData="M19,9h-4V3H9v6H5l7,7 7,-7zM5,18v2h14v-2H5z" />
</vector>

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Favorite Icon - Material Design 3
收藏图标
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurfaceVariant">
<path
android:fillColor="@android:color/white"
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z" />
</vector>

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Help Icon - Material Design 3
帮助图标
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurfaceVariant">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,19h-2v-2h2v2zM15.07,11.25l-0.9,0.92C13.45,12.9 13,13.5 13,15h-2v-0.5c0,-1.1 0.45,-2.1 1.17,-2.83l1.24,-1.26c0.37,-0.36 0.59,-0.86 0.59,-1.41 0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2H8c0,-2.21 1.79,-4 4,-4s4,1.79 4,4c0,0.88 -0.36,1.68 -0.93,2.25z" />
</vector>

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Login Icon - Material Design 3
登录图标
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurfaceVariant">
<path
android:fillColor="@android:color/white"
android:pathData="M10.3,7.7L10.3,7.7c-0.39,0.39 -0.39,1.01 0,1.4l1.9,1.9H3c-0.55,0 -1,0.45 -1,1v0c0,0.55 0.45,1 1,1h9.2l-1.9,1.9c-0.39,0.39 -0.39,1.01 0,1.4l0,0c0.39,0.39 1.01,0.39 1.4,0l3.59,-3.59c0.39,-0.39 0.39,-1.02 0,-1.41L11.7,7.7C11.31,7.31 10.69,7.31 10.3,7.7zM20,19h-7c-0.55,0 -1,0.45 -1,1v0c0,0.55 0.45,1 1,1h7c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2h-7c-0.55,0 -1,0.45 -1,1v0c0,0.55 0.45,1 1,1h7V19z" />
</vector>

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Logout Icon - Material Design 3
退出登录图标
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurfaceVariant">
<path
android:fillColor="@android:color/white"
android:pathData="M10.09,15.59L11.5,17l5,-5 -5,-5 -1.41,1.41L12.67,11H3v2h9.67l-2.58,2.59zM19,3H5c-1.11,0 -2,0.9 -2,2v4h2V5h14v14H5v-4H3v4c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2z" />
</vector>

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Notes Icon - Material Design 3
笔记图标
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurfaceVariant">
<path
android:fillColor="@android:color/white"
android:pathData="M19,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM14,17H7v-2h7V17zM17,13H7v-2h10V13zM17,9H7V7h10V9z" />
</vector>

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Notes Logo - For Drawer Header
便签应用 Logo
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="72dp"
android:height="72dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/white">
<path
android:fillColor="@android:color/white"
android:pathData="M14,2H6C4.9,2 4,2.9 4,4v16c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V8L14,2zM16,18H8v-2h8V18zM16,14H8v-2h8V14zM13,9V3.5L18.5,9H13z" />
</vector>

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Reminder Icon - Material Design 3
提醒图标
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurfaceVariant">
<path
android:fillColor="@android:color/white"
android:pathData="M18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32V4c0,-0.83 -0.67,-1.5 -1.5,-1.5S10.5,3.17 10.5,4v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1L18,16zM13,16h-2v-2h2V16zM13,12h-2V8h2V12zM12,22c1.1,0 2,-0.9 2,-2h-4C10,21.1 10.9,22 12,22z" />
</vector>

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Settings Icon - Material Design 3
设置图标
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurfaceVariant">
<path
android:fillColor="@android:color/white"
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.49l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z" />
</vector>

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Sync Icon - Material Design 3
同步图标
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurfaceVariant">
<path
android:fillColor="@android:color/white"
android:pathData="M12,4V1L8,5l4,4V6c3.31,0 6,2.69 6,6c0,1.01 -0.25,1.97 -0.7,2.8l1.46,1.46C19.54,15.03 20,13.57 20,12C20,7.58 16.42,4 12,4zM12,18c-3.31,0 -6,-2.69 -6,-6c0,-1.01 0.25,-1.97 0.7,-2.8L5.24,7.74C4.46,8.97 4,10.43 4,12c0,4.42 3.58,8 8,8v3l4,-4l-4,-4V18z" />
</vector>

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Template Icon - Material Design 3
模板图标
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurfaceVariant">
<path
android:fillColor="@android:color/white"
android:pathData="M19,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM7,7h10v2H7V7zM7,11h10v2H7V11zM7,15h7v2H7V15z" />
</vector>

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Online Status Indicator
在线状态指示器(绿色圆点)
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/drawer_online_status" />
<stroke
android:width="2dp"
android:color="@color/white" />
</shape>

@ -0,0 +1,100 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="24dp"
android:gravity="center">
<!-- Logo/Title -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="小米便签"
android:textSize="32sp"
android:textStyle="bold"
android:textColor="?attr/colorPrimary"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="账号登录"
android:textSize="18sp"
android:layout_marginBottom="32dp" />
<!-- Username Input -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="用户名"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_marginBottom="16dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_username"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Password Input -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="密码"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
app:endIconMode="password_toggle"
android:layout_marginBottom="24dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Progress Bar -->
<ProgressBar
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
android:layout_marginBottom="16dp" />
<!-- Login Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_login"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="登录"
android:textSize="16sp"
app:cornerRadius="8dp"
android:layout_marginBottom="12dp" />
<!-- Register Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_register"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="注册新账号"
android:textSize="16sp"
style="?attr/materialButtonOutlinedStyle"
app:cornerRadius="8dp"
android:layout_marginBottom="16dp" />
<!-- Skip Login -->
<TextView
android:id="@+id/tv_skip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="暂不登录,使用本地模式"
android:textColor="?attr/colorPrimary"
android:padding="8dp" />
</LinearLayout>

@ -0,0 +1,149 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<!-- Title -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/sync_title"
android:textSize="24sp"
android:textStyle="bold"
android:layout_marginBottom="16dp" />
<!-- Login Status Card -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardElevation="2dp"
app:cardCornerRadius="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/sync_login_status"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_device_id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/sync_device_id_default"
android:textSize="14sp"
android:layout_marginTop="8dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Sync Settings -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardElevation="2dp"
app:cardCornerRadius="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- Auto Sync Switch -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/sync_auto_sync"
android:textSize="16sp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_auto_sync"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?android:attr/dividerVertical"
android:layout_marginVertical="12dp" />
<!-- Last Sync Time -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/sync_last_time"
android:textSize="14sp" />
<TextView
android:id="@+id/tv_last_sync_time"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/sync_never"
android:textSize="14sp"
android:layout_marginTop="4dp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?android:attr/dividerVertical"
android:layout_marginVertical="12dp" />
<!-- Sync Status -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/sync_status_label"
android:textSize="14sp" />
<TextView
android:id="@+id/tv_sync_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/sync_status_idle"
android:textSize="14sp"
android:layout_marginTop="4dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Progress Bar -->
<ProgressBar
android:id="@+id/progress_sync"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:visibility="invisible"
style="?android:attr/progressBarStyleHorizontal" />
<!-- Sync Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_sync_now"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/sync_button_now"
app:cornerRadius="8dp" />
</LinearLayout>

@ -0,0 +1,124 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- Title -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/conflict_title"
android:textSize="20sp"
android:textStyle="bold"
android:layout_marginBottom="16dp" />
<!-- Local Version -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardElevation="2dp"
app:cardCornerRadius="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/conflict_local_version"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?attr/colorPrimary" />
<TextView
android:id="@+id/tv_local_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:layout_marginTop="8dp"
android:maxLines="3"
android:ellipsize="end" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Cloud Version -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardElevation="2dp"
app:cardCornerRadius="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/conflict_cloud_version"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?attr/colorSecondary" />
<TextView
android:id="@+id/tv_cloud_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:layout_marginTop="8dp"
android:maxLines="3"
android:ellipsize="end" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Buttons -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_use_local"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:text="@string/conflict_use_local"
style="?attr/materialButtonOutlinedStyle" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_use_cloud"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="4dp"
android:text="@string/conflict_use_cloud"
style="?attr/materialButtonOutlinedStyle" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_merge"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:text="@string/conflict_merge"
android:enabled="false"
style="?attr/materialButtonOutlinedStyle" />
</LinearLayout>
</LinearLayout>

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
文件夹展开/收起图标
-->
<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/iv_expand_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@android:drawable/ic_menu_more"
android:contentDescription="@string/folder_title"
android:layout_gravity="center_vertical" />

@ -0,0 +1,125 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Navigation Drawer Header Layout - Fixed Version
修复图标过大问题
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/drawer_header_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/drawer_header_gradient"
android:padding="12dp">
<!-- ========== 未登录状态 ========== -->
<LinearLayout
android:id="@+id/header_not_logged_in"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<!-- 应用 Logo -->
<ImageView
android:id="@+id/iv_app_logo"
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@android:drawable/ic_menu_edit"
android:contentDescription="@string/app_name"
app:tint="@color/white" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginStart="12dp">
<!-- 应用名称 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textColor="@color/white"
android:textSize="16sp"
android:textStyle="bold" />
<!-- 登录提示 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/drawer_login_prompt"
android:textColor="@color/white"
android:textSize="12sp"
android:alpha="0.8" />
</LinearLayout>
<!-- 登录按钮 -->
<ImageButton
android:id="@+id/btn_login_prompt"
android:layout_width="32dp"
android:layout_height="32dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_login"
android:contentDescription="@string/drawer_login"
app:tint="@color/white" />
</LinearLayout>
<!-- ========== 已登录状态 ========== -->
<LinearLayout
android:id="@+id/header_logged_in"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:visibility="gone">
<!-- 用户头像 -->
<ImageView
android:id="@+id/iv_user_avatar"
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@drawable/ic_account_circle"
android:background="@drawable/online_status_indicator"
app:tint="@color/white" />
<!-- 用户信息 -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="12dp"
android:orientation="vertical">
<!-- 用户名 -->
<TextView
android:id="@+id/tv_username"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/drawer_default_username"
android:textColor="@color/white"
android:textSize="14sp"
android:textStyle="bold"
android:maxLines="1"
android:ellipsize="end" />
<!-- 设备ID -->
<TextView
android:id="@+id/tv_device_id"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/drawer_default_device_id"
android:textColor="@color/white"
android:textSize="11sp"
android:alpha="0.8"
android:maxLines="1"
android:ellipsize="end" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Drawer Menu Divider
菜单分组分隔线
-->
<View xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginHorizontal="16dp"
android:layout_marginVertical="8dp"
android:background="?attr/colorOutlineVariant" />

@ -0,0 +1,339 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Modern Sidebar Fragment Layout
使用自定义布局替代 NavigationView支持菜单项间插入文件夹树
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="?attr/colorSurface"
android:fitsSystemWindows="true">
<!-- 头部 -->
<include layout="@layout/drawer_header" />
<!-- 可滚动的菜单区域 -->
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- 全部笔记 -->
<LinearLayout
android:id="@+id/menu_all_notes"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:background="?attr/selectableItemBackground">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_notes"
app:tint="?attr/colorOnSurfaceVariant" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="16dp"
android:text="@string/drawer_all_notes"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- 回收站 -->
<LinearLayout
android:id="@+id/menu_trash"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:background="?attr/selectableItemBackground">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_delete"
app:tint="?attr/colorOnSurfaceVariant" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="16dp"
android:text="@string/menu_trash"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- 文件夹(可点击展开)-->
<LinearLayout
android:id="@+id/menu_folders"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:background="?attr/selectableItemBackground">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@android:drawable/ic_menu_myplaces"
app:tint="?attr/colorOnSurfaceVariant" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="16dp"
android:text="@string/folder_title"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface" />
<ImageView
android:id="@+id/iv_folder_expand"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@android:drawable/ic_menu_more"
app:tint="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<!-- 文件夹树(默认隐藏,点击文件夹后展开)-->
<LinearLayout
android:id="@+id/folder_tree_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="40dp"
android:text="@string/folder_title"
android:textSize="12sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurfaceVariant" />
<ImageButton
android:id="@+id/btn_create_folder"
android:layout_width="32dp"
android:layout_height="32dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@android:drawable/ic_input_add"
android:contentDescription="@string/menu_create_folder"
app:tint="?attr/colorPrimary" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_folder_tree"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxHeight="200dp"
android:clipToPadding="false"
android:layout_marginStart="40dp" />
</LinearLayout>
<!-- 同步设置 -->
<LinearLayout
android:id="@+id/menu_sync_settings"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:background="?attr/selectableItemBackground">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_cloud_settings"
app:tint="?attr/colorOnSurfaceVariant" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="16dp"
android:text="@string/drawer_sync_settings"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- 模板 -->
<LinearLayout
android:id="@+id/menu_templates"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:background="?attr/selectableItemBackground">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_template"
app:tint="?attr/colorOnSurfaceVariant" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="16dp"
android:text="@string/menu_templates"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- 导出 -->
<LinearLayout
android:id="@+id/menu_export"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:background="?attr/selectableItemBackground">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_export"
app:tint="?attr/colorOnSurfaceVariant" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="16dp"
android:text="@string/menu_export"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- 设置 -->
<LinearLayout
android:id="@+id/menu_settings"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:background="?attr/selectableItemBackground">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_settings"
app:tint="?attr/colorOnSurfaceVariant" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="16dp"
android:text="@string/menu_settings"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- 登录 -->
<LinearLayout
android:id="@+id/menu_login"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:background="?attr/selectableItemBackground">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_login"
app:tint="?attr/colorOnSurfaceVariant" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="16dp"
android:text="@string/drawer_login"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- 退出登录(默认隐藏)-->
<LinearLayout
android:id="@+id/menu_logout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:background="?attr/selectableItemBackground"
android:visibility="gone">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_logout"
app:tint="?attr/colorOnSurfaceVariant" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="16dp"
android:text="@string/drawer_logout"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</LinearLayout>

@ -18,22 +18,11 @@
android:layout_width="0dp"
android:layout_height="match_parent" />
<!-- 展开/收起箭头 -->
<!-- 文件夹图标(兼展开/收起功能) -->
<ImageView
android:id="@+id/iv_expand_icon"
android:id="@+id/iv_folder_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@android:drawable/arrow_down_float"
android:contentDescription="展开/收起"
android:scaleType="centerInside"
android:rotation="0" />
<!-- 文件夹图标 -->
<ImageView
android:id="@+id/iv_folder_icon"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="8dp"
android:src="@android:drawable/ic_menu_myplaces"
android:contentDescription="文件夹"
android:scaleType="centerInside" />

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Navigation Drawer Menu - Simplified
移除分隔线,简化菜单项
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<!-- ========== 笔记相关 ========== -->
<group
android:id="@+id/group_notes"
android:checkableBehavior="single">
<item
android:id="@+id/nav_all_notes"
android:icon="@drawable/ic_notes"
android:title="@string/drawer_all_notes"
android:checked="true" />
<item
android:id="@+id/nav_trash"
android:icon="@drawable/ic_delete"
android:title="@string/menu_trash" />
</group>
<!-- ========== 文件夹(可折叠)========== -->
<group
android:id="@+id/group_folders"
android:checkableBehavior="none">
<item
android:id="@+id/nav_folders"
android:icon="@android:drawable/ic_menu_more"
android:title="@string/folder_title"
app:actionLayout="@layout/drawer_folder_expand_icon" />
</group>
<!-- ========== 云同步 ========== -->
<group
android:id="@+id/group_sync"
android:checkableBehavior="none">
<item
android:id="@+id/nav_sync_settings"
android:icon="@drawable/ic_cloud_settings"
android:title="@string/drawer_sync_settings" />
</group>
<!-- ========== 其他 ========== -->
<group
android:id="@+id/group_other"
android:checkableBehavior="none">
<item
android:id="@+id/nav_templates"
android:icon="@drawable/ic_template"
android:title="@string/menu_templates" />
<item
android:id="@+id/nav_export"
android:icon="@drawable/ic_export"
android:title="@string/menu_export" />
<item
android:id="@+id/nav_settings"
android:icon="@drawable/ic_settings"
android:title="@string/menu_settings" />
</group>
<!-- ========== 账户操作 ========== -->
<group
android:id="@+id/group_account"
android:checkableBehavior="none">
<item
android:id="@+id/nav_login"
android:icon="@drawable/ic_login"
android:title="@string/drawer_login" />
<item
android:id="@+id/nav_logout"
android:icon="@drawable/ic_logout"
android:title="@string/drawer_logout"
android:visible="false" />
</group>
</menu>

@ -42,4 +42,11 @@
<color name="bg_eye_care_green">#C7EDCC</color>
<color name="bg_warm">#FFE0B2</color>
<color name="bg_cool">#E1BEE7</color>
<!-- Drawer Header Colors - Material Design 3 Blue Theme -->
<color name="drawer_header_gradient_start">#1976D2</color>
<color name="drawer_header_gradient_center">#2196F3</color>
<color name="drawer_header_gradient_end">#64B5F6</color>
<color name="drawer_online_status">#4CAF50</color>
<color name="drawer_sync_status_bg">#1AFFFFFF</color>
</resources>

@ -190,4 +190,59 @@
<string name="menu_rich_text">Rich Text</string>
<string name="menu_restore">Restore</string>
<string name="menu_permanent_delete">Delete Forever</string>
<!-- Drawer Navigation -->
<string name="drawer_login_prompt">点击登录/注册</string>
<string name="drawer_default_username">用户</string>
<string name="drawer_default_device_id">Device ID: Unknown</string>
<string name="drawer_sync_status_synced">已同步</string>
<string name="drawer_sync_status_syncing">同步中...</string>
<string name="drawer_sync_status_failed">同步失败</string>
<string name="drawer_group_notes">笔记</string>
<string name="drawer_group_sync">云同步</string>
<string name="drawer_group_other">其他</string>
<string name="drawer_all_notes">全部笔记</string>
<string name="drawer_favorites">收藏</string>
<string name="drawer_reminders">提醒</string>
<string name="drawer_sync_now">立即同步</string>
<string name="drawer_sync_settings">同步设置</string>
<string name="drawer_help">帮助与反馈</string>
<string name="drawer_login">登录</string>
<string name="drawer_logout">退出登录</string>
<!-- Cloud Sync -->
<string name="sync_title">Cloud Sync</string>
<string name="sync_login_status">Login Status</string>
<string name="sync_device_id_default">Device ID: Not initialized</string>
<string name="sync_auto_sync">Auto Sync</string>
<string name="sync_last_time">Last Sync Time:</string>
<string name="sync_never">Never</string>
<string name="sync_status_label">Sync Status:</string>
<string name="sync_status_idle">Idle</string>
<string name="sync_status_syncing">Syncing...</string>
<string name="sync_status_success">Sync Successful</string>
<string name="sync_status_failed">Sync Failed</string>
<string name="sync_button_now">Sync Now</string>
<string name="sync_toast_started">Sync started</string>
<string name="sync_toast_success">Sync completed successfully</string>
<string name="sync_toast_failed">Sync failed: %1$s</string>
<!-- Logout -->
<string name="dialog_logout_title">退出登录</string>
<string name="dialog_logout_message">确定要退出登录吗?退出后本地笔记将保留,但无法同步到云端。</string>
<string name="dialog_logout_confirm">退出</string>
<string name="toast_logout_success">已退出登录</string>
<!-- Folder -->
<string name="folder_title">文件夹</string>
<string name="error_create_folder">创建文件夹失败</string>
<!-- Conflict Resolution -->
<string name="conflict_title">Note Conflict</string>
<string name="conflict_local_version">Local Version</string>
<string name="conflict_cloud_version">Cloud Version</string>
<string name="conflict_use_local">Use Local</string>
<string name="conflict_use_cloud">Use Cloud</string>
<string name="conflict_merge">Merge</string>
<string name="conflict_merge_hint">Merge feature coming soon</string>
</resources>

@ -65,4 +65,10 @@
<style name="NoteActionBarStyle" parent="@android:style/Widget.Holo.Light.ActionBar.Solid">
<item name="android:visibility">visible</item>
</style>
<!-- Drawer Navigation Styles -->
<style name="CircleImageView" parent="">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">50%</item>
</style>
</resources>

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

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

Loading…
Cancel
Save