diff --git a/src/notes/MainActivity.java b/src/notes/MainActivity.java index 1582fca..86a8c83 100644 --- a/src/notes/MainActivity.java +++ b/src/notes/MainActivity.java @@ -1,6 +1,10 @@ package net.micode.notes; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; import android.os.Bundle; +import android.os.StrictMode; +import android.util.Log; import androidx.activity.EdgeToEdge; import androidx.appcompat.app.AppCompatActivity; @@ -8,11 +12,21 @@ import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; +import net.micode.notes.data.NotesDatabaseHelper; + public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + + // 启用StrictMode检测UI线程的磁盘操作 + StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder() + .detectDiskReads() + .detectDiskWrites() + .penaltyLog() + .build()); + EdgeToEdge.enable(this); setContentView(R.layout.activity_main); ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> { @@ -21,4 +35,44 @@ public class MainActivity extends AppCompatActivity { return insets; }); } + private void checkDatabase() { + new Thread(() -> { + try { + NotesDatabaseHelper dbHelper = NotesDatabaseHelper.getInstance(this); + SQLiteDatabase db = dbHelper.getReadableDatabase(); + + // 检查表是否存在 + Cursor cursor = db.rawQuery( + "SELECT name FROM sqlite_master WHERE type='table' AND name='note_attachment'", + null); + boolean tableExists = cursor != null && cursor.getCount() > 0; + Log.d("Debug", "附件表存在: " + tableExists); + if (cursor != null) cursor.close(); + + // 查询附件数据 + cursor = db.rawQuery("SELECT COUNT(*) FROM note_attachment", null); + if (cursor != null && cursor.moveToFirst()) { + int count = cursor.getInt(0); + Log.d("Debug", "附件表记录数: " + count); + } + if (cursor != null) cursor.close(); + + // 列出所有附件 + cursor = db.query("note_attachment", null, null, null, null, null, null); + if (cursor != null) { + Log.d("Debug", "附件详情 - 总数: " + cursor.getCount()); + while (cursor.moveToNext()) { + long id = cursor.getLong(cursor.getColumnIndexOrThrow("_id")); + long noteId = cursor.getLong(cursor.getColumnIndexOrThrow("note_id")); + String path = cursor.getString(cursor.getColumnIndexOrThrow("file_path")); + Log.d("Debug", String.format("附件: id=%d, noteId=%d, path=%s", id, noteId, path)); + } + cursor.close(); + } + + } catch (Exception e) { + Log.e("Debug", "数据库检查失败", e); + } + }).start(); + } } diff --git a/src/notes/data/AttachmentManager.java b/src/notes/data/AttachmentManager.java new file mode 100644 index 0000000..d92d750 --- /dev/null +++ b/src/notes/data/AttachmentManager.java @@ -0,0 +1,344 @@ +package net.micode.notes.data; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Environment; +import android.text.TextUtils; +import android.util.Log; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.util.ArrayList; +import java.util.List; + +/** + * 附件管理器,负责附件的数据库操作和文件管理 + */ +public class AttachmentManager { + private static final String TAG = "AttachmentManager"; + + private final Context mContext; + private final ContentResolver mContentResolver; + + /** + * 附件文件存储目录 + */ + private static final String ATTACHMENT_DIR = "attachments"; + + /** + * 构造函数 + * @param context 上下文 + */ + public AttachmentManager(Context context) { + mContext = context.getApplicationContext(); + mContentResolver = mContext.getContentResolver(); + } + + /** + * 添加附件 + * @param noteId 笔记ID + * @param type 附件类型(Notes.ATTACHMENT_TYPE_GALLERY 或 Notes.ATTACHMENT_TYPE_CAMERA) + * @param sourceFile 源文件(来自相册或相机) + * @return 附件ID,失败返回-1 + */ + public long addAttachment(long noteId, int type, File sourceFile) { + if (sourceFile == null || !sourceFile.exists()) { + Log.e(TAG, "Source file does not exist"); + return -1; + } + + // 复制文件到应用私有存储 + File destFile = copyToPrivateStorage(sourceFile, noteId); + if (destFile == null) { + Log.e(TAG, "Failed to copy file to private storage"); + return -1; + } + + // 插入数据库记录 + ContentValues values = new ContentValues(); + values.put(Notes.AttachmentColumns.NOTE_ID, noteId); + values.put(Notes.AttachmentColumns.TYPE, type); + values.put(Notes.AttachmentColumns.FILE_PATH, destFile.getAbsolutePath()); + values.put(Notes.AttachmentColumns.CREATED_TIME, System.currentTimeMillis()); + + try { + Uri uri = mContentResolver.insert(Notes.CONTENT_ATTACHMENT_URI, values); + if (uri != null) { + String idStr = uri.getLastPathSegment(); + if (!TextUtils.isEmpty(idStr)) { + long attachmentId = Long.parseLong(idStr); + // 更新笔记的附件状态 + updateNoteAttachmentStatus(noteId, true); + return attachmentId; + } + } + } catch (Exception e) { + Log.e(TAG, "Failed to insert attachment", e); + // 插入失败时删除已复制的文件 + destFile.delete(); + } + + return -1; + } + + /** + * 删除附件 + * @param attachmentId 附件ID + * @return 是否成功 + */ + public boolean deleteAttachment(long attachmentId) { + // 先获取笔记ID + long noteId = getNoteIdByAttachmentId(attachmentId); + if (noteId <= 0) { + Log.e(TAG, "Failed to get note id for attachment " + attachmentId); + return false; + } + + // 获取文件路径并删除文件 + String filePath = getAttachmentFilePath(attachmentId); + if (filePath != null) { + File file = new File(filePath); + if (file.exists()) { + file.delete(); + } + } + + // 删除数据库记录 + Uri uri = Uri.withAppendedPath(Notes.CONTENT_ATTACHMENT_URI, String.valueOf(attachmentId)); + int count = mContentResolver.delete(uri, null, null); + boolean success = count > 0; + + if (success) { + // 检查笔记是否还有其他附件 + List remainingAttachments = getAttachmentsByNoteId(noteId); + if (remainingAttachments.isEmpty()) { + // 没有附件了,更新笔记状态 + updateNoteAttachmentStatus(noteId, false); + } + } + + return success; + } + + /** + * 删除笔记的所有附件 + * @param noteId 笔记ID + * @return 删除的附件数量 + */ + public int deleteAttachmentsByNoteId(long noteId) { + List attachments = getAttachmentsByNoteId(noteId); + int deletedCount = 0; + for (Attachment attachment : attachments) { + if (deleteAttachment(attachment.id)) { + deletedCount++; + } + } + return deletedCount; + } + + /** + * 获取附件的文件路径 + * @param attachmentId 附件ID + * @return 文件路径,失败返回null + */ + public String getAttachmentFilePath(long attachmentId) { + String[] projection = { Notes.AttachmentColumns.FILE_PATH }; + Uri uri = Uri.withAppendedPath(Notes.CONTENT_ATTACHMENT_URI, String.valueOf(attachmentId)); + Cursor cursor = null; + try { + cursor = mContentResolver.query(uri, projection, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + return cursor.getString(0); + } + } catch (Exception e) { + Log.e(TAG, "Failed to get attachment file path", e); + } finally { + if (cursor != null) { + cursor.close(); + } + } + return null; + } + + /** + * 获取笔记的所有附件 + * @param noteId 笔记ID + * @return 附件列表 + */ + public List getAttachmentsByNoteId(long noteId) { + List attachments = new ArrayList<>(); + String[] projection = { + Notes.AttachmentColumns.ID, + Notes.AttachmentColumns.NOTE_ID, + Notes.AttachmentColumns.TYPE, + Notes.AttachmentColumns.FILE_PATH, + Notes.AttachmentColumns.CREATED_TIME + }; + String selection = Notes.AttachmentColumns.NOTE_ID + "=?"; + String[] selectionArgs = { String.valueOf(noteId) }; + String sortOrder = Notes.AttachmentColumns.CREATED_TIME + " ASC"; + + Cursor cursor = null; + try { + cursor = mContentResolver.query(Notes.CONTENT_ATTACHMENT_URI, + projection, selection, selectionArgs, sortOrder); + if (cursor != null) { + while (cursor.moveToNext()) { + Attachment attachment = new Attachment(); + attachment.id = cursor.getLong(0); + attachment.noteId = cursor.getLong(1); + attachment.type = cursor.getInt(2); + attachment.filePath = cursor.getString(3); + attachment.createdTime = cursor.getLong(4); + attachments.add(attachment); + } + } + } catch (Exception e) { + Log.e(TAG, "Failed to get attachments", e); + } finally { + if (cursor != null) { + cursor.close(); + } + } + return attachments; + } + + /** + * 复制文件到应用私有存储 + * @param sourceFile 源文件 + * @param noteId 笔记ID(用于生成文件名) + * @return 目标文件,失败返回null + */ + private File copyToPrivateStorage(File sourceFile, long noteId) { + File destDir = getAttachmentStorageDir(); + if (destDir == null || !destDir.exists() && !destDir.mkdirs()) { + Log.e(TAG, "Failed to create attachment directory"); + return null; + } + + // 生成唯一文件名:note_{noteId}_{timestamp}_{random}.jpg + String timestamp = String.valueOf(System.currentTimeMillis()); + String random = String.valueOf((int)(Math.random() * 1000)); + String extension = getFileExtension(sourceFile.getName()); + String fileName = "note_" + noteId + "_" + timestamp + "_" + random + extension; + File destFile = new File(destDir, fileName); + + try { + copyFile(sourceFile, destFile); + return destFile; + } catch (IOException e) { + Log.e(TAG, "Failed to copy file", e); + return null; + } + } + + /** + * 获取附件存储目录 + * @return 目录文件 + */ + public File getAttachmentStorageDir() { + File picturesDir = mContext.getExternalFilesDir(Environment.DIRECTORY_PICTURES); + if (picturesDir == null) { + picturesDir = new File(mContext.getFilesDir(), "Pictures"); + } + return new File(picturesDir, ATTACHMENT_DIR); + } + + /** + * 获取文件扩展名 + * @param fileName 文件名 + * @return 扩展名(包含点) + */ + private String getFileExtension(String fileName) { + int dotIndex = fileName.lastIndexOf('.'); + if (dotIndex > 0 && dotIndex < fileName.length() - 1) { + return fileName.substring(dotIndex); + } + return ".jpg"; + } + + /** + * 复制文件 + * @param source 源文件 + * @param dest 目标文件 + * @throws IOException IO异常 + */ + private void copyFile(File source, File dest) throws IOException { + try (FileChannel sourceChannel = new FileInputStream(source).getChannel(); + FileChannel destChannel = new FileOutputStream(dest).getChannel()) { + destChannel.transferFrom(sourceChannel, 0, sourceChannel.size()); + } + } + + /** + * 通过附件ID获取笔记ID + * @param attachmentId 附件ID + * @return 笔记ID,失败返回-1 + */ + private long getNoteIdByAttachmentId(long attachmentId) { + String[] projection = { Notes.AttachmentColumns.NOTE_ID }; + Uri uri = Uri.withAppendedPath(Notes.CONTENT_ATTACHMENT_URI, String.valueOf(attachmentId)); + Cursor cursor = null; + try { + cursor = mContentResolver.query(uri, projection, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + return cursor.getLong(0); + } + } catch (Exception e) { + Log.e(TAG, "Failed to get note id by attachment id", e); + } finally { + if (cursor != null) { + cursor.close(); + } + } + return -1; + } + + /** + * 更新笔记的附件状态 + * @param noteId 笔记ID + * @param hasAttachment 是否有附件 + */ + private void updateNoteAttachmentStatus(long noteId, boolean hasAttachment) { + ContentValues values = new ContentValues(); + values.put(Notes.NoteColumns.HAS_ATTACHMENT, hasAttachment ? 1 : 0); + values.put(Notes.NoteColumns.LOCAL_MODIFIED, 1); + values.put(Notes.NoteColumns.MODIFIED_DATE, System.currentTimeMillis()); + + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId); + try { + mContentResolver.update(uri, values, null, null); + } catch (Exception e) { + Log.e(TAG, "Failed to update note attachment status", e); + } + } + + /** + * 附件数据模型 + */ + public static class Attachment { + public long id; + public long noteId; + public int type; + public String filePath; + public long createdTime; + + @Override + public String toString() { + return "Attachment{" + + "id=" + id + + ", noteId=" + noteId + + ", type=" + type + + ", filePath='" + filePath + '\'' + + ", createdTime=" + createdTime + + '}'; + } + } +} \ No newline at end of file diff --git a/src/notes/data/Notes.java b/src/notes/data/Notes.java index 0be82f2..40b36c3 100644 --- a/src/notes/data/Notes.java +++ b/src/notes/data/Notes.java @@ -140,6 +140,47 @@ public class Notes { public static final String CALL_NOTE = CallNote.CONTENT_ITEM_TYPE; } + /** + * 附件类型常量 + */ + public static final int ATTACHMENT_TYPE_GALLERY = 0; + public static final int ATTACHMENT_TYPE_CAMERA = 1; + + /** + * 附件表列名定义接口 + */ + public interface AttachmentColumns { + /** + * The unique ID for a row + *

