From 74a9944960c4ea3428d69db82eaa87606f01c7e4 Mon Sep 17 00:00:00 2001 From: s2_cc <1702138968@qq.com> Date: Sun, 1 Feb 2026 19:54:16 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9bug=E7=9A=84=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E8=A1=A5=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/notes/cloud/OSSConfig.java | 63 +++ src/notes/cloud/OSSManager.java | 555 ++++++++++++++++++++++-- src/notes/cloud/SyncDialogManager.java | 132 ++++++ src/notes/cloud/SyncManager.java | 311 ++++++++++++- src/notes/cloud/SyncTask.java | 6 +- src/notes/data/NotesDatabaseHelper.java | 2 +- src/notes/model/Note.java | 3 +- src/notes/tool/DataUtils.java | 19 + src/notes/tool/NoteSyncUtils.java | 131 +++++- src/notes/ui/NoteEditActivity.java | 284 +++++++++--- src/notes/ui/NotesListActivity.java | 387 +++++++++++++++-- 11 files changed, 1768 insertions(+), 125 deletions(-) create mode 100644 src/notes/cloud/OSSConfig.java create mode 100644 src/notes/cloud/SyncDialogManager.java diff --git a/src/notes/cloud/OSSConfig.java b/src/notes/cloud/OSSConfig.java new file mode 100644 index 0000000..64c1241 --- /dev/null +++ b/src/notes/cloud/OSSConfig.java @@ -0,0 +1,63 @@ +/* + * 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.cloud; + +/** + * OSSConfig类 - 阿里云OSS配置常量 + * + * 重要提示:以下参数需要您从阿里云控制台获取并填写 + * + * 获取方式: + * 1. 登录阿里云控制台:https://oss.console.aliyun.com/ + * 2. 创建或选择您的OSS Bucket + * 3. 在"概览"页面查看Endpoint + * 4. 在"AccessKey管理"中创建或查看AccessKey + * + * 安全建议: + * - 不要将AccessKey提交到版本控制系统 + * - 建议使用RAM子账号并限制权限 + * - 生产环境建议使用STS临时凭证 + */ +public class OSSConfig { + + // OSS服务端点 - 根据您的Bucket所在区域填写 + // 示例:华东1(杭州):https://oss-cn-hangzhou.aliyuncs.com + // 示例:华北2(北京):https://oss-cn-beijing.aliyuncs.com + // 示例:华南1(深圳):https://oss-cn-shenzhen.aliyuncs.com + public static final String OSS_ENDPOINT = "https://oss-cn-wuhan-lr.aliyuncs.com"; + + // 阿里云AccessKey ID - 在阿里云控制台的"AccessKey管理"中创建 + public static final String OSS_ACCESS_KEY_ID = "LTAI5tAiNrYEtYykvN9xNn3w"; + + // 阿里云AccessKey Secret - 在阿里云控制台的"AccessKey管理"中创建 + public static final String OSS_ACCESS_KEY_SECRET = "JnRHqdTMBIoaONvEPNB8RyypZPADaM"; + + // OSS存储桶名称 - 在阿里云OSS控制台创建Bucket时指定的名称 + public static final String OSS_BUCKET_NAME = "mini-notes"; + + // OSS文件存储路径前缀 - 所有便签数据将存储在此路径下 + public static final String OSS_FILE_PREFIX = "notes/"; + + // OSS连接超时时间(毫秒) + public static final int OSS_CONNECTION_TIMEOUT = 300000; + + // OSS Socket超时时间(毫秒) + public static final int OSS_SOCKET_TIMEOUT = 300000; + + // OSS最大重试次数 + public static final int OSS_MAX_RETRY_COUNT = 30; +} diff --git a/src/notes/cloud/OSSManager.java b/src/notes/cloud/OSSManager.java index 180eb74..1f48b58 100644 --- a/src/notes/cloud/OSSManager.java +++ b/src/notes/cloud/OSSManager.java @@ -19,10 +19,21 @@ package net.micode.notes.cloud; import android.content.Context; import android.util.Log; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; +import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; /** * OSSManager类 - 阿里云OSS网络层封装 @@ -31,30 +42,36 @@ import java.io.InputStream; * 处理OSS相关的网络异常 * 生成基于用户名的文件路径 * - * 注意:当前为模拟实现,实际项目中需要添加阿里云OSS SDK依赖 + * 使用HTTP直接调用阿里云OSS API实现 */ public class OSSManager { private static final String TAG = "OSSManager"; - // OSS配置信息(实际项目中应从配置文件或服务器获取) - private static final String OSS_ENDPOINT = "https://oss-cn-hangzhou.aliyuncs.com"; - private static final String OSS_ACCESS_KEY = "your_access_key"; - private static final String OSS_SECRET_KEY = "your_secret_key"; - private static final String OSS_BUCKET_NAME = "your_bucket_name"; - - // 文件路径前缀 - private static final String OSS_FILE_PREFIX = "notes/"; - private Context mContext; + private String mAccessKeyId; + private String mAccessKeySecret; + private String mBucketName; + private String mFilePrefix; /** * 构造方法 * @param context 上下文对象 */ public OSSManager(Context context) { - mContext = context; - // 模拟初始化OSS客户端 - Log.d(TAG, "OSS client initialized successfully (mock)"); + mContext = context.getApplicationContext(); + loadOSSConfig(); + } + + /** + * 从配置类加载OSS配置 + */ + private void loadOSSConfig() { + mAccessKeyId = OSSConfig.OSS_ACCESS_KEY_ID; + mAccessKeySecret = OSSConfig.OSS_ACCESS_KEY_SECRET; + mBucketName = OSSConfig.OSS_BUCKET_NAME; + mFilePrefix = OSSConfig.OSS_FILE_PREFIX; + + Log.d(TAG, "OSS config loaded - Bucket: " + mBucketName); } /** @@ -63,7 +80,31 @@ public class OSSManager { * @return 文件路径 */ public String getFilePath(String username) { - return OSS_FILE_PREFIX + "notes_" + username + ".json"; + return mFilePrefix + "notes_" + username + ".json"; + } + + /** + * 构建OSS URL(硬编码三级域名格式) + * @param filePath 文件路径 + * @return 完整的URL + */ + private String buildOSSUrl(String filePath) { + try { + // 只对路径中的文件名部分进行编码,保留路径分隔符 + String[] pathParts = filePath.split("/"); + StringBuilder encodedPath = new StringBuilder(); + for (int i = 0; i < pathParts.length; i++) { + if (i > 0) { + encodedPath.append("/"); + } + encodedPath.append(URLEncoder.encode(pathParts[i], "UTF-8").replace("+", "%20")); + } + String baseUrl = "https://" + mBucketName + ".oss-cn-wuhan-lr.aliyuncs.com"; + return baseUrl + "/" + encodedPath.toString(); + } catch (Exception e) { + Log.e(TAG, "Failed to build OSS URL", e); + return ""; + } } /** @@ -73,10 +114,107 @@ public class OSSManager { * @return 是否上传成功 */ public boolean uploadFile(String filePath, String content) { - // 模拟上传操作 - Log.d(TAG, "File uploaded successfully (mock): " + filePath); - Log.d(TAG, "Upload content length: " + content.length()); - return true; + HttpURLConnection connection = null; + OutputStream outputStream = null; + InputStream errorStream = null; + + try { + String urlStr = buildOSSUrl(filePath); + Log.d(TAG, "Building URL for upload: " + filePath); + Log.d(TAG, "Generated URL: " + urlStr); + + URL url = new URL(urlStr); + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("PUT"); + connection.setDoOutput(true); + connection.setConnectTimeout(OSSConfig.OSS_CONNECTION_TIMEOUT); + connection.setReadTimeout(OSSConfig.OSS_SOCKET_TIMEOUT); + + byte[] data = content.getBytes("UTF-8"); + String contentMD5 = ""; + String contentType = "application/json; charset=utf-8"; + + String date = getGMTDate(); + + connection.setRequestProperty("Content-Length", String.valueOf(data.length)); + connection.setRequestProperty("Content-Type", contentType); + connection.setRequestProperty("Date", date); + + String canonicalizedResource = "/" + mBucketName+ "/" + filePath; + String signature = generateSignature("PUT", contentMD5, contentType, date, canonicalizedResource); + + connection.setRequestProperty("Authorization", "OSS " + mAccessKeyId + ":" + signature); + + Log.d(TAG, "Upload URL: " + urlStr); + Log.d(TAG, "CanonicalizedResource: " + canonicalizedResource); + Log.d(TAG, "Date: " + date); + Log.d(TAG, "Signature: " + signature); + Log.d(TAG, "Content length: " + data.length); + + // 尝试建立连接 + Log.d(TAG, "Connecting to OSS..."); + connection.connect(); + + // 写入数据 + Log.d(TAG, "Writing data to OSS..."); + outputStream = connection.getOutputStream(); + outputStream.write(data); + outputStream.flush(); + Log.d(TAG, "Data written successfully"); + + // 获取响应 + Log.d(TAG, "Getting response from OSS..."); + int responseCode = connection.getResponseCode(); + Log.d(TAG, "Upload response code: " + responseCode); + + // 获取响应消息 + String responseMessage = connection.getResponseMessage(); + Log.d(TAG, "Upload response message: " + responseMessage); + + if (responseCode == 200 || responseCode == 201) { + Log.d(TAG, "File uploaded successfully: " + filePath); + return true; + } else { + errorStream = connection.getErrorStream(); + if (errorStream != null) { + String errorResponse = readStream(errorStream); + Log.e(TAG, "Upload error response: " + errorResponse); + } else { + Log.e(TAG, "No error stream available for response code: " + responseCode); + // 尝试获取响应头信息 + Log.e(TAG, "Response headers:"); + for (int i = 0; ; i++) { + String headerName = connection.getHeaderFieldKey(i); + if (headerName == null) break; + String headerValue = connection.getHeaderField(i); + Log.e(TAG, headerName + ": " + headerValue); + } + } + Log.e(TAG, "Upload failed with response code: " + responseCode + ", message: " + responseMessage); + return false; + } + + } catch (IOException e) { + Log.e(TAG, "IO error during upload", e); + return false; + } catch (Exception e) { + Log.e(TAG, "OSS error during upload", e); + return false; + } finally { + try { + if (outputStream != null) { + outputStream.close(); + } + if (errorStream != null) { + errorStream.close(); + } + if (connection != null) { + connection.disconnect(); + } + } catch (IOException e) { + Log.e(TAG, "Error closing connection", e); + } + } } /** @@ -85,9 +223,85 @@ public class OSSManager { * @return 文件内容,如果下载失败返回null */ public String downloadFile(String filePath) { - // 模拟下载操作,返回空数据 - Log.d(TAG, "File downloaded successfully (mock): " + filePath); - return "{\"user\":\"test\",\"sync_time\":1620000000000,\"notes\":[]}"; + HttpURLConnection connection = null; + InputStream inputStream = null; + InputStream errorStream = null; + BufferedReader reader = null; + + try { + String urlStr = buildOSSUrl(filePath); + URL url = new URL(urlStr); + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(OSSConfig.OSS_CONNECTION_TIMEOUT); + connection.setReadTimeout(OSSConfig.OSS_SOCKET_TIMEOUT); + + String date = getGMTDate(); + String contentMD5 = ""; + String contentType = ""; + + connection.setRequestProperty("Date", date); + + String canonicalizedResource = "/" + mBucketName+ "/" + filePath; + String signature = generateSignature("GET", contentMD5, contentType, date, canonicalizedResource); + + connection.setRequestProperty("Authorization", "OSS " + mAccessKeyId + ":" + signature); + + Log.d(TAG, "Download URL: " + urlStr); + Log.d(TAG, "CanonicalizedResource: " + canonicalizedResource); + Log.d(TAG, "Date: " + date); + Log.d(TAG, "Signature: " + signature); + + int responseCode = connection.getResponseCode(); + Log.d(TAG, "Download response code: " + responseCode); + + if (responseCode == 200) { + inputStream = connection.getInputStream(); + reader = new BufferedReader(new InputStreamReader(inputStream, "UTF-8")); + + StringBuilder content = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + content.append(line); + } + + Log.d(TAG, "File downloaded successfully: " + filePath); + Log.d(TAG, "Content length: " + content.length()); + return content.toString(); + } else if (responseCode == 404) { + Log.d(TAG, "File does not exist in OSS: " + filePath); + return null; + } else { + errorStream = connection.getErrorStream(); + if (errorStream != null) { + String errorResponse = readStream(errorStream); + Log.e(TAG, "Download error response: " + errorResponse); + } + Log.e(TAG, "Download failed with response code: " + responseCode); + return null; + } + + } catch (Exception e) { + Log.e(TAG, "OSS error during download", e); + return null; + } finally { + try { + if (reader != null) { + reader.close(); + } + if (inputStream != null) { + inputStream.close(); + } + if (errorStream != null) { + errorStream.close(); + } + if (connection != null) { + connection.disconnect(); + } + } catch (IOException e) { + Log.e(TAG, "Error closing connection", e); + } + } } /** @@ -96,16 +310,299 @@ public class OSSManager { * @return 文件是否存在 */ public boolean doesFileExist(String filePath) { - // 模拟文件存在检查 - Log.d(TAG, "File exists check (mock): " + filePath + " = true"); - return true; + HttpURLConnection connection = null; + InputStream errorStream = null; + + try { + String urlStr = buildOSSUrl(filePath); + URL url = new URL(urlStr); + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("HEAD"); + connection.setConnectTimeout(OSSConfig.OSS_CONNECTION_TIMEOUT); + connection.setReadTimeout(OSSConfig.OSS_SOCKET_TIMEOUT); + + String date = getGMTDate(); + String contentMD5 = ""; + String contentType = ""; + + connection.setRequestProperty("Date", date); + + String canonicalizedResource = "/" + mBucketName+ "/" + filePath; + String signature = generateSignature("HEAD", contentMD5, contentType, date, canonicalizedResource); + + connection.setRequestProperty("Authorization", "OSS " + mAccessKeyId + ":" + signature); + + int responseCode = connection.getResponseCode(); + boolean exists = (responseCode == 200); + Log.d(TAG, "File exists check: " + filePath + " = " + exists + " (response code: " + responseCode + ")"); + + if (!exists && responseCode != 404) { + errorStream = connection.getErrorStream(); + if (errorStream != null) { + String errorResponse = readStream(errorStream); + Log.e(TAG, "Existence check error response: " + errorResponse); + } + } + + return exists; + + } catch (Exception e) { + Log.e(TAG, "OSS error during existence check", e); + return false; + } finally { + try { + if (errorStream != null) { + errorStream.close(); + } + if (connection != null) { + connection.disconnect(); + } + } catch (IOException e) { + Log.e(TAG, "Error closing connection", e); + } + } } /** * 释放OSS客户端资源 */ public void release() { - // 模拟释放资源 - Log.d(TAG, "OSS client released (mock)"); + Log.d(TAG, "OSS client released"); + } + + /** + * 检查OSS配置是否有效 + * @return 配置是否有效 + */ + public boolean isConfigValid() { + return !mAccessKeyId.equals("your_access_key_id_here") && + !mAccessKeySecret.equals("your_access_key_secret_here") && + !mBucketName.equals("your_bucket_name_here"); + } + + /** + * 生成GMT格式的日期 + * @return GMT日期字符串 + */ + private String getGMTDate() { + SimpleDateFormat dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US); + dateFormat.setTimeZone(TimeZone.getTimeZone("GMT")); + return dateFormat.format(new Date()); + } + + /** + * 生成OSS签名 + * @param method HTTP方法 + * @param contentMD5 Content-MD5值(可以为空) + * @param contentType Content-Type值 + * @param date Date头 + * @param canonicalizedResource 规范化资源 + * @return 签名字符串 + */ + private String generateSignature(String method, String contentMD5, String contentType, String date, String canonicalizedResource) { + try { + String stringToSign = method + "\n" + + (contentMD5 != null ? contentMD5 : "") + "\n" + + (contentType != null ? contentType : "") + "\n" + + date + "\n" + + canonicalizedResource; + + Log.d(TAG, "String to sign: \"" + stringToSign.replace("\n", "\\n") + "\""); + + Mac mac = Mac.getInstance("HmacSHA1"); + SecretKeySpec secretKeySpec = new SecretKeySpec(mAccessKeySecret.getBytes("UTF-8"), "HmacSHA1"); + mac.init(secretKeySpec); + + byte[] signatureBytes = mac.doFinal(stringToSign.getBytes("UTF-8")); + String signature = new String(android.util.Base64.encode(signatureBytes, android.util.Base64.NO_WRAP)); + + return signature; + + } catch (Exception e) { + Log.e(TAG, "Failed to generate signature", e); + return ""; + } + } + + /** + * 读取输入流内容 + * @param inputStream 输入流 + * @return 字符串内容 + */ + private String readStream(InputStream inputStream) { + BufferedReader reader = null; + try { + reader = new BufferedReader(new InputStreamReader(inputStream, "UTF-8")); + StringBuilder response = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + return response.toString(); + } catch (Exception e) { + Log.e(TAG, "Failed to read stream", e); + return ""; + } finally { + try { + if (reader != null) { + reader.close(); + } + } catch (IOException e) { + Log.e(TAG, "Error closing reader", e); + } + } + } + + /** + * 上传二进制文件到OSS + * @param filePath 文件路径 + * @param data 二进制数据 + * @param contentType 内容类型 + * @return 是否上传成功 + */ + public boolean uploadBinaryFile(String filePath, byte[] data, String contentType) { + HttpURLConnection connection = null; + OutputStream outputStream = null; + InputStream errorStream = null; + + try { + String urlStr = buildOSSUrl(filePath); + URL url = new URL(urlStr); + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("PUT"); + connection.setDoOutput(true); + connection.setConnectTimeout(OSSConfig.OSS_CONNECTION_TIMEOUT); + connection.setReadTimeout(OSSConfig.OSS_SOCKET_TIMEOUT); + + String date = getGMTDate(); + String contentMD5 = ""; + + connection.setRequestProperty("Content-Length", String.valueOf(data.length)); + connection.setRequestProperty("Content-Type", contentType); + connection.setRequestProperty("Date", date); + + String canonicalizedResource = "/" + mBucketName + "/" + filePath; + String signature = generateSignature("PUT", contentMD5, contentType, date, canonicalizedResource); + + connection.setRequestProperty("Authorization", "OSS " + mAccessKeyId + ":" + signature); + + Log.d(TAG, "Upload binary file URL: " + urlStr); + Log.d(TAG, "Content length: " + data.length); + + connection.connect(); + outputStream = connection.getOutputStream(); + outputStream.write(data); + outputStream.flush(); + + int responseCode = connection.getResponseCode(); + Log.d(TAG, "Upload binary file response code: " + responseCode); + + if (responseCode == 200 || responseCode == 201) { + Log.d(TAG, "Binary file uploaded successfully: " + filePath); + return true; + } else { + errorStream = connection.getErrorStream(); + if (errorStream != null) { + String errorResponse = readStream(errorStream); + Log.e(TAG, "Upload binary file error response: " + errorResponse); + } + Log.e(TAG, "Upload binary file failed with response code: " + responseCode); + return false; + } + } catch (Exception e) { + Log.e(TAG, "Failed to upload binary file", e); + return false; + } finally { + try { + if (outputStream != null) { + outputStream.close(); + } + if (errorStream != null) { + errorStream.close(); + } + if (connection != null) { + connection.disconnect(); + } + } catch (IOException e) { + Log.e(TAG, "Error closing connection", e); + } + } + } + + /** + * 从OSS下载二进制文件 + * @param filePath 文件路径 + * @return 二进制数据,如果下载失败返回null + */ + public byte[] downloadBinaryFile(String filePath) { + HttpURLConnection connection = null; + InputStream inputStream = null; + InputStream errorStream = null; + + try { + String urlStr = buildOSSUrl(filePath); + URL url = new URL(urlStr); + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(OSSConfig.OSS_CONNECTION_TIMEOUT); + connection.setReadTimeout(OSSConfig.OSS_SOCKET_TIMEOUT); + + String date = getGMTDate(); + String contentMD5 = ""; + String contentType = ""; + + connection.setRequestProperty("Date", date); + + String canonicalizedResource = "/" + mBucketName + "/" + filePath; + String signature = generateSignature("GET", contentMD5, contentType, date, canonicalizedResource); + + connection.setRequestProperty("Authorization", "OSS " + mAccessKeyId + ":" + signature); + + Log.d(TAG, "Download binary file URL: " + urlStr); + + int responseCode = connection.getResponseCode(); + Log.d(TAG, "Download binary file response code: " + responseCode); + + if (responseCode == 200) { + inputStream = connection.getInputStream(); + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int len; + while ((len = inputStream.read(buffer)) != -1) { + baos.write(buffer, 0, len); + } + byte[] data = baos.toByteArray(); + Log.d(TAG, "Binary file downloaded successfully: " + filePath + ", size: " + data.length); + return data; + } else if (responseCode == 404) { + Log.d(TAG, "Binary file does not exist in OSS: " + filePath); + return null; + } else { + errorStream = connection.getErrorStream(); + if (errorStream != null) { + String errorResponse = readStream(errorStream); + Log.e(TAG, "Download binary file error response: " + errorResponse); + } + Log.e(TAG, "Download binary file failed with response code: " + responseCode); + return null; + } + } catch (Exception e) { + Log.e(TAG, "Failed to download binary file", e); + return null; + } finally { + try { + if (inputStream != null) { + inputStream.close(); + } + if (errorStream != null) { + errorStream.close(); + } + if (connection != null) { + connection.disconnect(); + } + } catch (IOException e) { + Log.e(TAG, "Error closing connection", e); + } + } } -} \ No newline at end of file +} diff --git a/src/notes/cloud/SyncDialogManager.java b/src/notes/cloud/SyncDialogManager.java new file mode 100644 index 0000000..5285548 --- /dev/null +++ b/src/notes/cloud/SyncDialogManager.java @@ -0,0 +1,132 @@ +/* + * 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.cloud; + +import android.content.Context; +import android.app.AlertDialog; +import android.content.DialogInterface; + +/** + * SyncDialogManager类 - 同步对话框管理器 + * + * 负责显示同步相关的对话框 + * 提供上传下载选择对话框 + * 提供上传确认对话框 + * 保持与原有UI风格一致 + */ +public class SyncDialogManager { + private static final String TAG = "SyncDialogManager"; + + /** + * 同步选项回调接口 + */ + public interface SyncOptionListener { + /** + * 用户选择上传操作 + */ + void onUploadSelected(); + + /** + * 用户选择下载操作 + */ + void onDownloadSelected(); + + /** + * 用户取消操作 + */ + void onCanceled(); + } + + /** + * 确认操作回调接口 + */ + public interface ConfirmListener { + /** + * 用户确认操作 + */ + void onConfirm(); + + /** + * 用户取消操作 + */ + void onCancel(); + } + + /** + * 显示上传下载选择对话框 + * @param context 上下文对象 + * @param listener 回调监听器 + */ + public static void showSyncOptionsDialog(Context context, final SyncOptionListener listener) { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle("同步操作选择") + .setMessage("请选择要执行的同步操作") + .setNegativeButton("下载", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + if (listener != null) { + listener.onDownloadSelected(); + } + } + }) + .setPositiveButton("上传", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + if (listener != null) { + listener.onUploadSelected(); + } + } + }) + .setOnCancelListener(new DialogInterface.OnCancelListener() { + @Override + public void onCancel(DialogInterface dialog) { + if (listener != null) { + listener.onCanceled(); + } + } + }) + .show(); + } + + /** + * 显示上传确认对话框 + * @param context 上下文对象 + * @param listener 回调监听器 + */ + public static void showUploadConfirmDialog(Context context, final ConfirmListener listener) { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle("上传确认") + .setMessage("上传会覆盖云端内容,确定要继续吗?") + .setNegativeButton("取消", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + if (listener != null) { + listener.onCancel(); + } + } + }) + .setPositiveButton("确认", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + if (listener != null) { + listener.onConfirm(); + } + } + }) + .show(); + } +} diff --git a/src/notes/cloud/SyncManager.java b/src/notes/cloud/SyncManager.java index d195474..39e933d 100644 --- a/src/notes/cloud/SyncManager.java +++ b/src/notes/cloud/SyncManager.java @@ -17,11 +17,22 @@ package net.micode.notes.cloud; import android.content.Context; +import android.database.Cursor; +import android.net.Uri; import android.util.Log; import net.micode.notes.account.AccountManager; +import net.micode.notes.data.AttachmentManager; +import net.micode.notes.data.Notes; import net.micode.notes.tool.NoteSyncUtils; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.File; +import java.io.FileOutputStream; +import java.util.List; + /** * SyncManager类 - 同步协调器 * @@ -89,6 +100,15 @@ public class SyncManager { return; } + // 检查OSS配置是否有效 + if (!mOssManager.isConfigValid()) { + Log.w(TAG, "OSS configuration is invalid"); + if (callback != null) { + callback.onSyncFailed("OSS配置无效,请检查配置文件"); + } + return; + } + // 检查用户登录状态 if (!AccountManager.isUserLoggedIn(mContext)) { Log.w(TAG, "User not logged in"); @@ -125,7 +145,31 @@ public class SyncManager { return false; } - // 上传到OSS + // 解析JSON获取附件列表 + JSONObject root = new JSONObject(jsonContent); + JSONArray notesArray = root.optJSONArray("notes"); + if (notesArray != null) { + for (int i = 0; i < notesArray.length(); i++) { + JSONObject noteObj = notesArray.optJSONObject(i); + if (noteObj != null) { + JSONArray attachmentsArray = noteObj.optJSONArray("attachments"); + if (attachmentsArray != null) { + for (int j = 0; j < attachmentsArray.length(); j++) { + JSONObject attachmentObj = attachmentsArray.optJSONObject(j); + if (attachmentObj != null) { + String filePath = attachmentObj.optString("file_path", ""); + if (!filePath.isEmpty()) { + // 上传附件文件到OSS + uploadAttachmentFile(username, filePath); + } + } + } + } + } + } + } + + // 上传JSON数据到OSS String filePath = mOssManager.getFilePath(username); boolean success = mOssManager.uploadFile(filePath, jsonContent); Log.d(TAG, "Upload notes result: " + success); @@ -136,6 +180,56 @@ public class SyncManager { } } + /** + * 上传单个附件文件到OSS + * @param username 用户名 + * @param localFilePath 本地文件路径 + * @return 是否上传成功 + */ + private boolean uploadAttachmentFile(String username, String localFilePath) { + try { + File file = new File(localFilePath); + if (!file.exists()) { + Log.w(TAG, "Attachment file does not exist: " + localFilePath); + return false; + } + + // 读取文件内容 + java.io.FileInputStream fis = new java.io.FileInputStream(file); + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int len; + while ((len = fis.read(buffer)) != -1) { + baos.write(buffer, 0, len); + } + fis.close(); + byte[] data = baos.toByteArray(); + + // 构建OSS文件路径 + String fileName = file.getName(); + String ossFilePath = OSSConfig.OSS_FILE_PREFIX + "attachments/" + username + "/" + fileName; + + // 上传到OSS + String contentType = "image/jpeg"; // 默认图片类型 + if (fileName.toLowerCase().endsWith(".png")) { + contentType = "image/png"; + } else if (fileName.toLowerCase().endsWith(".gif")) { + contentType = "image/gif"; + } + + boolean success = mOssManager.uploadBinaryFile(ossFilePath, data, contentType); + if (success) { + Log.d(TAG, "Attachment uploaded successfully: " + ossFilePath); + } else { + Log.e(TAG, "Failed to upload attachment: " + ossFilePath); + } + return success; + } catch (Exception e) { + Log.e(TAG, "Failed to upload attachment file: " + localFilePath, e); + return false; + } + } + /** * 从云端下载笔记 * @param username 用户名 @@ -152,6 +246,30 @@ public class SyncManager { return false; } + // 解析JSON获取附件列表 + JSONObject root = new JSONObject(jsonContent); + JSONArray notesArray = root.optJSONArray("notes"); + if (notesArray != null) { + for (int i = 0; i < notesArray.length(); i++) { + JSONObject noteObj = notesArray.optJSONObject(i); + if (noteObj != null) { + JSONArray attachmentsArray = noteObj.optJSONArray("attachments"); + if (attachmentsArray != null) { + for (int j = 0; j < attachmentsArray.length(); j++) { + JSONObject attachmentObj = attachmentsArray.optJSONObject(j); + if (attachmentObj != null) { + String filePath2 = attachmentObj.optString("file_path", ""); + if (!filePath2.isEmpty()) { + // 下载附件文件从OSS + downloadAttachmentFile(username, filePath2); + } + } + } + } + } + } + } + // 解析并合并数据 boolean success = NoteSyncUtils.jsonToLocalNotes(mContext, jsonContent); Log.d(TAG, "Download notes result: " + success); @@ -162,6 +280,49 @@ public class SyncManager { } } + /** + * 从OSS下载单个附件文件 + * @param username 用户名 + * @param originalFilePath 原始文件路径(用于获取文件名) + * @return 是否下载成功 + */ + private boolean downloadAttachmentFile(String username, String originalFilePath) { + try { + // 获取文件名 + File originalFile = new File(originalFilePath); + String fileName = originalFile.getName(); + + // 构建OSS文件路径 + String ossFilePath = OSSConfig.OSS_FILE_PREFIX + "attachments/" + username + "/" + fileName; + + // 从OSS下载文件 + byte[] data = mOssManager.downloadBinaryFile(ossFilePath); + if (data == null) { + Log.w(TAG, "Attachment not found in OSS: " + ossFilePath); + return false; + } + + // 获取附件存储目录 + AttachmentManager attachmentManager = new AttachmentManager(mContext); + File storageDir = attachmentManager.getAttachmentStorageDir(); + if (!storageDir.exists()) { + storageDir.mkdirs(); + } + + // 保存文件到本地 + File destFile = new File(storageDir, fileName); + FileOutputStream fos = new FileOutputStream(destFile); + fos.write(data); + fos.close(); + + Log.d(TAG, "Attachment downloaded successfully: " + destFile.getAbsolutePath()); + return true; + } catch (Exception e) { + Log.e(TAG, "Failed to download attachment file: " + originalFilePath, e); + return false; + } + } + /** * 同步回调接口 */ @@ -182,4 +343,152 @@ public class SyncManager { */ void onSyncFailed(String errorMessage); } + + /** + * 只执行上传操作 + * @param callback 同步回调 + */ + public void triggerUpload(final SyncCallback callback) { + if (mIsSyncing) { + Log.d(TAG, "Upload already in progress"); + if (callback != null) { + callback.onSyncFailed("上传已在进行中"); + } + return; + } + + // 检查OSS配置是否有效 + if (!mOssManager.isConfigValid()) { + Log.w(TAG, "OSS configuration is invalid"); + if (callback != null) { + callback.onSyncFailed("OSS配置无效,请检查配置文件"); + } + return; + } + + // 检查用户登录状态 + if (!AccountManager.isUserLoggedIn(mContext)) { + Log.w(TAG, "User not logged in"); + if (callback != null) { + callback.onSyncFailed("请先登录后再同步"); + } + return; + } + + final String username = AccountManager.getCurrentUser(mContext); + if (username.isEmpty()) { + Log.w(TAG, "Empty username"); + if (callback != null) { + callback.onSyncFailed("用户名为空"); + } + return; + } + + // 设置同步状态 + setSyncing(true); + + // 通知同步开始 + if (callback != null) { + callback.onSyncStart(); + } + + // 在后台执行上传操作 + new Thread(new Runnable() { + @Override + public void run() { + try { + boolean success = uploadNotes(username); + setSyncing(false); + + if (callback != null) { + if (success) { + callback.onSyncSuccess(); + } else { + callback.onSyncFailed("上传失败,请重试"); + } + } + } catch (Exception e) { + Log.e(TAG, "Upload failed", e); + setSyncing(false); + if (callback != null) { + callback.onSyncFailed("上传失败:" + e.getMessage()); + } + } + } + }).start(); + } + + /** + * 只执行下载操作 + * @param callback 同步回调 + */ + public void triggerDownload(final SyncCallback callback) { + if (mIsSyncing) { + Log.d(TAG, "Download already in progress"); + if (callback != null) { + callback.onSyncFailed("下载已在进行中"); + } + return; + } + + // 检查OSS配置是否有效 + if (!mOssManager.isConfigValid()) { + Log.w(TAG, "OSS configuration is invalid"); + if (callback != null) { + callback.onSyncFailed("OSS配置无效,请检查配置文件"); + } + return; + } + + // 检查用户登录状态 + if (!AccountManager.isUserLoggedIn(mContext)) { + Log.w(TAG, "User not logged in"); + if (callback != null) { + callback.onSyncFailed("请先登录后再同步"); + } + return; + } + + final String username = AccountManager.getCurrentUser(mContext); + if (username.isEmpty()) { + Log.w(TAG, "Empty username"); + if (callback != null) { + callback.onSyncFailed("用户名为空"); + } + return; + } + + // 设置同步状态 + setSyncing(true); + + // 通知同步开始 + if (callback != null) { + callback.onSyncStart(); + } + + // 在后台执行下载操作 + new Thread(new Runnable() { + @Override + public void run() { + try { + boolean success = downloadNotes(username); + setSyncing(false); + + if (callback != null) { + if (success) { + callback.onSyncSuccess(); + } else { + callback.onSyncFailed("下载失败,请重试"); + } + } + } catch (Exception e) { + Log.e(TAG, "Download failed", e); + setSyncing(false); + if (callback != null) { + callback.onSyncFailed("下载失败:" + e.getMessage()); + } + } + } + }).start(); + } } \ No newline at end of file diff --git a/src/notes/cloud/SyncTask.java b/src/notes/cloud/SyncTask.java index cbdc6f7..a8dc6e9 100644 --- a/src/notes/cloud/SyncTask.java +++ b/src/notes/cloud/SyncTask.java @@ -104,7 +104,7 @@ public class SyncTask extends AsyncTask { boolean uploadSuccess = mSyncManager.uploadNotes(mUsername); if (!uploadSuccess) { Log.e(TAG, "Upload failed"); - return new SyncResult(false, "上传失败,请重试"); + return new SyncResult(false, "上传到云端失败"); } publishProgress(100); // 100% 进度 @@ -112,7 +112,7 @@ public class SyncTask extends AsyncTask { return new SyncResult(true, null); } catch (Exception e) { Log.e(TAG, "Sync task failed", e); - return new SyncResult(false, "同步失败,请重试"); + return new SyncResult(false, "OSS操作失败,请稍后重试"); } } @@ -149,4 +149,4 @@ public class SyncTask extends AsyncTask { Log.d(TAG, "Sync task finished with result: " + result.success); } -} \ No newline at end of file +} diff --git a/src/notes/data/NotesDatabaseHelper.java b/src/notes/data/NotesDatabaseHelper.java index b42d678..35102ac 100644 --- a/src/notes/data/NotesDatabaseHelper.java +++ b/src/notes/data/NotesDatabaseHelper.java @@ -115,7 +115,7 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { NoteColumns.LOCAL_MODIFIED + " INTEGER NOT NULL DEFAULT 0," + NoteColumns.ORIGIN_PARENT_ID + " INTEGER NOT NULL DEFAULT 0," + NoteColumns.GTASK_ID + " TEXT NOT NULL DEFAULT ''," + - NoteColumns.VERSION + " INTEGER NOT NULL DEFAULT 0" + + NoteColumns.VERSION + " INTEGER NOT NULL DEFAULT 0," + NoteColumns.IS_LOCKED + " INTEGER NOT NULL DEFAULT 0" + ")"; diff --git a/src/notes/model/Note.java b/src/notes/model/Note.java index e6b5b33..d621918 100644 --- a/src/notes/model/Note.java +++ b/src/notes/model/Note.java @@ -387,7 +387,8 @@ public class Note { return null; } } - return null; + // 如果只有插入操作(没有更新操作),也返回成功 + return ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId); } } } diff --git a/src/notes/tool/DataUtils.java b/src/notes/tool/DataUtils.java index 0798df1..b7efa00 100644 --- a/src/notes/tool/DataUtils.java +++ b/src/notes/tool/DataUtils.java @@ -32,6 +32,7 @@ import net.micode.notes.data.Notes.NoteColumns; import net.micode.notes.ui.NotesListAdapter.AppWidgetAttribute; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; public class DataUtils { @@ -112,12 +113,30 @@ public class DataUtils { return true; } + // 先查询所有笔记的当前parent_id,用于记录原始文件夹 + HashMap noteToParentMap = new HashMap<>(); + for (long id : ids) { + Cursor cursor = resolver.query(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, id), + new String[]{NoteColumns.PARENT_ID}, null, null, null); + if (cursor != null) { + if (cursor.moveToFirst()) { + noteToParentMap.put(id, cursor.getLong(0)); + } + cursor.close(); + } + } + // 构建批量更新操作列表 ArrayList operationList = new ArrayList(); for (long id : ids) { ContentProviderOperation.Builder builder = ContentProviderOperation .newUpdate(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, id)); builder.withValue(NoteColumns.PARENT_ID, folderId); // 更新父文件夹ID + // 记录原始父文件夹ID(如果查询到的话) + Long originParentId = noteToParentMap.get(id); + if (originParentId != null) { + builder.withValue(NoteColumns.ORIGIN_PARENT_ID, originParentId); + } builder.withValue(NoteColumns.LOCAL_MODIFIED, 1); // 标记为已本地修改 operationList.add(builder.build()); } diff --git a/src/notes/tool/NoteSyncUtils.java b/src/notes/tool/NoteSyncUtils.java index c234e88..45d50e8 100644 --- a/src/notes/tool/NoteSyncUtils.java +++ b/src/notes/tool/NoteSyncUtils.java @@ -28,12 +28,15 @@ import net.micode.notes.data.Notes.NoteColumns; import net.micode.notes.data.Notes.DataColumns; import net.micode.notes.data.Notes.TextNote; import net.micode.notes.data.Notes.CallNote; +import net.micode.notes.data.AttachmentManager; +import net.micode.notes.model.Note; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.util.HashMap; +import java.util.List; import java.util.Map; /** @@ -149,6 +152,21 @@ public class NoteSyncUtils { } noteObj.put("data", dataObj); + + // 附件信息 + JSONArray attachmentsArray = new JSONArray(); + AttachmentManager attachmentManager = new AttachmentManager(context); + List attachments = attachmentManager.getAttachmentsByNoteId(noteId); + for (AttachmentManager.Attachment attachment : attachments) { + JSONObject attachmentObj = new JSONObject(); + attachmentObj.put("id", attachment.id); + attachmentObj.put("type", attachment.type); + attachmentObj.put("file_path", attachment.filePath); + attachmentObj.put("created_time", attachment.createdTime); + attachmentsArray.put(attachmentObj); + } + noteObj.put("attachments", attachmentsArray); + return noteObj; } catch (Exception e) { Log.e(TAG, "Failed to create note JSON", e); @@ -225,18 +243,117 @@ public class NoteSyncUtils { */ private static void createLocalNote(Context context, JSONObject noteObj) { try { - // 创建笔记基本信息 - ContentResolver resolver = context.getContentResolver(); + Log.d(TAG, "Creating new note from cloud: " + noteObj.toString()); - // 注意:这里需要使用Note类的方法来创建笔记,因为需要处理数据关联 - // 简化实现,直接插入数据 + // 获取笔记基本信息 + long parentId = noteObj.optLong("parent_id", Notes.ID_ROOT_FOLDER); + long createdDate = noteObj.optLong("created_date", System.currentTimeMillis()); + long modifiedDate = noteObj.optLong("modified_date", System.currentTimeMillis()); + long alertDate = noteObj.optLong("alert_date", 0); + String snippet = noteObj.optString("snippet", ""); + int bgColorId = noteObj.optInt("bg_color_id", 0); + int hasAttachment = noteObj.optInt("has_attachment", 0); + int isLocked = noteObj.optInt("is_locked", 0); - // 创建笔记记录 - // 实际项目中应使用Note类的syncNote方法 - Log.d(TAG, "Creating new note from cloud: " + noteObj.toString()); + // 获取笔记内容 + JSONObject dataObj = noteObj.optJSONObject("data"); + String noteType = dataObj.optString("type", "text"); + + // 创建新笔记 + long noteId = Note.getNewNoteId(context, parentId); + Log.d(TAG, "Created new note with id: " + noteId); + + // 创建Note对象 + Note note = new Note(); + + // 设置笔记基本信息 + note.setNoteValue(NoteColumns.CREATED_DATE, String.valueOf(createdDate)); + note.setNoteValue(NoteColumns.MODIFIED_DATE, String.valueOf(modifiedDate)); + note.setNoteValue(NoteColumns.ALERTED_DATE, String.valueOf(alertDate)); + note.setNoteValue(NoteColumns.SNIPPET, snippet); + note.setNoteValue(NoteColumns.BG_COLOR_ID, String.valueOf(bgColorId)); + note.setNoteValue(NoteColumns.HAS_ATTACHMENT, String.valueOf(hasAttachment)); + note.setNoteValue(NoteColumns.IS_LOCKED, String.valueOf(isLocked)); + + // 设置笔记内容 + if ("text".equals(noteType)) { + // 文本笔记 + String content = dataObj.optString("content", ""); + int mode = dataObj.optInt("mode", 0); + + note.setTextData(TextNote.CONTENT, content); + note.setTextData(TextNote.MODE, String.valueOf(mode)); + Log.d(TAG, "Set text note content: " + content); + } else if ("call".equals(noteType)) { + // 通话笔记 + String content = dataObj.optString("content", ""); + long callDate = dataObj.optLong("call_date", 0); + String phoneNumber = dataObj.optString("phone_number", ""); + + note.setCallData(CallNote.CONTENT, content); + note.setCallData(CallNote.CALL_DATE, String.valueOf(callDate)); + note.setCallData(CallNote.PHONE_NUMBER, phoneNumber); + Log.d(TAG, "Set call note content: " + content + ", phone: " + phoneNumber); + } + + // 同步笔记到数据库 + boolean success = note.syncNote(context, noteId); + Log.d(TAG, "Sync note result: " + success); + + if (success) { + Log.d(TAG, "Note created successfully from cloud: " + noteId); + + // 恢复附件信息 + JSONArray attachmentsArray = noteObj.optJSONArray("attachments"); + if (attachmentsArray != null && attachmentsArray.length() > 0) { + Log.d(TAG, "Restoring " + attachmentsArray.length() + " attachments for note: " + noteId); + for (int i = 0; i < attachmentsArray.length(); i++) { + JSONObject attachmentObj = attachmentsArray.optJSONObject(i); + if (attachmentObj != null) { + // 创建附件记录(附件文件需要从OSS下载) + String filePath = attachmentObj.optString("file_path", ""); + int type = attachmentObj.optInt("type", 0); + long createdTime = attachmentObj.optLong("created_time", System.currentTimeMillis()); + + // 注意:这里只记录附件元数据,实际的文件下载由SyncManager处理 + // 文件下载后会更新filePath + createAttachmentRecord(context, noteId, type, filePath, createdTime); + } + } + } + } else { + Log.e(TAG, "Failed to sync note to local database: " + noteId); + } } catch (Exception e) { Log.e(TAG, "Failed to create local note", e); } } + + /** + * 创建附件记录(不复制文件,只创建数据库记录) + * @param context 上下文对象 + * @param noteId 笔记ID + * @param type 附件类型 + * @param filePath 文件路径 + * @param createdTime 创建时间 + */ + private static void createAttachmentRecord(Context context, long noteId, int type, String filePath, long createdTime) { + try { + android.content.ContentValues values = new android.content.ContentValues(); + values.put(Notes.AttachmentColumns.NOTE_ID, noteId); + values.put(Notes.AttachmentColumns.TYPE, type); + values.put(Notes.AttachmentColumns.FILE_PATH, filePath); + values.put(Notes.AttachmentColumns.CREATED_TIME, createdTime); + + android.net.Uri uri = context.getContentResolver().insert(Notes.CONTENT_ATTACHMENT_URI, values); + if (uri != null) { + Log.d(TAG, "Created attachment record: " + uri + " for note: " + noteId); + } else { + Log.e(TAG, "Failed to create attachment record for note: " + noteId); + } + } catch (Exception e) { + Log.e(TAG, "Failed to create attachment record", e); + } + } } \ No newline at end of file diff --git a/src/notes/ui/NoteEditActivity.java b/src/notes/ui/NoteEditActivity.java index a645b21..11d270d 100644 --- a/src/notes/ui/NoteEditActivity.java +++ b/src/notes/ui/NoteEditActivity.java @@ -191,6 +191,9 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen private static final int REQUEST_CAMERA_PREVIEW = 1005; private String mCurrentPhotoPath; // 相机拍照临时文件路径 + // 密码验证标志 + private boolean mPasswordVerified = false; // 标记密码是否已验证通过 + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -358,8 +361,11 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen if (TextUtils.equals(Intent.ACTION_VIEW, action)) { // 检查便签是否加锁 if (isNoteLocked()) { + // 便签加锁,需要验证密码,此时不设置mPasswordVerified标志 showPasswordDialogForLockedNote(); } else { + // 未加锁便签,标记密码已验证(无需验证) + mPasswordVerified = true; // 隐藏软键盘 getWindow().setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN @@ -370,6 +376,8 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen loadAttachments(); } } else if (TextUtils.equals(Intent.ACTION_INSERT_OR_EDIT, action)) { + // 新建或编辑模式,标记密码已验证(新建便签无需密码) + mPasswordVerified = true; // 显示软键盘 getWindow().setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE @@ -412,6 +420,7 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen /** * 显示密码输入对话框用于打开加锁便签 + * 支持密码验证和忘记密码功能(通过密保问题重置密码) */ private void showPasswordDialogForLockedNote() { AlertDialog.Builder builder = new AlertDialog.Builder(this); @@ -432,7 +441,8 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen return; } if (PasswordManager.verifyPassword(NoteEditActivity.this, password)) { - // 密码验证通过,初始化UI + // 密码验证通过,设置标志位并初始化UI + mPasswordVerified = true; getWindow().setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN | WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); @@ -450,15 +460,168 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen finish(); } }); - builder.show(); - builder.setCancelable(false); + + // 添加"忘记密码"按钮,点击后通过密保问题验证重置密码 + builder.setNeutralButton(R.string.btn_forgot_password, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + Log.d(TAG, "User clicked forgot password button, showing security questions dialog"); + showSecurityQuestionsForNoteUnlock(); + } + }); + + AlertDialog dialog = builder.create(); + dialog.setCancelable(false); + dialog.show(); + } + + /** + * 显示密保问题验证对话框(用于便签解锁时忘记密码) + * 验证成功后允许用户重置密码并自动解锁便签 + */ + private void showSecurityQuestionsForNoteUnlock() { + // 检查是否设置了密保问题 + if (!NotesPreferenceActivity.hasSecurityQuestionsSet(this)) { + Log.w(TAG, "Security questions not set, cannot reset password"); + Toast.makeText(this, "请先在设置中设置密保问题", Toast.LENGTH_SHORT).show(); + finish(); + return; + } + + Log.d(TAG, "Showing security questions dialog for note unlock"); + + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.title_forgot_password); + builder.setIcon(android.R.drawable.ic_dialog_alert); + builder.setMessage("请回答密保问题以重置密码"); + + // 创建布局 + LinearLayout layout = new LinearLayout(this); + layout.setOrientation(LinearLayout.VERTICAL); + layout.setPadding(40, 20, 40, 20); + + // 创建姓名输入框 + final EditText nameInput = new EditText(this); + nameInput.setHint("请输入姓名"); + layout.addView(nameInput); + + // 创建生日输入框 + final EditText birthdayInput = new EditText(this); + birthdayInput.setHint("请输入生日 (YYYY-MM-DD)"); + layout.addView(birthdayInput); + + builder.setView(layout); + + // 设置确定按钮 + builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + String name = nameInput.getText().toString().trim(); + String birthday = birthdayInput.getText().toString().trim(); + + Log.d(TAG, "Verifying security questions - name: " + name + ", birthday: " + birthday); + + if (NotesPreferenceActivity.verifySecurityQuestions(NoteEditActivity.this, name, birthday)) { + Log.i(TAG, "Security questions verified successfully, showing reset password dialog"); + // 验证成功,显示设置新密码对话框 + showResetPasswordForNoteUnlock(); + } else { + Log.w(TAG, "Security questions verification failed"); + Toast.makeText(NoteEditActivity.this, "密保问题回答错误", Toast.LENGTH_SHORT).show(); + finish(); + } + } + }); + + // 设置取消按钮 + builder.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + Log.d(TAG, "User cancelled security questions dialog"); + finish(); + } + }); + + AlertDialog dialog = builder.create(); + dialog.setCancelable(false); + dialog.show(); + } + + /** + * 显示重置密码对话框,重置后自动解锁便签 + * 用户设置新密码后,自动验证并显示便签内容 + */ + private void showResetPasswordForNoteUnlock() { + Log.d(TAG, "Showing reset password dialog for note unlock"); + + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("设置新密码"); + builder.setIcon(android.R.drawable.ic_dialog_info); + builder.setMessage("验证成功,请设置新密码"); + + // 创建密码输入框 + final EditText passwordInput = new EditText(this); + passwordInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); + passwordInput.setHint("请输入新密码"); + LinearLayout layout = new LinearLayout(this); + layout.setOrientation(LinearLayout.VERTICAL); + layout.setPadding(40, 20, 40, 20); + layout.addView(passwordInput); + builder.setView(layout); + + // 设置确定按钮 + builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + String newPassword = passwordInput.getText().toString().trim(); + if (newPassword.isEmpty()) { + Log.w(TAG, "New password is empty"); + Toast.makeText(NoteEditActivity.this, "密码不能为空", Toast.LENGTH_SHORT).show(); + finish(); + return; + } + + Log.i(TAG, "Setting new password for note unlock"); + + // 设置新密码 + if (PasswordManager.setPassword(NoteEditActivity.this, newPassword)) { + Log.i(TAG, "Password reset successfully, auto-unlocking note"); + Toast.makeText(NoteEditActivity.this, "密码重置成功", Toast.LENGTH_SHORT).show(); + + // 自动解锁并显示便签 + mPasswordVerified = true; + getWindow().setSoftInputMode( + WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN + | WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); + initNoteScreen(); + loadAttachments(); + } else { + Log.e(TAG, "Failed to set new password"); + Toast.makeText(NoteEditActivity.this, "密码设置失败", Toast.LENGTH_SHORT).show(); + finish(); + } + } + }); + + // 设置取消按钮 + builder.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + Log.d(TAG, "User cancelled reset password dialog"); + finish(); + } + }); + + AlertDialog dialog = builder.create(); + dialog.setCancelable(false); + dialog.show(); } @Override protected void onResume() { super.onResume(); - // 只有当mWorkingNote已经初始化完成时才初始化界面 - if (mWorkingNote != null) { + // 只有当mWorkingNote已经初始化完成且密码已验证(或未加锁)时才初始化界面 + if (mWorkingNote != null && mPasswordVerified) { initNoteScreen(); // 初始化笔记界面(已包含老年人模式应用) } } @@ -537,8 +700,9 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen /** * 对于没有ID的新笔记,首先保存它以生成ID * 如果编辑的笔记不值得保存,则没有ID,相当于创建新笔记 + * 只有在密码已验证通过时才保存,防止加锁便签在密码错误时内容被覆盖 */ - if (!mWorkingNote.existInDatabase()) { + if (mPasswordVerified && !mWorkingNote.existInDatabase()) { saveNote(); } outState.putLong(Intent.EXTRA_UID, mWorkingNote.getNoteId()); // 保存笔记ID @@ -635,13 +799,70 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen // 应用老年人模式 ElderModeUtils.applyElderMode(this, findViewById(android.R.id.content)); + + // 初始化格式化工具栏按钮 + initFormatToolbar(); + } + + /** + * 初始化格式化工具栏 + */ + private void initFormatToolbar() { + // 字体大小按钮 + View btnFontSize = findViewById(R.id.btn_font_size); + if (btnFontSize != null) { + btnFontSize.setOnClickListener(v -> { + mFontSizeSelector.setVisibility(View.VISIBLE); + findViewById(sFontSelectorSelectionMap.get(mFontSizeId)).setVisibility(View.VISIBLE); + }); + } + + // 粗体按钮 + View btnBold = findViewById(R.id.btn_bold); + if (btnBold != null) { + btnBold.setOnClickListener(v -> applyRichTextStyle(android.graphics.Typeface.BOLD)); + } + + // 斜体按钮 + View btnItalic = findViewById(R.id.btn_italic); + if (btnItalic != null) { + btnItalic.setOnClickListener(v -> applyRichTextStyle(android.graphics.Typeface.ITALIC)); + } + + // 下划线按钮 + View btnUnderline = findViewById(R.id.btn_underline); + if (btnUnderline != null) { + btnUnderline.setOnClickListener(v -> applyUnderlineStyle()); + } + + // 删除线按钮 + View btnStrikethrough = findViewById(R.id.btn_strikethrough); + if (btnStrikethrough != null) { + btnStrikethrough.setOnClickListener(v -> applyStrikethroughStyle()); + } + + // 列表模式按钮 + View btnListMode = findViewById(R.id.btn_list_mode); + if (btnListMode != null) { + btnListMode.setOnClickListener(v -> { + // 切换列表模式/普通模式 + mWorkingNote.setCheckListMode(mWorkingNote.getCheckListMode() == 0 ? + TextNote.MODE_CHECK_LIST : 0); + }); + } + + // 插入图片按钮 + View btnInsertImage = findViewById(R.id.btn_insert_image); + if (btnInsertImage != null) { + btnInsertImage.setOnClickListener(v -> handleInsertImage()); + } } @Override protected void onPause() { super.onPause(); - // 保存笔记 - if(saveNote()) { + // 只有在密码已验证通过时才保存笔记,防止加锁便签在密码错误时内容被覆盖 + if (mPasswordVerified && saveNote()) { Log.d(TAG, "Note data was saved with length:" + mWorkingNote.getContent().length()); } clearSettingState(); // 清除设置状态 @@ -782,12 +1003,7 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen getMenuInflater().inflate(R.menu.note_edit, menu); // 普通笔记菜单 } - // 设置列表模式/普通模式菜单项标题 - if (mWorkingNote.getCheckListMode() == TextNote.MODE_CHECK_LIST) { - menu.findItem(R.id.menu_list_mode).setTitle(R.string.menu_normal_mode); - } else { - menu.findItem(R.id.menu_list_mode).setTitle(R.string.menu_list_mode); - } + // 列表模式已移至工具栏,不再在菜单中设置标题 // 根据是否有闹钟提醒显示/隐藏相关菜单项 if (mWorkingNote.hasClockAlert()) { @@ -825,18 +1041,7 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen builder.setNegativeButton(android.R.string.cancel, null); builder.show(); break; - case R.id.menu_font_size: - mFontSizeSelector.setVisibility(View.VISIBLE); // 显示字体大小选择器 - findViewById(sFontSelectorSelectionMap.get(mFontSizeId)).setVisibility(View.VISIBLE); - break; - case R.id.menu_list_mode: - // 切换列表模式/普通模式 - mWorkingNote.setCheckListMode(mWorkingNote.getCheckListMode() == 0 ? - TextNote.MODE_CHECK_LIST : 0); - break; - case R.id.menu_insert_image: - handleInsertImage(); // 插入图片 - break; + // 字体大小、列表模式、插入图片已移至工具栏 case R.id.menu_share: getWorkingText(); // 获取工作文本 sendTo(this, mWorkingNote.getContent()); // 分享笔记 @@ -850,18 +1055,7 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen case R.id.menu_delete_remind: mWorkingNote.setAlertDate(0, false); // 删除提醒 break; - case R.id.menu_bold: - applyRichTextStyle(android.graphics.Typeface.BOLD); - break; - case R.id.menu_italic: - applyRichTextStyle(android.graphics.Typeface.ITALIC); - break; - case R.id.menu_underline: - applyUnderlineStyle(); - break; - case R.id.menu_strikethrough: - applyStrikethroughStyle(); - break; + // 粗体、斜体、下划线、删除线、字体大小已移至工具栏 default: break; } @@ -919,17 +1113,9 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen Log.d(TAG, "Wrong note id, should not happen"); } - // 根据是否启用同步模式执行不同的删除操作 - if (!isSyncMode()) { - // 普通模式:直接删除 - if (!DataUtils.batchDeleteNotes(getContentResolver(), ids)) { - Log.e(TAG, "Delete Note error"); - } - } else { - // 同步模式:移动到回收站 - if (!DataUtils.batchMoveToFolder(getContentResolver(), ids, Notes.ID_TRASH_FOLDER)) { - Log.e(TAG, "Move notes to trash folder error, should not happens"); - } + // 将笔记移动到回收站,而不是直接删除 + if (!DataUtils.batchMoveToFolder(getContentResolver(), ids, Notes.ID_TRASH_FOLDER)) { + Log.e(TAG, "Move notes to trash folder error, should not happens"); } } mWorkingNote.markDeleted(true); // 标记为已删除 diff --git a/src/notes/ui/NotesListActivity.java b/src/notes/ui/NotesListActivity.java index 9f9cc8a..382a602 100644 --- a/src/notes/ui/NotesListActivity.java +++ b/src/notes/ui/NotesListActivity.java @@ -70,6 +70,7 @@ import androidx.appcompat.widget.Toolbar; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import net.micode.notes.cloud.SyncManager; +import net.micode.notes.cloud.SyncDialogManager; import net.micode.notes.R; import net.micode.notes.data.Notes; @@ -117,7 +118,8 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe private enum ListEditState { NOTE_LIST, // 普通笔记列表状态 SUB_FOLDER, // 子文件夹浏览状态 - CALL_RECORD_FOLDER // 通话记录文件夹浏览状态 + CALL_RECORD_FOLDER, // 通话记录文件夹浏览状态 + SEARCH // 搜索状态 }; // 当前列表状态 @@ -153,6 +155,11 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe // 下拉刷新布局 private SwipeRefreshLayout mSwipeRefreshLayout; + + // 搜索相关变量 + private String mSearchKey = ""; // 搜索关键词 + private View mSearchContainer; // 搜索容器视图 + private EditText mSearchEditText; // 搜索输入框 // 普通查询条件(非根文件夹) private static final String NORMAL_SELECTION = NoteColumns.PARENT_ID + "=?"; @@ -206,19 +213,35 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe if (itemData == null || itemData.getType() != Notes.TYPE_NOTE) { return; } - + + // 查询笔记的原始父文件夹ID + long originParentId = Notes.ID_ROOT_FOLDER; // 默认恢复到根文件夹 + Cursor cursor = mContentResolver.query(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, itemData.getId()), + new String[]{NoteColumns.ORIGIN_PARENT_ID}, null, null, null); + if (cursor != null) { + if (cursor.moveToFirst()) { + originParentId = cursor.getLong(0); + // 如果原始文件夹ID无效或者是回收站,则恢复到根文件夹 + if (originParentId <= 0 || originParentId == Notes.ID_TRASH_FOLDER) { + originParentId = Notes.ID_ROOT_FOLDER; + } + } + cursor.close(); + } + // 显示确认对话框 AlertDialog.Builder builder = new AlertDialog.Builder(this); builder.setTitle("恢复笔记"); builder.setMessage("确定要恢复这条笔记吗?"); builder.setIcon(android.R.drawable.ic_dialog_info); + final long targetFolderId = originParentId; builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { - // 恢复笔记到根文件夹 + // 恢复笔记到原始文件夹 HashSet ids = new HashSet(); ids.add(itemData.getId()); - if (DataUtils.batchMoveToFolder(mContentResolver, ids, Notes.ID_ROOT_FOLDER)) { + if (DataUtils.batchMoveToFolder(mContentResolver, ids, targetFolderId)) { Toast.makeText(NotesListActivity.this, "笔记已恢复", Toast.LENGTH_SHORT).show(); } else { Toast.makeText(NotesListActivity.this, "恢复失败", Toast.LENGTH_SHORT).show(); @@ -330,8 +353,8 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe mSwipeRefreshLayout.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener() { @Override public void onRefresh() { - // 下拉刷新时触发同步 - triggerSync(); + // 下拉刷新时只执行下载操作 + executeDownload(); } }); // 设置刷新颜色 @@ -575,10 +598,11 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe // 根据当前文件夹选择查询条件 String selection = (mCurrentFolderId == Notes.ID_ROOT_FOLDER) ? ROOT_FOLDER_SELECTION : NORMAL_SELECTION; + // 修改排序规则:先按类型降序(系统文件夹在前),再按alerted_date降序(置顶便签在前),最后按修改时间降序 mBackgroundQueryHandler.startQuery(FOLDER_NOTE_LIST_QUERY_TOKEN, null, Notes.CONTENT_NOTE_URI, NoteItemData.PROJECTION, selection, new String[] { String.valueOf(mCurrentFolderId) - }, NoteColumns.TYPE + " DESC," + NoteColumns.MODIFIED_DATE + " DESC"); + }, NoteColumns.TYPE + " DESC," + NoteColumns.ALERTED_DATE + " DESC," + NoteColumns.MODIFIED_DATE + " DESC"); } /** @@ -654,10 +678,19 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe protected HashSet doInBackground(Void... unused) { // 获取选中笔记的小部件属性 HashSet widgets = mNotesListAdapter.getSelectedWidget(); - // 无论是否同步模式,都将笔记移动到回收站 - if (!DataUtils.batchMoveToFolder(mContentResolver, mNotesListAdapter - .getSelectedItemIds(), Notes.ID_TRASH_FOLDER)) { - Log.e(TAG, "Move notes to trash folder error, should not happens"); + HashSet selectedIds = mNotesListAdapter.getSelectedItemIds(); + + // 根据当前文件夹决定删除方式 + if (mCurrentFolderId == Notes.ID_TRASH_FOLDER) { + // 在回收站中,彻底删除笔记 + if (!DataUtils.batchDeleteNotes(mContentResolver, selectedIds)) { + Log.e(TAG, "Delete notes error, should not happens"); + } + } else { + // 不在回收站中,将笔记移动到回收站 + if (!DataUtils.batchMoveToFolder(mContentResolver, selectedIds, Notes.ID_TRASH_FOLDER)) { + Log.e(TAG, "Move notes to trash folder error, should not happens"); + } } return widgets; } @@ -683,25 +716,51 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe */ private void batchPinNotes() { new AsyncTask>() { + private int pinnedCount = 0; + private int unpinnedCount = 0; + @Override protected HashSet doInBackground(Void... unused) { // 获取选中笔记的小部件属性 HashSet widgets = mNotesListAdapter.getSelectedWidget(); HashSet selectedIds = mNotesListAdapter.getSelectedItemIds(); - // 批量更新笔记的alerted_date字段为一个很大的值,表示置顶 + // 批量更新笔记的alerted_date字段 for (Long noteId : selectedIds) { - ContentValues values = new ContentValues(); - values.put(NoteColumns.ALERTED_DATE, 9999999999999L); // 很大的值,表示置顶 - values.put(NoteColumns.LOCAL_MODIFIED, 1); - values.put(NoteColumns.MODIFIED_DATE, System.currentTimeMillis()); - try { + // 查询笔记当前的alerted_date值 + Cursor cursor = getContentResolver().query( + ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId), + new String[]{NoteColumns.ALERTED_DATE}, + null, null, null); + + long currentAlertedDate = 0; + if (cursor != null && cursor.moveToFirst()) { + currentAlertedDate = cursor.getLong(0); + cursor.close(); + } + + ContentValues values = new ContentValues(); + + // 判断是否已经置顶(alerted_date大于当前时间) + if (currentAlertedDate > System.currentTimeMillis()) { + // 已经置顶,取消置顶 + values.put(NoteColumns.ALERTED_DATE, 0); // 0表示未置顶 + unpinnedCount++; + } else { + // 未置顶,设置置顶 + values.put(NoteColumns.ALERTED_DATE, 9999999999999L); // 很大的值,表示置顶 + pinnedCount++; + } + + values.put(NoteColumns.LOCAL_MODIFIED, 1); + values.put(NoteColumns.MODIFIED_DATE, System.currentTimeMillis()); + getContentResolver().update( ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId), values, null, null); } catch (Exception e) { - Log.e(TAG, "Error pinning note: " + noteId, e); + Log.e(TAG, "Error toggling pin status: " + noteId, e); } } return widgets; @@ -718,7 +777,16 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe } } } - Toast.makeText(NotesListActivity.this, "已置顶" + mNotesListAdapter.getSelectedCount() + "条笔记", Toast.LENGTH_SHORT).show(); + + // 显示相应的提示信息 + if (pinnedCount > 0 && unpinnedCount > 0) { + Toast.makeText(NotesListActivity.this, "已置顶" + pinnedCount + "条笔记,已取消置顶" + unpinnedCount + "条笔记", Toast.LENGTH_SHORT).show(); + } else if (pinnedCount > 0) { + Toast.makeText(NotesListActivity.this, "已置顶" + pinnedCount + "条笔记", Toast.LENGTH_SHORT).show(); + } else if (unpinnedCount > 0) { + Toast.makeText(NotesListActivity.this, "已取消置顶" + unpinnedCount + "条笔记", Toast.LENGTH_SHORT).show(); + } + mModeCallBack.finishActionMode(); // 结束多选模式 } }.execute(); @@ -927,6 +995,9 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe public void onBackPressed() { // 处理返回键,根据当前状态返回上一级或退出 switch (mState) { + case SEARCH: + exitSearch(); + break; case SUB_FOLDER: mCurrentFolderId = Notes.ID_ROOT_FOLDER; mState = ListEditState.NOTE_LIST; @@ -1079,6 +1150,8 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe getMenuInflater().inflate(R.menu.sub_folder, menu); } else if (mState == ListEditState.CALL_RECORD_FOLDER) { getMenuInflater().inflate(R.menu.call_record_folder, menu); + } else if (mState == ListEditState.SEARCH) { + // 搜索状态下不显示菜单,因为搜索框已经占据了工具栏 } else { Log.e(TAG, "Wrong state:" + mState); } @@ -1115,10 +1188,128 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe @Override public boolean onSearchRequested() { - startSearch(null, false, null /* appData */, false); + toggleToSearch(); return true; } + /** + * 显示搜索框 + * 在根目录时,显示顶部搜索框;在子目录时,切换到根目录再显示搜索框 + */ + private void showSearch() { + if (mState != ListEditState.SEARCH) { + // 切换到搜索状态 + mState = ListEditState.SEARCH; + // 隐藏新建笔记按钮 + mAddNewNote.setVisibility(View.GONE); + // 隐藏标题栏,显示搜索容器 + mTitleBar.setVisibility(View.GONE); + mSearchContainer = findViewById(R.id.search_container); + mSearchContainer.setVisibility(View.VISIBLE); + mSearchEditText = (EditText) mSearchContainer.findViewById(R.id.search_input); + // 设置搜索输入框监听器 + mSearchEditText.addTextChangedListener(new TextWatcher() { + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void afterTextChanged(Editable s) { + mSearchKey = s.toString(); + // 根据搜索关键字刷新列表 + startSearchQuery(); + } + }); + + // 监听搜索框软键盘的"完成"键 + mSearchEditText.setOnEditorActionListener((v, actionId, event) -> { + if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH) { + mSearchKey = mSearchEditText.getText().toString(); + startSearchQuery(); + return true; + } + return false; + }); + + mSearchEditText.requestFocus(); + showSoftInput(); + + // 设置取消按钮点击监听器 + Button cancelButton = (Button) mSearchContainer.findViewById(R.id.search_cancel); + cancelButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + exitSearch(); + } + }); + } + } + + /** + * 启动搜索查询 + */ + private void startSearchQuery() { + // 只搜索SNIPPET列,因为CONTENT列在DataColumns中 + String selection = NoteColumns.SNIPPET + " LIKE ?"; + mBackgroundQueryHandler.startQuery(FOLDER_NOTE_LIST_QUERY_TOKEN, null, + Notes.CONTENT_NOTE_URI, NoteItemData.PROJECTION, selection, + new String[] {"%" + mSearchKey + "%"}, + NoteColumns.MODIFIED_DATE + " DESC"); + } + + /** + * 切换到搜索状态 + * 如果当前不在搜索状态,调用 showSearch;如果已经在搜索状态但有新的搜索关键字, + * 则直接刷新搜索结果 + */ + private void toggleToSearch() { + if (mState != ListEditState.SEARCH) { + // 如果当前不在根目录,先切换到根目录 + if (mCurrentFolderId != Notes.ID_ROOT_FOLDER) { + mCurrentFolderId = Notes.ID_ROOT_FOLDER; + mState = ListEditState.NOTE_LIST; + startAsyncNotesListQuery(); + } + showSearch(); + } else { + if (!mSearchKey.equals(mSearchEditText.getText().toString())) { + startSearchQuery(); + } + } + } + + /** + * 退出搜索状态 + */ + private void exitSearch() { + if (mState == ListEditState.SEARCH) { + // 切换回普通笔记列表状态 + mState = ListEditState.NOTE_LIST; + // 清空搜索关键字 + mSearchKey = ""; + // 隐藏搜索容器,显示标题栏 + if (mSearchContainer != null) { + mSearchContainer.setVisibility(View.GONE); + } + mTitleBar.setVisibility(View.VISIBLE); + mTitleBar.setText(R.string.app_name); + // 显示新建笔记按钮 + mAddNewNote.setVisibility(View.VISIBLE); + // 隐藏软键盘 + if (mSearchEditText != null) { + hideSoftInput(mSearchEditText); + } + // 刷新笔记列表 + startAsyncNotesListQuery(); + // 重新创建菜单,确保设置图标显示 + invalidateOptionsMenu(); + } + } + /** * 导出笔记为文本文件 */ @@ -1228,6 +1419,13 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe Log.e(TAG, "Wrong note type in CALL_RECORD_FOLDER"); } break; + case SEARCH: + if (item.getType() == Notes.TYPE_NOTE) { + openNode(item); // 打开笔记 + } else { + Log.e(TAG, "Wrong note type in SEARCH"); + } + break; default: break; } @@ -1264,13 +1462,45 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe * 触发同步操作 */ private void triggerSync() { - SyncManager.getInstance(this).sync(new SyncManager.SyncCallback() { + SyncDialogManager.showSyncOptionsDialog(this, new SyncDialogManager.SyncOptionListener() { + @Override + public void onUploadSelected() { + SyncDialogManager.showUploadConfirmDialog(NotesListActivity.this, new SyncDialogManager.ConfirmListener() { + @Override + public void onConfirm() { + executeUpload(); + } + + @Override + public void onCancel() { + // 取消操作,返回选择对话框 + } + }); + } + + @Override + public void onDownloadSelected() { + executeDownload(); + } + + @Override + public void onCanceled() { + // 取消操作 + } + }); + } + + /** + * 执行上传操作 + */ + private void executeUpload() { + SyncManager.getInstance(this).triggerUpload(new SyncManager.SyncCallback() { @Override public void onSyncStart() { runOnUiThread(new Runnable() { @Override public void run() { - Toast.makeText(NotesListActivity.this, "正在同步...", Toast.LENGTH_SHORT).show(); + Toast.makeText(NotesListActivity.this, "正在上传...", Toast.LENGTH_SHORT).show(); // 开始刷新动画 if (mSwipeRefreshLayout != null) { mSwipeRefreshLayout.setRefreshing(true); @@ -1284,7 +1514,58 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe runOnUiThread(new Runnable() { @Override public void run() { - Toast.makeText(NotesListActivity.this, "同步成功", Toast.LENGTH_SHORT).show(); + Toast.makeText(NotesListActivity.this, "上传成功", Toast.LENGTH_SHORT).show(); + // 停止刷新动画 + if (mSwipeRefreshLayout != null) { + mSwipeRefreshLayout.setRefreshing(false); + } + // 刷新笔记列表 + startAsyncNotesListQuery(); + } + }); + } + + @Override + public void onSyncFailed(final String errorMessage) { + runOnUiThread(new Runnable() { + @Override + public void run() { + Toast.makeText(NotesListActivity.this, errorMessage, Toast.LENGTH_SHORT).show(); + // 停止刷新动画 + if (mSwipeRefreshLayout != null) { + mSwipeRefreshLayout.setRefreshing(false); + } + } + }); + } + }); + } + + /** + * 执行下载操作 + */ + private void executeDownload() { + SyncManager.getInstance(this).triggerDownload(new SyncManager.SyncCallback() { + @Override + public void onSyncStart() { + runOnUiThread(new Runnable() { + @Override + public void run() { + Toast.makeText(NotesListActivity.this, "正在下载...", Toast.LENGTH_SHORT).show(); + // 开始刷新动画 + if (mSwipeRefreshLayout != null) { + mSwipeRefreshLayout.setRefreshing(true); + } + } + }); + } + + @Override + public void onSyncSuccess() { + runOnUiThread(new Runnable() { + @Override + public void run() { + Toast.makeText(NotesListActivity.this, "下载成功", Toast.LENGTH_SHORT).show(); // 停止刷新动画 if (mSwipeRefreshLayout != null) { mSwipeRefreshLayout.setRefreshing(false); @@ -1618,9 +1899,12 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe /** * 显示文件夹密码输入对话框 + * 支持密码验证和忘记密码功能(通过密保问题重置密码) * @param folderId 文件夹ID */ private void showFolderPasswordDialog(final long folderId) { + Log.d(TAG, "Showing folder password dialog for folderId: " + folderId); + AlertDialog.Builder builder = new AlertDialog.Builder(this); builder.setTitle("输入密码"); builder.setIcon(android.R.drawable.ic_lock_idle_lock); @@ -1635,11 +1919,15 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe builder.setPositiveButton("确定", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { - String password = passwordInput.getText().toString(); + String password = passwordInput.getText().toString().trim(); + Log.d(TAG, "User entered password for folder: " + folderId); + if (checkFolderPassword(folderId, password)) { + Log.i(TAG, "Folder password verified successfully, entering folder: " + folderId); // 密码正确,进入文件夹 enterEncryptedFolder(folderId); } else { + Log.w(TAG, "Folder password verification failed for folder: " + folderId); // 密码错误,显示提示 Toast.makeText(NotesListActivity.this, "密码错误,请重新输入", Toast.LENGTH_SHORT).show(); // 重新显示密码输入对话框 @@ -1651,23 +1939,37 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe // 设置取消按钮 builder.setNegativeButton("取消", null); + // 添加"忘记密码"按钮,点击后通过密保问题验证重置密码 + builder.setNeutralButton(R.string.btn_forgot_password, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + Log.d(TAG, "User clicked forgot password button for folder: " + folderId); + // 显示通过密保问题重置密码的对话框 + showResetPasswordWithSecurityQuestionsDialog(folderId); + } + }); + // 显示对话框 builder.show(); } /** * 通过密保问题重置文件夹密码 + * 验证成功后允许用户设置新密码 * @param folderId 文件夹ID */ private void showResetPasswordWithSecurityQuestionsDialog(final long folderId) { + Log.d(TAG, "Showing reset password with security questions dialog for folder: " + folderId); + // 检查是否设置了密保问题 if (!NotesPreferenceActivity.hasSecurityQuestionsSet(this)) { + Log.w(TAG, "Security questions not set, cannot reset folder password"); Toast.makeText(this, "请先在设置中设置密保问题", Toast.LENGTH_SHORT).show(); return; } AlertDialog.Builder builder = new AlertDialog.Builder(this); - builder.setTitle("忘记密码"); + builder.setTitle(R.string.title_forgot_password); builder.setIcon(android.R.drawable.ic_dialog_alert); builder.setMessage("请回答密保问题以重置密码"); @@ -1689,15 +1991,20 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe builder.setView(layout); // 设置确定按钮 - builder.setPositiveButton("确定", new DialogInterface.OnClickListener() { + builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { - String name = nameInput.getText().toString(); - String birthday = birthdayInput.getText().toString(); + String name = nameInput.getText().toString().trim(); + String birthday = birthdayInput.getText().toString().trim(); + + Log.d(TAG, "Verifying security questions for folder password reset - name: " + name); + if (NotesPreferenceActivity.verifySecurityQuestions(NotesListActivity.this, name, birthday)) { + Log.i(TAG, "Security questions verified successfully for folder: " + folderId); // 验证成功,显示设置新密码的对话框 showSetNewFolderPasswordDialog(folderId); } else { + Log.w(TAG, "Security questions verification failed for folder password reset"); // 验证失败,显示提示 Toast.makeText(NotesListActivity.this, "密保问题回答错误", Toast.LENGTH_SHORT).show(); } @@ -1705,7 +2012,7 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe }); // 设置取消按钮 - builder.setNegativeButton("取消", null); + builder.setNegativeButton(android.R.string.cancel, null); // 显示对话框 builder.show(); @@ -1713,25 +2020,35 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe /** * 显示设置新文件夹密码的对话框 + * 用户设置新密码后,需要重新输入密码才能进入文件夹 * @param folderId 文件夹ID */ private void showSetNewFolderPasswordDialog(final long folderId) { + Log.d(TAG, "Showing set new folder password dialog for folder: " + folderId); + AlertDialog.Builder builder = new AlertDialog.Builder(this); builder.setTitle("设置新密码"); builder.setIcon(android.R.drawable.ic_dialog_info); + builder.setMessage("验证成功,请设置新密码"); // 创建密码输入框 final EditText passwordInput = new EditText(this); passwordInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); passwordInput.setHint("请输入新密码"); - builder.setView(passwordInput); + LinearLayout layout = new LinearLayout(this); + layout.setOrientation(LinearLayout.VERTICAL); + layout.setPadding(40, 20, 40, 20); + layout.addView(passwordInput); + builder.setView(layout); // 设置确定按钮 - builder.setPositiveButton("确定", new DialogInterface.OnClickListener() { + builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { - String newPassword = passwordInput.getText().toString(); + String newPassword = passwordInput.getText().toString().trim(); + if (!newPassword.isEmpty()) { + Log.i(TAG, "Setting new password for folder: " + folderId); // 保存新密码 SharedPreferences preferences = getSharedPreferences("notes_prefs", Context.MODE_PRIVATE); SharedPreferences.Editor editor = preferences.edit(); @@ -1739,8 +2056,10 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe editor.apply(); // 显示提示 - Toast.makeText(NotesListActivity.this, "密码重置成功", Toast.LENGTH_SHORT).show(); + Toast.makeText(NotesListActivity.this, "密码重置成功,请使用新密码进入文件夹", Toast.LENGTH_SHORT).show(); + Log.i(TAG, "Folder password reset successfully for folder: " + folderId); } else { + Log.w(TAG, "New folder password is empty"); // 密码为空,显示提示 Toast.makeText(NotesListActivity.this, "密码不能为空", Toast.LENGTH_SHORT).show(); } @@ -1748,7 +2067,7 @@ public class NotesListActivity extends AppCompatActivity implements OnClickListe }); // 设置取消按钮 - builder.setNegativeButton("取消", null); + builder.setNegativeButton(android.R.string.cancel, null); // 显示对话框 builder.show();