From 802f8283187b9bc39539565e7b4de8a3aae76ecd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8C=85=E5=B0=94=E4=BF=8A?=
+ * 管理阿里云EMAS服务的初始化和配置,包括推送服务、云数据库等。 + *
+ */ +public class AliyunService { + + private static final String TAG = "AliyunService"; + private static AliyunService sInstance; + private String mDeviceId; + + private AliyunService() { + // Private constructor for singleton + } + + /** + * 获取AliyunService单例实例 + * + * @return AliyunService实例 + */ + public static synchronized AliyunService getInstance() { + if (sInstance == null) { + sInstance = new AliyunService(); + } + return sInstance; + } + + /** + * 初始化阿里云服务 + * + * @param context 应用上下文 + */ + public void initialize(Context context) { + Log.i(TAG, "Initializing AliyunService with AppKey: " + AliyunConfig.APP_KEY); + + try { + // Initialize push service + PushServiceFactory.init(context); + CloudPushService pushService = PushServiceFactory.getCloudPushService(); + + pushService.register(context, AliyunConfig.APP_KEY, AliyunConfig.APP_SECRET, new CommonCallback() { + @Override + public void onSuccess(String response) { + mDeviceId = pushService.getDeviceId(); + Log.i(TAG, "Alibaba Cloud Push SDK registered successfully"); + Log.i(TAG, "Device ID: " + mDeviceId); + } + + @Override + public void onFailed(String errorCode, String errorMessage) { + Log.e(TAG, "Alibaba Cloud Push SDK registration failed: " + errorCode + " - " + errorMessage); + } + }); + + } catch (Exception e) { + Log.e(TAG, "Failed to initialize AliyunService", e); + } + } + + /** + * 获取设备ID + * + * @return 设备ID + */ + public String getDeviceId() { + return mDeviceId; + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/api/CloudCallback.java b/src/Notesmaster/app/src/main/java/net/micode/notes/api/CloudCallback.java new file mode 100644 index 0000000..5fb8235 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/api/CloudCallback.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.api; + +/** + * 云数据库操作回调接口 + * + * @param+ * 通过HTTP API与阿里云EMAS Serverless交互。 + *
+ */ +public class CloudDatabaseHelper { + + private static final String TAG = "CloudDatabaseHelper"; + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + private static final String API_NOTES = AliyunConfig.BASE_URL + "/notes"; + + private String mUserId; + private String mDeviceId; + private String mAuthToken; + private OkHttpClient mHttpClient; + + public CloudDatabaseHelper(String userId, String deviceId, String authToken) { + mUserId = userId; + mDeviceId = deviceId; + mAuthToken = authToken; + mHttpClient = new OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .addInterceptor(new RetryInterceptor()) + .build(); + } + + /** + * 上传笔记到云端 + */ + public void uploadNote(WorkingNote note, CloudCallback+ * 实现指数退避重试策略,对网络超时和临时错误进行自动重试。 + * 支持最多3次重试,每次重试间隔递增(1秒、2秒、4秒)。 + *
+ */ +public class RetryInterceptor implements Interceptor { + + private static final String TAG = "RetryInterceptor"; + private static final int MAX_RETRY_COUNT = 3; + private static final long INITIAL_RETRY_DELAY_MS = 1000; + + @Override + public Response intercept(Chain chain) throws IOException { + Request request = chain.request(); + Response response = null; + IOException exception = null; + + for (int retryCount = 0; retryCount <= MAX_RETRY_COUNT; retryCount++) { + try { + response = chain.proceed(request); + + // 如果响应成功,直接返回 + if (response.isSuccessful()) { + return response; + } + + // 对于服务器错误 (5xx) 进行重试 + if (response.code() >= 500 && response.code() < 600) { + if (retryCount < MAX_RETRY_COUNT) { + Log.w(TAG, "Server error " + response.code() + ", retrying... (" + (retryCount + 1) + "/" + MAX_RETRY_COUNT + ")"); + response.close(); + waitBeforeRetry(retryCount); + continue; + } + } else { + // 客户端错误 (4xx) 不重试 + return response; + } + } catch (SocketTimeoutException | UnknownHostException e) { + // 网络超时和主机不可达时重试 + exception = e; + if (retryCount < MAX_RETRY_COUNT) { + Log.w(TAG, "Network error, retrying... (" + (retryCount + 1) + "/" + MAX_RETRY_COUNT + "): " + e.getMessage()); + waitBeforeRetry(retryCount); + } else { + throw e; + } + } catch (IOException e) { + // 其他 IO 异常也尝试重试 + exception = e; + if (retryCount < MAX_RETRY_COUNT) { + Log.w(TAG, "IO error, retrying... (" + (retryCount + 1) + "/" + MAX_RETRY_COUNT + "): " + e.getMessage()); + waitBeforeRetry(retryCount); + } else { + throw e; + } + } + } + + // 如果所有重试都失败了 + if (exception != null) { + throw exception; + } + + return response; + } + + /** + * 指数退避等待 + */ + private void waitBeforeRetry(int retryCount) { + long delay = INITIAL_RETRY_DELAY_MS * (1L << retryCount); // 1s, 2s, 4s + try { + Thread.sleep(delay); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/auth/AnonymousAuthManager.java b/src/Notesmaster/app/src/main/java/net/micode/notes/auth/AnonymousAuthManager.java new file mode 100644 index 0000000..331fe83 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/auth/AnonymousAuthManager.java @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.auth; + +import android.content.Context; +import android.content.SharedPreferences; +import android.provider.Settings; +import android.util.Log; + +import java.util.UUID; + +/** + * 匿名认证管理器 + *+ * 管理匿名用户的认证信息,包括生成和存储用户ID、设备ID等。 + * 使用单例模式确保全局只有一个认证管理器实例。 + *
+ */ +public class AnonymousAuthManager { + + private static final String TAG = "AnonymousAuthManager"; + private static final String PREFS_NAME = "AnonymousAuth"; + private static final String KEY_USER_ID = "anonymous_user_id"; + private static final String KEY_DEVICE_ID = "device_id"; + + private static AnonymousAuthManager sInstance; + private SharedPreferences mPrefs; + private String mUserId; + private String mDeviceId; + + private AnonymousAuthManager(Context context) { + mPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + } + + /** + * 获取AnonymousAuthManager单例实例 + * + * @param context 应用上下文 + * @return AnonymousAuthManager实例 + */ + public static synchronized AnonymousAuthManager getInstance(Context context) { + if (sInstance == null) { + sInstance = new AnonymousAuthManager(context.getApplicationContext()); + } + return sInstance; + } + + /** + * 初始化认证管理器 + * + * @param context 应用上下文 + */ + public void initialize(Context context) { + if (mUserId == null) { + mUserId = mPrefs.getString(KEY_USER_ID, null); + if (mUserId == null) { + mUserId = generateAnonymousUserId(); + mPrefs.edit().putString(KEY_USER_ID, mUserId).apply(); + Log.i(TAG, "Generated new anonymous user ID"); + } + } + + if (mDeviceId == null) { + mDeviceId = mPrefs.getString(KEY_DEVICE_ID, null); + if (mDeviceId == null) { + mDeviceId = generateDeviceId(context); + mPrefs.edit().putString(KEY_DEVICE_ID, mDeviceId).apply(); + Log.i(TAG, "Generated new device ID"); + } + } + + Log.i(TAG, "AnonymousAuthManager initialized successfully"); + } + + /** + * 获取用户ID + * + * @return 用户ID + */ + public String getUserId() { + return mUserId; + } + + /** + * 获取设备ID + * + * @return 设备ID + */ + public String getDeviceId() { + return mDeviceId; + } + + /** + * 生成匿名用户ID + * + * @return 格式为 anon_xxx 的用户ID + */ + private String generateAnonymousUserId() { + return "anon_" + UUID.randomUUID().toString().replace("-", ""); + } + + /** + * 生成设备ID + * + * @param context 应用上下文 + * @return 设备ID + */ + private String generateDeviceId(Context context) { + String androidId = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID); + if (androidId == null || androidId.isEmpty()) { + androidId = UUID.randomUUID().toString(); + } + return "device_" + androidId; + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/auth/UserAuthManager.java b/src/Notesmaster/app/src/main/java/net/micode/notes/auth/UserAuthManager.java new file mode 100644 index 0000000..aef9662 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/auth/UserAuthManager.java @@ -0,0 +1,473 @@ +/* + * Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.auth; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import androidx.annotation.Nullable; + +import net.micode.notes.api.AliyunConfig; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +/** + * 用户认证管理器(阿里云EMAS Serverless HTTP API版本) + *+ * 使用阿里云EMAS Serverless的HTTP API进行用户认证。 + * 需要先登录阿里云控制台创建EMAS应用并开通Serverless服务。 + *
+ */ +public class UserAuthManager { + + private static final String TAG = "UserAuthManager"; + private static final String PREFS_NAME = "UserAuth"; + private static final String KEY_USER_ID = "user_id"; + private static final String KEY_USERNAME = "username"; + private static final String KEY_AUTH_TOKEN = "auth_token"; + private static final String KEY_REFRESH_TOKEN = "refresh_token"; + private static final String KEY_IS_LOGGED_IN = "is_logged_in"; + private static final String KEY_DEVICE_ID = "device_id"; + private static final String KEY_TOKEN_EXPIRE_TIME = "token_expire_time"; + + // Token过期时间:7天 + private static final long TOKEN_EXPIRE_DURATION = 7 * 24 * 60 * 60 * 1000; + + // EMAS Serverless API地址 + private static final String BASE_URL = AliyunConfig.BASE_URL; + private static final String API_AUTH = BASE_URL + "/auth"; + private static final String API_REFRESH_TOKEN = BASE_URL + "/auth/refresh"; + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + + private static UserAuthManager sInstance; + private final ExecutorService mExecutor; + private final OkHttpClient mHttpClient; + private SharedPreferences mPrefs; + private Context mContext; + + private String mUserId; + private String mUsername; + private String mAuthToken; + private String mRefreshToken; + private String mDeviceId; + private boolean mIsLoggedIn; + private long mTokenExpireTime; + + private UserAuthManager(Context context) { + mContext = context.getApplicationContext(); + mPrefs = mContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + mExecutor = Executors.newSingleThreadExecutor(); + mHttpClient = new OkHttpClient.Builder() + .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .writeTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .addInterceptor(new net.micode.notes.api.RetryInterceptor()) + .build(); + loadUserInfo(); + } + + public static synchronized UserAuthManager getInstance(Context context) { + if (sInstance == null) { + sInstance = new UserAuthManager(context); + } + return sInstance; + } + + /** + * 初始化(配置EMAS Serverless) + */ + public void initialize(Context context) { + Log.d(TAG, "Initializing UserAuthManager"); + Log.d(TAG, "AppKey: " + AliyunConfig.APP_KEY); + // 这里可以添加EMAS SDK初始化(如果需要) + } + + /** + * 认证回调接口 + */ + public interface AuthCallback { + void onSuccess(String userId, String username); + void onError(String error); + } + + /** + * 加载本地存储的用户信息 + */ + private void loadUserInfo() { + mIsLoggedIn = mPrefs.getBoolean(KEY_IS_LOGGED_IN, false); + mUserId = mPrefs.getString(KEY_USER_ID, null); + mUsername = mPrefs.getString(KEY_USERNAME, null); + mAuthToken = mPrefs.getString(KEY_AUTH_TOKEN, null); + mRefreshToken = mPrefs.getString(KEY_REFRESH_TOKEN, null); + mTokenExpireTime = mPrefs.getLong(KEY_TOKEN_EXPIRE_TIME, 0); + mDeviceId = mPrefs.getString(KEY_DEVICE_ID, null); + + if (mDeviceId == null) { + mDeviceId = generateDeviceId(); + mPrefs.edit().putString(KEY_DEVICE_ID, mDeviceId).apply(); + } + + Log.d(TAG, "User info loaded, logged in: " + mIsLoggedIn); + } + + /** + * 用户注册 + */ + public void register(String username, String password, AuthCallback callback) { + if (username == null || username.isEmpty() || password == null || password.isEmpty()) { + callback.onError("用户名和密码不能为空"); + return; + } + + mExecutor.execute(() -> { + try { + String hashedPassword = hashPassword(password); + + // 构建JSON请求体 + JSONObject json = new JSONObject(); + json.put("action", "register"); + json.put("username", username); + json.put("password", hashedPassword); + json.put("deviceId", mDeviceId); + json.put("appKey", AliyunConfig.APP_KEY); + + RequestBody body = RequestBody.create(json.toString(), JSON); + Request request = new Request.Builder() + .url(API_AUTH) + .post(body) + .build(); + + mHttpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + Log.e(TAG, "Register failed", e); + callback.onError("网络错误: " + e.getMessage()); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + handleAuthResponse(response, username, callback); + } + }); + } catch (Exception e) { + Log.e(TAG, "Register error", e); + callback.onError("注册失败: " + e.getMessage()); + } + }); + } + + /** + * 用户登录 + */ + public void login(String username, String password, AuthCallback callback) { + if (username == null || username.isEmpty() || password == null || password.isEmpty()) { + callback.onError("用户名和密码不能为空"); + return; + } + + mExecutor.execute(() -> { + try { + String hashedPassword = hashPassword(password); + + // 构建JSON请求体 + JSONObject json = new JSONObject(); + json.put("action", "login"); + json.put("username", username); + json.put("password", hashedPassword); + json.put("deviceId", mDeviceId); + json.put("appKey", AliyunConfig.APP_KEY); + + RequestBody body = RequestBody.create(json.toString(), JSON); + Request request = new Request.Builder() + .url(API_AUTH) + .post(body) + .build(); + + mHttpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + Log.e(TAG, "Login failed", e); + callback.onError("网络错误: " + e.getMessage()); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + handleAuthResponse(response, username, callback); + } + }); + } catch (Exception e) { + Log.e(TAG, "Login error", e); + callback.onError("登录失败: " + e.getMessage()); + } + }); + } + + /** + * 处理认证响应 + */ + private void handleAuthResponse(Response response, String username, AuthCallback callback) { + String responseBody = null; + try { + if (!response.isSuccessful()) { + String errorBody = response.body() != null ? response.body().string() : "Unknown error"; + callback.onError("服务器错误: " + response.code() + " - " + errorBody); + return; + } + + responseBody = response.body().string(); + JSONObject json = new JSONObject(responseBody); + + if (json.getBoolean("success")) { + mUserId = json.getString("userId"); + // 支持两种字段名:token 或 authToken + if (json.has("token")) { + mAuthToken = json.getString("token"); + } else if (json.has("authToken")) { + mAuthToken = json.getString("authToken"); + } + if (json.has("refreshToken")) { + mRefreshToken = json.getString("refreshToken"); + } + mTokenExpireTime = System.currentTimeMillis() + TOKEN_EXPIRE_DURATION; + mUsername = username; + mIsLoggedIn = true; + + saveUserInfo(); + + callback.onSuccess(mUserId, mUsername); + } else { + callback.onError(json.getString("message")); + } + } catch (JSONException | IOException e) { + Log.e(TAG, "Parse response error", e); + callback.onError("解析响应失败"); + } finally { + response.close(); + } + } + + /** + * 保存用户信息到本地 + */ + private void saveUserInfo() { + mPrefs.edit() + .putBoolean(KEY_IS_LOGGED_IN, mIsLoggedIn) + .putString(KEY_USER_ID, mUserId) + .putString(KEY_USERNAME, mUsername) + .putString(KEY_AUTH_TOKEN, mAuthToken) + .putString(KEY_REFRESH_TOKEN, mRefreshToken) + .putLong(KEY_TOKEN_EXPIRE_TIME, mTokenExpireTime) + .putString(KEY_DEVICE_ID, mDeviceId) + .apply(); + } + + /** + * 登出 + */ + public void logout() { + mIsLoggedIn = false; + mUserId = null; + mUsername = null; + mAuthToken = null; + mRefreshToken = null; + mTokenExpireTime = 0; + + mPrefs.edit() + .putBoolean(KEY_IS_LOGGED_IN, false) + .remove(KEY_USER_ID) + .remove(KEY_USERNAME) + .remove(KEY_AUTH_TOKEN) + .remove(KEY_REFRESH_TOKEN) + .remove(KEY_TOKEN_EXPIRE_TIME) + .apply(); + + Log.i(TAG, "User logged out"); + } + + /** + * 密码哈希(使用SHA-256) + * + * @throws RuntimeException 如果SHA-256算法不可用 + */ + private String hashPassword(String password) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(password.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + StringBuilder hexString = new StringBuilder(); + for (byte b : hash) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) hexString.append('0'); + hexString.append(hex); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + Log.e(TAG, "Hash algorithm not found", e); + throw new RuntimeException("密码哈希失败:系统不支持SHA-256算法", e); + } + } + + /** + * 生成设备ID + */ + private String generateDeviceId() { + return "device_" + java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 16); + } + + // ==================== Getter Methods ==================== + + public boolean isLoggedIn() { + return mIsLoggedIn; + } + + @Nullable + public String getUserId() { + return mUserId; + } + + @Nullable + public String getUsername() { + return mUsername; + } + + @Nullable + public String getAuthToken() { + return mAuthToken; + } + + public String getDeviceId() { + return mDeviceId; + } + + @Nullable + public String getRefreshToken() { + return mRefreshToken; + } + + /** + * 检查Token是否即将过期(24小时内) + */ + public boolean isTokenExpiringSoon() { + if (!mIsLoggedIn || mTokenExpireTime == 0) { + return false; + } + long timeUntilExpire = mTokenExpireTime - System.currentTimeMillis(); + return timeUntilExpire < 24 * 60 * 60 * 1000; // 24小时内过期 + } + + /** + * 检查Token是否已过期 + */ + public boolean isTokenExpired() { + if (!mIsLoggedIn || mTokenExpireTime == 0) { + return false; + } + return System.currentTimeMillis() >= mTokenExpireTime; + } + + /** + * 刷新Token + * + * @param callback 刷新回调 + */ + public void refreshToken(TokenRefreshCallback callback) { + if (mRefreshToken == null) { + callback.onError("没有可用的刷新令牌"); + return; + } + + mExecutor.execute(() -> { + try { + JSONObject json = new JSONObject(); + json.put("action", "refresh"); + json.put("refreshToken", mRefreshToken); + json.put("deviceId", mDeviceId); + + RequestBody body = RequestBody.create(json.toString(), JSON); + Request request = new Request.Builder() + .url(API_REFRESH_TOKEN) + .post(body) + .build(); + + mHttpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + Log.e(TAG, "Token refresh failed", e); + callback.onError("网络错误: " + e.getMessage()); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + String responseBody = null; + try { + if (!response.isSuccessful()) { + callback.onError("服务器错误: " + response.code()); + return; + } + + responseBody = response.body().string(); + JSONObject json = new JSONObject(responseBody); + + if (json.getBoolean("success")) { + mAuthToken = json.getString("token"); + if (json.has("refreshToken")) { + mRefreshToken = json.getString("refreshToken"); + } + mTokenExpireTime = System.currentTimeMillis() + TOKEN_EXPIRE_DURATION; + saveUserInfo(); + callback.onSuccess(mAuthToken); + } else { + callback.onError(json.getString("message")); + } + } catch (JSONException e) { + Log.e(TAG, "Parse refresh response error", e); + callback.onError("解析响应失败"); + } finally { + response.close(); + } + } + }); + } catch (Exception e) { + Log.e(TAG, "Refresh token error", e); + callback.onError("刷新失败: " + e.getMessage()); + } + }); + } + + /** + * Token刷新回调接口 + */ + public interface TokenRefreshCallback { + void onSuccess(String newToken); + void onError(String error); + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/data/Notes.java b/src/Notesmaster/app/src/main/java/net/micode/notes/data/Notes.java index 520d27a..b4c6688 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/data/Notes.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/data/Notes.java @@ -285,6 +285,36 @@ public class Notes { *Type : INTEGER (long)
*/ public static final String GTASK_FINISHED_TIME = "gtask_finished_time"; + + /** + * Cloud User ID for sync + *Type : TEXT
+ */ + public static final String CLOUD_USER_ID = "cloud_user_id"; + + /** + * Cloud Device ID for sync + *Type : TEXT
+ */ + public static final String CLOUD_DEVICE_ID = "cloud_device_id"; + + /** + * Sync Status: 0=Not synced, 1=Syncing, 2=Synced, 3=Conflict + *Type : INTEGER
+ */ + public static final String SYNC_STATUS = "sync_status"; + + /** + * Last Sync Time (Timestamp) + *Type : INTEGER (long)
+ */ + public static final String LAST_SYNC_TIME = "last_sync_time"; + + /** + * Cloud Note ID for sync (UUID) + *Type : TEXT
+ */ + public static final String CLOUD_NOTE_ID = "cloud_note_id"; } public interface DataColumns { diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesDatabaseHelper.java b/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesDatabaseHelper.java index b960d55..92bfc6c 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesDatabaseHelper.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesDatabaseHelper.java @@ -66,11 +66,11 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { /** * 数据库版本号 *- * 当前数据库版本为8,用于跟踪数据库结构变更。 + * 当前数据库版本为12,用于跟踪数据库结构变更。 * 当数据库版本变更时,onUpgrade方法会被调用以执行升级逻辑。 *
*/ - private static final int DB_VERSION = 10; + private static final int DB_VERSION = 13; /** * 数据库表名常量接口 @@ -156,7 +156,11 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { NoteColumns.VERSION + " INTEGER NOT NULL DEFAULT 0," + NoteColumns.TOP + " INTEGER NOT NULL DEFAULT 0," + NoteColumns.LOCKED + " INTEGER NOT NULL DEFAULT 0," + - NoteColumns.TITLE + " TEXT NOT NULL DEFAULT ''" + + NoteColumns.TITLE + " TEXT NOT NULL DEFAULT ''," + + NoteColumns.CLOUD_USER_ID + " TEXT NOT NULL DEFAULT ''," + + NoteColumns.CLOUD_DEVICE_ID + " TEXT NOT NULL DEFAULT ''," + + NoteColumns.SYNC_STATUS + " INTEGER NOT NULL DEFAULT 0," + + NoteColumns.LAST_SYNC_TIME + " INTEGER NOT NULL DEFAULT 0" + ")"; /** @@ -578,6 +582,24 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { oldVersion++; } + // 从V10升级到V11 + if (oldVersion == 10) { + upgradeToV11(db); + oldVersion++; + } + + // 从V11升级到V12 + if (oldVersion == 11) { + upgradeToV12(db); + oldVersion++; + } + + // 从V12升级到V13 + if (oldVersion == 12) { + upgradeToV13(db); + oldVersion++; + } + // 如果需要,重新创建触发器 if (reCreateTriggers) { reCreateNoteTableTriggers(db); @@ -674,13 +696,32 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { } } - if (cursor != null) { - cursor.close(); - } - } catch (Exception e) { - Log.e(TAG, "Failed to fix database in onOpen", e); - } - } + boolean hasCloudNoteIdColumn = false; + if (cursor != null) { + if (cursor.getColumnIndex(NoteColumns.CLOUD_NOTE_ID) != -1) { + hasCloudNoteIdColumn = true; + } + } + + if (!hasCloudNoteIdColumn) { + try { + db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.CLOUD_NOTE_ID + + " TEXT NOT NULL DEFAULT ''"); + db.execSQL("CREATE INDEX IF NOT EXISTS idx_cloud_note_id ON " + TABLE.NOTE + + "(" + NoteColumns.CLOUD_NOTE_ID + ")"); + Log.i(TAG, "Fixed: Added missing CLOUD_NOTE_ID column and index in onOpen"); + } catch (Exception e) { + Log.e(TAG, "Failed to add CLOUD_NOTE_ID column in onOpen", e); + } + } + + if (cursor != null) { + cursor.close(); + } + } catch (Exception e) { + Log.e(TAG, "Failed to fix database in onOpen", e); + } + } /** * 升级数据库到V2版本 @@ -840,6 +881,86 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { createPresetTemplates(db); } + /** + * 升级数据库到V11版本 + *+ * 添加云同步相关列:CLOUD_USER_ID, CLOUD_DEVICE_ID, SYNC_STATUS, LAST_SYNC_TIME + *
+ * + * @param db SQLiteDatabase实例 + */ + private void upgradeToV11(SQLiteDatabase db) { + try { + db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.CLOUD_USER_ID + + " TEXT NOT NULL DEFAULT ''"); + db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.CLOUD_DEVICE_ID + + " TEXT NOT NULL DEFAULT ''"); + db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.SYNC_STATUS + + " INTEGER NOT NULL DEFAULT 0"); + db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.LAST_SYNC_TIME + + " INTEGER NOT NULL DEFAULT 0"); + Log.i(TAG, "Upgraded database to V11: Added cloud sync columns"); + } catch (Exception e) { + Log.e(TAG, "Failed to add cloud sync columns in V11 upgrade", e); + } + } + + /** + * 升级数据库到V12版本 + *+ * 添加cloud_note_id列用于云端笔记唯一标识 + *
+ * + * @param db SQLiteDatabase实例 + */ + private void upgradeToV12(SQLiteDatabase db) { + try { + db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.CLOUD_NOTE_ID + + " TEXT NOT NULL DEFAULT ''"); + db.execSQL("CREATE INDEX IF NOT EXISTS idx_cloud_note_id ON " + TABLE.NOTE + + "(" + NoteColumns.CLOUD_NOTE_ID + ")"); + Log.i(TAG, "Upgraded database to V12: Added cloud_note_id column and index"); + } catch (Exception e) { + Log.e(TAG, "Failed to add cloud_note_id column in V12 upgrade", e); + } + } + + /** + * 升级数据库到V13版本 + *+ * 数据迁移:修复文件夹title为空的问题 + * 将所有title为空的文件夹的title字段设置为snippet的值 + *
+ * + * @param db SQLiteDatabase实例 + */ + private void upgradeToV13(SQLiteDatabase db) { + try { + String sql = "UPDATE " + TABLE.NOTE + + " SET " + NoteColumns.TITLE + " = " + NoteColumns.SNIPPET + + " WHERE " + NoteColumns.TYPE + " = " + Notes.TYPE_FOLDER + + " AND (" + NoteColumns.TITLE + " IS NULL OR " + NoteColumns.TITLE + " = '')"; + db.execSQL(sql); + + android.database.Cursor cursor = db.rawQuery( + "SELECT COUNT(*) FROM " + TABLE.NOTE + + " WHERE " + NoteColumns.TYPE + " = " + Notes.TYPE_FOLDER + + " AND (" + NoteColumns.TITLE + " IS NOT NULL OR " + NoteColumns.TITLE + " != '')", + null); + if (cursor != null) { + if (cursor.moveToFirst()) { + int count = cursor.getInt(0); + Log.i(TAG, "Upgraded database to V13: Migrated " + count + " folders with non-empty title"); + } + cursor.close(); + } + + Log.i(TAG, "Successfully upgraded database to V13: Fixed folder title migration"); + } catch (Exception e) { + Log.e(TAG, "Failed to migrate folder titles in V13 upgrade", e); + } + } + /** * 创建模板系统文件夹 * @@ -884,6 +1005,7 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { values.put(NoteColumns.PARENT_ID, parentId); values.put(NoteColumns.TYPE, Notes.TYPE_FOLDER); values.put(NoteColumns.SNIPPET, name); + values.put(NoteColumns.TITLE, name); values.put(NoteColumns.CREATED_DATE, System.currentTimeMillis()); values.put(NoteColumns.MODIFIED_DATE, System.currentTimeMillis()); values.put(NoteColumns.NOTES_COUNT, 0); diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesRepository.java b/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesRepository.java index 237b3db..9bd4d1a 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesRepository.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesRepository.java @@ -30,6 +30,8 @@ import net.micode.notes.data.Notes.DataColumns; import net.micode.notes.data.Notes.NoteColumns; import net.micode.notes.data.Notes.TextNote; import net.micode.notes.model.Note; +import net.micode.notes.model.WorkingNote; +import net.micode.notes.sync.SyncConstants; import java.util.ArrayList; import java.util.HashMap; @@ -90,6 +92,7 @@ public class NotesRepository { private final ContentResolver contentResolver; private final ExecutorService executor; + private final Context context; // 选择条件常量 private static final String NORMAL_SELECTION = NoteColumns.PARENT_ID + " = ?"; @@ -195,6 +198,15 @@ public class NotesRepository { */ public NotesRepository(ContentResolver contentResolver) { this.contentResolver = contentResolver; + this.context = null; + // 使用单线程Executor确保数据访问的顺序性 + this.executor = java.util.concurrent.Executors.newSingleThreadExecutor(); + Log.d(TAG, "NotesRepository initialized"); + } + + public NotesRepository(Context context) { + this.context = context.getApplicationContext(); + this.contentResolver = context.getContentResolver(); // 使用单线程Executor确保数据访问的顺序性 this.executor = java.util.concurrent.Executors.newSingleThreadExecutor(); Log.d(TAG, "NotesRepository initialized"); @@ -444,6 +456,7 @@ public class NotesRepository { values.put(NoteColumns.PARENT_ID, parentId); values.put(NoteColumns.TYPE, Notes.TYPE_FOLDER); values.put(NoteColumns.SNIPPET, name); + values.put(NoteColumns.TITLE, name); values.put(NoteColumns.CREATED_DATE, currentTime); values.put(NoteColumns.MODIFIED_DATE, currentTime); values.put(NoteColumns.LOCAL_MODIFIED, 1); @@ -1358,4 +1371,499 @@ public class NotesRepository { } return title; } + + // ==================== Cloud Sync Methods ==================== + + /** + * 获取未同步的笔记列表 + *+ * 查询所有 LOCAL_MODIFIED = 1 的笔记 + *
+ * + * @param callback 回调接口,返回未同步笔记列表 + */ + public void getUnsyncedNotes(Callback+ * 更新 LOCAL_MODIFIED = 0 和 SYNC_STATUS = 2 + *
+ * + * @param noteId 笔记ID + */ + public void markAsSynced(long noteId) { + executor.execute(() -> { + try { + ContentValues values = new ContentValues(); + values.put(NoteColumns.LOCAL_MODIFIED, 0); + values.put(NoteColumns.SYNC_STATUS, 2); + values.put(NoteColumns.LAST_SYNC_TIME, System.currentTimeMillis()); + + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId); + int rows = contentResolver.update(uri, values, null, null); + + if (rows > 0) { + Log.d(TAG, "Marked note as synced: " + noteId); + } + } catch (Exception e) { + Log.e(TAG, "Failed to mark note as synced: " + noteId, e); + } + }); + } + + /** + * 更新笔记同步状态 + * + * @param noteId 笔记ID + * @param status 同步状态 (0=未同步, 1=同步中, 2=已同步, 3=冲突) + */ + public void updateSyncStatus(long noteId, int status) { + executor.execute(() -> { + try { + ContentValues values = new ContentValues(); + values.put(NoteColumns.SYNC_STATUS, status); + + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId); + int rows = contentResolver.update(uri, values, null, null); + + if (rows > 0) { + Log.d(TAG, "Updated sync status for note " + noteId + " to " + status); + } + } catch (Exception e) { + Log.e(TAG, "Failed to update sync status: " + noteId, e); + } + }); + } + + /** + * 获取最后同步时间 + * + * @param callback 回调接口,返回最后同步时间(毫秒) + */ + public void getLastSyncTime(Callback+ * 根据当前登录用户过滤,只返回该用户的笔记 + *
+ * + * @param cloudUserId 当前用户的云端ID + * @param callback 回调接口,返回本地修改过的笔记列表 + */ + public void getLocalModifiedNotes(String cloudUserId, Callback+ * 将设备上所有笔记(无论之前的cloud_user_id是谁)的cloud_user_id更新为新用户, + * 并标记为需要同步。这样新用户登录后可以把设备上的所有笔记上传到云端。 + *
+ * + * @param newUserId 新用户的云端ID + * @param callback 回调接口,返回接管的笔记数量 + */ + public void takeoverAllNotes(String newUserId, Callback+ * 用于表示从云数据库下载的笔记数据 + *
+ */ +public class CloudNote { + + private static final String TAG = "CloudNote"; + + private String mNoteId; + private String mCloudNoteId; + private String mTitle; + private String mContent; + private String mParentId; + private int mType; + private long mCreatedTime; + private long mModifiedTime; + private int mVersion; + private String mDeviceId; + + /** + * 从JSON构造CloudNote + */ + public CloudNote(JSONObject json) throws JSONException { + mCloudNoteId = json.optString("cloudNoteId", ""); + mNoteId = json.optString("noteId", ""); + mTitle = json.optString("title", ""); + mContent = json.optString("content", ""); + mParentId = json.optString("parentId", "0"); + mType = json.optInt("type", 0); + mCreatedTime = json.optLong("createdTime", System.currentTimeMillis()); + mModifiedTime = json.optLong("modifiedTime", System.currentTimeMillis()); + mVersion = json.optInt("version", 1); + mDeviceId = json.optString("deviceId", ""); + } + + /** + * 从WorkingNote构造CloudNote(用于上传) + */ + public CloudNote(WorkingNote note, String deviceId) { + mCloudNoteId = note.getCloudNoteId() != null ? note.getCloudNoteId() : ""; + mNoteId = String.valueOf(note.getNoteId()); + mTitle = note.getTitle(); + mContent = note.getContent(); + mParentId = String.valueOf(note.getFolderId()); + mType = note.getType(); + mCreatedTime = System.currentTimeMillis(); + mModifiedTime = note.getModifiedDate(); + mVersion = 1; + mDeviceId = deviceId; + } + + /** + * 转换为WorkingNote + * 注意:这会创建一个新的本地笔记或更新现有笔记 + * 修复:先检查本地是否存在该云端ID的笔记,决定是创建还是更新 + */ + public WorkingNote toWorkingNote(Context context, String userId) { + try { + long noteId = Long.parseLong(mNoteId); + + // 先检查本地是否存在该云端ID的笔记 + boolean existsInLocal = noteExistsInDatabase(context, noteId); + + WorkingNote note; + if (existsInLocal) { + // 本地存在,加载并更新 + note = WorkingNote.load(context, noteId); + Log.d(TAG, "Updating existing note from cloud: " + noteId); + } else { + // 本地不存在,创建新笔记(不指定ID,让数据库生成) + note = WorkingNote.createEmptyNote(context, 0); + Log.d(TAG, "Creating new note from cloud, cloud ID: " + noteId); + } + + note.setType(mType); + note.setTitle(mTitle); + note.setContent(mContent); + note.setFolderId(Long.parseLong(mParentId)); + note.setModifiedDate(mModifiedTime); + note.setCloudUserId(userId); + + note.setCloudNoteId(mCloudNoteId); + note.setSyncStatus(net.micode.notes.sync.SyncConstants.SYNC_STATUS_SYNCED); + note.setLocalModified(0); + note.setLastSyncTime(System.currentTimeMillis()); + + return note; + } catch (Exception e) { + Log.e(TAG, "转换WorkingNote失败", e); + return null; + } + } + + /** + * 检查指定ID的笔记是否在本地数据库存在 + * @param context 应用上下文 + * @param noteId 笔记ID(云端ID) + * @return 如果存在返回 true,否则返回 false + */ + private boolean noteExistsInDatabase(Context context, long noteId) { + Cursor cursor = null; + try { + cursor = context.getContentResolver().query( + ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId), + new String[]{Notes.NoteColumns.ID}, + null, + null, + null + ); + boolean exists = cursor != null && cursor.getCount() > 0; + Log.d(TAG, "Note exists in local DB: " + noteId + " - " + exists); + return exists; + } catch (Exception e) { + Log.e(TAG, "Failed to check note existence: " + noteId, e); + return false; + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + /** + * 转换为WorkingNote(向后兼容,使用默认userId) + * @deprecated 请使用 {@link #toWorkingNote(Context, String)} + */ + @Deprecated + public WorkingNote toWorkingNote(Context context) { + return toWorkingNote(context, ""); + } + + /** + * 转换为JSON(用于上传) + */ + public JSONObject toJson() throws JSONException { + JSONObject json = new JSONObject(); + if (mCloudNoteId != null && !mCloudNoteId.isEmpty()) { + json.put("cloudNoteId", mCloudNoteId); + } + json.put("noteId", mNoteId); + json.put("title", mTitle); + json.put("content", mContent); + json.put("parentId", mParentId); + json.put("type", mType); + json.put("createdTime", mCreatedTime); + json.put("modifiedTime", mModifiedTime); + json.put("version", mVersion); + json.put("deviceId", mDeviceId); + return json; + } + + // Getters + public String getCloudNoteId() { return mCloudNoteId; } + public String getNoteId() { return mNoteId; } + public String getTitle() { return mTitle; } + public String getContent() { return mContent; } + public String getParentId() { return mParentId; } + public int getType() { return mType; } + public long getCreatedTime() { return mCreatedTime; } + public long getModifiedTime() { return mModifiedTime; } + public int getVersion() { return mVersion; } + public String getDeviceId() { return mDeviceId; } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/model/Note.java b/src/Notesmaster/app/src/main/java/net/micode/notes/model/Note.java index 4cbd456..2daf8b7 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/model/Note.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/model/Note.java @@ -57,18 +57,34 @@ public class Note { * 在数据库中创建一条新笔记记录,并返回其 ID。 * 初始化笔记的创建时间、修改时间、类型和父文件夹 ID。 * - * + * * @param context 应用上下文 * @param folderId 父文件夹 ID * @return 新创建的笔记 ID,失败时返回 0 */ public static synchronized long getNewNoteId(Context context, long folderId) { + return getNewNoteId(context, folderId, Notes.TYPE_NOTE); + } + + /** + * 创建新笔记 ID(支持指定类型) + *+ * 在数据库中创建一条新笔记记录,并返回其 ID。 + * 初始化笔记的创建时间、修改时间、类型和父文件夹 ID。 + *
+ * + * @param context 应用上下文 + * @param folderId 父文件夹 ID + * @param type 笔记类型:0=普通笔记, 1=文件夹, 2=系统, 3=待办 + * @return 新创建的笔记 ID,失败时返回 0 + */ + public static synchronized long getNewNoteId(Context context, long folderId, int type) { // 在数据库中创建新笔记 ContentValues values = new ContentValues(); long createdTime = System.currentTimeMillis(); values.put(NoteColumns.CREATED_DATE, createdTime); values.put(NoteColumns.MODIFIED_DATE, createdTime); - values.put(NoteColumns.TYPE, Notes.TYPE_NOTE); + values.put(NoteColumns.TYPE, type); values.put(NoteColumns.LOCAL_MODIFIED, 1); values.put(NoteColumns.PARENT_ID, folderId); Uri uri = context.getContentResolver().insert(Notes.CONTENT_NOTE_URI, values); @@ -421,5 +437,50 @@ public class Note { } return null; } + + // ==================== 云同步相关方法 ==================== + + void setCloudUserId(String userId) { + mNoteDiffValues.put(NoteColumns.CLOUD_USER_ID, userId); + } + + String getCloudUserId() { + return mNoteDiffValues.getAsString(NoteColumns.CLOUD_USER_ID); + } + + void setCloudDeviceId(String deviceId) { + mNoteDiffValues.put(NoteColumns.CLOUD_DEVICE_ID, deviceId); + } + + String getCloudDeviceId() { + return mNoteDiffValues.getAsString(NoteColumns.CLOUD_DEVICE_ID); + } + + void setSyncStatus(int status) { + mNoteDiffValues.put(NoteColumns.SYNC_STATUS, status); + } + + int getSyncStatus() { + Integer status = mNoteDiffValues.getAsInteger(NoteColumns.SYNC_STATUS); + return status != null ? status : 0; + } + + void setLastSyncTime(long time) { + mNoteDiffValues.put(NoteColumns.LAST_SYNC_TIME, time); + } + + long getLastSyncTime() { + Long time = mNoteDiffValues.getAsLong(NoteColumns.LAST_SYNC_TIME); + return time != null ? time : 0; + } + + void setLocalModified(int modified) { + mNoteDiffValues.put(NoteColumns.LOCAL_MODIFIED, modified); + } + + int getLocalModified() { + Integer modified = mNoteDiffValues.getAsInteger(NoteColumns.LOCAL_MODIFIED); + return modified != null ? modified : 0; + } } } diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/model/WorkingNote.java b/src/Notesmaster/app/src/main/java/net/micode/notes/model/WorkingNote.java index deb1d2c..5ec9caa 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/model/WorkingNote.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/model/WorkingNote.java @@ -73,9 +73,30 @@ public class WorkingNote { /** 父文件夹 ID */ private long mFolderId; + /** 笔记类型: 0=普通笔记, 1=文件夹, 2=系统, 3=待办 */ + private int mType; + /** 应用上下文 */ private Context mContext; + /** 同步状态 */ + private int mSyncStatus; + + /** 最后同步时间 */ + private long mLastSyncTime; + + /** 本地修改标记 */ + private int mLocalModified; + + /** 云端用户ID */ + private String mCloudUserId; + + /** 云端设备ID */ + private String mCloudDeviceId; + + /** 云端笔记ID */ + private String mCloudNoteId; + /** 日志标签 */ private static final String TAG = "WorkingNote"; @@ -103,8 +124,10 @@ public class WorkingNote { NoteColumns.BG_COLOR_ID, NoteColumns.WIDGET_ID, NoteColumns.WIDGET_TYPE, + NoteColumns.LOCAL_MODIFIED, NoteColumns.MODIFIED_DATE, - NoteColumns.TITLE + NoteColumns.TITLE, + NoteColumns.TYPE }; /** 数据 ID 列索引 */ @@ -135,7 +158,13 @@ public class WorkingNote { private static final int NOTE_WIDGET_TYPE_COLUMN = 4; /** 笔记修改日期列索引 */ - private static final int NOTE_MODIFIED_DATE_COLUMN = 5; + private static final int NOTE_MODIFIED_DATE_COLUMN = 6; + + /** 笔记类型列索引 */ + private static final int NOTE_TYPE_COLUMN = 8; + + /** 云端笔记ID列索引 */ + private static final int NOTE_CLOUD_NOTE_ID_COLUMN = 9; /** * 新建笔记构造函数 @@ -159,6 +188,8 @@ public class WorkingNote { mIsDeleted = false; mMode = 0; mWidgetType = Notes.TYPE_WIDGET_INVALIDE; + mLocalModified = 1; // 新建笔记需要同步 + mType = Notes.TYPE_NOTE; // 默认为普通笔记类型 } /** @@ -200,6 +231,7 @@ public class WorkingNote { mWidgetType = cursor.getInt(NOTE_WIDGET_TYPE_COLUMN); mAlertDate = cursor.getLong(NOTE_ALERTED_DATE_COLUMN); mModifiedDate = cursor.getLong(NOTE_MODIFIED_DATE_COLUMN); + mType = cursor.getInt(NOTE_TYPE_COLUMN); // Load title int titleIndex = cursor.getColumnIndex(NoteColumns.TITLE); @@ -208,6 +240,14 @@ public class WorkingNote { } else { mTitle = ""; } + + // Load cloud note id + int cloudNoteIdIndex = cursor.getColumnIndex(NoteColumns.CLOUD_NOTE_ID); + if (cloudNoteIdIndex != -1) { + mCloudNoteId = cursor.getString(cloudNoteIdIndex); + } else { + mCloudNoteId = ""; + } } cursor.close(); } else { @@ -258,7 +298,7 @@ public class WorkingNote { ** 创建一个新的空笔记对象,并设置默认属性。 *
- * + * * @param context 应用上下文 * @param folderId 父文件夹 ID * @param widgetId Widget ID @@ -275,6 +315,21 @@ public class WorkingNote { return note; } + /** + * 创建空笔记(简化版) + *+ * 创建一个新的空笔记对象,用于云同步。 + *
+ * + * @param context 应用上下文 + * @param noteId 笔记 ID + * @return 新创建的 WorkingNote 对象 + */ + public static WorkingNote createEmptyNote(Context context, long noteId) { + WorkingNote note = new WorkingNote(context, noteId, 0); + return note; + } + /** * 加载已有笔记 *@@ -378,6 +433,10 @@ public class WorkingNote { public void setTitle(String title) { mTitle = title; mNote.setNoteValue(NoteColumns.TITLE, mTitle); + // 对于文件夹类型,同时设置 snippet 字段以保持兼容性 + if (mType == Notes.TYPE_FOLDER) { + mNote.setNoteValue(NoteColumns.SNIPPET, mTitle); + } } public String getTitle() { @@ -622,13 +681,162 @@ public class WorkingNote { /** * 获取 Widget 类型 - * + * * @return Widget 类型 */ public int getWidgetType() { return mWidgetType; } + // ==================== 云同步相关方法 ==================== + + /** + * 设置云端用户ID + */ + public void setCloudUserId(String userId) { + mCloudUserId = userId; + } + + /** + * 获取云端用户ID + */ + public String getCloudUserId() { + return mCloudUserId; + } + + /** + * 设置云端设备ID + */ + public void setCloudDeviceId(String deviceId) { + mCloudDeviceId = deviceId; + } + + /** + * 获取云端设备ID + */ + public String getCloudDeviceId() { + return mCloudDeviceId; + } + + /** + * 设置同步状态 + * @param status 0=未同步, 1=同步中, 2=已同步, 3=冲突 + */ + public void setSyncStatus(int status) { + mSyncStatus = status; + } + + /** + * 获取同步状态 + */ + public int getSyncStatus() { + return mSyncStatus; + } + + /** + * 设置最后同步时间 + */ + public void setLastSyncTime(long time) { + mLastSyncTime = time; + } + + /** + * 获取最后同步时间 + */ + public long getLastSyncTime() { + return mLastSyncTime; + } + + /** + * 设置本地修改标记 + * @param modified 0=未修改, 1=已修改 + */ + public void setLocalModified(int modified) { + mLocalModified = modified; + } + + /** + * 获取本地修改标记 + */ + public int getLocalModified() { + return mLocalModified; + } + + /** + * 设置笔记内容 + */ + public void setContent(String content) { + mContent = content; + mNote.setTextData(DataColumns.CONTENT, mContent); + } + + /** + * 设置文件夹ID + */ + public void setFolderId(long folderId) { + mFolderId = folderId; + mNote.setNoteValue(NoteColumns.PARENT_ID, String.valueOf(mFolderId)); + } + + /** + * 设置修改日期 + */ + public void setModifiedDate(long date) { + mModifiedDate = date; + mNote.setNoteValue(NoteColumns.MODIFIED_DATE, String.valueOf(mModifiedDate)); + } + + /** + * 设置笔记类型 + * @param type 0=普通笔记, 1=文件夹, 2=系统, 3=待办 + */ + public void setType(int type) { + mType = type; + mNote.setNoteValue(NoteColumns.TYPE, String.valueOf(mType)); + } + + /** + * 获取笔记类型 + * @return 0=普通笔记, 1=文件夹, 2=系统, 3=待办 + */ + public int getType() { + return mType; + } + + /** + * 获取云端笔记ID + * @return 云端笔记ID(UUID),未上传时为空字符串 + */ + public String getCloudNoteId() { + return mCloudNoteId; + } + + /** + * 设置云端笔记ID + * @param cloudNoteId 云端笔记ID(UUID) + */ + public void setCloudNoteId(String cloudNoteId) { + mCloudNoteId = cloudNoteId; + mNote.setNoteValue(NoteColumns.CLOUD_NOTE_ID, mCloudNoteId != null ? mCloudNoteId : ""); + } + + /** + * 从CloudNote更新当前笔记 + * 用于云端下载后更新本地笔记 + */ + public void updateFrom(CloudNote cloudNote) { + setTitle(cloudNote.getTitle()); + setContent(cloudNote.getContent()); + setFolderId(Long.parseLong(cloudNote.getParentId())); + setModifiedDate(cloudNote.getModifiedTime()); + setType(cloudNote.getType()); + setCloudNoteId(cloudNote.getCloudNoteId()); + setSyncStatus(net.micode.notes.sync.SyncConstants.SYNC_STATUS_SYNCED); + setLastSyncTime(System.currentTimeMillis()); + setLocalModified(0); + saveNote(); + } + /** * 笔记设置变更监听器接口 *
diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/sync/Conflict.java b/src/Notesmaster/app/src/main/java/net/micode/notes/sync/Conflict.java new file mode 100644 index 0000000..43d05df --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/sync/Conflict.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.sync; + +import net.micode.notes.model.CloudNote; +import net.micode.notes.model.WorkingNote; + +/** + * 冲突数据模型 + *
+ * 表示本地笔记和云端笔记的冲突 + *
+ */ +public class Conflict { + + private WorkingNote mLocalNote; + private CloudNote mCloudNote; + private String mNoteId; + private long mConflictTime; + private ConflictType mType; + + public enum ConflictType { + BOTH_MODIFIED, // 双方都修改过 + VERSION_MISMATCH // 版本号不匹配 + } + + public Conflict(WorkingNote localNote, CloudNote cloudNote) { + this(localNote, cloudNote, ConflictType.BOTH_MODIFIED); + } + + public Conflict(WorkingNote localNote, CloudNote cloudNote, ConflictType type) { + mLocalNote = localNote; + mCloudNote = cloudNote; + mNoteId = String.valueOf(localNote.getNoteId()); + mConflictTime = System.currentTimeMillis(); + mType = type; + } + + public WorkingNote getLocalNote() { + return mLocalNote; + } + + public CloudNote getCloudNote() { + return mCloudNote; + } + + public String getNoteId() { + return mNoteId; + } + + public long getConflictTime() { + return mConflictTime; + } + + public ConflictType getType() { + return mType; + } + + /** + * 获取冲突描述 + */ + public String getConflictDescription() { + return "本地修改时间: " + mLocalNote.getModifiedDate() + + "\n云端修改时间: " + mCloudNote.getModifiedTime(); + } + + /** + * 获取本地标题预览 + */ + public String getLocalTitle() { + return mLocalNote.getTitle(); + } + + /** + * 获取云端标题预览 + */ + public String getCloudTitle() { + return mCloudNote.getTitle(); + } + + /** + * 获取本地内容预览(前100字符) + */ + public String getLocalContentPreview() { + String content = mLocalNote.getContent(); + if (content == null) return ""; + return content.length() > 100 ? content.substring(0, 100) + "..." : content; + } + + /** + * 获取云端内容预览(前100字符) + */ + public String getCloudContentPreview() { + String content = mCloudNote.getContent(); + if (content == null) return ""; + return content.length() > 100 ? content.substring(0, 100) + "..." : content; + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/sync/NotesPushMessageReceiver.java b/src/Notesmaster/app/src/main/java/net/micode/notes/sync/NotesPushMessageReceiver.java new file mode 100644 index 0000000..98eb284 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/sync/NotesPushMessageReceiver.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.sync; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +/** + * 推送消息接收器 + *+ * 接收云端推送的同步通知消息,触发本地同步操作。 + *
+ */ +public class NotesPushMessageReceiver extends BroadcastReceiver { + + private static final String TAG = "NotesPushMessageReceiver"; + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null || intent.getAction() == null) { + return; + } + + String action = intent.getAction(); + Log.d(TAG, "Received push message: " + action); + + if ("com.alibaba.push2.action.NOTIFICATION_OPENED".equals(action)) { + handleNotificationOpened(context, intent); + } else if ("com.alibaba.push2.action.MESSAGE_RECEIVED".equals(action)) { + handleMessageReceived(context, intent); + } + } + + private void handleNotificationOpened(Context context, Intent intent) { + Log.d(TAG, "Notification opened"); + // TODO: Handle notification open action + } + + private void handleMessageReceived(Context context, Intent intent) { + Log.d(TAG, "Message received"); + // Check if this is a sync message + String messageAction = intent.getStringExtra("action"); + if ("sync".equals(messageAction)) { + Log.d(TAG, "Sync action received, broadcasting sync intent"); + Intent syncIntent = new Intent(SyncConstants.ACTION_SYNC); + context.sendBroadcast(syncIntent); + } + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/sync/SyncConstants.java b/src/Notesmaster/app/src/main/java/net/micode/notes/sync/SyncConstants.java new file mode 100644 index 0000000..1c8a3a1 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/sync/SyncConstants.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.sync; + +/** + * 同步常量定义 + *+ * 定义云同步功能中使用的所有常量,包括同步状态、错误码等。 + *
+ */ +public class SyncConstants { + + private SyncConstants() { + // Utility class, prevent instantiation + } + + /** + * 同步状态:未同步 + */ + public static final int SYNC_STATUS_NOT_SYNCED = 0; + + /** + * 同步状态:同步中 + */ + public static final int SYNC_STATUS_SYNCING = 1; + + /** + * 同步状态:已同步 + */ + public static final int SYNC_STATUS_SYNCED = 2; + + /** + * 同步状态:冲突 + */ + public static final int SYNC_STATUS_CONFLICT = 3; + + /** + * 同步广播Action + */ + public static final String ACTION_SYNC = "com.micode.notes.ACTION_SYNC"; +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/sync/SyncManager.java b/src/Notesmaster/app/src/main/java/net/micode/notes/sync/SyncManager.java new file mode 100644 index 0000000..e8b3898 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/sync/SyncManager.java @@ -0,0 +1,746 @@ +/* + * Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.sync; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import androidx.annotation.Nullable; + +import net.micode.notes.api.CloudCallback; +import net.micode.notes.api.CloudDatabaseHelper; +import net.micode.notes.auth.UserAuthManager; +import net.micode.notes.data.NotesRepository; +import net.micode.notes.model.CloudNote; +import net.micode.notes.model.WorkingNote; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +/** + * 同步管理器 + *+ * 负责管理笔记的云同步操作,包括上传本地修改、下载云端更新、处理冲突等。 + * 使用单例模式确保全局只有一个同步管理器实例。 + *
+ *+ * 修复记录: + * 1. 修复同步时间更新逻辑 - 确保所有笔记处理完成后再更新时间戳 + * 2. 优化线程同步机制 - 使用 CountDownLatch 替代 synchronized/wait/notify + * 3. 添加全量同步支持 - 支持强制下载所有云端笔记 + * 4. 添加同步进度回调 - 支持实时显示同步进度 + *
+ */ +public class SyncManager { + + private static final String TAG = "SyncManager"; + private static final String PREFS_SYNC = "sync_settings"; + private static final String KEY_LAST_SYNC = "last_sync_time"; + private static final String KEY_IS_FIRST_SYNC = "is_first_sync"; + private static final long SYNC_TIMEOUT_SECONDS = 60; + + private final ExecutorService mExecutor; + private Context mContext; + private SharedPreferences mPrefs; + private List+ * 用于新用户登录后,将设备上所有笔记上传到云端。 + * 不管笔记的 LOCAL_MODIFIED 状态如何,都会上传。 + *
+ * + * @param callback 同步回调 + */ + public void uploadAllNotes(SyncCallback callback) { + Log.d(TAG, "========== Starting upload all notes =========="); + + mExecutor.execute(() -> { + try { + performUploadAll(); + Log.d(TAG, "Upload all notes completed successfully"); + if (callback != null) { + callback.onSuccess(); + } + } catch (Exception e) { + Log.e(TAG, "Upload all notes failed", e); + if (callback != null) { + callback.onError(e.getMessage()); + } + } + }); + } + + /** + * 执行笔记同步(带进度回调) + * + * @param forceFullSync 是否强制全量同步 + * @param callback 同步回调 + * @param progressCallback 进度回调 + */ + public void syncNotesWithProgress(boolean forceFullSync, SyncCallback callback, + SyncProgressCallback progressCallback) { + syncNotesInternal(forceFullSync, callback, progressCallback); + } + + /** + * 内部同步方法 + */ + private void syncNotesInternal(boolean forceFullSync, SyncCallback callback, + SyncProgressCallback progressCallback) { + Log.d(TAG, "========== Starting sync operation =========="); + Log.d(TAG, "Force full sync: " + forceFullSync); + + mExecutor.execute(() -> { + try { + performSync(forceFullSync, progressCallback); + Log.d(TAG, "Sync completed successfully"); + if (callback != null) { + callback.onSuccess(); + } + } catch (Exception e) { + Log.e(TAG, "Sync failed", e); + if (callback != null) { + callback.onError(e.getMessage()); + } + } + }); + } + + /** + * 执行实际的同步操作 + */ + private void performSync(boolean forceFullSync, SyncProgressCallback progressCallback) throws Exception { + UserAuthManager authManager = UserAuthManager.getInstance(mContext); + if (!authManager.isLoggedIn()) { + throw new RuntimeException("用户未登录"); + } + + // 检查并刷新Token + ensureValidToken(authManager); + + NotesRepository repo = new NotesRepository(mContext); + String authToken = authManager.getAuthToken(); + Log.d(TAG, "Auth token: " + (authToken != null ? authToken.substring(0, Math.min(20, authToken.length())) + "..." : "null")); + + CloudDatabaseHelper cloudHelper = new CloudDatabaseHelper( + authManager.getUserId(), + authManager.getDeviceId(), + authToken + ); + + // 1. 上传本地修改的笔记(只上传当前用户的笔记) + if (progressCallback != null) { + progressCallback.onProgress(0, 100, "正在上传本地修改..."); + } + uploadNotesSync(repo, cloudHelper, progressCallback, authManager.getUserId()); + + // 2. 下载云端更新的笔记 + if (progressCallback != null) { + progressCallback.onProgress(50, 100, "正在下载云端更新..."); + } + boolean downloadSuccess = downloadNotesSync(repo, cloudHelper, forceFullSync, progressCallback, authManager.getUserId()); + + // 3. 只有在下载成功后才更新同步时间 + if (downloadSuccess) { + updateSyncFlags(); + // 标记已完成首次同步 + markFirstSyncCompleted(); + } else { + throw new RuntimeException("下载云端笔记失败,同步时间未更新"); + } + } + + /** + * 确保Token有效 + */ + private void ensureValidToken(UserAuthManager authManager) throws Exception { + if (authManager.isTokenExpired()) { + Log.w(TAG, "Token已过期,尝试刷新..."); + final CountDownLatch latch = new CountDownLatch(1); + final AtomicBoolean refreshSuccess = new AtomicBoolean(false); + final AtomicReference+ * 使用WorkManager执行后台同步任务,定期同步笔记到云端。 + *
+ */ +public class SyncWorker extends Worker { + + private static final String TAG = "SyncWorker"; + private static final String WORK_NAME = "cloudSync"; + + public SyncWorker(@NonNull Context context, @NonNull WorkerParameters params) { + super(context, params); + } + + @NonNull + @Override + public Result doWork() { + Log.d(TAG, "Starting background sync work"); + + final CountDownLatch latch = new CountDownLatch(1); + final boolean[] success = {false}; + + SyncManager.getInstance().syncNotes(new SyncManager.SyncCallback() { + @Override + public void onSuccess() { + Log.d(TAG, "Background sync completed successfully"); + success[0] = true; + latch.countDown(); + } + + @Override + public void onError(String error) { + Log.e(TAG, "Background sync failed: " + error); + success[0] = false; + latch.countDown(); + } + }); + + try { + latch.await(60, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Log.e(TAG, "Sync work interrupted", e); + return Result.retry(); + } + + return success[0] ? Result.success() : Result.retry(); + } + + /** + * 初始化定期同步任务 + * + * @param context 应用上下文 + */ + public static void initialize(Context context) { + Log.d(TAG, "Initializing periodic sync work"); + + Constraints constraints = new Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiresBatteryNotLow(true) + .build(); + + PeriodicWorkRequest syncWork = new PeriodicWorkRequest.Builder( + SyncWorker.class, 30, TimeUnit.MINUTES) + .setConstraints(constraints) + .build(); + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.REPLACE, + syncWork); + + Log.d(TAG, "Periodic sync work scheduled (30 minutes interval)"); + } + + /** + * 取消定期同步任务 + * + * @param context 应用上下文 + */ + public static void cancel(Context context) { + Log.d(TAG, "Canceling periodic sync work"); + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME); + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/ConflictResolutionDialog.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/ConflictResolutionDialog.java new file mode 100644 index 0000000..ab4abbb --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/ConflictResolutionDialog.java @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.ui; + +import android.app.Dialog; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; + +import com.google.android.material.button.MaterialButton; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import net.micode.notes.R; +import net.micode.notes.model.WorkingNote; + +/** + * 冲突解决对话框 + *+ * 当本地笔记和云端笔记发生冲突时显示,让用户选择保留哪个版本。 + *
+ */ +public class ConflictResolutionDialog extends DialogFragment { + + private static final String TAG = "ConflictResolutionDialog"; + private static final String ARG_LOCAL_TITLE = "local_title"; + private static final String ARG_LOCAL_CONTENT = "local_content"; + private static final String ARG_CLOUD_TITLE = "cloud_title"; + private static final String ARG_CLOUD_CONTENT = "cloud_content"; + + private ConflictResolutionListener mListener; + + /** + * 冲突解决监听器接口 + */ + public interface ConflictResolutionListener { + void onChooseLocal(); + void onChooseCloud(); + void onMerge(String mergedTitle, String mergedContent); + } + + /** + * 创建冲突解决对话框实例 + * + * @param localNote 本地笔记 + * @param cloudNote 云端笔记 + * @return ConflictResolutionDialog实例 + */ + public static ConflictResolutionDialog newInstance(WorkingNote localNote, WorkingNote cloudNote) { + ConflictResolutionDialog dialog = new ConflictResolutionDialog(); + Bundle args = new Bundle(); + args.putString(ARG_LOCAL_TITLE, localNote.getTitle()); + args.putString(ARG_LOCAL_CONTENT, localNote.getContent()); + args.putString(ARG_CLOUD_TITLE, cloudNote.getTitle()); + args.putString(ARG_CLOUD_CONTENT, cloudNote.getContent()); + dialog.setArguments(args); + return dialog; + } + + /** + * 设置冲突解决监听器 + * + * @param listener 监听器 + */ + public void setConflictResolutionListener(ConflictResolutionListener listener) { + mListener = listener; + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext()); + + LayoutInflater inflater = requireActivity().getLayoutInflater(); + View view = inflater.inflate(R.layout.dialog_conflict_resolution, null); + + // Get arguments + Bundle args = getArguments(); + if (args != null) { + String localTitle = args.getString(ARG_LOCAL_TITLE, ""); + String localContent = args.getString(ARG_LOCAL_CONTENT, ""); + String cloudTitle = args.getString(ARG_CLOUD_TITLE, ""); + String cloudContent = args.getString(ARG_CLOUD_CONTENT, ""); + + // Set content previews (first 100 chars) + TextView tvLocalContent = view.findViewById(R.id.tv_local_content); + tvLocalContent.setText(truncateContent(localTitle, localContent)); + + TextView tvCloudContent = view.findViewById(R.id.tv_cloud_content); + tvCloudContent.setText(truncateContent(cloudTitle, cloudContent)); + } + + // Setup buttons + MaterialButton btnUseLocal = view.findViewById(R.id.btn_use_local); + MaterialButton btnUseCloud = view.findViewById(R.id.btn_use_cloud); + MaterialButton btnMerge = view.findViewById(R.id.btn_merge); + + btnUseLocal.setOnClickListener(v -> { + Log.d(TAG, "User chose local version"); + if (mListener != null) { + mListener.onChooseLocal(); + } + dismiss(); + }); + + btnUseCloud.setOnClickListener(v -> { + Log.d(TAG, "User chose cloud version"); + if (mListener != null) { + mListener.onChooseCloud(); + } + dismiss(); + }); + + btnMerge.setOnClickListener(v -> { + Log.d(TAG, "User chose merge"); + showMergeDialog(args); + }); + + builder.setView(view); + return builder.create(); + } + + private String truncateContent(String title, String content) { + String fullText = title + "\n" + content; + if (fullText.length() > 100) { + return fullText.substring(0, 100) + "..."; + } + return fullText; + } + + /** + * 显示合并编辑对话框 + */ + private void showMergeDialog(Bundle args) { + if (args == null) return; + + String localTitle = args.getString(ARG_LOCAL_TITLE, ""); + String localContent = args.getString(ARG_LOCAL_CONTENT, ""); + String cloudTitle = args.getString(ARG_CLOUD_TITLE, ""); + String cloudContent = args.getString(ARG_CLOUD_CONTENT, ""); + + // 智能合并:合并标题和内容 + String mergedTitle = mergeText(localTitle, cloudTitle); + String mergedContent = mergeText(localContent, cloudContent); + + // 创建编辑对话框 + androidx.appcompat.app.AlertDialog.Builder builder = new androidx.appcompat.app.AlertDialog.Builder(requireContext()); + builder.setTitle("合并笔记"); + + // 创建输入布局 + android.widget.LinearLayout layout = new android.widget.LinearLayout(requireContext()); + layout.setOrientation(android.widget.LinearLayout.VERTICAL); + layout.setPadding(50, 30, 50, 30); + + // 标题输入 + final android.widget.EditText etTitle = new android.widget.EditText(requireContext()); + etTitle.setHint("标题"); + etTitle.setText(mergedTitle); + layout.addView(etTitle); + + // 内容输入 + final android.widget.EditText etContent = new android.widget.EditText(requireContext()); + etContent.setHint("内容"); + etContent.setText(mergedContent); + etContent.setMinLines(5); + layout.addView(etContent); + + builder.setView(layout); + + builder.setPositiveButton("保存", (dialog, which) -> { + String finalTitle = etTitle.getText().toString().trim(); + String finalContent = etContent.getText().toString().trim(); + + if (mListener != null) { + mListener.onMerge(finalTitle, finalContent); + } + dismiss(); + }); + + builder.setNegativeButton("取消", (dialog, which) -> dialog.dismiss()); + + builder.show(); + } + + /** + * 合并两段文本 + * 如果内容相同返回其中一个,不同则合并 + */ + private String mergeText(String local, String cloud) { + if (local == null || local.isEmpty()) return cloud; + if (cloud == null || cloud.isEmpty()) return local; + if (local.equals(cloud)) return local; + + // 简单的合并策略:用分隔符连接 + return local + "\n\n--- 云端版本 ---\n\n" + cloud; + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/LoginActivity.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/LoginActivity.java new file mode 100644 index 0000000..b602a4e --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/LoginActivity.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.ui; + +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.ProgressBar; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.lifecycle.ViewModelProvider; + +import com.google.android.material.button.MaterialButton; +import com.google.android.material.textfield.TextInputEditText; + +import net.micode.notes.R; +import net.micode.notes.auth.UserAuthManager; +import net.micode.notes.viewmodel.LoginViewModel; + +/** + * 登录界面 + * + *+ * 提供用户登录和注册功能。 + * 登录成功后,用户的笔记会自动同步到云端。 + *
+ *+ * 遵循 MVVM 架构,所有业务逻辑委托给 {@link LoginViewModel}。 + *
+ */ +public class LoginActivity extends AppCompatActivity { + + private static final String TAG = "LoginActivity"; + + private TextInputEditText mEtUsername; + private TextInputEditText mEtPassword; + private MaterialButton mBtnLogin; + private MaterialButton mBtnRegister; + private View mTvSkip; + private ProgressBar mProgressBar; + + private LoginViewModel mViewModel; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // 检查是否已经登录 + UserAuthManager authManager = UserAuthManager.getInstance(this); + if (authManager.isLoggedIn()) { + startMainActivity(); + return; + } + + setContentView(R.layout.activity_login); + + mViewModel = new ViewModelProvider(this).get(LoginViewModel.class); + + initViews(); + setupListeners(); + observeViewModel(); + } + + private void initViews() { + mEtUsername = findViewById(R.id.et_username); + mEtPassword = findViewById(R.id.et_password); + mBtnLogin = findViewById(R.id.btn_login); + mBtnRegister = findViewById(R.id.btn_register); + mTvSkip = findViewById(R.id.tv_skip); + mProgressBar = findViewById(R.id.progress_bar); + } + + private void setupListeners() { + mBtnLogin.setOnClickListener(v -> attemptLogin()); + mBtnRegister.setOnClickListener(v -> attemptRegister()); + mTvSkip.setOnClickListener(v -> skipLogin()); + } + + private void observeViewModel() { + mViewModel.getIsLoading().observe(this, isLoading -> showLoading(isLoading)); + + mViewModel.getErrorMessage().observe(this, error -> { + if (error != null) { + Toast.makeText(this, error, Toast.LENGTH_LONG).show(); + mViewModel.clearError(); + } + }); + + mViewModel.getLoginSuccess().observe(this, success -> { + if (success) { + Integer migratedCount = mViewModel.getMigratedNotesCount().getValue(); + String message = (migratedCount != null && migratedCount > 0) + ? "登录成功!已迁移 " + migratedCount + " 条本地笔记" + : "登录成功!"; + Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); + startMainActivity(); + } + }); + } + + private void attemptLogin() { + String username = mEtUsername.getText().toString().trim(); + String password = mEtPassword.getText().toString().trim(); + mViewModel.login(username, password); + } + + private void attemptRegister() { + String username = mEtUsername.getText().toString().trim(); + String password = mEtPassword.getText().toString().trim(); + mViewModel.register(username, password); + } + + private void skipLogin() { + Toast.makeText(this, "使用本地模式(不同步)", Toast.LENGTH_SHORT).show(); + startMainActivity(); + } + + private void startMainActivity() { + Intent intent = new Intent(this, NotesListActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(intent); + finish(); + } + + private void showLoading(boolean show) { + mProgressBar.setVisibility(show ? View.VISIBLE : View.GONE); + mBtnLogin.setEnabled(!show); + mBtnRegister.setEnabled(!show); + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java index f232e6c..efdb3e1 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java @@ -1449,10 +1449,33 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen * {@link #RESULT_OK} is used to identify the create/edit state */ setResult(RESULT_OK); + + // 触发同步(如果用户已登录) + triggerBackgroundSync(); } return saved; } + /** + * 触发后台同步(如果用户已登录) + */ + private void triggerBackgroundSync() { + net.micode.notes.auth.UserAuthManager authManager = net.micode.notes.auth.UserAuthManager.getInstance(this); + if (authManager.isLoggedIn()) { + net.micode.notes.sync.SyncManager.getInstance().syncNotes(new net.micode.notes.sync.SyncManager.SyncCallback() { + @Override + public void onSuccess() { + Log.d("NoteEditActivity", "Background sync completed after save"); + } + + @Override + public void onError(String error) { + Log.e("NoteEditActivity", "Background sync failed after save: " + error); + } + }); + } + } + /** * 发送到桌面 *
diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NotesListActivity.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NotesListActivity.java
index f89a549..56f0b01 100644
--- a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NotesListActivity.java
+++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NotesListActivity.java
@@ -18,9 +18,11 @@ package net.micode.notes.ui;
import android.app.AlertDialog;
import android.appwidget.AppWidgetManager;
+import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
+import android.content.IntentFilter;
import android.os.Bundle;
import android.text.InputFilter;
import android.text.TextUtils;
@@ -48,9 +50,13 @@ import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import net.micode.notes.R;
+import net.micode.notes.api.CloudCallback;
+import net.micode.notes.auth.UserAuthManager;
import net.micode.notes.data.Notes;
+import net.micode.notes.sync.SyncManager;
import net.micode.notes.data.NotesRepository;
import net.micode.notes.databinding.NoteListBinding;
+import net.micode.notes.model.WorkingNote;
import net.micode.notes.tool.SecurityManager;
import net.micode.notes.ui.NoteInfoAdapter;
import net.micode.notes.viewmodel.NotesListViewModel;
@@ -91,9 +97,30 @@ public class NotesListActivity extends AppCompatActivity
// 多选模式状态
private boolean isMultiSelectMode = false;
-
+
// 待打开的受保护笔记
private long mPendingNodeIdToOpen = -1;
+
+ // 同步广播接收器
+ private BroadcastReceiver syncReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if ("com.micode.notes.ACTION_SYNC".equals(intent.getAction())) {
+ Log.d(TAG, "Received sync broadcast, triggering sync");
+ SyncManager.getInstance().syncNotes(new SyncManager.SyncCallback() {
+ @Override
+ public void onSuccess() {
+ Log.d(TAG, "Auto-sync completed successfully");
+ }
+
+ @Override
+ public void onError(String error) {
+ Log.e(TAG, "Auto-sync failed: " + error);
+ }
+ });
+ }
+ }
+ };
private int mPendingNodeTypeToOpen = -1;
private static final String KEY_PENDING_NODE_ID = "pending_node_id";
private static final String KEY_PENDING_NODE_TYPE = "pending_node_type";
@@ -123,6 +150,14 @@ public class NotesListActivity extends AppCompatActivity
initViewModel();
+ // 初始化SyncManager并设置冲突监听器
+ SyncManager.getInstance().initialize(this);
+ SyncManager.getInstance().setConflictListener(conflict -> {
+ runOnUiThread(() -> {
+ showConflictResolutionDialog(conflict);
+ });
+ });
+
// 恢复 pending 状态和当前文件夹
if (savedInstanceState != null) {
mPendingNodeIdToOpen = savedInstanceState.getLong(KEY_PENDING_NODE_ID, -1);
@@ -165,6 +200,45 @@ public class NotesListActivity extends AppCompatActivity
// 这样可以保证从 PasswordActivity 返回时,如果 onStart 先执行,不会重置为根目录
viewModel.refreshNotes();
}
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+
+ // 注册同步广播接收器
+ IntentFilter filter = new IntentFilter("com.micode.notes.ACTION_SYNC");
+ registerReceiver(syncReceiver, filter);
+
+ // 切换到前台时触发同步(如果用户已登录)
+ UserAuthManager authManager = UserAuthManager.getInstance(this);
+ if (authManager.isLoggedIn()) {
+ long lastSync = SyncManager.getInstance().getLastSyncTime();
+ long currentTime = System.currentTimeMillis();
+ // 如果超过30分钟未同步,自动触发同步
+ if (currentTime - lastSync > 30 * 60 * 1000) {
+ Log.d(TAG, "Auto-syncing on resume (last sync: " + lastSync + ")");
+ SyncManager.getInstance().syncNotes(new SyncManager.SyncCallback() {
+ @Override
+ public void onSuccess() {
+ Log.d(TAG, "Auto-sync on resume completed");
+ }
+
+ @Override
+ public void onError(String error) {
+ Log.e(TAG, "Auto-sync on resume failed: " + error);
+ }
+ });
+ }
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ // 注销同步广播接收器
+ unregisterReceiver(syncReceiver);
+ }
+
private void initViewModel() {
NotesRepository repository = new NotesRepository(getContentResolver());
viewModel = new ViewModelProvider(this,
@@ -926,16 +1000,38 @@ public class NotesListActivity extends AppCompatActivity
@Override
public void onSyncSelected() {
- // TODO: 实现同步功能
- Log.d(TAG, "Sync selected");
- Toast.makeText(this, "同步功能待实现", Toast.LENGTH_SHORT).show();
+ Log.d(TAG, "Sync selected, launching SyncActivity");
+ Intent intent = new Intent(this, SyncActivity.class);
+ startActivity(intent);
}
@Override
public void onLoginSelected() {
- // TODO: 实现登录功能
- Log.d(TAG, "Login selected");
- Toast.makeText(this, "登录功能待实现", Toast.LENGTH_SHORT).show();
+ Log.d(TAG, "Login selected, launching LoginActivity");
+ if (binding.drawerLayout != null) {
+ binding.drawerLayout.closeDrawer(sidebarFragment);
+ }
+ Intent intent = new Intent(this, LoginActivity.class);
+ startActivity(intent);
+ }
+
+ @Override
+ public void onLogoutSelected() {
+ Log.d(TAG, "Logout selected, user logged out");
+ if (binding.drawerLayout != null) {
+ binding.drawerLayout.closeDrawer(sidebarFragment);
+ }
+ refreshSidebar();
+ Toast.makeText(this, R.string.toast_logout_success, Toast.LENGTH_SHORT).show();
+ }
+
+ private void refreshSidebar() {
+ androidx.fragment.app.Fragment fragment = getSupportFragmentManager()
+ .findFragmentById(R.id.sidebar_fragment);
+ if (fragment instanceof SidebarFragment) {
+ ((SidebarFragment) fragment).updateUserState();
+ ((SidebarFragment) fragment).refreshFolderTree();
+ }
}
@Override
@@ -1229,6 +1325,127 @@ public class NotesListActivity extends AppCompatActivity
}
}
+ /**
+ * 显示冲突解决对话框
+ */
+ private void showConflictResolutionDialog(net.micode.notes.sync.Conflict conflict) {
+ try {
+ net.micode.notes.model.WorkingNote cloudWorkingNote = conflict.getCloudNote().toWorkingNote(this);
+ if (cloudWorkingNote == null) {
+ Log.e(TAG, "Failed to convert CloudNote to WorkingNote");
+ return;
+ }
+
+ ConflictResolutionDialog dialog = ConflictResolutionDialog.newInstance(
+ conflict.getLocalNote(),
+ cloudWorkingNote
+ );
+ dialog.setConflictResolutionListener(new ConflictResolutionDialog.ConflictResolutionListener() {
+ @Override
+ public void onChooseLocal() {
+ handleConflictChoice(conflict, "local");
+ }
+
+ @Override
+ public void onChooseCloud() {
+ handleConflictChoice(conflict, "cloud");
+ }
+
+ @Override
+ public void onMerge(String mergedTitle, String mergedContent) {
+ handleConflictMerge(conflict, mergedTitle, mergedContent);
+ }
+ });
+ dialog.show(getSupportFragmentManager(), "conflict");
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to show conflict dialog", e);
+ }
+ }
+
+ /**
+ * 处理冲突合并
+ */
+ private void handleConflictMerge(net.micode.notes.sync.Conflict conflict, String mergedTitle, String mergedContent) {
+ net.micode.notes.auth.UserAuthManager authManager = net.micode.notes.auth.UserAuthManager.getInstance(this);
+ if (!authManager.isLoggedIn()) {
+ Toast.makeText(this, "未登录,无法同步", Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ // 更新本地笔记为合并后的内容
+ conflict.getLocalNote().setWorkingText(mergedContent);
+ conflict.getLocalNote().saveNote();
+
+ // 上传合并后的版本到云端
+ net.micode.notes.api.CloudDatabaseHelper cloudHelper = new net.micode.notes.api.CloudDatabaseHelper(
+ authManager.getUserId(),
+ authManager.getDeviceId(),
+ authManager.getAuthToken()
+ );
+
+ cloudHelper.uploadNote(conflict.getLocalNote(), new CloudCallback
- * 显示文件夹树、菜单项和操作按钮
- * 提供文件夹导航、创建、展开/收起等功能
+ * 使用自定义 LinearLayout 替代 NavigationView
+ * 文件夹树在"文件夹"菜单项位置直接展开
*
+ * 显示云同步设置,包括登录状态、同步开关、同步按钮和进度显示。
+ *
+ * 管理登录相关的业务逻辑,包括用户认证、匿名数据迁移、首次登录全量同步等。
+ * 遵循 MVVM 架构,将业务逻辑从 Activity 中分离。
+ *
+ * 修复记录:
+ * 1. 修复登录后缺少全量下载的问题 - 登录后强制执行全量同步
+ * 2. 添加同步状态监听 - 同步完成后再通知登录成功
+ * 3. 优化匿名数据迁移流程 - 迁移后执行全量同步确保数据完整
+ * 关键逻辑:
+ * 1. 新用户接管设备上所有笔记(无论之前属于谁)
+ * 2. 将所有笔记标记为需要同步
+ * 3. 执行全量同步,上传到云端
+ * 关键逻辑:
+ * 1. 首先上传设备上所有笔记到新用户的云端
+ * 2. 然后下载云端其他笔记(如果有)
+ * 这样新用户可以把设备上的所有内容都保存到云端。
+ *