Type: INTEGER (long)

+ */ + public static final String ID = "_id"; + + /** + * The note's id this attachment belongs to + *

Type: INTEGER (long)

+ */ + public static final String NOTE_ID = "note_id"; + + /** + * Attachment type (0=gallery, 1=camera) + *

Type: INTEGER

+ */ + public static final String TYPE = "type"; + + /** + * File path in private storage + *

Type: TEXT

+ */ + public static final String FILE_PATH = "file_path"; + + /** + * Created time + *

Type: INTEGER (long)

+ */ + public static final String CREATED_TIME = "created_time"; + } + /** * 用于查询所有笔记和文件夹的Uri */ @@ -150,6 +191,11 @@ public class Notes { */ public static final Uri CONTENT_DATA_URI = Uri.parse("content://" + AUTHORITY + "/data"); + /** + * 用于查询附件的Uri + */ + public static final Uri CONTENT_ATTACHMENT_URI = Uri.parse("content://" + AUTHORITY + "/attachment"); + /** * 笔记表列名定义接口 */ diff --git a/src/notes/data/NotesDatabaseHelper.java b/src/notes/data/NotesDatabaseHelper.java index abda0d7..e796307 100644 --- a/src/notes/data/NotesDatabaseHelper.java +++ b/src/notes/data/NotesDatabaseHelper.java @@ -18,6 +18,7 @@ package net.micode.notes.data; import android.content.ContentValues; import android.content.Context; +import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.util.Log; @@ -48,7 +49,7 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { /** * 数据库版本号,版本迭代历史详见 {@link #onUpgrade} 方法 */ - private static final int DB_VERSION = 4; + private static final int DB_VERSION = 7; /** * 数据库表名定义接口 @@ -56,6 +57,7 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { * 定义两个核心表: * 1. {@link #NOTE}:便签元数据表,存储文件夹、标题、时间、类型等信息。 * 2. {@link #DATA}:便签内容数据表,支持多种 MIME 类型(文本、图片、提醒等)。 + * 3. {@link #ATTACHMENT}:便签附件表,存储图片等附件信息。 */ public interface TABLE { /** @@ -67,6 +69,11 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { * 数据表名 */ public static final String DATA = "data"; + + /** + * 附件表名 + */ + public static final String ATTACHMENT = "note_attachment"; } /** @@ -136,6 +143,26 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { DataColumns.DATA5 + " TEXT NOT NULL DEFAULT ''" + ")"; + /** + * 创建附件表 (note_attachment) 的 SQL 语句。 + *

+ * 存储便签的图片附件信息,支持本地相册和相机拍照两种来源。 + * 核心字段说明: + * - _id: 主键(与Android ContentProvider标准保持一致) + * - note_id: 关联的笔记ID + * - type: 附件类型(0=本地相册,1=相机拍照) + * - file_path: 图片文件在应用私有存储中的绝对路径 + * - created_time: 创建时间戳 + */ + private static final String CREATE_ATTACHMENT_TABLE_SQL = + "CREATE TABLE " + TABLE.ATTACHMENT + "(" + + "_id INTEGER PRIMARY KEY," + + "note_id INTEGER NOT NULL," + + "type INTEGER NOT NULL DEFAULT 0," + + "file_path TEXT NOT NULL," + + "created_time INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)" + + ")"; + /** * 在 data 表的 NOTE_ID 列上创建索引的 SQL 语句。 *

@@ -403,6 +430,20 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { Log.d(TAG, "data table has been created"); } + /** + * 创建附件表 (note_attachment) 及其索引。 + * + * @param db 可写的数据库实例。 + */ + public void createAttachmentTable(SQLiteDatabase db) { + // 执行创建表SQL + db.execSQL(CREATE_ATTACHMENT_TABLE_SQL); + // 创建note_id索引以提高查询性能 + db.execSQL("CREATE INDEX IF NOT EXISTS attachment_note_id_index ON " + + TABLE.ATTACHMENT + "(note_id);"); + Log.d(TAG, "attachment table has been created"); + } + /** * 重建 data 表的所有 SQL 触发器。 *

@@ -428,7 +469,7 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { * @param context 应用上下文。 * @return NotesDatabaseHelper 的单例实例。 */ - static synchronized NotesDatabaseHelper getInstance(Context context) { + public static synchronized NotesDatabaseHelper getInstance(Context context) { if (mInstance == null) { mInstance = new NotesDatabaseHelper(context); } @@ -438,7 +479,7 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { /** * 数据库首次创建时回调。 *

- * 执行顺序:先创建 note 表(含系统文件夹),再创建 data 表。 + * 执行顺序:先创建 note 表(含系统文件夹),再创建 data 表,最后创建附件表。 * * @param db 数据库实例。 */ @@ -448,6 +489,8 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { createNoteTable(db); // 创建数据表 createDataTable(db); + // 创建附件表 + createAttachmentTable(db); } /** @@ -486,13 +529,31 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { oldVersion++; } - // 4. 如果表结构在v3升级中发生较大变更,需要重建触发器以确保兼容性 + // 4. 从版本4升级到版本5(支持图片插入功能) + if (oldVersion == 4) { + upgradeToV5(db); + oldVersion++; + } + + // 5. 从版本5升级到版本6(添加附件表) + if (oldVersion == 5) { + upgradeToV6(db); + oldVersion++; + } + + // 6. 从版本6升级到版本7(修复附件表主键列名) + if (oldVersion == 6) { + upgradeToV7(db); + oldVersion++; + } + + // 7. 如果表结构在v3升级中发生较大变更,需要重建触发器以确保兼容性 if (reCreateTriggers) { reCreateNoteTableTriggers(db); reCreateDataTableTriggers(db); } - // 5. 最终版本校验:确保所有升级步骤已执行完毕 + // 8. 最终版本校验:确保所有升级步骤已执行完毕 if (oldVersion != newVersion) { throw new IllegalStateException("Upgrade notes database to version " + newVersion + "fails"); @@ -555,4 +616,126 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.VERSION + " INTEGER NOT NULL DEFAULT 0"); } + + /** + * 升级数据库到版本 5。 + *

+ * 升级方式:渐进式迁移,添加图片查询索引。 + * 主要变更:支持图片插入功能。 + *

+ * 设计说明: + * - 图片数据存储在 data 表中,使用现有的 DATA3(图片路径)、DATA4(图片宽度)、DATA5(图片高度)字段 + * - 通过 MIME_TYPE 区分图片数据类型 + * - 为图片查询添加索引以提高性能 + * + * @param db 可写的数据库实例。 + */ + private void upgradeToV5(SQLiteDatabase db) { + // 为图片数据添加查询索引,提高图片查询性能 + db.execSQL("CREATE INDEX IF NOT EXISTS image_data_index ON " + TABLE.DATA + "(" + DataColumns.MIME_TYPE + ") WHERE " + + DataColumns.MIME_TYPE + " LIKE 'image/%'"); + Log.d(TAG, "Database upgraded to version 5 (image support with index)"); + } + + /** + * 升级数据库到版本 6。 + *

+ * 升级方式:渐进式迁移,添加附件表。 + * 主要变更:支持图片附件存储,独立表结构便于管理。 + * + * @param db 可写的数据库实例。 + */ + private void upgradeToV6(SQLiteDatabase db) { + // 创建附件表 + createAttachmentTable(db); + Log.d(TAG, "Database upgraded to version 6 (attachment table added)"); + } + + /** + * 升级数据库到版本 7。 + *

+ * 升级方式:渐进式迁移,修复附件表主键列名不一致问题。 + * 主要变更:将附件表的主键列名从 "id" 改为 "_id",与Android ContentProvider标准保持一致。 + * + * @param db 可写的数据库实例。 + */ + private void upgradeToV7(SQLiteDatabase db) { + // 检查附件表是否存在 + Cursor cursor = db.rawQuery("SELECT name FROM sqlite_master WHERE type='table' AND name='note_attachment'", null); + boolean tableExists = cursor != null && cursor.getCount() > 0; + if (cursor != null) { + cursor.close(); + } + + if (tableExists) { + // 检查当前表结构中的主键列名 + cursor = db.rawQuery("PRAGMA table_info(note_attachment)", null); + boolean hasIdColumn = false; + boolean hasUnderscoreIdColumn = false; + + if (cursor != null) { + while (cursor.moveToNext()) { + String columnName = cursor.getString(1); // 列名在索引1 + if ("id".equals(columnName)) { + hasIdColumn = true; + } else if ("_id".equals(columnName)) { + hasUnderscoreIdColumn = true; + } + } + cursor.close(); + } + + if (hasIdColumn && !hasUnderscoreIdColumn) { + // 需要将id列重命名为_id + Log.d(TAG, "Starting migration: renaming 'id' column to '_id' in note_attachment table"); + + // 使用事务确保迁移的原子性 + db.beginTransaction(); + try { + // 1. 创建临时表 + db.execSQL("CREATE TABLE note_attachment_temp (" + + "_id INTEGER PRIMARY KEY," + + "note_id INTEGER NOT NULL," + + "type INTEGER NOT NULL DEFAULT 0," + + "file_path TEXT NOT NULL," + + "created_time INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)" + + ")"); + + // 2. 复制数据 + db.execSQL("INSERT INTO note_attachment_temp (_id, note_id, type, file_path, created_time) " + + "SELECT id, note_id, type, file_path, created_time FROM note_attachment"); + + // 3. 删除原表 + db.execSQL("DROP TABLE note_attachment"); + + // 4. 重命名临时表 + db.execSQL("ALTER TABLE note_attachment_temp RENAME TO note_attachment"); + + // 5. 重新创建索引 + db.execSQL("CREATE INDEX IF NOT EXISTS attachment_note_id_index ON note_attachment(note_id)"); + + db.setTransactionSuccessful(); + Log.d(TAG, "Successfully migrated note_attachment table: renamed 'id' to '_id'"); + } catch (Exception e) { + Log.e(TAG, "Failed to migrate note_attachment table", e); + throw e; + } finally { + db.endTransaction(); + } + } else if (hasUnderscoreIdColumn) { + Log.d(TAG, "Table already has correct '_id' column, no migration needed"); + } else { + Log.w(TAG, "Unexpected table structure for note_attachment, recreating table"); + // 如果表结构异常,重新创建表 + db.execSQL("DROP TABLE IF EXISTS note_attachment"); + createAttachmentTable(db); + } + } else { + // 表不存在,直接创建新表 + createAttachmentTable(db); + Log.d(TAG, "Created note_attachment table with correct column names"); + } + + Log.d(TAG, "Database upgraded to version 7 (attachment table column name fixed)"); + } } \ No newline at end of file diff --git a/src/notes/data/NotesProvider.java b/src/notes/data/NotesProvider.java index 8810f7a..1387465 100644 --- a/src/notes/data/NotesProvider.java +++ b/src/notes/data/NotesProvider.java @@ -32,6 +32,7 @@ import android.util.Log; import net.micode.notes.R; import net.micode.notes.data.Notes.DataColumns; import net.micode.notes.data.Notes.NoteColumns; +import net.micode.notes.data.Notes.AttachmentColumns; import net.micode.notes.data.NotesDatabaseHelper.TABLE; @@ -86,6 +87,16 @@ public class NotesProvider extends ContentProvider { */ private static final int URI_SEARCH_SUGGEST = 6; + /** + * URI类型:所有附件 + */ + private static final int URI_ATTACHMENT = 7; + + /** + * URI类型:单个附件 + */ + private static final int URI_ATTACHMENT_ITEM = 8; + /** * 静态初始化块,初始化UriMatcher */ @@ -99,6 +110,8 @@ public class NotesProvider extends ContentProvider { mMatcher.addURI(Notes.AUTHORITY, "search", URI_SEARCH); // content://micode_notes/search mMatcher.addURI(Notes.AUTHORITY, SearchManager.SUGGEST_URI_PATH_QUERY, URI_SEARCH_SUGGEST); // 搜索建议 mMatcher.addURI(Notes.AUTHORITY, SearchManager.SUGGEST_URI_PATH_QUERY + "/*", URI_SEARCH_SUGGEST); // 带参数的搜索建议 + mMatcher.addURI(Notes.AUTHORITY, "attachment", URI_ATTACHMENT); // content://micode_notes/attachment + mMatcher.addURI(Notes.AUTHORITY, "attachment/#", URI_ATTACHMENT_ITEM); // content://micode_notes/attachment/1 } /** @@ -166,6 +179,15 @@ public class NotesProvider extends ContentProvider { c = db.query(TABLE.DATA, projection, DataColumns.ID + "=" + id + parseSelection(selection), selectionArgs, null, null, sortOrder); break; + case URI_ATTACHMENT: + c = db.query(TABLE.ATTACHMENT, projection, selection, selectionArgs, null, null, + sortOrder); + break; + case URI_ATTACHMENT_ITEM: + id = uri.getPathSegments().get(1); + c = db.query(TABLE.ATTACHMENT, projection, AttachmentColumns.ID + "=" + id + + parseSelection(selection), selectionArgs, null, null, sortOrder); + break; case URI_SEARCH: case URI_SEARCH_SUGGEST: if (sortOrder != null || projection != null) { @@ -225,6 +247,14 @@ public class NotesProvider extends ContentProvider { } insertedId = dataId = db.insert(TABLE.DATA, null, values); break; + case URI_ATTACHMENT: + if (values.containsKey(AttachmentColumns.NOTE_ID)) { + noteId = values.getAsLong(AttachmentColumns.NOTE_ID); + } else { + Log.d(TAG, "Wrong attachment format without note id:" + values.toString()); + } + insertedId = db.insert(TABLE.ATTACHMENT, null, values); + break; default: throw new IllegalArgumentException("Unknown URI " + uri); } @@ -284,6 +314,14 @@ public class NotesProvider extends ContentProvider { DataColumns.ID + "=" + id + parseSelection(selection), selectionArgs); deleteData = true; break; + case URI_ATTACHMENT: + count = db.delete(TABLE.ATTACHMENT, selection, selectionArgs); + break; + case URI_ATTACHMENT_ITEM: + id = uri.getPathSegments().get(1); + count = db.delete(TABLE.ATTACHMENT, + Notes.AttachmentColumns.ID + "=" + id + parseSelection(selection), selectionArgs); + break; default: throw new IllegalArgumentException("Unknown URI " + uri); } @@ -331,6 +369,14 @@ public class NotesProvider extends ContentProvider { + parseSelection(selection), selectionArgs); updateData = true; break; + case URI_ATTACHMENT: + count = db.update(TABLE.ATTACHMENT, values, selection, selectionArgs); + break; + case URI_ATTACHMENT_ITEM: + id = uri.getPathSegments().get(1); + count = db.update(TABLE.ATTACHMENT, values, "id=" + id + + parseSelection(selection), selectionArgs); + break; default: throw new IllegalArgumentException("Unknown URI " + uri); } diff --git a/src/notes/model/WorkingNote.java b/src/notes/model/WorkingNote.java index 084ae77..6d1f613 100644 --- a/src/notes/model/WorkingNote.java +++ b/src/notes/model/WorkingNote.java @@ -29,8 +29,13 @@ import net.micode.notes.data.Notes.DataColumns; import net.micode.notes.data.Notes.DataConstants; import net.micode.notes.data.Notes.NoteColumns; import net.micode.notes.data.Notes.TextNote; +import net.micode.notes.data.AttachmentManager; import net.micode.notes.tool.ResourceParser.NoteBgResources; +import java.io.File; +import java.util.ArrayList; +import java.util.List; + /** * WorkingNote类 - 表示正在编辑或使用中的笔记 @@ -46,6 +51,10 @@ public class WorkingNote { * 内部封装的Note对象 */ private Note mNote; + /** + * 附件管理器 + */ + private AttachmentManager mAttachmentManager; /** * 笔记ID */ @@ -145,6 +154,7 @@ public class WorkingNote { mModifiedDate = System.currentTimeMillis(); mFolderId = folderId; mNote = new Note(); + mAttachmentManager = new AttachmentManager(context); mNoteId = 0; mIsDeleted = false; mMode = 0; @@ -158,6 +168,7 @@ public class WorkingNote { mFolderId = folderId; mIsDeleted = false; mNote = new Note(); + mAttachmentManager = new AttachmentManager(context); loadNote(); } @@ -379,6 +390,61 @@ public class WorkingNote { return mWidgetType; } + /** + * 添加附件 + * @param type 附件类型(Notes.ATTACHMENT_TYPE_GALLERY 或 Notes.ATTACHMENT_TYPE_CAMERA) + * @param sourceFile 源文件 + * @return 附件ID,失败返回-1 + */ + public long addAttachment(int type, File sourceFile) { + if (mAttachmentManager == null || mNoteId <= 0) { + return -1; + } + return mAttachmentManager.addAttachment(mNoteId, type, sourceFile); + } + + /** + * 删除附件 + * @param attachmentId 附件ID + * @return 是否成功 + */ + public boolean deleteAttachment(long attachmentId) { + if (mAttachmentManager == null) { + return false; + } + return mAttachmentManager.deleteAttachment(attachmentId); + } + + /** + * 删除笔记的所有附件 + * @return 删除的附件数量 + */ + public int deleteAllAttachments() { + if (mAttachmentManager == null) { + return 0; + } + return mAttachmentManager.deleteAttachmentsByNoteId(mNoteId); + } + + /** + * 获取笔记的所有附件 + * @return 附件列表 + */ + public List getAttachments() { + if (mAttachmentManager == null) { + return new ArrayList<>(); + } + return mAttachmentManager.getAttachmentsByNoteId(mNoteId); + } + + /** + * 获取附件管理器实例 + * @return 附件管理器 + */ + public AttachmentManager getAttachmentManager() { + return mAttachmentManager; + } + public interface NoteSettingChangedListener { /** * Called when the background color of current note has just changed diff --git a/src/notes/tool/BackupUtils.java b/src/notes/tool/BackupUtils.java index dba5d6a..8fff9dc 100644 --- a/src/notes/tool/BackupUtils.java +++ b/src/notes/tool/BackupUtils.java @@ -249,7 +249,7 @@ public class BackupUtils { Notes.CONTENT_NOTE_URI, NOTE_PROJECTION, "(" + NoteColumns.TYPE + "=" + Notes.TYPE_FOLDER + " AND " - + NoteColumns.PARENT_ID + "<>" + Notes.ID_TRASH_FOLER + ") OR " + + NoteColumns.PARENT_ID + "<>" + Notes.ID_TRASH_FOLDER + ") OR " + NoteColumns.ID + "=" + Notes.ID_CALL_RECORD_FOLDER, null, null); if (folderCursor != null) { @@ -300,60 +300,9 @@ public class BackupUtils { return STATE_SYSTEM_ERROR; } - // 第一部分:导出文件夹及其中的笔记 - // 查询所有文件夹(排除回收站,但包含通话记录文件夹) - Cursor folderCursor = mContext.getContentResolver().query( - Notes.CONTENT_NOTE_URI, - NOTE_PROJECTION, - "(" + NoteColumns.TYPE + "=" + Notes.TYPE_FOLDER + " AND " - + NoteColumns.PARENT_ID + "<>" + Notes.ID_TRASH_FOLDER + ") OR " - + NoteColumns.ID + "=" + Notes.ID_CALL_RECORD_FOLDER, null, null); - - if (folderCursor != null) { - if (folderCursor.moveToFirst()) { - do { - // 输出文件夹名称 - String folderName = ""; - if(folderCursor.getLong(NOTE_COLUMN_ID) == Notes.ID_CALL_RECORD_FOLDER) { - folderName = mContext.getString(R.string.call_record_folder_name); - } else { - folderName = folderCursor.getString(NOTE_COLUMN_SNIPPET); - } - if (!TextUtils.isEmpty(folderName)) { - ps.println(String.format(getFormat(FORMAT_FOLDER_NAME), folderName)); - } - // 导出该文件夹下的所有笔记 - String folderId = folderCursor.getString(NOTE_COLUMN_ID); - exportFolderToText(folderId, ps); - } while (folderCursor.moveToNext()); - } - folderCursor.close(); - } - // 第二部分:导出根目录下的笔记(不属于任何文件夹的笔记) - Cursor noteCursor = mContext.getContentResolver().query( - Notes.CONTENT_NOTE_URI, - NOTE_PROJECTION, - NoteColumns.TYPE + "=" + +Notes.TYPE_NOTE + " AND " + NoteColumns.PARENT_ID - + "=0", null, null); - if (noteCursor != null) { - if (noteCursor.moveToFirst()) { - do { - ps.println(String.format(getFormat(FORMAT_NOTE_DATE), DateFormat.format( - mContext.getString(R.string.format_datetime_mdhm), - noteCursor.getLong(NOTE_COLUMN_MODIFIED_DATE)))); - // 导出该笔记的内容 - String noteId = noteCursor.getString(NOTE_COLUMN_ID); - exportNoteToText(noteId, ps); - } while (noteCursor.moveToNext()); - } - noteCursor.close(); - } - // 关闭输出流 - ps.close(); - return STATE_SUCCESS; } /** diff --git a/src/notes/tool/ElderModeUtils.java b/src/notes/tool/ElderModeUtils.java index 11f4132..d2e4d4b 100644 --- a/src/notes/tool/ElderModeUtils.java +++ b/src/notes/tool/ElderModeUtils.java @@ -19,6 +19,7 @@ package net.micode.notes.tool; import android.content.Context; import android.content.SharedPreferences; import android.preference.PreferenceManager; +import android.util.TypedValue; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; @@ -66,7 +67,7 @@ public class ElderModeUtils { // 只增大用户输入内容的字体 if (id == R.id.tv_title || // 笔记列表中的标题(用户输入内容) id == R.id.note_edit_view) { // 编辑界面中的内容(用户输入) - textView.setTextSize(textView.getTextSize() * ELDER_MODE_FONT_SCALE); + textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, textView.getTextSize() * ELDER_MODE_FONT_SCALE); } } else if (view instanceof ViewGroup) { // 递归处理ViewGroup中的所有子View @@ -84,9 +85,10 @@ public class ElderModeUtils { * @param view 要清除老年人模式的View */ public static void clearElderMode(Context context, View view) { - if (!isElderModeEnabled(context)) { - return; - } + // 移除这个检查,因为清除操作应该在老年人模式禁用时执行 + // if (!isElderModeEnabled(context)) { + // return; + // } // 只对用户输入的内容应用字体恢复,系统信息保持不变 if (view instanceof TextView) { @@ -96,7 +98,7 @@ public class ElderModeUtils { // 只恢复用户输入内容的字体 if (id == R.id.tv_title || // 笔记列表中的标题(用户输入内容) id == R.id.note_edit_view) { // 编辑界面中的内容(用户输入) - textView.setTextSize(textView.getTextSize() / ELDER_MODE_FONT_SCALE); + textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, textView.getTextSize() / ELDER_MODE_FONT_SCALE); } } else if (view instanceof ViewGroup) { // 递归处理ViewGroup中的所有子View diff --git a/src/notes/tool/PermissionHelper.java b/src/notes/tool/PermissionHelper.java new file mode 100644 index 0000000..5c9e183 --- /dev/null +++ b/src/notes/tool/PermissionHelper.java @@ -0,0 +1,148 @@ +package net.micode.notes.tool; + +import android.app.Activity; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; + +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; + +import java.util.ArrayList; +import java.util.List; + +/** + * 权限帮助类,简化运行时权限请求 + */ +public class PermissionHelper { + + /** + * 检查权限是否已授予 + * @param context 上下文 + * @param permission 权限 + * @return 是否已授权 + */ + public static boolean isPermissionGranted(Context context, String permission) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return true; + } + return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED; + } + + /** + * 检查多个权限是否已全部授予 + * @param context 上下文 + * @param permissions 权限数组 + * @return 是否全部已授权 + */ + public static boolean arePermissionsGranted(Context context, String[] permissions) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return true; + } + for (String permission : permissions) { + if (!isPermissionGranted(context, permission)) { + return false; + } + } + return true; + } + + /** + * 请求权限 + * @param activity 活动 + * @param permissions 权限数组 + * @param requestCode 请求码 + */ + public static void requestPermissions(Activity activity, String[] permissions, int requestCode) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return; + } + ActivityCompat.requestPermissions(activity, permissions, requestCode); + } + + /** + * 请求单个权限 + * @param activity 活动 + * @param permission 权限 + * @param requestCode 请求码 + */ + public static void requestPermission(Activity activity, String permission, int requestCode) { + requestPermissions(activity, new String[]{permission}, requestCode); + } + + /** + * 处理权限请求结果 + * @param requestCode 请求码 + * @param permissions 权限数组 + * @param grantResults 授权结果 + * @param listener 回调监听器 + */ + public static void handlePermissionResult(int requestCode, String[] permissions, int[] grantResults, + OnPermissionResultListener listener) { + if (listener == null) { + return; + } + + List granted = new ArrayList<>(); + List denied = new ArrayList<>(); + + for (int i = 0; i < permissions.length; i++) { + if (grantResults[i] == PackageManager.PERMISSION_GRANTED) { + granted.add(permissions[i]); + } else { + denied.add(permissions[i]); + } + } + + if (denied.isEmpty()) { + listener.onAllGranted(requestCode, granted); + } else { + listener.onDenied(requestCode, granted, denied); + } + } + + /** + * 检查是否应该显示权限请求说明 + * @param activity 活动 + * @param permission 权限 + * @return 是否应该显示说明 + */ + public static boolean shouldShowRequestPermissionRationale(Activity activity, String permission) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return false; + } + return ActivityCompat.shouldShowRequestPermissionRationale(activity, permission); + } + + /** + * 权限请求结果监听器 + */ + public interface OnPermissionResultListener { + /** + * 所有权限都被授予 + * @param requestCode 请求码 + * @param grantedPermissions 被授予的权限列表 + */ + void onAllGranted(int requestCode, List grantedPermissions); + + /** + * 部分或全部权限被拒绝 + * @param requestCode 请求码 + * @param grantedPermissions 被授予的权限列表 + * @param deniedPermissions 被拒绝的权限列表 + */ + void onDenied(int requestCode, List grantedPermissions, List deniedPermissions); + } + + /** + * 常用权限常量 + */ + public static class Permissions { + public static final String CAMERA = android.Manifest.permission.CAMERA; + public static final String READ_EXTERNAL_STORAGE = android.Manifest.permission.READ_EXTERNAL_STORAGE; + public static final String WRITE_EXTERNAL_STORAGE = android.Manifest.permission.WRITE_EXTERNAL_STORAGE; + + // Android 13+ 需要单独请求媒体权限 + public static final String READ_MEDIA_IMAGES = android.Manifest.permission.READ_MEDIA_IMAGES; + } +} \ No newline at end of file diff --git a/src/notes/ui/CameraPreviewDialogFragment.java b/src/notes/ui/CameraPreviewDialogFragment.java new file mode 100644 index 0000000..e5c0840 --- /dev/null +++ b/src/notes/ui/CameraPreviewDialogFragment.java @@ -0,0 +1,97 @@ +package net.micode.notes.ui; + +import android.app.Dialog; +import android.app.DialogFragment; +import android.content.DialogInterface; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; + +import net.micode.notes.R; + +import java.io.File; + +/** + * 相机预览确认对话框 + */ +public class CameraPreviewDialogFragment extends DialogFragment { + + public static final String TAG = "CameraPreviewDialogFragment"; + + public interface OnCameraPreviewListener { + void onConfirm(File photoFile); + void onRetake(); + } + + private OnCameraPreviewListener mListener; + private File mPhotoFile; + + public CameraPreviewDialogFragment() { + // Required empty public constructor + } + + public void setPhotoFile(File photoFile) { + mPhotoFile = photoFile; + } + + public void setOnCameraPreviewListener(OnCameraPreviewListener listener) { + mListener = listener; + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + Dialog dialog = new Dialog(getActivity()); + dialog.setContentView(R.layout.dialog_camera_preview); + return dialog; + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.dialog_camera_preview, container, false); + + ImageView previewImage = view.findViewById(R.id.preview_image); + Button btnRetake = view.findViewById(R.id.btn_retake); + Button btnConfirm = view.findViewById(R.id.btn_confirm); + + // 加载预览图片 + if (mPhotoFile != null && mPhotoFile.exists()) { + // 使用BitmapFactory解码图片,避免OOM + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = 4; // 降低采样率,减少内存占用 + options.inJustDecodeBounds = false; + Bitmap bitmap = BitmapFactory.decodeFile(mPhotoFile.getAbsolutePath(), options); + if (bitmap != null) { + previewImage.setImageBitmap(bitmap); + } + } + + btnRetake.setOnClickListener(v -> { + if (mListener != null) { + mListener.onRetake(); + } + dismiss(); + }); + + btnConfirm.setOnClickListener(v -> { + if (mListener != null && mPhotoFile != null) { + mListener.onConfirm(mPhotoFile); + } + dismiss(); + }); + + return view; + } + + @Override + public void onDismiss(DialogInterface dialog) { + super.onDismiss(dialog); + mListener = null; + mPhotoFile = null; + } +} \ No newline at end of file diff --git a/src/notes/ui/ImageInsertDialogFragment.java b/src/notes/ui/ImageInsertDialogFragment.java new file mode 100644 index 0000000..fa95aaa --- /dev/null +++ b/src/notes/ui/ImageInsertDialogFragment.java @@ -0,0 +1,78 @@ +package net.micode.notes.ui; + +import android.app.Dialog; +import android.app.DialogFragment; +import android.content.DialogInterface; +import android.content.Intent; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; + +import net.micode.notes.R; + +/** + * 图片插入对话框,提供从相册选择或拍照两种方式 + */ +public class ImageInsertDialogFragment extends DialogFragment { + + public static final String TAG = "ImageInsertDialogFragment"; + + public interface OnImageInsertListener { + void onGallerySelected(); + void onCameraSelected(); + } + + private OnImageInsertListener mListener; + + public ImageInsertDialogFragment() { + // Required empty public constructor + } + + public void setOnImageInsertListener(OnImageInsertListener listener) { + mListener = listener; + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + // 使用自定义布局,而不是AlertDialog + Dialog dialog = new Dialog(getActivity()); + dialog.setContentView(R.layout.dialog_image_insert); + return dialog; + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.dialog_image_insert, container, false); + + Button btnGallery = view.findViewById(R.id.btn_gallery); + Button btnCamera = view.findViewById(R.id.btn_camera); + Button btnCancel = view.findViewById(R.id.btn_cancel); + + btnGallery.setOnClickListener(v -> { + if (mListener != null) { + mListener.onGallerySelected(); + } + dismiss(); + }); + + btnCamera.setOnClickListener(v -> { + if (mListener != null) { + mListener.onCameraSelected(); + } + dismiss(); + }); + + btnCancel.setOnClickListener(v -> dismiss()); + + return view; + } + + @Override + public void onDismiss(DialogInterface dialog) { + super.onDismiss(dialog); + mListener = null; + } +} \ No newline at end of file diff --git a/src/notes/ui/NoteEditActivity.java b/src/notes/ui/NoteEditActivity.java index afd6110..753d1a5 100644 --- a/src/notes/ui/NoteEditActivity.java +++ b/src/notes/ui/NoteEditActivity.java @@ -28,7 +28,10 @@ import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; import android.graphics.Paint; +import android.net.Uri; +import android.os.AsyncTask; import android.os.Bundle; +import android.os.Environment; import android.preference.PreferenceManager; import android.text.Spannable; import android.text.SpannableString; @@ -47,6 +50,7 @@ import android.widget.CheckBox; import android.widget.CompoundButton; import android.widget.CompoundButton.OnCheckedChangeListener; import android.widget.EditText; +import android.widget.ImageButton; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; @@ -65,13 +69,29 @@ import net.micode.notes.ui.DateTimePickerDialog.OnDateTimeSetListener; import net.micode.notes.ui.NoteEditText.OnTextViewChangeListener; import net.micode.notes.widget.NoteWidgetProvider_2x; import net.micode.notes.widget.NoteWidgetProvider_4x; - +import net.micode.notes.data.AttachmentManager; +import net.micode.notes.tool.PermissionHelper; +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; import java.util.HashMap; import java.util.HashSet; +import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; +import androidx.annotation.NonNull; +import androidx.core.content.FileProvider; + /** * 笔记编辑Activity - 小米便签的核心编辑界面 * 实现OnClickListener, NoteSettingChangedListener, OnTextViewChangeListener接口 @@ -151,17 +171,27 @@ public class NoteEditActivity extends Activity implements OnClickListener, private String mUserQuery; // 用户搜索查询(用于高亮显示) private Pattern mPattern; // 用于高亮搜索结果的模式 + // 附件相关 + private LinearLayout mAttachmentContainer; // 附件容器 + private static final int REQUEST_GALLERY = 1001; + private static final int REQUEST_CAMERA = 1002; + private static final int REQUEST_PERMISSION_CAMERA = 1003; + private static final int REQUEST_PERMISSION_STORAGE = 1004; + private static final int REQUEST_CAMERA_PREVIEW = 1005; + private String mCurrentPhotoPath; // 相机拍照临时文件路径 + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); this.setContentView(R.layout.note_edit); // 设置布局文件 + + initResources(); // 初始化资源 // 初始化Activity状态,如果初始化失败则结束Activity if (savedInstanceState == null && !initActivityState(getIntent())) { finish(); return; } - initResources(); // 初始化资源 } /** @@ -210,18 +240,32 @@ public class NoteEditActivity extends Activity implements OnClickListener, finish(); return false; } else { - // 加载笔记 - mWorkingNote = WorkingNote.load(this, noteId); - if (mWorkingNote == null) { - Log.e(TAG, "load note failed with note id" + noteId); - finish(); - return false; - } + // 在后台线程加载笔记,避免阻塞UI线程 + new AsyncTask() { + @Override + protected WorkingNote doInBackground(Long... params) { + long id = params[0]; + try { + return WorkingNote.load(NoteEditActivity.this, id); + } catch (Exception e) { + Log.e(TAG, "load note failed with note id" + id, e); + return null; + } + } + + @Override + protected void onPostExecute(WorkingNote result) { + if (result != null) { + mWorkingNote = result; + setupWorkingNoteAndInitializeUI(Intent.ACTION_VIEW); + } else { + Log.e(TAG, "load note failed"); + finish(); + } + } + }.execute(noteId); + return true; } - // 隐藏软键盘 - getWindow().setSoftInputMode( - WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN - | WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); } // 处理新建或编辑笔记的请求 else if(TextUtils.equals(Intent.ACTION_INSERT_OR_EDIT, intent.getAction())) { @@ -245,45 +289,88 @@ public class NoteEditActivity extends Activity implements OnClickListener, // 检查是否已存在相同的通话记录笔记 if ((noteId = DataUtils.getNoteIdByPhoneNumberAndCallDate(getContentResolver(), phoneNumber, callDate)) > 0) { - mWorkingNote = WorkingNote.load(this, noteId); - if (mWorkingNote == null) { - Log.e(TAG, "load call note failed with note id" + noteId); - finish(); - return false; - } + // 在后台线程加载通话记录笔记 + new AsyncTask() { + @Override + protected WorkingNote doInBackground(Long... params) { + long id = params[0]; + try { + return WorkingNote.load(NoteEditActivity.this, id); + } catch (Exception e) { + Log.e(TAG, "load call note failed with note id" + id, e); + return null; + } + } + + @Override + protected void onPostExecute(WorkingNote result) { + if (result != null) { + mWorkingNote = result; + setupWorkingNoteAndInitializeUI(Intent.ACTION_INSERT_OR_EDIT); + } else { + Log.e(TAG, "load call note failed"); + finish(); + } + } + }.execute(noteId); } else { - // 创建新的通话记录笔记 + // 创建新的通话记录笔记(无需数据库操作,直接在UI线程处理) mWorkingNote = WorkingNote.createEmptyNote(this, folderId, widgetId, widgetType, bgResId); mWorkingNote.convertToCallNote(phoneNumber, callDate); + setupWorkingNoteAndInitializeUI(Intent.ACTION_INSERT_OR_EDIT); } } else { - // 创建普通笔记 + // 创建普通笔记(无需数据库操作,直接在UI线程处理) mWorkingNote = WorkingNote.createEmptyNote(this, folderId, widgetId, widgetType, bgResId); + setupWorkingNoteAndInitializeUI(Intent.ACTION_INSERT_OR_EDIT); } - - // 显示软键盘 - getWindow().setSoftInputMode( - WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE - | WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); } else { Log.e(TAG, "Intent not specified action, should not support"); finish(); return false; } - // 设置笔记设置变化监听器 - mWorkingNote.setOnSettingStatusChangedListener(this); return true; } + + /** + * 设置WorkingNote并初始化UI + */ + private void setupWorkingNoteAndInitializeUI(String action) { + if (mWorkingNote != null) { + // 设置笔记设置变化监听器 + mWorkingNote.setOnSettingStatusChangedListener(this); + + // 根据不同的操作类型设置软键盘模式 + if (TextUtils.equals(Intent.ACTION_VIEW, action)) { + // 隐藏软键盘 + getWindow().setSoftInputMode( + WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN + | WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); + } else if (TextUtils.equals(Intent.ACTION_INSERT_OR_EDIT, action)) { + // 显示软键盘 + getWindow().setSoftInputMode( + WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE + | WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); + } + + // 初始化UI界面 + initNoteScreen(); + + // 加载附件 + loadAttachments(); + } + } @Override protected void onResume() { super.onResume(); - initNoteScreen(); // 初始化笔记界面 - // 每次恢复时应用老年人模式 - ElderModeUtils.applyElderMode(this, findViewById(android.R.id.content)); + // 只有当mWorkingNote已经初始化完成时才初始化界面 + if (mWorkingNote != null) { + initNoteScreen(); // 初始化笔记界面(已包含老年人模式应用) + } } /** @@ -319,6 +406,9 @@ public class NoteEditActivity extends Activity implements OnClickListener, // 显示闹钟提醒头部 showAlertHeader(); + + // 在设置字体外观后应用老年人模式,确保老年人模式的字体设置不被覆盖 + ElderModeUtils.applyElderMode(this, mNoteEditor); } /** @@ -442,6 +532,9 @@ public class NoteEditActivity extends Activity implements OnClickListener, mEditTextList = (LinearLayout) findViewById(R.id.note_edit_list); + // 初始化附件容器 + mAttachmentContainer = (LinearLayout) findViewById(R.id.note_attachment_container); + // 应用老年人模式 ElderModeUtils.applyElderMode(this, findViewById(android.R.id.content)); } @@ -630,6 +723,9 @@ public class NoteEditActivity extends Activity implements OnClickListener, 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()); // 分享笔记 @@ -1049,4 +1145,293 @@ public class NoteEditActivity extends Activity implements OnClickListener, private void showToast(int resId, int duration) { Toast.makeText(this, resId, duration).show(); } + + // ==================== 附件相关方法 ==================== + + /** + * 加载附件列表并显示 + */ + private void loadAttachments() { + if (mWorkingNote == null || mWorkingNote.getNoteId() <= 0) { + return; + } + + List attachments = mWorkingNote.getAttachments(); + if (attachments.isEmpty()) { + mAttachmentContainer.setVisibility(View.GONE); + return; + } + + mAttachmentContainer.setVisibility(View.VISIBLE); + mAttachmentContainer.removeAllViews(); + + for (AttachmentManager.Attachment attachment : attachments) { + addAttachmentView(attachment); + } + } + + /** + * 添加附件视图 + * @param attachment 附件数据 + */ + private void addAttachmentView(AttachmentManager.Attachment attachment) { + View view = LayoutInflater.from(this).inflate(R.layout.image_item, mAttachmentContainer, false); + ImageView imageView = view.findViewById(R.id.image_view); + ImageButton deleteButton = view.findViewById(R.id.btn_delete); + + // 使用Glide加载图片 + Glide.with(this) + .load(new File(attachment.filePath)) + .override(800, 800) + .centerCrop() + .transition(DrawableTransitionOptions.withCrossFade()) + .into(imageView); + + // 长按删除 + view.setOnLongClickListener(v -> { + new AlertDialog.Builder(this) + .setTitle(R.string.delete_image) + .setMessage(R.string.confirm_delete_image) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + if (mWorkingNote.deleteAttachment(attachment.id)) { + mAttachmentContainer.removeView(view); + if (mAttachmentContainer.getChildCount() == 0) { + mAttachmentContainer.setVisibility(View.GONE); + } + showToast(R.string.image_deleted); + } + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + return true; + }); + + // 删除按钮点击 + deleteButton.setOnClickListener(v -> view.performLongClick()); + + mAttachmentContainer.addView(view); + } + + /** + * 处理菜单项点击:插入图片 + */ + private void handleInsertImage() { + ImageInsertDialogFragment dialog = new ImageInsertDialogFragment(); + dialog.setOnImageInsertListener(new ImageInsertDialogFragment.OnImageInsertListener() { + @Override + public void onGallerySelected() { + requestGalleryPermission(); + } + + @Override + public void onCameraSelected() { + requestCameraPermission(); + } + }); + dialog.show(getFragmentManager(), ImageInsertDialogFragment.TAG); + } + + /** + * 请求相册权限 + */ + private void requestGalleryPermission() { + String[] permissions; + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + permissions = new String[]{PermissionHelper.Permissions.READ_MEDIA_IMAGES}; + } else { + permissions = new String[]{PermissionHelper.Permissions.READ_EXTERNAL_STORAGE}; + } + + if (PermissionHelper.arePermissionsGranted(this, permissions)) { + openGallery(); + } else { + PermissionHelper.requestPermissions(this, permissions, REQUEST_PERMISSION_STORAGE); + } + } + + /** + * 请求相机权限 + */ + private void requestCameraPermission() { + String[] permissions = {PermissionHelper.Permissions.CAMERA}; + if (PermissionHelper.arePermissionsGranted(this, permissions)) { + openCamera(); + } else { + PermissionHelper.requestPermissions(this, permissions, REQUEST_PERMISSION_CAMERA); + } + } + + /** + * 打开相册选择图片 + */ + private void openGallery() { + Intent intent = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI); + intent.setType("image/*"); + startActivityForResult(intent, REQUEST_GALLERY); + } + + /** + * 打开相机拍照 + */ + private void openCamera() { + Intent takePictureIntent = new Intent(android.provider.MediaStore.ACTION_IMAGE_CAPTURE); + File photoFile = createImageFile(); + if (photoFile != null) { + mCurrentPhotoPath = photoFile.getAbsolutePath(); + try { + Uri photoURI = FileProvider.getUriForFile(this, + getApplicationContext().getPackageName() + ".fileprovider", + photoFile); + takePictureIntent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, photoURI); + startActivityForResult(takePictureIntent, REQUEST_CAMERA); + } catch (Exception e) { + Log.e(TAG, "Failed to get URI from FileProvider", e); + } + } else { + Log.e(TAG, "Failed to create image file"); + } + } + + /** + * 创建临时图片文件 + * @return 文件对象,失败返回null + */ + private File createImageFile() { + String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(new Date()); + String imageFileName = "JPEG_" + timeStamp + "_"; + File storageDir = getExternalFilesDir(Environment.DIRECTORY_PICTURES); + try { + File image = File.createTempFile(imageFileName, ".jpg", storageDir); + return image; + } catch (IOException e) { + Log.e(TAG, "Failed to create image file", e); + return null; + } + } + + /** + * 处理权限请求结果 + */ + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + PermissionHelper.handlePermissionResult(requestCode, permissions, grantResults, + new PermissionHelper.OnPermissionResultListener() { + @Override + public void onAllGranted(int requestCode, List grantedPermissions) { + if (requestCode == REQUEST_PERMISSION_STORAGE) { + openGallery(); + } else if (requestCode == REQUEST_PERMISSION_CAMERA) { + openCamera(); + } + } + + @Override + public void onDenied(int requestCode, List grantedPermissions, List deniedPermissions) { + showToast(R.string.permission_denied); + } + }); + } + + /** + * 处理Activity结果(图片选择/拍照) + */ + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (resultCode != RESULT_OK) { + return; + } + + if (requestCode == REQUEST_GALLERY && data != null) { + Uri selectedImage = data.getData(); + if (selectedImage != null) { + processSelectedImage(selectedImage, Notes.ATTACHMENT_TYPE_GALLERY); + } + } else if (requestCode == REQUEST_CAMERA) { + if (mCurrentPhotoPath != null) { + File photoFile = new File(mCurrentPhotoPath); + if (photoFile.exists()) { + showCameraPreviewDialog(photoFile); + } + } + } + } + + /** + * 处理选择的图片 + * @param imageUri 图片URI + * @param type 附件类型 + */ + private void processSelectedImage(Uri imageUri, int type) { + try { + File sourceFile = new File(imageUri.getPath()); + if (!sourceFile.exists()) { + // 如果直接路径不存在,尝试通过ContentResolver获取 + sourceFile = getFileFromUri(imageUri); + } + if (sourceFile != null && sourceFile.exists()) { + long attachmentId = mWorkingNote.addAttachment(type, sourceFile); + if (attachmentId > 0) { + loadAttachments(); + showToast(R.string.image_added); + } else { + showToast(R.string.failed_to_add_image); + } + } + } catch (Exception e) { + Log.e(TAG, "Failed to process image", e); + showToast(R.string.failed_to_add_image); + } + } + + /** + * 从URI获取文件 + * @param uri 图片URI + * @return 文件对象,失败返回null + */ + private File getFileFromUri(Uri uri) { + // 简化实现:对于content:// URI,直接复制到临时文件 + try { + InputStream inputStream = getContentResolver().openInputStream(uri); + if (inputStream == null) return null; + + File tempFile = File.createTempFile("temp_image", ".jpg", getCacheDir()); + FileOutputStream outputStream = new FileOutputStream(tempFile); + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + outputStream.close(); + inputStream.close(); + return tempFile; + } catch (IOException e) { + Log.e(TAG, "Failed to get file from URI", e); + return null; + } + } + + /** + * 显示相机预览对话框 + * @param photoFile 拍照生成的图片文件 + */ + private void showCameraPreviewDialog(File photoFile) { + CameraPreviewDialogFragment dialog = new CameraPreviewDialogFragment(); + dialog.setPhotoFile(photoFile); + dialog.setOnCameraPreviewListener(new CameraPreviewDialogFragment.OnCameraPreviewListener() { + @Override + public void onConfirm(File photoFile) { + // 确认使用照片,添加到附件 + processSelectedImage(Uri.fromFile(photoFile), Notes.ATTACHMENT_TYPE_CAMERA); + } + + @Override + public void onRetake() { + // 重拍,重新打开相机 + openCamera(); + } + }); + dialog.show(getFragmentManager(), CameraPreviewDialogFragment.TAG); + } } diff --git a/src/notes/ui/NotesListItem.java b/src/notes/ui/NotesListItem.java index 83e8f00..702657d 100644 --- a/src/notes/ui/NotesListItem.java +++ b/src/notes/ui/NotesListItem.java @@ -27,6 +27,7 @@ import android.widget.TextView; import net.micode.notes.R; import net.micode.notes.data.Notes; import net.micode.notes.tool.DataUtils; +import net.micode.notes.tool.ElderModeUtils; import net.micode.notes.tool.ResourceParser.NoteItemBgResources; /** @@ -126,6 +127,9 @@ public class NotesListItem extends LinearLayout { // 根据数据类型和位置设置背景 setBackground(data); + + // 在设置完所有内容后应用老年人模式,确保字体大小正确 + ElderModeUtils.applyElderMode(context, mTitle); } /** diff --git a/src/notes/ui/NotesPreferenceActivity.java b/src/notes/ui/NotesPreferenceActivity.java index be2bc44..8d64562 100644 --- a/src/notes/ui/NotesPreferenceActivity.java +++ b/src/notes/ui/NotesPreferenceActivity.java @@ -85,7 +85,7 @@ public class NotesPreferenceActivity extends PreferenceActivity { mReceiver = new GTaskReceiver(); // 创建广播接收器 IntentFilter filter = new IntentFilter(); filter.addAction(GTaskSyncService.GTASK_SERVICE_BROADCAST_NAME); // 注册同步服务广播 - registerReceiver(mReceiver, filter); // 注册广播接收器 + registerReceiver(mReceiver, filter, Context.RECEIVER_NOT_EXPORTED); // 注册广播接收器,标记为非导出 mOriAccounts = null; // 添加设置界面的头部视图