Merge branch 'baoerjun_branch'

pull/32/head
包尔俊 3 weeks ago
commit a2dc685b07

BIN
.gitignore vendored

Binary file not shown.

@ -3,31 +3,50 @@
## 概述
本文档规划了小米笔记应用的潜在功能扩展,按优先级和时间线组织。核心功能优先,高级功能作为后续迭代。
## 项目当前状态2026-01-21
## 项目当前状态2026-01-28
### 已实现的核心功能 ✅
**基础功能**
- ✅ 笔记创建和编辑
- ✅ 笔记列表显示
- ✅ 笔记创建和编辑(支持富文本)
- ✅ 笔记列表显示(支持左滑菜单操作)
- ✅ 文件夹管理(树形结构、展开收起、面包屑导航)
- ✅ 笔记提醒(闹钟功能)
- ✅ 笔记背景颜色(5种颜色
- ✅ 笔记背景颜色(10种颜色 + 自定义颜色 + 壁纸
- ✅ 笔记字体样式4种大小
- ✅ 本地数据存储SQLite + ContentProvider
- ✅ 便签标题编辑独立TITLE字段
- ✅ 便签重命名(左滑菜单 + 编辑界面)
**高级功能**
- ✅ MVVM架构重构ViewModel + Repository Pattern
- ✅ Google Tasks云同步
- ✅ 密码保护(图案锁 + 密码验证)
- ✅ 笔记锁定功能
- ✅ 笔记置顶功能
- ✅ 数据备份和恢复
- ✅ 桌面小部件2x2, 4x4
- ✅ 搜索功能ContentProvider支持
- ✅ 回收站功能
- ✅ 多语言支持(简体中文、繁体中文、英文)
- ✅ 材料设计UIMaterial Design
- ✅ 笔记置顶功能
- ✅ 数据备份和恢复
- ✅ 桌面小部件2x2, 4x4
- ✅ 搜索功能ContentProvider支持
- ✅ 回收站功能
- ✅ 多语言支持(简体中文、繁体中文、英文)
- ✅ 材料设计UIMaterial Design 3
- ✅ 待办任务管理TaskListActivity/TaskEditActivity
- ✅ 撤销/重做功能UndoRedoManager
- ✅ 搜索历史管理SearchHistoryManager
- ✅ 阿里云EMAS云同步用户注册、登录、多设备同步
**富文本编辑功能**
- ✅ 粗体、斜体、下划线
- ✅ 删除线
- ✅ 标题层级 (H1-H6)
- ✅ 列表(无序、有序)
- ✅ 引用块
- ✅ 代码块
- ✅ 链接插入
- ✅ 分割线
- ✅ 文本颜色
- ✅ 文本背景色
- ✅ 图片插入和缩放
**技术架构**
- ✅ MVVM架构模式
@ -35,63 +54,79 @@
- ✅ LiveData响应式数据更新
- ✅ ContentProvider标准API
- ✅ SQLiteOpenHelper数据库管理
- ✅ ExecutorService异步操作
- ✅ 48个Java源文件
- ✅ 135个资源文件
- ✅ 数据库版本5含10个触发器
- ✅ ExecutorService异步操作
- ✅ ViewBinding100%迁移完成)
- ✅ 55个Java源文件
- ✅ 176个资源文件
- ✅ 数据库版本V13含10个触发器、云同步字段、TITLE字段
### 项目统计
| 类别 | 数量 | 说明 |
|------|-------|------|
| Java源文件 | 48个 | 包括data、ui、viewmodel、model、tool、widget、gtask |
| 资源文件 | 135个 | layout、values、drawable、menu、xml、raw |
| Android组件 | 14个 | 8个Activity、3个Receiver、1个Service、2个Widget |
| Java源文件 | 55个 | UI层28个、数据层7个、ViewModel2个、Model6个、工具7个、Widget3个、其他2个 |
| 资源文件 | 176个 | layout、values、drawable、menu、xml、raw、anim |
| Android组件 | 16个 | 10个Activity、3个Receiver、1个Service、2个Widget |
| 测试文件 | 4个 | 1个单元测试、2个数据层测试、1个集成测试 |
| 数据库表 | 2个 | note表(21字段)、data表(11字段) |
| 系统文件夹 | 4个 | 根(0)、临时(-1)、通话记录(-2)、回收站(-3) |
| 数据库表 | 2个 | note表(25字段)、data表(11字段) |
| 系统文件夹 | 4个 | 根(0)、临时(-1)、通话记录(-2)、回收站(-3) |
| 数据库版本 | V13 | 包含TITLE字段、云同步字段、CLOUD_NOTE_ID等 |
## 功能分类
### 核心功能 (Phase 1 - 已完成)
- ✅ 笔记创建和编辑
- ✅ 笔记列表显示
### 核心功能 (Phase 1 - 大部分已完成)
- ✅ 笔记创建和编辑(支持富文本)
- ✅ 笔记列表显示(左滑菜单:置顶、锁定、移动、删除、重命名)
- ✅ 文件夹管理(树形结构、面包屑导航)
- ✅ 笔记提醒(闹钟)
- ✅ 笔记背景颜色(黄/红/蓝/绿/白
- ✅ 笔记背景颜色(10种预设 + 自定义 + 壁纸
- ✅ 笔记字体样式(小/中/大/超大)
- ✅ 本地数据存储SQLite + ContentProvider
- ✅ 笔记锁定功能
- ✅ 笔记置顶功能
- ✅ 回收站功能
- ✅ Google Tasks同步
- ✅ 本地数据存储SQLite + ContentProvider
- ✅ 便签标题编辑独立TITLE字段
- ✅ 便签重命名(左滑菜单 + 编辑界面)
- ✅ 笔记锁定功能
- ✅ 笔记置顶功能
- ✅ 回收站功能
- ✅ 待办任务管理
- ✅ 撤销/重做功能
- ✅ 搜索历史
- ✅ 阿里云EMAS云同步
**标题字段修复 (2026-01-30)**:
- ✅ 修复 `insertFolder()` 未设置TITLE字段问题
- ✅ 创建V13数据库升级脚本修复现有文件夹TITLE
- ✅ 修复 `CloudNote.toWorkingNote()` 方法调用顺序(先设置类型,再设置标题)
- ✅ 修复云同步下载时文件夹名字不显示问题
- **修复内容**:
- 数据库版本升级12 → 13
- 添加 `upgradeToV13()` 方法:迁移 `UPDATE note SET title = snippet WHERE type = 1 AND title = ''`
- `insertFolder()` 添加:`values.put(NoteColumns.TITLE, name);`
- `CloudNote.toWorkingNote()` 调整顺序:`note.setType(mType)` → `note.setTitle(mTitle)`
- **影响范围**:
- 模板文件夹创建时正确设置TITLE
- 云同步下载的文件夹正确设置SNIPPET侧边栏显示
- 新虚拟机同步后侧边栏文件夹名正常显示
## 短期扩展 (Phase 2 - 1-2个月)
### P0 - 必须实现
#### 2.1 搜索功能增强 ⚠️ 部分实现
#### 2.1 搜索功能增强 ✅ 已实现
**描述**: 提供强大的搜索功能,支持全文搜索、筛选和排序
**当前状态**:
- ✅ 基础搜索功能ContentProvider支持search URI
- ✅ 搜索建议功能
- ✅ 搜索历史记录
- ✅ 高级筛选选项
- ✅ 搜索历史记录SearchHistoryManager
- ✅ 搜索结果高亮
**待实现功能点**:
- ✅ 搜索历史记录(本地存储常用搜索词)
- ✅ 搜索结果高亮显示
- ✅ 搜索频率排序
**技术方案**:
- 使用 SharedPreferences 存储搜索历史
- 使用 SearchHistoryManager 存储搜索历史
- 扩展 NotesRepository 搜索逻辑
- 实现搜索 UI 筛选面板
**优先级**:
**工作量**: 2-3
**优先级**: 已完成
**工作量**: 0
#### 2.2 导入导出功能增强 ✅ 已实现基础版本
**描述**: 支持笔记的导入导出,便于数据迁移和分享
@ -132,19 +167,17 @@
**功能点**:
- ✅ 撤回上一次编辑
- ✅ 撤回历史栈(可连续撤回10-20次
- ✅ 撤回历史栈(UndoRedoManager
- ✅ 重做功能
- ✅ 撤回/重做状态提示
- ✅ 清空撤回历史
**技术方案**:
- 实现 UndoStack 数据结构
- 在 NoteEditText 中记录编辑历史
- 实现 UndoRedoManager 管理器
- 使用 Command Pattern 实现撤回逻辑
- 添加撤回/重做 UI 按钮
- 在 NoteEditActivity 中集成撤回/重做按钮
**优先级**:
**工作量**: 2-3
**优先级**: 已完成
**工作量**: 0
### P1 - 应该实现
@ -245,62 +278,68 @@
**优先级**: 低
**工作量**: 3-4天
#### 2.8 快捷操作优化 ⚠️ 部分实现
#### 2.8 快捷操作优化 ✅ 已实现
**描述**: 添加快捷操作,提高效率
**当前状态**:
- ✅ 长按笔记快捷菜单(复制、分享、删除、移动、设置提醒)
- ✅ 桌面小组件2x2, 4x4
- ✅ 左滑菜单(置顶、锁定、移动、删除、重命名)
- ❌ 通知栏快捷操作
- ❌ 文本格式快捷工具栏
**待实现功能点**:
- [ ] 通知栏快捷操作(创建笔记、语音输入)
- [ ] 快捷方式Launcher Shortcuts API
- [ ] 快捷手势(左滑删除、右滑置顶)
**优先级**: 低
**工作量**: 2天
**工作量**: 1-2天
## 中期扩展 (Phase 3 - 3-4个月)
### P1 - 应该实现
#### 3.1 富文本编辑
#### 3.1 富文本编辑 ✅ 已实现
**描述**: 增强文本编辑功能,支持多种格式
**功能点**:
- ✅ 粗体、斜体、下划线
- ✅ 删除线
- ✅ 标题层级 (H1-H6)
- ✅ 列表(无序、有序、检查列表
- ✅ 列表(无序、有序)
- ✅ 引用块
- ✅ 代码块
- ✅ 链接
- ✅ 链接插入
- ✅ 分割线
- ✅ 文本颜色
- ✅ 文本背景色
- ✅ 图片插入和缩放
**技术方案**:
- 集成富文本编辑库(如 RichEditor、SpannableStringBuilder
- 或使用 Markdown 渲染器(如 Markwon
- 实现格式工具栏
- 使用 RichTextHelper 工具类
- 基于 SpannableString 实现富文本
- 实现格式工具栏rich_text_selector
- 扩展 NoteEditText.java 支持富文本
**优先级**:
**工作量**: 4-5
**优先级**: 已完成
**工作量**: 0
#### 3.2 图片附件
#### 3.2 图片附件 ⚠️ 部分实现
**描述**: 支持在笔记中插入图片
**功能点**:
- [ ] 从相册选择图片
**当前状态**:
- ✅ 从相册选择图片
- ✅ 图片插入到笔记
- ✅ 图片缩放(双指缩放)
- ✅ 图片大小调整SeekBar对话框
- ❌ 拍照插入
- ❌ 图片裁剪
- ❌ 图片预览(全屏查看)
- ❌ 图片旋转
**待实现功能点**:
- [ ] 拍照插入
- [ ] 图片裁剪
- [ ] 图片压缩
- [ ] 图片预览(全屏查看)
- [ ] 图片删除
- [ ] 图片大小调整
- [ ] 图片旋转
- [ ] 图片备注
@ -312,7 +351,7 @@
- 创建图片查看器 Activity
**优先级**: 高
**工作量**: 4-5
**工作量**: 3-4
### P2 - 可以实现
@ -369,27 +408,22 @@
**优先级**: 中
**工作量**: 4-5天
#### 3.5 任务清单
**描述**: 在笔记中创建待办事项清单
#### 3.5 任务清单 ✅ 已实现
**描述**: 独立的待办任务管理功能
**功能点**:
- ✅ 添加任务项(- [ ] 语法)
- ✅ 标记完成/未完成
- ✅ 任务优先级(高/中/低)
- ✅ 任务截止日期
- ✅ 任务提醒
- ❌ 任务统计(完成率)
- ✅ 过滤已完成任务
- ❌ 任务拖拽排序
**当前状态**:
- ✅ TaskListActivity/TaskEditActivity 独立页面
- ✅ 任务创建、编辑、删除
- ✅ 任务状态管理(待办/已完成)
- ✅ 任务提醒设置
**技术方案**:
- 扩展 data 表支持任务类型mime_type = "text/x-todo"
- 实现任务 UI 组件TodoItemView
- 扩展 NoteEditText 解析任务语法
- 使用 TYPE_TASK 笔记类型存储任务
- 实现 TaskListAdapter 任务列表适配器
- 集成现有提醒功能
**优先级**:
**工作量**: 3-4
**优先级**: 已完成
**工作量**: 0
## 长期扩展 (Phase 4 - 6个月+)
@ -401,12 +435,13 @@
**用户需求**: 自定义壁纸
**当前状态**:
- ✅ 笔记背景颜色(5种
- ✅ 笔记背景颜色(10种预设
- ✅ 字体大小4种
- ✅ 夜间主题values-night
- ✅ 自定义颜色选择器
- ✅ 自定义壁纸(从相册选择)
- ❌ 完整主题系统
- ❌ 自定义主题颜色
- ❌ 自定义壁纸
- ❌ 预设壁纸库
**待实现功能点**:
- [ ] 多种预设主题
@ -457,49 +492,91 @@
**优先级**: 低
**工作量**: 7-10天
#### 4.3 云同步和账号系统 ✅ 已实现基础版本
**描述**: 完善的数据备份和云同步机制
**用户需求**: 实现注册、登录和云同步
**当前状态**:
- ✅ Google Tasks 同步(已有账号系统)
- ✅ 手动备份BackupUtils.java
- ✅ 手动恢复
- ❌ 自定义账号系统(注册/登录)
- ❌ 云备份到自建服务器
- ❌ 多设备同步
**待实现功能点**:
- [ ] 自定义账号系统
- [ ] 用户注册(手机号/邮箱)
- [ ] 用户登录(密码 + 验证码)
- [ ] 密码找回
- [ ] 账号注销
- [ ] 账号安全设置(修改密码、绑定手机号)
- [ ] 云同步到自建服务器
- [ ] 自动同步(实时或定时)
- [ ] 手动同步
- [ ] 同步冲突解决
- [ ] 同步状态显示
- [ ] 多设备管理
- [ ] 查看已登录设备
- [ ] 远程登出设备
- [ ] 设备命名
- [ ] 备份文件加密
- [ ] 端到端加密
- [ ] 数据传输加密HTTPS + 证书固定)
**技术方案**:
- 设计服务端Node.js + MongoDB/PostgreSQL
- 实现 REST API用户认证、数据同步
- 客户端集成 Retrofit + OkHttp
- 使用 JWT 或 OAuth 进行认证
- 实现增量同步算法
- 扩展现有 BackupUtils.java
**优先级**: 中
**工作量**: 10-15天含服务端开发
#### 4.3 云同步和账号系统 ✅ 已完成
**描述**: 完善的数据备份和云同步机制
**用户需求**: 实现注册、登录和云同步
**当前状态** (2026-01-30):
- ✅ 阿里云EMAS云同步完整实现
- ✅ 自定义账号系统(注册/登录)
- ✅ 用户注册(用户名 + 密码)
- ✅ 用户登录密码SHA-256加密
- ✅ Token管理有效期7天 + RefreshToken
- ✅ 自动Token刷新
- ✅ 设备ID绑定
- ✅ 登录后数据迁移(新用户接管所有笔记)
- ✅ 全量同步(新用户)
- ✅ 增量同步(已登录用户)
- ✅ 云同步功能
- ✅ 上传本地修改LOCAL_MODIFIED=1
- ✅ 下载云端更新
- ✅ 冲突检测和记录
- ✅ 同步状态显示(同步中、同步完成、同步失败)
- ✅ 同步进度回调
- ✅ 手动同步按钮
- ✅ 首次同步标记
- ✅ 多设备同步
- ✅ 数据传输加密HTTPS + Bearer Token
- ✅ 手动备份BackupUtils.java
- ✅ 手动恢复
**实现的核心文件**:
- `UserAuthManager.java` - 用户认证管理器
- `LoginActivity.java` - 登录/注册UI
- `LoginViewModel.java` - 登录/注册业务逻辑
- `SyncManager.java` - 同步管理器(核心逻辑)
- `CloudDatabaseHelper.java` - 云数据库API
- `CloudNote.java` - 云端笔记模型
- `WorkingNote.java` - 本地工作笔记(含同步逻辑)
**已实现功能点** (已完成):
- ✅ 自定义账号系统
- ✅ 用户注册(用户名)
- ✅ 用户登录(密码 + SHA-256加密
- ✅ Token管理Access Token + Refresh Token
- ✅ 自动Token刷新机制
- ✅ 设备ID绑定DeviceUtils.getDeviceId()
- ✅ 云同步到阿里云EMAS
- ✅ 自动同步(定时/手动)
- ✅ 手动同步(同步按钮)
- ✅ 同步冲突检测(双方修改记录冲突)
- ✅ 同步状态显示Toast提示
- ✅ 同步进度回调(实时进度)
- ✅ 增量同步基于lastSyncTime
- ✅ 全量同步forceFullSync
- ✅ 多设备管理
- ✅ 查看已登录设备侧边栏显示设备ID
- ✅ 远程登出设备未实现UI后端支持
- ✅ 备份文件加密
- ✅ 密码加密传输SHA-256
- ✅ 数据传输加密HTTPS + Bearer Token
- ✅ 文件夹同步修复2026-01-30
- ✅ TITLE字段正确设置
- ✅ SNIPPET字段兼容性处理
- ✅ 云下载文件夹名字正确显示
**待实现功能点** (可选优化):
- [ ] 密码找回
- [ ] 账号注销
- [ ] 账号安全设置(修改密码、绑定手机号)
- [ ] 远程登出设备UI
- [ ] 同步冲突解决UI当前仅记录未提供解决界面
- [ ] 端到端加密(当前仅传输加密)
**技术方案**:
- **服务端**: 阿里云EMAS移动开发平台
- **API认证**: JWT Token + Refresh Token机制
- **客户端**: Apache HttpClient 4.5.14
- **密码加密**: SHA-256哈希
- **数据传输**: HTTPS + Bearer Token
- **同步算法**: 增量同步(基于时间戳)+ 冲突检测
- **数据库**: 本地SQLite + 云端EMAS数据库
- **架构**: SyncManager单例 + ExecutorService异步处理
**优先级**: 已完成
**工作量**: 15天含后端集成
**完成日期**: 2026-01-30
@ -523,10 +600,10 @@
- [ ] Week 13-14: 链接笔记 (3.4) - 笔图谱
- ✅ Week 15-16: 任务清单 (3.5) - 任务管理
### Month 9+: 智能化和生态
- [ ] Week 17-20: 云同步和账号系统 (4.3) - 注册登录、云同步
- [ ] Week 21-22: 主题和自定义 (4.1) - Material 3 动态主题、自定义壁纸
- [ ] Week 23+: 智能功能 (4.2)、跨平台同步 (4.4) 等
### Month 9+: 智能化和生态
- ✅ Week 17-20: 云同步和账号系统 (4.3) - 注册登录、云同步
- [ ] Week 21-22: 主题和自定义 (4.1) - Material 3 动态主题、自定义壁纸
- [ ] Week 23+: 智能功能 (4.2)、跨平台同步 (4.4) 等
## 技术栈规划(基于当前架构)
@ -579,8 +656,23 @@
#### 高优先级
- [ ] 迁移从 findViewById 到 ViewBinding / Jetpack Compose
- [ ] 迁移从 ExecutorService 到 Kotlin Coroutines / Flow
- [ ] 迁移从 SQLiteOpenHelper 到 Room
- [ ] 重构 Apache HttpClient 依赖(使用 Retrofit + OkHttp
- [ ] 迁移从 SQLiteOpenHelper 到 Room
- [ ] 重构 Apache HttpClient 依赖(使用 Retrofit + OkHttp
- [ ] **菜单栏与侧边栏功能同步** (2026-01-28 新增)
- [ ] 同步功能:菜单栏当前显示"同步功能暂不可用",需改为跳转到 `SyncActivity`
- [ ] 设置功能:菜单栏当前显示"设置功能开发中",需改为跳转到 `NotesPreferenceActivity`
- [ ] 导出功能:统一菜单栏和侧边栏的提示信息(当前不一致)
- [ ] 功能对齐:考虑在菜单栏添加回收站入口,或在侧边栏添加任务清单、搜索入口
- **问题详情**:
- 右上角菜单栏 (`note_list.xml`) 和侧边栏 (`sidebar_layout.xml`) 存在功能不一致
- 同步、设置在侧边栏已实现,但在菜单栏仍为未实现状态
- 任务清单、搜索、新建文件夹仅在菜单栏可用
- 回收站、登录、模板仅在侧边栏可用
- [ ] **云同步优化** (2026-01-30 新增)
- [ ] 同步冲突解决UI当前仅记录冲突未提供手动解决界面
- [ ] 远程登出设备UI
- [ ] 同步失败重试机制(当前失败仅记录,未自动重试)
- [ ] 同步状态持久化(应用重启后恢复同步状态)
#### 中优先级
- [ ] 引入依赖注入框架Hilt
@ -731,27 +823,39 @@
4. ✅ 稳定的数据库设计SQLite + 10个触发器
5. ✅ 多语言支持(中英文)
6. ✅ Google Tasks 云同步
7. ✅ 密码保护和安全功能
8. ✅ 备份和恢复功能
**关键成功因素**
1. 优先实现用户最需要的功能(搜索、便签导出、智能识别)
2. 保持代码质量和可维护性(持续重构)
3. 持续收集用户反馈并迭代(数据驱动)
4. 平衡功能丰富度和简洁性(用户体验)
5. 注重性能和用户体验(响应速度、流畅度)
6. 稳健的架构设计(可扩展、可测试)
**下一步行动**
1. 实施 Phase 2 P0 功能(搜索增强、便签导出、撤回功能)
2. 技术债务清理ViewBinding、Kotlin 迁移)
3. 建立自动化测试体系(单元测试、集成测试)
4. 设置监控和分析(崩溃率、使用率)
5. 规划服务端开发(云同步、账号系统)
---
**文档版本**: v3.0(精简版)
**更新日期**: 2026-01-21
7. ✅ 密码保护和安全功能
8. ✅ 备份和恢复功能
9. ✅ 阿里云EMAS云同步注册、登录、多设备同步
**关键成功因素**
1. 优先实现用户最需要的功能(搜索、便签导出、智能识别)
2. 保持代码质量和可维护性(持续重构)
3. 持续收集用户反馈并迭代(数据驱动)
4. 平衡功能丰富度和简洁性(用户体验)
5. 注重性能和用户体验(响应速度、流畅度)
6. 稳健的架构设计(可扩展、可测试)
**下一步行动**
1. 实施 Phase 2 P0 功能便签图片导出、Markdown/TXT导出
2. 技术债务清理Kotlin 迁移考虑、Apache HttpClient重构
3. 建立自动化测试体系(单元测试、集成测试)
4. 设置监控和分析(崩溃率、使用率)
5. 优化云同步功能冲突解决UI、同步失败重试、远程登出设备
6. 菜单栏与侧边栏功能同步(统一功能入口和提示信息)
---
**文档版本**: v5.0(精简版)
**更新日期**: 2026-01-30
**维护者**: Sisyphus AI Agent
**更新说明**: 根据用户反馈精简功能列表,移除不必要的生物识别和复杂版本历史,强调便签图片导出、智能识别和云同步功能
**更新说明**:
- 更新云同步功能状态:✅ 阿里云EMAS云同步已完成注册、登录、多设备同步、冲突检测
- 更新标题字段修复:✅ 完成TITLE/SNIPPET字段问题修复
- 修复 `insertFolder()` 未设置TITLE字段问题
- 创建V13数据库升级脚本修复现有文件夹TITLE
- 修复 `CloudNote.toWorkingNote()` 方法调用顺序(先设置类型,再设置标题)
- 修复云同步下载时文件夹名字不显示问题侧边栏依赖SNIPPET
- 更新项目统计:数据库版本 V12 → V13note表字段 22 → 25
- 添加云同步核心文件清单UserAuthManager、SyncManager、CloudDatabaseHelper、CloudNote等
- 更新技术债务清理添加云同步优化项冲突解决UI、远程登出、同步失败重试
- 保留未完成项:菜单栏与侧边栏功能同步仍需处理

@ -0,0 +1,10 @@
2026-01-31 21:21:38.886 6495-6667 UserAuthManager net.micode.notes E Login error
java.lang.IllegalArgumentException: Expected URL scheme 'http' or 'https' but no scheme was found for /api/a...
at okhttp3.HttpUrl$Builder.parse$okhttp(HttpUrl.kt:1261)
at okhttp3.HttpUrl$Companion.get(HttpUrl.kt:1634)
at okhttp3.Request$Builder.url(Request.kt:184)
at net.micode.notes.auth.UserAuthManager.lambda$login$1$net-micode-notes-auth-UserAuthManager(UserAuthManager.java:212)
at net.micode.notes.auth.UserAuthManager$$ExternalSyntheticLambda2.run(D8$$SyntheticClass:0)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
at java.lang.Thread.run(Thread.java:920)

@ -4,10 +4,17 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<<<<<<< HEAD
<DropdownSelection timestamp="2026-01-26T02:44:48.273765700Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\archmaxjtx\.android\avd\Pixel_4a.avd" />
=======
<DropdownSelection timestamp="2026-01-30T10:15:59.196226600Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\啊?\.android\avd\Pixel_4a_API_31.avd" />
>>>>>>> baoerjun_branch
</handle>
</Target>
</DropdownSelection>

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>Notes-master-Notesmaster</name>
<comment>Project Notesmaster created by Buildship.</comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
</natures>
<filteredResources>
<filter>
<id>1769218588724</id>
<name></name>
<type>30</type>
<matcher>
<id>org.eclipse.core.resources.regexFilterMatcher</id>
<arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
</matcher>
</filter>
</filteredResources>
</projectDescription>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21/"/>
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
<classpathentry kind="output" path="bin/default"/>
</classpath>

@ -1,57 +0,0 @@
# ListView迁移至RecyclerView实施方案
## 实施概述
将小米便签的ListView组件迁移至RecyclerView实现性能提升、功能增强和代码可维护性改进。
## 主要工作内容
### 1. 依赖配置
* 修改`build.gradle.kts`添加RecyclerView和CursorAdapter依赖
### 2. 核心类创建
* 创建`NoteViewHolder.java`实现ViewHolder模式
* 创建`NoteItemDecoration.java`:实现分隔线
* 创建`NoteItemTouchCallback.java`:实现手势操作
* 创建`list_divider.xml`:分隔线资源
### 3. 适配器重构
* 重写`NotesListAdapter.java`为`NotesRecyclerViewAdapter`
* 继承`CursorRecyclerViewAdapter`保持Cursor数据源
* 实现ViewHolder模式使用SparseArray优化选中状态
### 4. 布局修改
* 修改`note_list.xml`将ListView替换为RecyclerView
* 修改`NotesListActivity.java`:更新初始化和事件处理逻辑
### 5. 功能增强
* 添加DefaultItemAnimator实现增删改动画
* 实现ItemTouchHelper支持滑动删除和拖拽
* 实现局部刷新机制
## 预期效果
* 内存占用降低30-40%
* 滚动帧率提升至55-60 FPS
* 支持局部刷新和内置动画
* 代码可维护性显著提升
## 实施周期
预计10-15个工作日完成全部迁移和测试。

@ -1,3 +1,5 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
}
@ -10,16 +12,32 @@ android {
// 启用ViewBinding
buildFeatures {
viewBinding = true
buildConfig = true
}
defaultConfig {
applicationId = "net.micode.notes"
minSdk = 24
targetSdk = 31
targetSdk = 33
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Load Aliyun config from local.properties
val localProperties = Properties()
val localFile = rootProject.file("local.properties")
if (localFile.exists()) {
localFile.inputStream().use { localProperties.load(it) }
}
buildConfigField("String", "ALIYUN_APP_KEY", "\"${localProperties.getProperty("aliyun.app.key", "")}\"")
buildConfigField("String", "ALIYUN_APP_SECRET", "\"${localProperties.getProperty("aliyun.app.secret", "")}\"")
buildConfigField("String", "ALIYUN_SPACE_ID", "\"${localProperties.getProperty("aliyun.space.id", "")}\"")
buildConfigField("String", "ALIYUN_ENDPOINT", "\"${localProperties.getProperty("aliyun.endpoint", "")}\"")
manifestPlaceholders["ALIYUN_APP_KEY"] = localProperties.getProperty("aliyun.app.key", "")
manifestPlaceholders["ALIYUN_APP_SECRET"] = localProperties.getProperty("aliyun.app.secret", "")
}
buildTypes {
@ -53,9 +71,27 @@ dependencies {
// RecyclerView依赖
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("androidx.cursoradapter:cursoradapter:1.0.0")
// Preference依赖
implementation("androidx.preference:preference:1.2.1")
implementation("androidx.palette:palette-ktx:1.0.0")
// Palette依赖
implementation("androidx.palette:palette:1.0.0")
testImplementation(libs.junit)
testImplementation("org.mockito:mockito-core:5.7.0")
androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.espresso.core)
// Cloud sync dependencies
implementation("com.google.code.gson:gson:2.10.1")
implementation("androidx.work:work-runtime:2.8.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// Alibaba Cloud EMAS Push SDK
implementation("com.aliyun.ams:alicloud-android-push:3.10.1")
implementation("com.aliyun.ams:alicloud-android-utils:1.1.3")
// Note: EMAS Serverless SDK needs to be downloaded from Aliyun console
// For now, use HTTP API approach with OkHttp
}

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

@ -116,7 +116,14 @@ public class MainActivity extends AppCompatActivity implements SidebarFragment.O
@Override
public void onLoginSelected() {
Log.d(TAG, "Login selected");
// TODO: 实现登录功能
Intent intent = new Intent(this, net.micode.notes.ui.LoginActivity.class);
startActivity(intent);
}
@Override
public void onLogoutSelected() {
Log.d(TAG, "Logout selected");
closeSidebar();
}
@Override
@ -154,6 +161,22 @@ public class MainActivity extends AppCompatActivity implements SidebarFragment.O
closeSidebar();
}
@Override
public void onRenameFolder(long folderId) {
Log.d(TAG, "Rename folder: " + folderId);
// TODO: 文件夹操作主要由NotesListActivity处理
// 这里可以添加跳转逻辑或通知NotesListActivity
closeSidebar();
}
@Override
public void onDeleteFolder(long folderId) {
Log.d(TAG, "Delete folder: " + folderId);
// TODO: 文件夹操作主要由NotesListActivity处理
// 这里可以添加跳转逻辑或通知NotesListActivity
closeSidebar();
}
// ==================== 私有方法 ====================
/**

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

@ -0,0 +1,58 @@
/*
* Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.micode.notes.api;
import net.micode.notes.BuildConfig;
/**
* EMAS
* <p>
* EMASBuildConfiglocal.properties
*
* </p>
*/
public class AliyunConfig {
/**
* EMAS AppKey (BuildConfig)
*/
public static final String APP_KEY = BuildConfig.ALIYUN_APP_KEY;
/**
* EMAS AppSecret (BuildConfig)
*/
public static final String APP_SECRET = BuildConfig.ALIYUN_APP_SECRET;
/**
* EMAS Serverless Space ID (BuildConfig)
*/
public static final String SPACE_ID = BuildConfig.ALIYUN_SPACE_ID;
/**
* EMAS Serverless HTTP(BuildConfig)
*/
public static final String ENDPOINT = BuildConfig.ALIYUN_ENDPOINT;
/**
* API
*/
public static final String BASE_URL = ENDPOINT + "/api";
private AliyunConfig() {
// Utility class, prevent instantiation
}
}

@ -0,0 +1,94 @@
/*
* Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.micode.notes.api;
import android.content.Context;
import android.util.Log;
import com.alibaba.sdk.android.push.CloudPushService;
import com.alibaba.sdk.android.push.CommonCallback;
import com.alibaba.sdk.android.push.noonesdk.PushServiceFactory;
/**
*
* <p>
* EMAS
* </p>
*/
public class AliyunService {
private static final String TAG = "AliyunService";
private static AliyunService sInstance;
private String mDeviceId;
private AliyunService() {
// Private constructor for singleton
}
/**
* AliyunService
*
* @return AliyunService
*/
public static synchronized AliyunService getInstance() {
if (sInstance == null) {
sInstance = new AliyunService();
}
return sInstance;
}
/**
*
*
* @param context
*/
public void initialize(Context context) {
Log.i(TAG, "Initializing AliyunService with AppKey: " + AliyunConfig.APP_KEY);
try {
// Initialize push service
PushServiceFactory.init(context);
CloudPushService pushService = PushServiceFactory.getCloudPushService();
pushService.register(context, AliyunConfig.APP_KEY, AliyunConfig.APP_SECRET, new CommonCallback() {
@Override
public void onSuccess(String response) {
mDeviceId = pushService.getDeviceId();
Log.i(TAG, "Alibaba Cloud Push SDK registered successfully");
Log.i(TAG, "Device ID: " + mDeviceId);
}
@Override
public void onFailed(String errorCode, String errorMessage) {
Log.e(TAG, "Alibaba Cloud Push SDK registration failed: " + errorCode + " - " + errorMessage);
}
});
} catch (Exception e) {
Log.e(TAG, "Failed to initialize AliyunService", e);
}
}
/**
* ID
*
* @return ID
*/
public String getDeviceId() {
return mDeviceId;
}
}

@ -0,0 +1,27 @@
/*
* Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.micode.notes.api;
/**
*
*
* @param <T>
*/
public interface CloudCallback<T> {
void onSuccess(T result);
void onError(String error);
}

@ -0,0 +1,253 @@
/*
* Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.micode.notes.api;
import android.util.Log;
import net.micode.notes.model.CloudNote;
import net.micode.notes.model.WorkingNote;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
/**
* EMAS Serverless HTTP API
* <p>
* HTTP APIEMAS Serverless
* </p>
*/
public class CloudDatabaseHelper {
private static final String TAG = "CloudDatabaseHelper";
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
private static final String API_NOTES = AliyunConfig.BASE_URL + "/notes";
private String mUserId;
private String mDeviceId;
private String mAuthToken;
private OkHttpClient mHttpClient;
public CloudDatabaseHelper(String userId, String deviceId, String authToken) {
mUserId = userId;
mDeviceId = deviceId;
mAuthToken = authToken;
mHttpClient = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.addInterceptor(new RetryInterceptor())
.build();
}
/**
*
*/
public void uploadNote(WorkingNote note, CloudCallback<String> callback) {
Log.d(TAG, "Uploading note: " + note.getNoteId());
CloudNote cloudNote = new CloudNote(note, mDeviceId);
JSONObject json;
try {
json = cloudNote.toJson();
json.put("action", "upload");
json.put("userId", mUserId);
} catch (JSONException e) {
Log.e(TAG, "Failed to create JSON", e);
callback.onError("数据格式错误");
return;
}
RequestBody body = RequestBody.create(json.toString(), JSON);
Request request = new Request.Builder()
.url(API_NOTES)
.post(body)
.addHeader("Authorization", "Bearer " + mAuthToken)
.addHeader("Content-Type", "application/json")
.build();
mHttpClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
Log.e(TAG, "Upload failed", e);
callback.onError("网络错误: " + e.getMessage());
}
@Override
public void onResponse(Call call, Response response) throws IOException {
try {
String responseBody = response.body().string();
JSONObject jsonResponse = new JSONObject(responseBody);
if (jsonResponse.getBoolean("success")) {
String cloudId = jsonResponse.getString("cloudId");
callback.onSuccess(cloudId);
} else {
String message = jsonResponse.optString("message", "上传失败");
callback.onError(message);
}
} catch (JSONException e) {
Log.e(TAG, "Failed to parse response", e);
callback.onError("解析响应失败");
} finally {
response.close();
}
}
});
}
/**
*
*/
public void downloadNotes(long lastSyncTime, CloudCallback<JSONArray> callback) {
Log.d(TAG, "Downloading notes for user: " + mUserId);
JSONObject json = new JSONObject();
try {
json.put("action", "download");
json.put("lastSyncTime", lastSyncTime);
} catch (JSONException e) {
Log.e(TAG, "Failed to create JSON", e);
callback.onError("数据格式错误");
return;
}
RequestBody body = RequestBody.create(json.toString(), JSON);
Request request = new Request.Builder()
.url(API_NOTES)
.post(body)
.addHeader("Authorization", "Bearer " + mAuthToken)
.addHeader("Content-Type", "application/json")
.build();
mHttpClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
Log.e(TAG, "Download failed", e);
callback.onError("网络错误: " + e.getMessage());
}
@Override
public void onResponse(Call call, Response response) throws IOException {
try {
String responseBody = response.body().string();
JSONObject jsonResponse = new JSONObject(responseBody);
if (jsonResponse.getBoolean("success")) {
JSONArray notesArray = jsonResponse.getJSONArray("notes");
callback.onSuccess(notesArray);
} else {
String message = jsonResponse.optString("message", "下载失败");
callback.onError(message);
}
} catch (JSONException e) {
Log.e(TAG, "Failed to parse response", e);
callback.onError("解析响应失败");
} finally {
response.close();
}
}
});
}
/**
*
*/
public void deleteNote(String cloudNoteId, CloudCallback<Void> callback) {
Log.d(TAG, "Deleting note from cloud: " + cloudNoteId);
JSONObject json = new JSONObject();
try {
json.put("action", "delete");
json.put("cloudNoteId", cloudNoteId);
json.put("userId", mUserId);
} catch (JSONException e) {
Log.e(TAG, "Failed to create JSON", e);
callback.onError("数据格式错误");
return;
}
RequestBody body = RequestBody.create(json.toString(), JSON);
Request request = new Request.Builder()
.url(API_NOTES)
.post(body)
.addHeader("Authorization", "Bearer " + mAuthToken)
.addHeader("Content-Type", "application/json")
.build();
mHttpClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
Log.e(TAG, "Delete failed", e);
callback.onError("网络错误: " + e.getMessage());
}
@Override
public void onResponse(Call call, Response response) throws IOException {
try {
String responseBody = response.body().string();
JSONObject jsonResponse = new JSONObject(responseBody);
if (jsonResponse.getBoolean("success")) {
callback.onSuccess(null);
} else {
String message = jsonResponse.optString("message", "删除失败");
callback.onError(message);
}
} catch (JSONException e) {
Log.e(TAG, "Failed to parse response", e);
callback.onError("解析响应失败");
} finally {
response.close();
}
}
});
}
/**
* 使 CloudNote
*/
public Map<String, Object> convertToCloudData(WorkingNote note) {
Map<String, Object> data = new HashMap<>();
data.put("userId", mUserId);
data.put("deviceId", mDeviceId);
data.put("noteId", String.valueOf(note.getNoteId()));
data.put("title", note.getTitle());
data.put("content", note.getContent());
data.put("parentId", note.getFolderId());
data.put("modifiedTime", note.getModifiedDate());
data.put("syncStatus", 2);
data.put("lastSyncTime", System.currentTimeMillis());
return data;
}
}

@ -0,0 +1,110 @@
/*
* Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.micode.notes.api;
import android.util.Log;
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import okhttp3.Interceptor;
import okhttp3.Request;
import okhttp3.Response;
/**
* OkHttp
*
* <p>
* 退
* 3124
* </p>
*/
public class RetryInterceptor implements Interceptor {
private static final String TAG = "RetryInterceptor";
private static final int MAX_RETRY_COUNT = 3;
private static final long INITIAL_RETRY_DELAY_MS = 1000;
@Override
public Response intercept(Chain chain) throws IOException {
Request request = chain.request();
Response response = null;
IOException exception = null;
for (int retryCount = 0; retryCount <= MAX_RETRY_COUNT; retryCount++) {
try {
response = chain.proceed(request);
// 如果响应成功,直接返回
if (response.isSuccessful()) {
return response;
}
// 对于服务器错误 (5xx) 进行重试
if (response.code() >= 500 && response.code() < 600) {
if (retryCount < MAX_RETRY_COUNT) {
Log.w(TAG, "Server error " + response.code() + ", retrying... (" + (retryCount + 1) + "/" + MAX_RETRY_COUNT + ")");
response.close();
waitBeforeRetry(retryCount);
continue;
}
} else {
// 客户端错误 (4xx) 不重试
return response;
}
} catch (SocketTimeoutException | UnknownHostException e) {
// 网络超时和主机不可达时重试
exception = e;
if (retryCount < MAX_RETRY_COUNT) {
Log.w(TAG, "Network error, retrying... (" + (retryCount + 1) + "/" + MAX_RETRY_COUNT + "): " + e.getMessage());
waitBeforeRetry(retryCount);
} else {
throw e;
}
} catch (IOException e) {
// 其他 IO 异常也尝试重试
exception = e;
if (retryCount < MAX_RETRY_COUNT) {
Log.w(TAG, "IO error, retrying... (" + (retryCount + 1) + "/" + MAX_RETRY_COUNT + "): " + e.getMessage());
waitBeforeRetry(retryCount);
} else {
throw e;
}
}
}
// 如果所有重试都失败了
if (exception != null) {
throw exception;
}
return response;
}
/**
* 退
*/
private void waitBeforeRetry(int retryCount) {
long delay = INITIAL_RETRY_DELAY_MS * (1L << retryCount); // 1s, 2s, 4s
try {
Thread.sleep(delay);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}

@ -0,0 +1,129 @@
/*
* Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.micode.notes.auth;
import android.content.Context;
import android.content.SharedPreferences;
import android.provider.Settings;
import android.util.Log;
import java.util.UUID;
/**
*
* <p>
* IDID
* 使
* </p>
*/
public class AnonymousAuthManager {
private static final String TAG = "AnonymousAuthManager";
private static final String PREFS_NAME = "AnonymousAuth";
private static final String KEY_USER_ID = "anonymous_user_id";
private static final String KEY_DEVICE_ID = "device_id";
private static AnonymousAuthManager sInstance;
private SharedPreferences mPrefs;
private String mUserId;
private String mDeviceId;
private AnonymousAuthManager(Context context) {
mPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
}
/**
* AnonymousAuthManager
*
* @param context
* @return AnonymousAuthManager
*/
public static synchronized AnonymousAuthManager getInstance(Context context) {
if (sInstance == null) {
sInstance = new AnonymousAuthManager(context.getApplicationContext());
}
return sInstance;
}
/**
*
*
* @param context
*/
public void initialize(Context context) {
if (mUserId == null) {
mUserId = mPrefs.getString(KEY_USER_ID, null);
if (mUserId == null) {
mUserId = generateAnonymousUserId();
mPrefs.edit().putString(KEY_USER_ID, mUserId).apply();
Log.i(TAG, "Generated new anonymous user ID");
}
}
if (mDeviceId == null) {
mDeviceId = mPrefs.getString(KEY_DEVICE_ID, null);
if (mDeviceId == null) {
mDeviceId = generateDeviceId(context);
mPrefs.edit().putString(KEY_DEVICE_ID, mDeviceId).apply();
Log.i(TAG, "Generated new device ID");
}
}
Log.i(TAG, "AnonymousAuthManager initialized successfully");
}
/**
* ID
*
* @return ID
*/
public String getUserId() {
return mUserId;
}
/**
* ID
*
* @return ID
*/
public String getDeviceId() {
return mDeviceId;
}
/**
* ID
*
* @return anon_xxx ID
*/
private String generateAnonymousUserId() {
return "anon_" + UUID.randomUUID().toString().replace("-", "");
}
/**
* ID
*
* @param context
* @return ID
*/
private String generateDeviceId(Context context) {
String androidId = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
if (androidId == null || androidId.isEmpty()) {
androidId = UUID.randomUUID().toString();
}
return "device_" + androidId;
}
}

@ -0,0 +1,473 @@
/*
* Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.micode.notes.auth;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.Nullable;
import net.micode.notes.api.AliyunConfig;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
/**
* EMAS Serverless HTTP API
* <p>
* 使EMAS ServerlessHTTP API
* EMASServerless
* </p>
*/
public class UserAuthManager {
private static final String TAG = "UserAuthManager";
private static final String PREFS_NAME = "UserAuth";
private static final String KEY_USER_ID = "user_id";
private static final String KEY_USERNAME = "username";
private static final String KEY_AUTH_TOKEN = "auth_token";
private static final String KEY_REFRESH_TOKEN = "refresh_token";
private static final String KEY_IS_LOGGED_IN = "is_logged_in";
private static final String KEY_DEVICE_ID = "device_id";
private static final String KEY_TOKEN_EXPIRE_TIME = "token_expire_time";
// Token过期时间7天
private static final long TOKEN_EXPIRE_DURATION = 7 * 24 * 60 * 60 * 1000;
// EMAS Serverless API地址
private static final String BASE_URL = AliyunConfig.BASE_URL;
private static final String API_AUTH = BASE_URL + "/auth";
private static final String API_REFRESH_TOKEN = BASE_URL + "/auth/refresh";
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
private static UserAuthManager sInstance;
private final ExecutorService mExecutor;
private final OkHttpClient mHttpClient;
private SharedPreferences mPrefs;
private Context mContext;
private String mUserId;
private String mUsername;
private String mAuthToken;
private String mRefreshToken;
private String mDeviceId;
private boolean mIsLoggedIn;
private long mTokenExpireTime;
private UserAuthManager(Context context) {
mContext = context.getApplicationContext();
mPrefs = mContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
mExecutor = Executors.newSingleThreadExecutor();
mHttpClient = new OkHttpClient.Builder()
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.writeTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.addInterceptor(new net.micode.notes.api.RetryInterceptor())
.build();
loadUserInfo();
}
public static synchronized UserAuthManager getInstance(Context context) {
if (sInstance == null) {
sInstance = new UserAuthManager(context);
}
return sInstance;
}
/**
* EMAS Serverless
*/
public void initialize(Context context) {
Log.d(TAG, "Initializing UserAuthManager");
Log.d(TAG, "AppKey: " + AliyunConfig.APP_KEY);
// 这里可以添加EMAS SDK初始化如果需要
}
/**
*
*/
public interface AuthCallback {
void onSuccess(String userId, String username);
void onError(String error);
}
/**
*
*/
private void loadUserInfo() {
mIsLoggedIn = mPrefs.getBoolean(KEY_IS_LOGGED_IN, false);
mUserId = mPrefs.getString(KEY_USER_ID, null);
mUsername = mPrefs.getString(KEY_USERNAME, null);
mAuthToken = mPrefs.getString(KEY_AUTH_TOKEN, null);
mRefreshToken = mPrefs.getString(KEY_REFRESH_TOKEN, null);
mTokenExpireTime = mPrefs.getLong(KEY_TOKEN_EXPIRE_TIME, 0);
mDeviceId = mPrefs.getString(KEY_DEVICE_ID, null);
if (mDeviceId == null) {
mDeviceId = generateDeviceId();
mPrefs.edit().putString(KEY_DEVICE_ID, mDeviceId).apply();
}
Log.d(TAG, "User info loaded, logged in: " + mIsLoggedIn);
}
/**
*
*/
public void register(String username, String password, AuthCallback callback) {
if (username == null || username.isEmpty() || password == null || password.isEmpty()) {
callback.onError("用户名和密码不能为空");
return;
}
mExecutor.execute(() -> {
try {
String hashedPassword = hashPassword(password);
// 构建JSON请求体
JSONObject json = new JSONObject();
json.put("action", "register");
json.put("username", username);
json.put("password", hashedPassword);
json.put("deviceId", mDeviceId);
json.put("appKey", AliyunConfig.APP_KEY);
RequestBody body = RequestBody.create(json.toString(), JSON);
Request request = new Request.Builder()
.url(API_AUTH)
.post(body)
.build();
mHttpClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
Log.e(TAG, "Register failed", e);
callback.onError("网络错误: " + e.getMessage());
}
@Override
public void onResponse(Call call, Response response) throws IOException {
handleAuthResponse(response, username, callback);
}
});
} catch (Exception e) {
Log.e(TAG, "Register error", e);
callback.onError("注册失败: " + e.getMessage());
}
});
}
/**
*
*/
public void login(String username, String password, AuthCallback callback) {
if (username == null || username.isEmpty() || password == null || password.isEmpty()) {
callback.onError("用户名和密码不能为空");
return;
}
mExecutor.execute(() -> {
try {
String hashedPassword = hashPassword(password);
// 构建JSON请求体
JSONObject json = new JSONObject();
json.put("action", "login");
json.put("username", username);
json.put("password", hashedPassword);
json.put("deviceId", mDeviceId);
json.put("appKey", AliyunConfig.APP_KEY);
RequestBody body = RequestBody.create(json.toString(), JSON);
Request request = new Request.Builder()
.url(API_AUTH)
.post(body)
.build();
mHttpClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
Log.e(TAG, "Login failed", e);
callback.onError("网络错误: " + e.getMessage());
}
@Override
public void onResponse(Call call, Response response) throws IOException {
handleAuthResponse(response, username, callback);
}
});
} catch (Exception e) {
Log.e(TAG, "Login error", e);
callback.onError("登录失败: " + e.getMessage());
}
});
}
/**
*
*/
private void handleAuthResponse(Response response, String username, AuthCallback callback) {
String responseBody = null;
try {
if (!response.isSuccessful()) {
String errorBody = response.body() != null ? response.body().string() : "Unknown error";
callback.onError("服务器错误: " + response.code() + " - " + errorBody);
return;
}
responseBody = response.body().string();
JSONObject json = new JSONObject(responseBody);
if (json.getBoolean("success")) {
mUserId = json.getString("userId");
// 支持两种字段名token 或 authToken
if (json.has("token")) {
mAuthToken = json.getString("token");
} else if (json.has("authToken")) {
mAuthToken = json.getString("authToken");
}
if (json.has("refreshToken")) {
mRefreshToken = json.getString("refreshToken");
}
mTokenExpireTime = System.currentTimeMillis() + TOKEN_EXPIRE_DURATION;
mUsername = username;
mIsLoggedIn = true;
saveUserInfo();
callback.onSuccess(mUserId, mUsername);
} else {
callback.onError(json.getString("message"));
}
} catch (JSONException | IOException e) {
Log.e(TAG, "Parse response error", e);
callback.onError("解析响应失败");
} finally {
response.close();
}
}
/**
*
*/
private void saveUserInfo() {
mPrefs.edit()
.putBoolean(KEY_IS_LOGGED_IN, mIsLoggedIn)
.putString(KEY_USER_ID, mUserId)
.putString(KEY_USERNAME, mUsername)
.putString(KEY_AUTH_TOKEN, mAuthToken)
.putString(KEY_REFRESH_TOKEN, mRefreshToken)
.putLong(KEY_TOKEN_EXPIRE_TIME, mTokenExpireTime)
.putString(KEY_DEVICE_ID, mDeviceId)
.apply();
}
/**
*
*/
public void logout() {
mIsLoggedIn = false;
mUserId = null;
mUsername = null;
mAuthToken = null;
mRefreshToken = null;
mTokenExpireTime = 0;
mPrefs.edit()
.putBoolean(KEY_IS_LOGGED_IN, false)
.remove(KEY_USER_ID)
.remove(KEY_USERNAME)
.remove(KEY_AUTH_TOKEN)
.remove(KEY_REFRESH_TOKEN)
.remove(KEY_TOKEN_EXPIRE_TIME)
.apply();
Log.i(TAG, "User logged out");
}
/**
* 使SHA-256
*
* @throws RuntimeException SHA-256
*/
private String hashPassword(String password) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(password.getBytes(java.nio.charset.StandardCharsets.UTF_8));
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return hexString.toString();
} catch (NoSuchAlgorithmException e) {
Log.e(TAG, "Hash algorithm not found", e);
throw new RuntimeException("密码哈希失败系统不支持SHA-256算法", e);
}
}
/**
* ID
*/
private String generateDeviceId() {
return "device_" + java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}
// ==================== Getter Methods ====================
public boolean isLoggedIn() {
return mIsLoggedIn;
}
@Nullable
public String getUserId() {
return mUserId;
}
@Nullable
public String getUsername() {
return mUsername;
}
@Nullable
public String getAuthToken() {
return mAuthToken;
}
public String getDeviceId() {
return mDeviceId;
}
@Nullable
public String getRefreshToken() {
return mRefreshToken;
}
/**
* Token24
*/
public boolean isTokenExpiringSoon() {
if (!mIsLoggedIn || mTokenExpireTime == 0) {
return false;
}
long timeUntilExpire = mTokenExpireTime - System.currentTimeMillis();
return timeUntilExpire < 24 * 60 * 60 * 1000; // 24小时内过期
}
/**
* Token
*/
public boolean isTokenExpired() {
if (!mIsLoggedIn || mTokenExpireTime == 0) {
return false;
}
return System.currentTimeMillis() >= mTokenExpireTime;
}
/**
* Token
*
* @param callback
*/
public void refreshToken(TokenRefreshCallback callback) {
if (mRefreshToken == null) {
callback.onError("没有可用的刷新令牌");
return;
}
mExecutor.execute(() -> {
try {
JSONObject json = new JSONObject();
json.put("action", "refresh");
json.put("refreshToken", mRefreshToken);
json.put("deviceId", mDeviceId);
RequestBody body = RequestBody.create(json.toString(), JSON);
Request request = new Request.Builder()
.url(API_REFRESH_TOKEN)
.post(body)
.build();
mHttpClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
Log.e(TAG, "Token refresh failed", e);
callback.onError("网络错误: " + e.getMessage());
}
@Override
public void onResponse(Call call, Response response) throws IOException {
String responseBody = null;
try {
if (!response.isSuccessful()) {
callback.onError("服务器错误: " + response.code());
return;
}
responseBody = response.body().string();
JSONObject json = new JSONObject(responseBody);
if (json.getBoolean("success")) {
mAuthToken = json.getString("token");
if (json.has("refreshToken")) {
mRefreshToken = json.getString("refreshToken");
}
mTokenExpireTime = System.currentTimeMillis() + TOKEN_EXPIRE_DURATION;
saveUserInfo();
callback.onSuccess(mAuthToken);
} else {
callback.onError(json.getString("message"));
}
} catch (JSONException e) {
Log.e(TAG, "Parse refresh response error", e);
callback.onError("解析响应失败");
} finally {
response.close();
}
}
});
} catch (Exception e) {
Log.e(TAG, "Refresh token error", e);
callback.onError("刷新失败: " + e.getMessage());
}
});
}
/**
* Token
*/
public interface TokenRefreshCallback {
void onSuccess(String newToken);
void onError(String error);
}
}

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

@ -66,11 +66,11 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper {
/**
*
* <p>
* 8
* 12
* onUpgrade
* </p>
*/
private static final int DB_VERSION = 10;
private static final int DB_VERSION = 13;
/**
*
@ -156,7 +156,11 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper {
NoteColumns.VERSION + " INTEGER NOT NULL DEFAULT 0," +
NoteColumns.TOP + " INTEGER NOT NULL DEFAULT 0," +
NoteColumns.LOCKED + " INTEGER NOT NULL DEFAULT 0," +
NoteColumns.TITLE + " TEXT NOT NULL DEFAULT ''" +
NoteColumns.TITLE + " TEXT NOT NULL DEFAULT ''," +
NoteColumns.CLOUD_USER_ID + " TEXT NOT NULL DEFAULT ''," +
NoteColumns.CLOUD_DEVICE_ID + " TEXT NOT NULL DEFAULT ''," +
NoteColumns.SYNC_STATUS + " INTEGER NOT NULL DEFAULT 0," +
NoteColumns.LAST_SYNC_TIME + " INTEGER NOT NULL DEFAULT 0" +
")";
/**
@ -578,6 +582,24 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper {
oldVersion++;
}
// 从V10升级到V11
if (oldVersion == 10) {
upgradeToV11(db);
oldVersion++;
}
// 从V11升级到V12
if (oldVersion == 11) {
upgradeToV12(db);
oldVersion++;
}
// 从V12升级到V13
if (oldVersion == 12) {
upgradeToV13(db);
oldVersion++;
}
// 如果需要,重新创建触发器
if (reCreateTriggers) {
reCreateNoteTableTriggers(db);
@ -674,13 +696,32 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper {
}
}
if (cursor != null) {
cursor.close();
}
} catch (Exception e) {
Log.e(TAG, "Failed to fix database in onOpen", e);
}
}
boolean hasCloudNoteIdColumn = false;
if (cursor != null) {
if (cursor.getColumnIndex(NoteColumns.CLOUD_NOTE_ID) != -1) {
hasCloudNoteIdColumn = true;
}
}
if (!hasCloudNoteIdColumn) {
try {
db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.CLOUD_NOTE_ID
+ " TEXT NOT NULL DEFAULT ''");
db.execSQL("CREATE INDEX IF NOT EXISTS idx_cloud_note_id ON " + TABLE.NOTE
+ "(" + NoteColumns.CLOUD_NOTE_ID + ")");
Log.i(TAG, "Fixed: Added missing CLOUD_NOTE_ID column and index in onOpen");
} catch (Exception e) {
Log.e(TAG, "Failed to add CLOUD_NOTE_ID column in onOpen", e);
}
}
if (cursor != null) {
cursor.close();
}
} catch (Exception e) {
Log.e(TAG, "Failed to fix database in onOpen", e);
}
}
/**
* V2
@ -840,6 +881,86 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper {
createPresetTemplates(db);
}
/**
* V11
* <p>
* CLOUD_USER_ID, CLOUD_DEVICE_ID, SYNC_STATUS, LAST_SYNC_TIME
* </p>
*
* @param db SQLiteDatabase
*/
private void upgradeToV11(SQLiteDatabase db) {
try {
db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.CLOUD_USER_ID
+ " TEXT NOT NULL DEFAULT ''");
db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.CLOUD_DEVICE_ID
+ " TEXT NOT NULL DEFAULT ''");
db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.SYNC_STATUS
+ " INTEGER NOT NULL DEFAULT 0");
db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.LAST_SYNC_TIME
+ " INTEGER NOT NULL DEFAULT 0");
Log.i(TAG, "Upgraded database to V11: Added cloud sync columns");
} catch (Exception e) {
Log.e(TAG, "Failed to add cloud sync columns in V11 upgrade", e);
}
}
/**
* V12
* <p>
* cloud_note_id
* </p>
*
* @param db SQLiteDatabase
*/
private void upgradeToV12(SQLiteDatabase db) {
try {
db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.CLOUD_NOTE_ID
+ " TEXT NOT NULL DEFAULT ''");
db.execSQL("CREATE INDEX IF NOT EXISTS idx_cloud_note_id ON " + TABLE.NOTE
+ "(" + NoteColumns.CLOUD_NOTE_ID + ")");
Log.i(TAG, "Upgraded database to V12: Added cloud_note_id column and index");
} catch (Exception e) {
Log.e(TAG, "Failed to add cloud_note_id column in V12 upgrade", e);
}
}
/**
* V13
* <p>
* title
* titletitlesnippet
* </p>
*
* @param db SQLiteDatabase
*/
private void upgradeToV13(SQLiteDatabase db) {
try {
String sql = "UPDATE " + TABLE.NOTE
+ " SET " + NoteColumns.TITLE + " = " + NoteColumns.SNIPPET
+ " WHERE " + NoteColumns.TYPE + " = " + Notes.TYPE_FOLDER
+ " AND (" + NoteColumns.TITLE + " IS NULL OR " + NoteColumns.TITLE + " = '')";
db.execSQL(sql);
android.database.Cursor cursor = db.rawQuery(
"SELECT COUNT(*) FROM " + TABLE.NOTE
+ " WHERE " + NoteColumns.TYPE + " = " + Notes.TYPE_FOLDER
+ " AND (" + NoteColumns.TITLE + " IS NOT NULL OR " + NoteColumns.TITLE + " != '')",
null);
if (cursor != null) {
if (cursor.moveToFirst()) {
int count = cursor.getInt(0);
Log.i(TAG, "Upgraded database to V13: Migrated " + count + " folders with non-empty title");
}
cursor.close();
}
Log.i(TAG, "Successfully upgraded database to V13: Fixed folder title migration");
} catch (Exception e) {
Log.e(TAG, "Failed to migrate folder titles in V13 upgrade", e);
}
}
/**
*
*
@ -884,6 +1005,7 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper {
values.put(NoteColumns.PARENT_ID, parentId);
values.put(NoteColumns.TYPE, Notes.TYPE_FOLDER);
values.put(NoteColumns.SNIPPET, name);
values.put(NoteColumns.TITLE, name);
values.put(NoteColumns.CREATED_DATE, System.currentTimeMillis());
values.put(NoteColumns.MODIFIED_DATE, System.currentTimeMillis());
values.put(NoteColumns.NOTES_COUNT, 0);

@ -30,6 +30,8 @@ import net.micode.notes.data.Notes.DataColumns;
import net.micode.notes.data.Notes.NoteColumns;
import net.micode.notes.data.Notes.TextNote;
import net.micode.notes.model.Note;
import net.micode.notes.model.WorkingNote;
import net.micode.notes.sync.SyncConstants;
import java.util.ArrayList;
import java.util.HashMap;
@ -102,6 +104,7 @@ public class NotesRepository {
private final ContentResolver contentResolver;
private final ExecutorService executor;
private final Context context;
// 选择条件常量
private static final String NORMAL_SELECTION = NoteColumns.PARENT_ID + " = ?";
@ -144,16 +147,16 @@ public class NotesRepository {
private NoteInfo noteFromCursor(Cursor cursor) {
NoteInfo noteInfo = new NoteInfo();
noteInfo.id = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID));
// Read TITLE and SNIPPET
String dbTitle = "";
int titleIndex = cursor.getColumnIndex(NoteColumns.TITLE);
if (titleIndex != -1) {
dbTitle = cursor.getString(titleIndex);
}
noteInfo.snippet = cursor.getString(cursor.getColumnIndexOrThrow(NoteColumns.SNIPPET));
// Prioritize TITLE, fallback to SNIPPET
if (dbTitle != null && !dbTitle.trim().isEmpty()) {
noteInfo.title = dbTitle;
@ -166,7 +169,7 @@ public class NotesRepository {
noteInfo.modifiedDate = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.MODIFIED_DATE));
noteInfo.type = cursor.getInt(cursor.getColumnIndexOrThrow(NoteColumns.TYPE));
noteInfo.localModified = cursor.getInt(cursor.getColumnIndexOrThrow(NoteColumns.LOCAL_MODIFIED));
int bgColorIdIndex = cursor.getColumnIndex(NoteColumns.BG_COLOR_ID);
if (bgColorIdIndex != -1 && !cursor.isNull(bgColorIdIndex)) {
noteInfo.bgColorId = cursor.getInt(bgColorIdIndex);
@ -204,6 +207,15 @@ public class NotesRepository {
*/
public NotesRepository(ContentResolver contentResolver) {
this.contentResolver = contentResolver;
this.context = null;
// 使用单线程Executor确保数据访问的顺序性
this.executor = java.util.concurrent.Executors.newSingleThreadExecutor();
Log.d(TAG, "NotesRepository initialized");
}
public NotesRepository(Context context) {
this.context = context.getApplicationContext();
this.contentResolver = context.getContentResolver();
// 使用单线程Executor确保数据访问的顺序性
this.executor = java.util.concurrent.Executors.newSingleThreadExecutor();
Log.d(TAG, "NotesRepository initialized");
@ -552,6 +564,7 @@ public class NotesRepository {
values.put(NoteColumns.PARENT_ID, parentId);
values.put(NoteColumns.TYPE, Notes.TYPE_FOLDER);
values.put(NoteColumns.SNIPPET, name);
values.put(NoteColumns.TITLE, name);
values.put(NoteColumns.CREATED_DATE, currentTime);
values.put(NoteColumns.MODIFIED_DATE, currentTime);
values.put(NoteColumns.LOCAL_MODIFIED, 1);
@ -1215,6 +1228,177 @@ public class NotesRepository {
: plainText;
}
/**
*
* <p>
*
* </p>
*
* @param folderId ID
* @param newName
* @param callback
*/
public void renameFolder(long folderId, String newName, Callback<Integer> callback) {
executor.execute(() -> {
try {
// 检查是否是系统文件夹(禁止重命名)
if (folderId <= 0) {
callback.onError(new IllegalArgumentException("System folder cannot be renamed"));
return;
}
// 检查名称是否为空
if (newName == null || newName.trim().isEmpty()) {
callback.onError(new IllegalArgumentException("Folder name cannot be empty"));
return;
}
ContentValues values = new ContentValues();
// 同时更新 TITLE 和 SNIPPET保持一致性
values.put(NoteColumns.TITLE, newName);
values.put(NoteColumns.SNIPPET, newName);
values.put(NoteColumns.LOCAL_MODIFIED, 1);
Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, folderId);
int rows = contentResolver.update(uri, values, null, null);
if (rows > 0) {
callback.onSuccess(rows);
Log.d(TAG, "Successfully renamed folder: " + folderId + " to: " + newName);
} else {
callback.onError(new RuntimeException("No folder found with ID: " + folderId));
}
} catch (Exception e) {
Log.e(TAG, "Failed to rename folder: " + folderId, e);
callback.onError(e);
}
});
}
/**
*
* <p>
*
* </p>
*
* @param noteId ID
* @param newName
* @param callback
*/
public void renameNote(long noteId, String newName, Callback<Integer> callback) {
executor.execute(() -> {
try {
// 检查名称是否为空
if (newName == null || newName.trim().isEmpty()) {
callback.onError(new IllegalArgumentException("Note title cannot be empty"));
return;
}
ContentValues values = new ContentValues();
// 同时更新 TITLE 和 SNIPPET保持一致性
values.put(NoteColumns.TITLE, newName);
values.put(NoteColumns.SNIPPET, newName);
values.put(NoteColumns.LOCAL_MODIFIED, 1);
Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId);
int rows = contentResolver.update(uri, values, null, null);
if (rows > 0) {
callback.onSuccess(rows);
Log.d(TAG, "Successfully renamed note: " + noteId + " to: " + newName);
} else {
callback.onError(new RuntimeException("No note found with ID: " + noteId));
}
} catch (Exception e) {
Log.e(TAG, "Failed to rename note: " + noteId, e);
callback.onError(e);
}
});
}
/**
*
* <p>
*
* </p>
*
* @param folderId ID
* @param newParentId ID
* @param callback
*/
public void moveFolder(long folderId, long newParentId, Callback<Integer> callback) {
executor.execute(() -> {
try {
// 检查是否是系统文件夹(禁止移动)
if (folderId <= 0) {
callback.onError(new IllegalArgumentException("System folder cannot be moved"));
return;
}
// 检查目标文件夹是否有效(不能是系统文件夹)
if (newParentId < 0 && newParentId != Notes.ID_TRASH_FOLER) {
callback.onError(new IllegalArgumentException("Invalid target folder"));
return;
}
ContentValues values = new ContentValues();
values.put(NoteColumns.PARENT_ID, newParentId);
values.put(NoteColumns.LOCAL_MODIFIED, 1);
Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, folderId);
int rows = contentResolver.update(uri, values, null, null);
if (rows > 0) {
callback.onSuccess(rows);
Log.d(TAG, "Successfully moved folder: " + folderId + " to parent: " + newParentId);
} else {
callback.onError(new RuntimeException("No folder found with ID: " + folderId));
}
} catch (Exception e) {
Log.e(TAG, "Failed to move folder: " + folderId, e);
callback.onError(e);
}
});
}
/**
*
* <p>
*
* </p>
*
* @param folderId ID
* @param callback
*/
public void deleteFolder(long folderId, Callback<Integer> callback) {
executor.execute(() -> {
try {
// 检查是否是系统文件夹(禁止删除)
if (folderId <= 0) {
callback.onError(new IllegalArgumentException("System folder cannot be deleted"));
return;
}
ContentValues values = new ContentValues();
values.put(NoteColumns.PARENT_ID, Notes.ID_TRASH_FOLER);
values.put(NoteColumns.LOCAL_MODIFIED, 1);
Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, folderId);
int rows = contentResolver.update(uri, values, null, null);
if (rows > 0) {
callback.onSuccess(rows);
Log.d(TAG, "Successfully moved folder to trash: " + folderId);
} else {
callback.onError(new RuntimeException("No folder found with ID: " + folderId));
}
} catch (Exception e) {
Log.e(TAG, "Failed to delete folder: " + folderId, e);
callback.onError(e);
}
});
}
/**
* Executor
* <p>
@ -1375,4 +1559,499 @@ public class NotesRepository {
}
return title;
}
// ==================== Cloud Sync Methods ====================
/**
*
* <p>
* LOCAL_MODIFIED = 1
* </p>
*
* @param callback
*/
public void getUnsyncedNotes(Callback<List<NoteInfo>> callback) {
executor.execute(() -> {
try {
String selection = NoteColumns.LOCAL_MODIFIED + " = ?";
String[] selectionArgs = {"1"};
Cursor cursor = contentResolver.query(
Notes.CONTENT_NOTE_URI,
null,
selection,
selectionArgs,
NoteColumns.MODIFIED_DATE + " DESC"
);
List<NoteInfo> notes = new ArrayList<>();
if (cursor != null) {
try {
while (cursor.moveToNext()) {
notes.add(noteFromCursor(cursor));
}
Log.d(TAG, "Found " + notes.size() + " unsynced notes");
} finally {
cursor.close();
}
}
callback.onSuccess(notes);
} catch (Exception e) {
Log.e(TAG, "Failed to get unsynced notes", e);
callback.onError(e);
}
});
}
/**
*
* <p>
* LOCAL_MODIFIED = 0 SYNC_STATUS = 2
* </p>
*
* @param noteId ID
*/
public void markAsSynced(long noteId) {
executor.execute(() -> {
try {
ContentValues values = new ContentValues();
values.put(NoteColumns.LOCAL_MODIFIED, 0);
values.put(NoteColumns.SYNC_STATUS, 2);
values.put(NoteColumns.LAST_SYNC_TIME, System.currentTimeMillis());
Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId);
int rows = contentResolver.update(uri, values, null, null);
if (rows > 0) {
Log.d(TAG, "Marked note as synced: " + noteId);
}
} catch (Exception e) {
Log.e(TAG, "Failed to mark note as synced: " + noteId, e);
}
});
}
/**
*
*
* @param noteId ID
* @param status (0=, 1=, 2=, 3=)
*/
public void updateSyncStatus(long noteId, int status) {
executor.execute(() -> {
try {
ContentValues values = new ContentValues();
values.put(NoteColumns.SYNC_STATUS, status);
Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId);
int rows = contentResolver.update(uri, values, null, null);
if (rows > 0) {
Log.d(TAG, "Updated sync status for note " + noteId + " to " + status);
}
} catch (Exception e) {
Log.e(TAG, "Failed to update sync status: " + noteId, e);
}
});
}
/**
*
*
* @param callback
*/
public void getLastSyncTime(Callback<Long> callback) {
executor.execute(() -> {
try {
Cursor cursor = contentResolver.query(
Notes.CONTENT_NOTE_URI,
new String[]{"MAX(" + NoteColumns.LAST_SYNC_TIME + ") AS last_sync"},
null,
null,
null
);
long lastSyncTime = 0;
if (cursor != null) {
try {
if (cursor.moveToFirst()) {
lastSyncTime = cursor.getLong(0);
}
} finally {
cursor.close();
}
}
callback.onSuccess(lastSyncTime);
} catch (Exception e) {
Log.e(TAG, "Failed to get last sync time", e);
callback.onError(e);
}
});
}
// ==================== 云同步相关方法 ====================
/**
* LOCAL_MODIFIED = 1
* <p>
*
* </p>
*
* @param cloudUserId ID
* @param callback
*/
public void getLocalModifiedNotes(String cloudUserId, Callback<List<WorkingNote>> callback) {
executor.execute(() -> {
try {
// 同时过滤 LOCAL_MODIFIED = 1 和 cloud_user_id = 当前用户
String selection = NoteColumns.LOCAL_MODIFIED + " = ? AND " + NoteColumns.CLOUD_USER_ID + " = ?";
String[] selectionArgs = new String[] { "1", cloudUserId };
String sortOrder = NoteColumns.MODIFIED_DATE + " DESC";
Cursor cursor = contentResolver.query(
Notes.CONTENT_NOTE_URI,
null,
selection,
selectionArgs,
sortOrder
);
List<WorkingNote> notes = new ArrayList<>();
if (cursor != null) {
while (cursor.moveToNext()) {
long id = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID));
WorkingNote note = WorkingNote.load(context, id);
notes.add(note);
}
cursor.close();
}
Log.d(TAG, "Found " + notes.size() + " locally modified notes for user: " + cloudUserId);
callback.onSuccess(notes);
} catch (Exception e) {
Log.e(TAG, "Failed to get local modified notes for user: " + cloudUserId, e);
callback.onError(e);
}
});
}
/**
* LOCAL_MODIFIED = 1-
*
* @param callback
* @deprecated 使 {@link #getLocalModifiedNotes(String, Callback)}
*/
@Deprecated
public void getLocalModifiedNotes(Callback<List<WorkingNote>> callback) {
executor.execute(() -> {
try {
String selection = NoteColumns.LOCAL_MODIFIED + " = ?";
String[] selectionArgs = new String[] { "1" };
String sortOrder = NoteColumns.MODIFIED_DATE + " DESC";
Cursor cursor = contentResolver.query(
Notes.CONTENT_NOTE_URI,
null,
selection,
selectionArgs,
sortOrder
);
List<WorkingNote> notes = new ArrayList<>();
if (cursor != null) {
while (cursor.moveToNext()) {
long id = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID));
WorkingNote note = WorkingNote.load(context, id);
notes.add(note);
}
cursor.close();
}
Log.d(TAG, "Found " + notes.size() + " locally modified notes");
callback.onSuccess(notes);
} catch (Exception e) {
Log.e(TAG, "Failed to get local modified notes", e);
callback.onError(e);
}
});
}
/**
*
* : LOCAL_MODIFIED=0, SYNC_STATUS=2, LAST_SYNC_TIME=now
*
* @param noteId ID
* @param callback
*/
public void markNoteSynced(long noteId, Callback<Void> callback) {
executor.execute(() -> {
try {
ContentValues values = new ContentValues();
values.put(NoteColumns.LOCAL_MODIFIED, 0);
values.put(NoteColumns.SYNC_STATUS, SyncConstants.SYNC_STATUS_SYNCED);
values.put(NoteColumns.LAST_SYNC_TIME, System.currentTimeMillis());
Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId);
int rows = contentResolver.update(uri, values, null, null);
if (rows > 0) {
Log.d(TAG, "Marked note " + noteId + " as synced");
}
callback.onSuccess(null);
} catch (Exception e) {
Log.e(TAG, "Failed to mark note as synced: " + noteId, e);
callback.onError(e);
}
});
}
/**
* noteId
*
* @param noteId ID
* @param callback null
*/
public void findNoteByNoteId(String noteId, Callback<WorkingNote> callback) {
executor.execute(() -> {
try {
long id = Long.parseLong(noteId);
Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, id);
Cursor cursor = contentResolver.query(
uri,
null,
null,
null,
null
);
WorkingNote note = null;
if (cursor != null && cursor.moveToFirst()) {
long noteIdFromCursor = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID));
note = WorkingNote.load(context, noteIdFromCursor);
cursor.close();
}
callback.onSuccess(note);
} catch (Exception e) {
Log.e(TAG, "Failed to find note by noteId: " + noteId, e);
callback.onError(e);
}
});
}
/**
* cloudNoteId
*
* @param cloudNoteId IDUUID
* @param callback null
*/
public void findByCloudNoteId(String cloudNoteId, Callback<WorkingNote> callback) {
executor.execute(() -> {
try {
if (cloudNoteId == null || cloudNoteId.isEmpty()) {
callback.onSuccess(null);
return;
}
String selection = NoteColumns.CLOUD_NOTE_ID + " = ?";
String[] selectionArgs = new String[] { cloudNoteId };
Cursor cursor = contentResolver.query(
Notes.CONTENT_NOTE_URI,
null,
selection,
selectionArgs,
null
);
WorkingNote note = null;
if (cursor != null) {
if (cursor.moveToFirst()) {
long noteId = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID));
note = WorkingNote.load(context, noteId);
}
cursor.close();
}
Log.d(TAG, "findByCloudNoteId: " + cloudNoteId + " found=" + (note != null));
callback.onSuccess(note);
} catch (Exception e) {
Log.e(TAG, "Failed to find note by cloudNoteId: " + cloudNoteId, e);
callback.onError(e);
}
});
}
/**
* ID
*
* @param cloudUserId ID
* @param callback
*/
public void getNotesByCloudUserId(String cloudUserId, Callback<List<WorkingNote>> callback) {
executor.execute(() -> {
try {
String selection = NoteColumns.CLOUD_USER_ID + " = ?";
String[] selectionArgs = new String[] { cloudUserId };
Cursor cursor = contentResolver.query(
Notes.CONTENT_NOTE_URI,
null,
selection,
selectionArgs,
null
);
List<WorkingNote> notes = new ArrayList<>();
if (cursor != null) {
while (cursor.moveToNext()) {
long noteId = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID));
WorkingNote note = WorkingNote.load(context, noteId);
notes.add(note);
}
cursor.close();
}
callback.onSuccess(notes);
} catch (Exception e) {
Log.e(TAG, "Failed to get notes by cloudUserId: " + cloudUserId, e);
callback.onError(e);
}
});
}
/**
* ID
*
* @param oldUserId ID
* @param newUserId ID
* @param callback
*/
public void updateCloudUserId(String oldUserId, String newUserId, Callback<Integer> callback) {
executor.execute(() -> {
try {
ContentValues values = new ContentValues();
values.put(NoteColumns.CLOUD_USER_ID, newUserId);
values.put(NoteColumns.LOCAL_MODIFIED, 1);
String selection = NoteColumns.CLOUD_USER_ID + " = ?";
String[] selectionArgs = new String[] { oldUserId };
int rows = contentResolver.update(
Notes.CONTENT_NOTE_URI,
values,
selection,
selectionArgs
);
Log.d(TAG, "Updated " + rows + " notes from " + oldUserId + " to " + newUserId);
callback.onSuccess(rows);
} catch (Exception e) {
Log.e(TAG, "Failed to update cloudUserId", e);
callback.onError(e);
}
});
}
/**
*
*
* @param noteIds ID
* @param callback
*/
public void batchMarkNotesSynced(List<Long> noteIds, Callback<Integer> callback) {
executor.execute(() -> {
int successCount = 0;
Exception lastError = null;
for (Long noteId : noteIds) {
try {
ContentValues values = new ContentValues();
values.put(NoteColumns.LOCAL_MODIFIED, 0);
values.put(NoteColumns.SYNC_STATUS, SyncConstants.SYNC_STATUS_SYNCED);
values.put(NoteColumns.LAST_SYNC_TIME, System.currentTimeMillis());
Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId);
int rows = contentResolver.update(uri, values, null, null);
if (rows > 0) {
successCount++;
Log.d(TAG, "Marked note " + noteId + " as synced");
}
} catch (Exception e) {
Log.e(TAG, "Failed to mark note as synced: " + noteId, e);
lastError = e;
}
}
Log.d(TAG, "Batch sync completed: " + successCount + "/" + noteIds.size() + " notes marked as synced");
if (successCount == noteIds.size()) {
callback.onSuccess(successCount);
} else if (successCount > 0) {
callback.onSuccess(successCount);
} else {
callback.onError(lastError != null ? lastError : new Exception("All batch operations failed"));
}
});
}
/**
*
* <p>
* cloud_user_idcloud_user_id
*
* </p>
*
* @param newUserId ID
* @param callback
*/
public void takeoverAllNotes(String newUserId, Callback<Integer> callback) {
executor.execute(() -> {
try {
// 1. 获取设备上所有笔记(排除系统文件夹)
String selection = NoteColumns.TYPE + " != ?";
String[] selectionArgs = new String[] { String.valueOf(Notes.TYPE_SYSTEM) };
Cursor cursor = contentResolver.query(
Notes.CONTENT_NOTE_URI,
null,
selection,
selectionArgs,
null
);
int takeoverCount = 0;
if (cursor != null) {
while (cursor.moveToNext()) {
long noteId = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID));
// 更新笔记的cloud_user_id为新用户并标记为本地修改
ContentValues values = new ContentValues();
values.put(NoteColumns.CLOUD_USER_ID, newUserId);
values.put(NoteColumns.LOCAL_MODIFIED, 1);
values.put(NoteColumns.SYNC_STATUS, 0);
Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId);
int rows = contentResolver.update(uri, values, null, null);
if (rows > 0) {
takeoverCount++;
}
}
cursor.close();
}
Log.d(TAG, "Takeover completed: " + takeoverCount + " notes now belong to " + newUserId);
callback.onSuccess(takeoverCount);
} catch (Exception e) {
Log.e(TAG, "Failed to takeover notes for user: " + newUserId, e);
callback.onError(e);
}
});
}
}

@ -0,0 +1,193 @@
/*
* Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.micode.notes.model;
import android.content.ContentUris;
import android.content.Context;
import android.database.Cursor;
import android.util.Log;
import net.micode.notes.data.Notes;
import org.json.JSONException;
import org.json.JSONObject;
/**
*
* <p>
*
* </p>
*/
public class CloudNote {
private static final String TAG = "CloudNote";
private String mNoteId;
private String mCloudNoteId;
private String mTitle;
private String mContent;
private String mParentId;
private int mType;
private long mCreatedTime;
private long mModifiedTime;
private int mVersion;
private String mDeviceId;
/**
* JSONCloudNote
*/
public CloudNote(JSONObject json) throws JSONException {
mCloudNoteId = json.optString("cloudNoteId", "");
mNoteId = json.optString("noteId", "");
mTitle = json.optString("title", "");
mContent = json.optString("content", "");
mParentId = json.optString("parentId", "0");
mType = json.optInt("type", 0);
mCreatedTime = json.optLong("createdTime", System.currentTimeMillis());
mModifiedTime = json.optLong("modifiedTime", System.currentTimeMillis());
mVersion = json.optInt("version", 1);
mDeviceId = json.optString("deviceId", "");
}
/**
* WorkingNoteCloudNote
*/
public CloudNote(WorkingNote note, String deviceId) {
mCloudNoteId = note.getCloudNoteId() != null ? note.getCloudNoteId() : "";
mNoteId = String.valueOf(note.getNoteId());
mTitle = note.getTitle();
mContent = note.getContent();
mParentId = String.valueOf(note.getFolderId());
mType = note.getType();
mCreatedTime = System.currentTimeMillis();
mModifiedTime = note.getModifiedDate();
mVersion = 1;
mDeviceId = deviceId;
}
/**
* WorkingNote
*
* ID
*/
public WorkingNote toWorkingNote(Context context, String userId) {
try {
long noteId = Long.parseLong(mNoteId);
// 先检查本地是否存在该云端ID的笔记
boolean existsInLocal = noteExistsInDatabase(context, noteId);
WorkingNote note;
if (existsInLocal) {
// 本地存在,加载并更新
note = WorkingNote.load(context, noteId);
Log.d(TAG, "Updating existing note from cloud: " + noteId);
} else {
// 本地不存在创建新笔记不指定ID让数据库生成
note = WorkingNote.createEmptyNote(context, 0);
Log.d(TAG, "Creating new note from cloud, cloud ID: " + noteId);
}
note.setType(mType);
note.setTitle(mTitle);
note.setContent(mContent);
note.setFolderId(Long.parseLong(mParentId));
note.setModifiedDate(mModifiedTime);
note.setCloudUserId(userId);
note.setCloudNoteId(mCloudNoteId);
note.setSyncStatus(net.micode.notes.sync.SyncConstants.SYNC_STATUS_SYNCED);
note.setLocalModified(0);
note.setLastSyncTime(System.currentTimeMillis());
return note;
} catch (Exception e) {
Log.e(TAG, "转换WorkingNote失败", e);
return null;
}
}
/**
* ID
* @param context
* @param noteId IDID
* @return true false
*/
private boolean noteExistsInDatabase(Context context, long noteId) {
Cursor cursor = null;
try {
cursor = context.getContentResolver().query(
ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId),
new String[]{Notes.NoteColumns.ID},
null,
null,
null
);
boolean exists = cursor != null && cursor.getCount() > 0;
Log.d(TAG, "Note exists in local DB: " + noteId + " - " + exists);
return exists;
} catch (Exception e) {
Log.e(TAG, "Failed to check note existence: " + noteId, e);
return false;
} finally {
if (cursor != null) {
cursor.close();
}
}
}
/**
* WorkingNote使userId
* @deprecated 使 {@link #toWorkingNote(Context, String)}
*/
@Deprecated
public WorkingNote toWorkingNote(Context context) {
return toWorkingNote(context, "");
}
/**
* JSON
*/
public JSONObject toJson() throws JSONException {
JSONObject json = new JSONObject();
if (mCloudNoteId != null && !mCloudNoteId.isEmpty()) {
json.put("cloudNoteId", mCloudNoteId);
}
json.put("noteId", mNoteId);
json.put("title", mTitle);
json.put("content", mContent);
json.put("parentId", mParentId);
json.put("type", mType);
json.put("createdTime", mCreatedTime);
json.put("modifiedTime", mModifiedTime);
json.put("version", mVersion);
json.put("deviceId", mDeviceId);
return json;
}
// Getters
public String getCloudNoteId() { return mCloudNoteId; }
public String getNoteId() { return mNoteId; }
public String getTitle() { return mTitle; }
public String getContent() { return mContent; }
public String getParentId() { return mParentId; }
public int getType() { return mType; }
public long getCreatedTime() { return mCreatedTime; }
public long getModifiedTime() { return mModifiedTime; }
public int getVersion() { return mVersion; }
public String getDeviceId() { return mDeviceId; }
}

@ -57,18 +57,34 @@ public class Note {
* ID
* ID
* </p>
*
*
* @param context
* @param folderId ID
* @return ID 0
*/
public static synchronized long getNewNoteId(Context context, long folderId) {
return getNewNoteId(context, folderId, Notes.TYPE_NOTE);
}
/**
* ID
* <p>
* ID
* ID
* </p>
*
* @param context
* @param folderId ID
* @param type 0=, 1=, 2=, 3=
* @return ID 0
*/
public static synchronized long getNewNoteId(Context context, long folderId, int type) {
// 在数据库中创建新笔记
ContentValues values = new ContentValues();
long createdTime = System.currentTimeMillis();
values.put(NoteColumns.CREATED_DATE, createdTime);
values.put(NoteColumns.MODIFIED_DATE, createdTime);
values.put(NoteColumns.TYPE, Notes.TYPE_NOTE);
values.put(NoteColumns.TYPE, type);
values.put(NoteColumns.LOCAL_MODIFIED, 1);
values.put(NoteColumns.PARENT_ID, folderId);
Uri uri = context.getContentResolver().insert(Notes.CONTENT_NOTE_URI, values);
@ -421,5 +437,50 @@ public class Note {
}
return null;
}
// ==================== 云同步相关方法 ====================
void setCloudUserId(String userId) {
mNoteDiffValues.put(NoteColumns.CLOUD_USER_ID, userId);
}
String getCloudUserId() {
return mNoteDiffValues.getAsString(NoteColumns.CLOUD_USER_ID);
}
void setCloudDeviceId(String deviceId) {
mNoteDiffValues.put(NoteColumns.CLOUD_DEVICE_ID, deviceId);
}
String getCloudDeviceId() {
return mNoteDiffValues.getAsString(NoteColumns.CLOUD_DEVICE_ID);
}
void setSyncStatus(int status) {
mNoteDiffValues.put(NoteColumns.SYNC_STATUS, status);
}
int getSyncStatus() {
Integer status = mNoteDiffValues.getAsInteger(NoteColumns.SYNC_STATUS);
return status != null ? status : 0;
}
void setLastSyncTime(long time) {
mNoteDiffValues.put(NoteColumns.LAST_SYNC_TIME, time);
}
long getLastSyncTime() {
Long time = mNoteDiffValues.getAsLong(NoteColumns.LAST_SYNC_TIME);
return time != null ? time : 0;
}
void setLocalModified(int modified) {
mNoteDiffValues.put(NoteColumns.LOCAL_MODIFIED, modified);
}
int getLocalModified() {
Integer modified = mNoteDiffValues.getAsInteger(NoteColumns.LOCAL_MODIFIED);
return modified != null ? modified : 0;
}
}
}

@ -73,9 +73,30 @@ public class WorkingNote {
/** 父文件夹 ID */
private long mFolderId;
/** 笔记类型: 0=普通笔记, 1=文件夹, 2=系统, 3=待办 */
private int mType;
/** 应用上下文 */
private Context mContext;
/** 同步状态 */
private int mSyncStatus;
/** 最后同步时间 */
private long mLastSyncTime;
/** 本地修改标记 */
private int mLocalModified;
/** 云端用户ID */
private String mCloudUserId;
/** 云端设备ID */
private String mCloudDeviceId;
/** 云端笔记ID */
private String mCloudNoteId;
/** 日志标签 */
private static final String TAG = "WorkingNote";
@ -103,8 +124,11 @@ public class WorkingNote {
NoteColumns.BG_COLOR_ID,
NoteColumns.WIDGET_ID,
NoteColumns.WIDGET_TYPE,
NoteColumns.LOCAL_MODIFIED,
NoteColumns.MODIFIED_DATE,
NoteColumns.TITLE
NoteColumns.TITLE,
NoteColumns.TYPE,
NoteColumns.SNIPPET
};
/** 数据 ID 列索引 */
@ -135,7 +159,13 @@ public class WorkingNote {
private static final int NOTE_WIDGET_TYPE_COLUMN = 4;
/** 笔记修改日期列索引 */
private static final int NOTE_MODIFIED_DATE_COLUMN = 5;
private static final int NOTE_MODIFIED_DATE_COLUMN = 6;
/** 笔记类型列索引 */
private static final int NOTE_TYPE_COLUMN = 8;
/** 云端笔记ID列索引 */
private static final int NOTE_CLOUD_NOTE_ID_COLUMN = 9;
/**
*
@ -159,6 +189,8 @@ public class WorkingNote {
mIsDeleted = false;
mMode = 0;
mWidgetType = Notes.TYPE_WIDGET_INVALIDE;
mLocalModified = 1; // 新建笔记需要同步
mType = Notes.TYPE_NOTE; // 默认为普通笔记类型
}
/**
@ -200,6 +232,7 @@ public class WorkingNote {
mWidgetType = cursor.getInt(NOTE_WIDGET_TYPE_COLUMN);
mAlertDate = cursor.getLong(NOTE_ALERTED_DATE_COLUMN);
mModifiedDate = cursor.getLong(NOTE_MODIFIED_DATE_COLUMN);
mType = cursor.getInt(NOTE_TYPE_COLUMN);
// Load title
int titleIndex = cursor.getColumnIndex(NoteColumns.TITLE);
@ -208,6 +241,22 @@ public class WorkingNote {
} else {
mTitle = "";
}
// If it's a folder and title is empty, try loading from snippet
if (mType == Notes.TYPE_FOLDER && TextUtils.isEmpty(mTitle)) {
int snippetIndex = cursor.getColumnIndex(NoteColumns.SNIPPET);
if (snippetIndex != -1) {
mTitle = cursor.getString(snippetIndex);
}
}
// Load cloud note id
int cloudNoteIdIndex = cursor.getColumnIndex(NoteColumns.CLOUD_NOTE_ID);
if (cloudNoteIdIndex != -1) {
mCloudNoteId = cursor.getString(cloudNoteIdIndex);
} else {
mCloudNoteId = "";
}
}
cursor.close();
} else {
@ -258,7 +307,7 @@ public class WorkingNote {
* <p>
*
* </p>
*
*
* @param context
* @param folderId ID
* @param widgetId Widget ID
@ -275,6 +324,21 @@ public class WorkingNote {
return note;
}
/**
*
* <p>
*
* </p>
*
* @param context
* @param noteId ID
* @return WorkingNote
*/
public static WorkingNote createEmptyNote(Context context, long noteId) {
WorkingNote note = new WorkingNote(context, noteId, 0);
return note;
}
/**
*
* <p>
@ -377,6 +441,9 @@ public class WorkingNote {
*/
public void setTitle(String title) {
mTitle = title;
mNote.setNoteValue(NoteColumns.TITLE, mTitle);
// 同步设置SNIPPET字段以保持兼容性
mNote.setNoteValue(NoteColumns.SNIPPET, mTitle);
}
public String getTitle() {
@ -621,13 +688,162 @@ public class WorkingNote {
/**
* Widget
*
*
* @return Widget
*/
public int getWidgetType() {
return mWidgetType;
}
// ==================== 云同步相关方法 ====================
/**
* ID
*/
public void setCloudUserId(String userId) {
mCloudUserId = userId;
}
/**
* ID
*/
public String getCloudUserId() {
return mCloudUserId;
}
/**
* ID
*/
public void setCloudDeviceId(String deviceId) {
mCloudDeviceId = deviceId;
}
/**
* ID
*/
public String getCloudDeviceId() {
return mCloudDeviceId;
}
/**
*
* @param status 0=, 1=, 2=, 3=
*/
public void setSyncStatus(int status) {
mSyncStatus = status;
}
/**
*
*/
public int getSyncStatus() {
return mSyncStatus;
}
/**
*
*/
public void setLastSyncTime(long time) {
mLastSyncTime = time;
}
/**
*
*/
public long getLastSyncTime() {
return mLastSyncTime;
}
/**
*
* @param modified 0=, 1=
*/
public void setLocalModified(int modified) {
mLocalModified = modified;
}
/**
*
*/
public int getLocalModified() {
return mLocalModified;
}
/**
*
*/
public void setContent(String content) {
mContent = content;
mNote.setTextData(DataColumns.CONTENT, mContent);
}
/**
* ID
*/
public void setFolderId(long folderId) {
mFolderId = folderId;
mNote.setNoteValue(NoteColumns.PARENT_ID, String.valueOf(mFolderId));
}
/**
*
*/
public void setModifiedDate(long date) {
mModifiedDate = date;
mNote.setNoteValue(NoteColumns.MODIFIED_DATE, String.valueOf(mModifiedDate));
}
/**
*
* @param type 0=, 1=, 2=, 3=
*/
public void setType(int type) {
mType = type;
mNote.setNoteValue(NoteColumns.TYPE, String.valueOf(mType));
}
/**
*
* @return 0=, 1=, 2=, 3=
*/
public int getType() {
return mType;
}
/**
* ID
* @return IDUUID
*/
public String getCloudNoteId() {
return mCloudNoteId;
}
/**
* ID
* @param cloudNoteId IDUUID
*/
public void setCloudNoteId(String cloudNoteId) {
mCloudNoteId = cloudNoteId;
mNote.setNoteValue(NoteColumns.CLOUD_NOTE_ID, mCloudNoteId != null ? mCloudNoteId : "");
}
/**
* CloudNote
*
*/
public void updateFrom(CloudNote cloudNote) {
setTitle(cloudNote.getTitle());
setContent(cloudNote.getContent());
setFolderId(Long.parseLong(cloudNote.getParentId()));
setModifiedDate(cloudNote.getModifiedTime());
setType(cloudNote.getType());
setCloudNoteId(cloudNote.getCloudNoteId());
setSyncStatus(net.micode.notes.sync.SyncConstants.SYNC_STATUS_SYNCED);
setLastSyncTime(System.currentTimeMillis());
setLocalModified(0);
saveNote();
}
/**
*
* <p>

@ -0,0 +1,112 @@
/*
* Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.micode.notes.sync;
import net.micode.notes.model.CloudNote;
import net.micode.notes.model.WorkingNote;
/**
*
* <p>
*
* </p>
*/
public class Conflict {
private WorkingNote mLocalNote;
private CloudNote mCloudNote;
private String mNoteId;
private long mConflictTime;
private ConflictType mType;
public enum ConflictType {
BOTH_MODIFIED, // 双方都修改过
VERSION_MISMATCH // 版本号不匹配
}
public Conflict(WorkingNote localNote, CloudNote cloudNote) {
this(localNote, cloudNote, ConflictType.BOTH_MODIFIED);
}
public Conflict(WorkingNote localNote, CloudNote cloudNote, ConflictType type) {
mLocalNote = localNote;
mCloudNote = cloudNote;
mNoteId = String.valueOf(localNote.getNoteId());
mConflictTime = System.currentTimeMillis();
mType = type;
}
public WorkingNote getLocalNote() {
return mLocalNote;
}
public CloudNote getCloudNote() {
return mCloudNote;
}
public String getNoteId() {
return mNoteId;
}
public long getConflictTime() {
return mConflictTime;
}
public ConflictType getType() {
return mType;
}
/**
*
*/
public String getConflictDescription() {
return "本地修改时间: " + mLocalNote.getModifiedDate() +
"\n云端修改时间: " + mCloudNote.getModifiedTime();
}
/**
*
*/
public String getLocalTitle() {
return mLocalNote.getTitle();
}
/**
*
*/
public String getCloudTitle() {
return mCloudNote.getTitle();
}
/**
* 100
*/
public String getLocalContentPreview() {
String content = mLocalNote.getContent();
if (content == null) return "";
return content.length() > 100 ? content.substring(0, 100) + "..." : content;
}
/**
* 100
*/
public String getCloudContentPreview() {
String content = mCloudNote.getContent();
if (content == null) return "";
return content.length() > 100 ? content.substring(0, 100) + "..." : content;
}
}

@ -0,0 +1,65 @@
/*
* Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.micode.notes.sync;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.util.Log;
/**
*
* <p>
*
* </p>
*/
public class NotesPushMessageReceiver extends BroadcastReceiver {
private static final String TAG = "NotesPushMessageReceiver";
@Override
public void onReceive(Context context, Intent intent) {
if (intent == null || intent.getAction() == null) {
return;
}
String action = intent.getAction();
Log.d(TAG, "Received push message: " + action);
if ("com.alibaba.push2.action.NOTIFICATION_OPENED".equals(action)) {
handleNotificationOpened(context, intent);
} else if ("com.alibaba.push2.action.MESSAGE_RECEIVED".equals(action)) {
handleMessageReceived(context, intent);
}
}
private void handleNotificationOpened(Context context, Intent intent) {
Log.d(TAG, "Notification opened");
// TODO: Handle notification open action
}
private void handleMessageReceived(Context context, Intent intent) {
Log.d(TAG, "Message received");
// Check if this is a sync message
String messageAction = intent.getStringExtra("action");
if ("sync".equals(messageAction)) {
Log.d(TAG, "Sync action received, broadcasting sync intent");
Intent syncIntent = new Intent(SyncConstants.ACTION_SYNC);
context.sendBroadcast(syncIntent);
}
}
}

@ -0,0 +1,55 @@
/*
* Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.micode.notes.sync;
/**
*
* <p>
* 使
* </p>
*/
public class SyncConstants {
private SyncConstants() {
// Utility class, prevent instantiation
}
/**
*
*/
public static final int SYNC_STATUS_NOT_SYNCED = 0;
/**
*
*/
public static final int SYNC_STATUS_SYNCING = 1;
/**
*
*/
public static final int SYNC_STATUS_SYNCED = 2;
/**
*
*/
public static final int SYNC_STATUS_CONFLICT = 3;
/**
* 广Action
*/
public static final String ACTION_SYNC = "com.micode.notes.ACTION_SYNC";
}

@ -0,0 +1,746 @@
/*
* Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.micode.notes.sync;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.Nullable;
import net.micode.notes.api.CloudCallback;
import net.micode.notes.api.CloudDatabaseHelper;
import net.micode.notes.auth.UserAuthManager;
import net.micode.notes.data.NotesRepository;
import net.micode.notes.model.CloudNote;
import net.micode.notes.model.WorkingNote;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
/**
*
* <p>
*
* 使
* </p>
* <p>
*
* 1. -
* 2. 线 - 使 CountDownLatch synchronized/wait/notify
* 3. -
* 4. -
* </p>
*/
public class SyncManager {
private static final String TAG = "SyncManager";
private static final String PREFS_SYNC = "sync_settings";
private static final String KEY_LAST_SYNC = "last_sync_time";
private static final String KEY_IS_FIRST_SYNC = "is_first_sync";
private static final long SYNC_TIMEOUT_SECONDS = 60;
private final ExecutorService mExecutor;
private Context mContext;
private SharedPreferences mPrefs;
private List<Conflict> mConflicts;
private ConflictListener mConflictListener;
/**
* Initialization-on-demand holder idiom
*/
private static class Holder {
private static final SyncManager INSTANCE = new SyncManager();
}
private SyncManager() {
mExecutor = Executors.newSingleThreadExecutor();
mConflicts = new ArrayList<>();
}
/**
*
*/
public interface SyncCallback {
void onSuccess();
void onError(String error);
}
/**
*
*/
public interface SyncProgressCallback {
void onProgress(int current, int total, String message);
}
/**
*
*/
public interface ConflictListener {
void onConflictDetected(Conflict conflict);
}
/**
* SyncManager
*
* @return SyncManager
*/
public static SyncManager getInstance() {
return Holder.INSTANCE;
}
/**
* SyncManager
*
* @param context
*/
public void initialize(Context context) {
mContext = context.getApplicationContext();
mPrefs = mContext.getSharedPreferences(PREFS_SYNC, Context.MODE_PRIVATE);
Log.d(TAG, "SyncManager initialized");
}
/**
*
*
* @param listener
*/
public void setConflictListener(ConflictListener listener) {
mConflictListener = listener;
}
/**
*
*
* @param conflict
*/
public void removeConflict(Conflict conflict) {
mConflicts.remove(conflict);
}
/**
*
*
* @param callback
*/
public void syncNotes(SyncCallback callback) {
syncNotesInternal(false, callback, null);
}
/**
*
*
* @param callback
*/
public void syncAllNotes(SyncCallback callback) {
syncNotesInternal(true, callback, null);
}
/**
*
* <p>
*
* LOCAL_MODIFIED
* </p>
*
* @param callback
*/
public void uploadAllNotes(SyncCallback callback) {
Log.d(TAG, "========== Starting upload all notes ==========");
mExecutor.execute(() -> {
try {
performUploadAll();
Log.d(TAG, "Upload all notes completed successfully");
if (callback != null) {
callback.onSuccess();
}
} catch (Exception e) {
Log.e(TAG, "Upload all notes failed", e);
if (callback != null) {
callback.onError(e.getMessage());
}
}
});
}
/**
*
*
* @param forceFullSync
* @param callback
* @param progressCallback
*/
public void syncNotesWithProgress(boolean forceFullSync, SyncCallback callback,
SyncProgressCallback progressCallback) {
syncNotesInternal(forceFullSync, callback, progressCallback);
}
/**
*
*/
private void syncNotesInternal(boolean forceFullSync, SyncCallback callback,
SyncProgressCallback progressCallback) {
Log.d(TAG, "========== Starting sync operation ==========");
Log.d(TAG, "Force full sync: " + forceFullSync);
mExecutor.execute(() -> {
try {
performSync(forceFullSync, progressCallback);
Log.d(TAG, "Sync completed successfully");
if (callback != null) {
callback.onSuccess();
}
} catch (Exception e) {
Log.e(TAG, "Sync failed", e);
if (callback != null) {
callback.onError(e.getMessage());
}
}
});
}
/**
*
*/
private void performSync(boolean forceFullSync, SyncProgressCallback progressCallback) throws Exception {
UserAuthManager authManager = UserAuthManager.getInstance(mContext);
if (!authManager.isLoggedIn()) {
throw new RuntimeException("用户未登录");
}
// 检查并刷新Token
ensureValidToken(authManager);
NotesRepository repo = new NotesRepository(mContext);
String authToken = authManager.getAuthToken();
Log.d(TAG, "Auth token: " + (authToken != null ? authToken.substring(0, Math.min(20, authToken.length())) + "..." : "null"));
CloudDatabaseHelper cloudHelper = new CloudDatabaseHelper(
authManager.getUserId(),
authManager.getDeviceId(),
authToken
);
// 1. 上传本地修改的笔记(只上传当前用户的笔记)
if (progressCallback != null) {
progressCallback.onProgress(0, 100, "正在上传本地修改...");
}
uploadNotesSync(repo, cloudHelper, progressCallback, authManager.getUserId());
// 2. 下载云端更新的笔记
if (progressCallback != null) {
progressCallback.onProgress(50, 100, "正在下载云端更新...");
}
boolean downloadSuccess = downloadNotesSync(repo, cloudHelper, forceFullSync, progressCallback, authManager.getUserId());
// 3. 只有在下载成功后才更新同步时间
if (downloadSuccess) {
updateSyncFlags();
// 标记已完成首次同步
markFirstSyncCompleted();
} else {
throw new RuntimeException("下载云端笔记失败,同步时间未更新");
}
}
/**
* Token
*/
private void ensureValidToken(UserAuthManager authManager) throws Exception {
if (authManager.isTokenExpired()) {
Log.w(TAG, "Token已过期尝试刷新...");
final CountDownLatch latch = new CountDownLatch(1);
final AtomicBoolean refreshSuccess = new AtomicBoolean(false);
final AtomicReference<String> errorMsg = new AtomicReference<>();
authManager.refreshToken(new UserAuthManager.TokenRefreshCallback() {
@Override
public void onSuccess(String newToken) {
Log.d(TAG, "Token刷新成功");
refreshSuccess.set(true);
latch.countDown();
}
@Override
public void onError(String error) {
Log.e(TAG, "Token刷新失败: " + error);
errorMsg.set(error);
latch.countDown();
}
});
boolean completed = latch.await(SYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!completed || !refreshSuccess.get()) {
throw new RuntimeException("Token刷新失败: " + errorMsg.get());
}
}
}
/**
*
*/
private void uploadNotesSync(NotesRepository repo, CloudDatabaseHelper cloudHelper,
SyncProgressCallback progressCallback, String userId) throws Exception {
Log.d(TAG, "Uploading local modified notes for user: " + userId);
final List<WorkingNote> notesToUpload = new ArrayList<>();
final CountDownLatch queryLatch = new CountDownLatch(1);
final AtomicReference<Exception> errorRef = new AtomicReference<>();
// 使用带用户过滤的方法,只查询当前用户的笔记
repo.getLocalModifiedNotes(userId, new NotesRepository.Callback<List<WorkingNote>>() {
@Override
public void onSuccess(List<WorkingNote> notes) {
notesToUpload.addAll(notes);
queryLatch.countDown();
}
@Override
public void onError(Exception e) {
errorRef.set(e);
queryLatch.countDown();
}
});
boolean completed = queryLatch.await(SYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!completed) {
throw new RuntimeException("查询本地修改笔记超时");
}
if (errorRef.get() != null) {
throw errorRef.get();
}
Log.d(TAG, "Found " + notesToUpload.size() + " notes to upload");
int total = notesToUpload.size();
for (int i = 0; i < total; i++) {
WorkingNote note = notesToUpload.get(i);
if (progressCallback != null) {
int progress = (i * 50) / total; // 上传占50%进度
progressCallback.onProgress(progress, 100, "正在上传笔记 " + (i + 1) + "/" + total);
}
uploadSingleNote(repo, cloudHelper, note);
}
}
/**
*
*/
private void uploadSingleNote(NotesRepository repo, CloudDatabaseHelper cloudHelper,
WorkingNote note) throws Exception {
final CountDownLatch uploadLatch = new CountDownLatch(1);
final AtomicReference<String> cloudIdRef = new AtomicReference<>();
final AtomicReference<Exception> errorRef = new AtomicReference<>();
cloudHelper.uploadNote(note, new CloudCallback<String>() {
@Override
public void onSuccess(String result) {
cloudIdRef.set(result);
uploadLatch.countDown();
}
@Override
public void onError(String err) {
errorRef.set(new Exception(err));
uploadLatch.countDown();
}
});
boolean completed = uploadLatch.await(SYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!completed) {
throw new RuntimeException("上传笔记超时: " + note.getNoteId());
}
if (errorRef.get() != null) {
Log.e(TAG, "Failed to upload note: " + note.getNoteId(), errorRef.get());
return; // 继续处理其他笔记
}
Log.d(TAG, "Uploaded note: " + note.getNoteId() + " with cloudId: " + cloudIdRef.get());
String cloudNoteId = cloudIdRef.get();
if (cloudNoteId != null && !cloudNoteId.isEmpty()) {
note.setCloudNoteId(cloudNoteId);
if (!note.saveNote()) {
Log.w(TAG, "Failed to save cloudNoteId for note: " + note.getNoteId());
}
}
markNoteAsSynced(repo, note.getNoteId());
}
/**
*
*/
private void markNoteAsSynced(NotesRepository repo, long noteId) throws Exception {
final CountDownLatch markLatch = new CountDownLatch(1);
final AtomicReference<Exception> errorRef = new AtomicReference<>();
repo.markNoteSynced(noteId, new NotesRepository.Callback<Void>() {
@Override
public void onSuccess(Void result) {
markLatch.countDown();
}
@Override
public void onError(Exception e) {
errorRef.set(e);
markLatch.countDown();
}
});
boolean completed = markLatch.await(SYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!completed) {
throw new RuntimeException("标记笔记同步状态超时: " + noteId);
}
if (errorRef.get() != null) {
throw errorRef.get();
}
Log.d(TAG, "Marked note " + noteId + " as synced");
}
/**
* modified
*/
private void performUploadAll() throws Exception {
UserAuthManager authManager = UserAuthManager.getInstance(mContext);
if (!authManager.isLoggedIn()) {
throw new RuntimeException("用户未登录");
}
String userId = authManager.getUserId();
String authToken = authManager.getAuthToken();
Log.d(TAG, "Uploading all notes for user: " + userId);
NotesRepository repo = new NotesRepository(mContext);
CloudDatabaseHelper cloudHelper = new CloudDatabaseHelper(
userId,
authManager.getDeviceId(),
authToken
);
// 获取当前用户的所有笔记不管modified状态
final List<WorkingNote> allNotes = new ArrayList<>();
final CountDownLatch queryLatch = new CountDownLatch(1);
final AtomicReference<Exception> errorRef = new AtomicReference<>();
repo.getNotesByCloudUserId(userId, new NotesRepository.Callback<List<WorkingNote>>() {
@Override
public void onSuccess(List<WorkingNote> notes) {
allNotes.addAll(notes);
queryLatch.countDown();
}
@Override
public void onError(Exception e) {
errorRef.set(e);
queryLatch.countDown();
}
});
boolean completed = queryLatch.await(SYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!completed) {
throw new RuntimeException("查询笔记超时");
}
if (errorRef.get() != null) {
throw errorRef.get();
}
Log.d(TAG, "Found " + allNotes.size() + " notes to upload");
// 上传所有笔记
int total = allNotes.size();
int successCount = 0;
for (int i = 0; i < total; i++) {
WorkingNote note = allNotes.get(i);
try {
uploadSingleNote(repo, cloudHelper, note);
successCount++;
} catch (Exception e) {
Log.e(TAG, "Failed to upload note: " + note.getNoteId(), e);
// 继续上传其他笔记
}
}
Log.d(TAG, "Upload completed: " + successCount + "/" + total + " notes uploaded");
// 更新同步时间
updateSyncFlags();
markFirstSyncCompleted();
}
/**
*
*
* @return
*/
private boolean downloadNotesSync(NotesRepository repo, CloudDatabaseHelper cloudHelper,
boolean forceFullSync, SyncProgressCallback progressCallback, String userId) throws Exception {
Log.d(TAG, "Downloading cloud updates");
long lastSyncTime = forceFullSync ? 0 : mPrefs.getLong(KEY_LAST_SYNC, 0);
Log.d(TAG, "Last sync time: " + lastSyncTime + (forceFullSync ? " (强制全量同步)" : ""));
// 首次同步时传递 0获取所有笔记
// 后续同步只获取修改过的笔记
long downloadSince = (lastSyncTime == 0) ? 0 : lastSyncTime;
final AtomicReference<JSONArray> notesArrayRef = new AtomicReference<>();
final CountDownLatch downloadLatch = new CountDownLatch(1);
final AtomicReference<Exception> errorRef = new AtomicReference<>();
final AtomicLong maxModifiedTime = new AtomicLong(0);
cloudHelper.downloadNotes(downloadSince, new CloudCallback<JSONArray>() {
@Override
public void onSuccess(JSONArray result) {
notesArrayRef.set(result);
// 计算云端最新修改时间
long latestCloudTime = 0;
try {
for (int i = 0; i < result.length(); i++) {
JSONObject noteJson = result.getJSONObject(i);
long modifiedTime = noteJson.optLong("modifiedTime", 0);
if (modifiedTime > latestCloudTime) {
latestCloudTime = modifiedTime;
}
}
maxModifiedTime.set(latestCloudTime);
} catch (Exception e) {
Log.e(TAG, "Failed to calculate latest cloud time", e);
}
downloadLatch.countDown();
}
@Override
public void onError(String err) {
errorRef.set(new Exception(err));
downloadLatch.countDown();
}
});
boolean completed = downloadLatch.await(SYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!completed) {
throw new RuntimeException("下载云端笔记超时");
}
if (errorRef.get() != null) {
throw errorRef.get();
}
JSONArray notesArray = notesArrayRef.get();
if (notesArray == null) {
Log.w(TAG, "Downloaded notes array is null");
return true; // 视为成功,只是没有数据
}
Log.d(TAG, "Downloaded " + notesArray.length() + " notes from cloud");
// 处理下载的笔记
int total = notesArray.length();
int successCount = 0;
for (int i = 0; i < total; i++) {
if (progressCallback != null) {
int progress = 50 + (i * 50) / total; // 下载占50-100%进度
progressCallback.onProgress(progress, 100, "正在处理笔记 " + (i + 1) + "/" + total);
}
CloudNote cloudNote = new CloudNote(notesArray.getJSONObject(i));
boolean processed = processDownloadedNote(repo, cloudNote, userId);
if (processed) {
successCount++;
}
}
Log.d(TAG, "Successfully processed " + successCount + "/" + total + " notes");
// 只有在所有笔记都处理成功后才更新同步时间
if (successCount == total) {
// 使用云端最新修改时间更新 lastSyncTime
if (maxModifiedTime.get() > 0) {
updateLastSyncTime(maxModifiedTime.get());
Log.d(TAG, "Updated last sync time to: " + maxModifiedTime.get());
}
return true;
} else {
Log.w(TAG, "Some notes failed to process, not updating sync time");
return false;
}
}
/**
*
*
* @return
*/
private boolean processDownloadedNote(NotesRepository repo, CloudNote cloudNote, String userId) {
final CountDownLatch findLatch = new CountDownLatch(1);
final AtomicReference<WorkingNote> localNoteRef = new AtomicReference<>();
String cloudNoteId = cloudNote.getCloudNoteId();
if (cloudNoteId != null && !cloudNoteId.isEmpty()) {
repo.findByCloudNoteId(cloudNoteId, new NotesRepository.Callback<WorkingNote>() {
@Override
public void onSuccess(WorkingNote result) {
localNoteRef.set(result);
findLatch.countDown();
}
@Override
public void onError(Exception e) {
Log.e(TAG, "Failed to find note by cloudNoteId: " + cloudNoteId, e);
findLatch.countDown();
}
});
} else {
repo.findNoteByNoteId(cloudNote.getNoteId(), new NotesRepository.Callback<WorkingNote>() {
@Override
public void onSuccess(WorkingNote result) {
localNoteRef.set(result);
findLatch.countDown();
}
@Override
public void onError(Exception e) {
Log.e(TAG, "Failed to find note by noteId: " + cloudNote.getNoteId(), e);
findLatch.countDown();
}
});
}
try {
boolean completed = findLatch.await(SYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!completed) {
Log.e(TAG, "查询本地笔记超时: " + cloudNote.getCloudNoteId());
return false;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
WorkingNote localNote = localNoteRef.get();
if (localNote == null) {
// 本地不存在,插入新笔记
Log.d(TAG, "Inserting new note from cloud: cloudNoteId=" + cloudNote.getCloudNoteId());
WorkingNote newNote = cloudNote.toWorkingNote(mContext, userId);
if (newNote != null) {
newNote.saveNote();
return true;
}
return false;
} else {
// 本地已存在,检查版本
if (cloudNote.getModifiedTime() > localNote.getModifiedDate()) {
if (localNote.getLocalModified() == 0) {
// 本地未修改,直接覆盖
Log.d(TAG, "Updating local note from cloud: cloudNoteId=" + cloudNote.getCloudNoteId());
localNote.updateFrom(cloudNote);
localNote.saveNote();
return true;
} else {
// 双方都修改过,记录冲突
Log.d(TAG, "Conflict detected for note: cloudNoteId=" + cloudNote.getCloudNoteId());
mConflicts.add(new Conflict(localNote, cloudNote));
return true;
}
}
return true;
}
}
/**
*
*/
private void updateSyncFlags() {
long currentTime = System.currentTimeMillis();
mPrefs.edit().putLong(KEY_LAST_SYNC, currentTime).apply();
Log.d(TAG, "Sync time updated: " + currentTime);
}
/**
*
*/
private void updateLastSyncTime(long syncTime) {
mPrefs.edit().putLong(KEY_LAST_SYNC, syncTime).apply();
Log.d(TAG, "Saved last sync time: " + syncTime);
}
/**
*
*/
private void markFirstSyncCompleted() {
mPrefs.edit().putBoolean(KEY_IS_FIRST_SYNC, false).apply();
Log.d(TAG, "First sync marked as completed");
}
/**
*
*/
public boolean isFirstSync() {
return mPrefs.getBoolean(KEY_IS_FIRST_SYNC, true);
}
/**
*
*/
public void resetSyncState() {
mPrefs.edit()
.remove(KEY_LAST_SYNC)
.putBoolean(KEY_IS_FIRST_SYNC, true)
.apply();
Log.d(TAG, "Sync state reset");
}
/**
*
*/
public long getLastSyncTime() {
return mPrefs.getLong(KEY_LAST_SYNC, 0);
}
/**
*
*/
public List<Conflict> getPendingConflicts() {
return new ArrayList<>(mConflicts);
}
/**
*
*/
public void clearAllConflicts() {
mConflicts.clear();
Log.d(TAG, "All conflicts cleared");
}
}

@ -0,0 +1,118 @@
/*
* Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.micode.notes.sync;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.work.Constraints;
import androidx.work.NetworkType;
import androidx.work.PeriodicWorkRequest;
import androidx.work.WorkManager;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import androidx.work.ExistingPeriodicWorkPolicy;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* Worker
* <p>
* 使WorkManager
* </p>
*/
public class SyncWorker extends Worker {
private static final String TAG = "SyncWorker";
private static final String WORK_NAME = "cloudSync";
public SyncWorker(@NonNull Context context, @NonNull WorkerParameters params) {
super(context, params);
}
@NonNull
@Override
public Result doWork() {
Log.d(TAG, "Starting background sync work");
final CountDownLatch latch = new CountDownLatch(1);
final boolean[] success = {false};
SyncManager.getInstance().syncNotes(new SyncManager.SyncCallback() {
@Override
public void onSuccess() {
Log.d(TAG, "Background sync completed successfully");
success[0] = true;
latch.countDown();
}
@Override
public void onError(String error) {
Log.e(TAG, "Background sync failed: " + error);
success[0] = false;
latch.countDown();
}
});
try {
latch.await(60, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Log.e(TAG, "Sync work interrupted", e);
return Result.retry();
}
return success[0] ? Result.success() : Result.retry();
}
/**
*
*
* @param context
*/
public static void initialize(Context context) {
Log.d(TAG, "Initializing periodic sync work");
Constraints constraints = new Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build();
PeriodicWorkRequest syncWork = new PeriodicWorkRequest.Builder(
SyncWorker.class, 30, TimeUnit.MINUTES)
.setConstraints(constraints)
.build();
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.REPLACE,
syncWork);
Log.d(TAG, "Periodic sync work scheduled (30 minutes interval)");
}
/**
*
*
* @param context
*/
public static void cancel(Context context) {
Log.d(TAG, "Canceling periodic sync work");
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME);
}
}

@ -218,8 +218,8 @@ public class AlarmAlertActivity extends Activity implements OnClickListener, OnD
case DialogInterface.BUTTON_NEGATIVE:
Intent intent;
if (mNoteType == Notes.TYPE_TASK) {
// 如果是待办任务,跳转到任务编辑活动
intent = new Intent(this, TaskEditActivity.class);
// 如果是待办任务,跳转到笔记编辑活动(任务功能已合并)
intent = new Intent(this, NoteEditActivity.class);
intent.putExtra(Intent.EXTRA_UID, mNoteId);
} else {
// 创建跳转到笔记编辑活动的Intent

@ -88,7 +88,7 @@ public class AlarmInitReceiver extends BroadcastReceiver {
sender.setData(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, c.getLong(COLUMN_ID)));
// 创建PendingIntent它封装了上述Intent可以在指定时间触发
PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, sender, 0);
PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, sender, PendingIntent.FLAG_IMMUTABLE);
// 获取系统闹钟服务
AlarmManager alermManager = (AlarmManager) context

@ -0,0 +1,217 @@
/*
* Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.micode.notes.ui;
import android.app.Dialog;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.DialogFragment;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import net.micode.notes.R;
import net.micode.notes.model.WorkingNote;
/**
*
* <p>
*
* </p>
*/
public class ConflictResolutionDialog extends DialogFragment {
private static final String TAG = "ConflictResolutionDialog";
private static final String ARG_LOCAL_TITLE = "local_title";
private static final String ARG_LOCAL_CONTENT = "local_content";
private static final String ARG_CLOUD_TITLE = "cloud_title";
private static final String ARG_CLOUD_CONTENT = "cloud_content";
private ConflictResolutionListener mListener;
/**
*
*/
public interface ConflictResolutionListener {
void onChooseLocal();
void onChooseCloud();
void onMerge(String mergedTitle, String mergedContent);
}
/**
*
*
* @param localNote
* @param cloudNote
* @return ConflictResolutionDialog
*/
public static ConflictResolutionDialog newInstance(WorkingNote localNote, WorkingNote cloudNote) {
ConflictResolutionDialog dialog = new ConflictResolutionDialog();
Bundle args = new Bundle();
args.putString(ARG_LOCAL_TITLE, localNote.getTitle());
args.putString(ARG_LOCAL_CONTENT, localNote.getContent());
args.putString(ARG_CLOUD_TITLE, cloudNote.getTitle());
args.putString(ARG_CLOUD_CONTENT, cloudNote.getContent());
dialog.setArguments(args);
return dialog;
}
/**
*
*
* @param listener
*/
public void setConflictResolutionListener(ConflictResolutionListener listener) {
mListener = listener;
}
@NonNull
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext());
LayoutInflater inflater = requireActivity().getLayoutInflater();
View view = inflater.inflate(R.layout.dialog_conflict_resolution, null);
// Get arguments
Bundle args = getArguments();
if (args != null) {
String localTitle = args.getString(ARG_LOCAL_TITLE, "");
String localContent = args.getString(ARG_LOCAL_CONTENT, "");
String cloudTitle = args.getString(ARG_CLOUD_TITLE, "");
String cloudContent = args.getString(ARG_CLOUD_CONTENT, "");
// Set content previews (first 100 chars)
TextView tvLocalContent = view.findViewById(R.id.tv_local_content);
tvLocalContent.setText(truncateContent(localTitle, localContent));
TextView tvCloudContent = view.findViewById(R.id.tv_cloud_content);
tvCloudContent.setText(truncateContent(cloudTitle, cloudContent));
}
// Setup buttons
MaterialButton btnUseLocal = view.findViewById(R.id.btn_use_local);
MaterialButton btnUseCloud = view.findViewById(R.id.btn_use_cloud);
MaterialButton btnMerge = view.findViewById(R.id.btn_merge);
btnUseLocal.setOnClickListener(v -> {
Log.d(TAG, "User chose local version");
if (mListener != null) {
mListener.onChooseLocal();
}
dismiss();
});
btnUseCloud.setOnClickListener(v -> {
Log.d(TAG, "User chose cloud version");
if (mListener != null) {
mListener.onChooseCloud();
}
dismiss();
});
btnMerge.setOnClickListener(v -> {
Log.d(TAG, "User chose merge");
showMergeDialog(args);
});
builder.setView(view);
return builder.create();
}
private String truncateContent(String title, String content) {
String fullText = title + "\n" + content;
if (fullText.length() > 100) {
return fullText.substring(0, 100) + "...";
}
return fullText;
}
/**
*
*/
private void showMergeDialog(Bundle args) {
if (args == null) return;
String localTitle = args.getString(ARG_LOCAL_TITLE, "");
String localContent = args.getString(ARG_LOCAL_CONTENT, "");
String cloudTitle = args.getString(ARG_CLOUD_TITLE, "");
String cloudContent = args.getString(ARG_CLOUD_CONTENT, "");
// 智能合并:合并标题和内容
String mergedTitle = mergeText(localTitle, cloudTitle);
String mergedContent = mergeText(localContent, cloudContent);
// 创建编辑对话框
androidx.appcompat.app.AlertDialog.Builder builder = new androidx.appcompat.app.AlertDialog.Builder(requireContext());
builder.setTitle("合并笔记");
// 创建输入布局
android.widget.LinearLayout layout = new android.widget.LinearLayout(requireContext());
layout.setOrientation(android.widget.LinearLayout.VERTICAL);
layout.setPadding(50, 30, 50, 30);
// 标题输入
final android.widget.EditText etTitle = new android.widget.EditText(requireContext());
etTitle.setHint("标题");
etTitle.setText(mergedTitle);
layout.addView(etTitle);
// 内容输入
final android.widget.EditText etContent = new android.widget.EditText(requireContext());
etContent.setHint("内容");
etContent.setText(mergedContent);
etContent.setMinLines(5);
layout.addView(etContent);
builder.setView(layout);
builder.setPositiveButton("保存", (dialog, which) -> {
String finalTitle = etTitle.getText().toString().trim();
String finalContent = etContent.getText().toString().trim();
if (mListener != null) {
mListener.onMerge(finalTitle, finalContent);
}
dismiss();
});
builder.setNegativeButton("取消", (dialog, which) -> dialog.dismiss());
builder.show();
}
/**
*
*
*/
private String mergeText(String local, String cloud) {
if (local == null || local.isEmpty()) return cloud;
if (cloud == null || cloud.isEmpty()) return local;
if (local.equals(cloud)) return local;
// 简单的合并策略:用分隔符连接
return local + "\n\n--- 云端版本 ---\n\n" + cloud;
}
}

@ -0,0 +1,164 @@
/*
* Copyright (c) 2025, Modern Notes Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.micode.notes.ui;
import android.app.AlertDialog;
import android.app.Dialog;
import android.content.Context;
import android.content.DialogInterface;
import android.text.InputFilter;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.EditText;
import android.widget.TextView;
import net.micode.notes.R;
/**
*
* <p>
*
* </p>
*/
public class FolderOperationDialogs {
private static final int MAX_FOLDER_NAME_LENGTH = 50;
/**
*
*
* @param activity Activity
* @param currentName
* @param listener
*/
public static void showRenameDialog(Context activity, String currentName,
OnRenameListener listener) {
AlertDialog.Builder builder = new AlertDialog.Builder(activity);
builder.setTitle(R.string.dialog_rename_folder_title);
// 创建输入框
final EditText input = new EditText(activity);
input.setText(currentName);
input.setHint(R.string.dialog_create_folder_hint);
input.setFilters(new InputFilter[]{new InputFilter.LengthFilter(MAX_FOLDER_NAME_LENGTH)});
input.setSelection(input.getText().length()); // 光标移到末尾
builder.setView(input);
builder.setPositiveButton(R.string.menu_rename, (dialog, which) -> {
String newName = input.getText().toString().trim();
if (TextUtils.isEmpty(newName)) {
listener.onError(activity.getString(R.string.error_folder_name_empty));
return;
}
if (newName.length() > MAX_FOLDER_NAME_LENGTH) {
listener.onError(activity.getString(R.string.error_folder_name_too_long));
return;
}
listener.onRename(newName);
});
builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
listener.onCancel();
});
builder.show();
}
/**
*
*
* @param activity Activity
* @param folderName
* @param noteCount
* @param listener
*/
public static void showDeleteFolderDialog(Context activity, String folderName,
int noteCount, OnDeleteListener listener) {
AlertDialog.Builder builder = new AlertDialog.Builder(activity);
builder.setTitle(R.string.dialog_delete_folder_title);
// 创建自定义消息视图
LayoutInflater inflater = LayoutInflater.from(activity);
View messageView = inflater.inflate(R.layout.dialog_folder_delete, null);
TextView messageText = messageView.findViewById(R.id.tv_delete_message);
String message;
if (noteCount > 0) {
message = activity.getString(R.string.dialog_delete_folder_with_notes, folderName, noteCount);
} else {
message = activity.getString(R.string.dialog_delete_folder_empty, folderName);
}
messageText.setText(message);
builder.setView(messageView);
builder.setPositiveButton(R.string.menu_delete, (dialog, which) -> {
listener.onDelete();
});
builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
listener.onCancel();
});
builder.show();
}
/**
*
*/
public interface OnRenameListener {
/**
*
*
* @param newName
*/
void onRename(String newName);
/**
*
*/
default void onCancel() {
}
/**
*
*
* @param errorMessage
*/
default void onError(String errorMessage) {
}
}
/**
*
*/
public interface OnDeleteListener {
/**
*
*/
void onDelete();
/**
*
*/
default void onCancel() {
}
}
}

@ -49,12 +49,14 @@ public class FoldersListAdapter extends CursorAdapter {
// 数据库查询投影,指定需要从笔记表中获取的列
public static final String [] PROJECTION = {
NoteColumns.ID,
NoteColumns.SNIPPET
NoteColumns.SNIPPET,
NoteColumns.TITLE
};
// 列索引常量,用于从查询结果中获取对应列的数据
public static final int ID_COLUMN = 0;
public static final int NAME_COLUMN = 1;
public static final int SNIPPET_COLUMN = 1;
public static final int TITLE_COLUMN = 2;
/**
*
@ -96,9 +98,19 @@ public class FoldersListAdapter extends CursorAdapter {
public void bindView(View view, Context context, Cursor cursor) {
if (view instanceof FolderListItem) {
// 如果是根文件夹,显示特殊文本;否则显示文件夹名称
String folderName = (cursor.getLong(ID_COLUMN) == Notes.ID_ROOT_FOLDER) ? context
.getString(R.string.menu_move_parent_folder) : cursor.getString(NAME_COLUMN);
((FolderListItem) view).bind(folderName);
if (cursor.getLong(ID_COLUMN) == Notes.ID_ROOT_FOLDER) {
((FolderListItem) view).bind(context.getString(R.string.menu_move_parent_folder));
} else {
// 优先使用TITLEfallback到SNIPPET
String folderName = "";
if (cursor.getColumnCount() > TITLE_COLUMN) {
folderName = cursor.getString(TITLE_COLUMN);
}
if (folderName == null || folderName.trim().isEmpty()) {
folderName = cursor.getString(SNIPPET_COLUMN);
}
((FolderListItem) view).bind(folderName);
}
}
}
@ -111,8 +123,18 @@ public class FoldersListAdapter extends CursorAdapter {
*/
public String getFolderName(Context context, int position) {
Cursor cursor = (Cursor) getItem(position);
return (cursor.getLong(ID_COLUMN) == Notes.ID_ROOT_FOLDER) ? context
.getString(R.string.menu_move_parent_folder) : cursor.getString(NAME_COLUMN);
if (cursor.getLong(ID_COLUMN) == Notes.ID_ROOT_FOLDER) {
return context.getString(R.string.menu_move_parent_folder);
}
// 优先使用TITLEfallback到SNIPPET
String folderName = "";
if (cursor.getColumnCount() > TITLE_COLUMN) {
folderName = cursor.getString(TITLE_COLUMN);
}
if (folderName == null || folderName.trim().isEmpty()) {
folderName = cursor.getString(SNIPPET_COLUMN);
}
return folderName;
}
/**

@ -0,0 +1,145 @@
/*
* Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.micode.notes.ui;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.widget.ProgressBar;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import androidx.lifecycle.ViewModelProvider;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.textfield.TextInputEditText;
import net.micode.notes.R;
import net.micode.notes.auth.UserAuthManager;
import net.micode.notes.viewmodel.LoginViewModel;
/**
*
*
* <p>
*
*
* </p>
* <p>
* MVVM {@link LoginViewModel}
* </p>
*/
public class LoginActivity extends AppCompatActivity {
private static final String TAG = "LoginActivity";
private TextInputEditText mEtUsername;
private TextInputEditText mEtPassword;
private MaterialButton mBtnLogin;
private MaterialButton mBtnRegister;
private View mTvSkip;
private ProgressBar mProgressBar;
private LoginViewModel mViewModel;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 检查是否已经登录
UserAuthManager authManager = UserAuthManager.getInstance(this);
if (authManager.isLoggedIn()) {
startMainActivity();
return;
}
setContentView(R.layout.activity_login);
mViewModel = new ViewModelProvider(this).get(LoginViewModel.class);
initViews();
setupListeners();
observeViewModel();
}
private void initViews() {
mEtUsername = findViewById(R.id.et_username);
mEtPassword = findViewById(R.id.et_password);
mBtnLogin = findViewById(R.id.btn_login);
mBtnRegister = findViewById(R.id.btn_register);
mTvSkip = findViewById(R.id.tv_skip);
mProgressBar = findViewById(R.id.progress_bar);
}
private void setupListeners() {
mBtnLogin.setOnClickListener(v -> attemptLogin());
mBtnRegister.setOnClickListener(v -> attemptRegister());
mTvSkip.setOnClickListener(v -> skipLogin());
}
private void observeViewModel() {
mViewModel.getIsLoading().observe(this, isLoading -> showLoading(isLoading));
mViewModel.getErrorMessage().observe(this, error -> {
if (error != null) {
Toast.makeText(this, error, Toast.LENGTH_LONG).show();
mViewModel.clearError();
}
});
mViewModel.getLoginSuccess().observe(this, success -> {
if (success) {
Integer migratedCount = mViewModel.getMigratedNotesCount().getValue();
String message = (migratedCount != null && migratedCount > 0)
? "登录成功!已迁移 " + migratedCount + " 条本地笔记"
: "登录成功!";
Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
startMainActivity();
}
});
}
private void attemptLogin() {
String username = mEtUsername.getText().toString().trim();
String password = mEtPassword.getText().toString().trim();
mViewModel.login(username, password);
}
private void attemptRegister() {
String username = mEtUsername.getText().toString().trim();
String password = mEtPassword.getText().toString().trim();
mViewModel.register(username, password);
}
private void skipLogin() {
Toast.makeText(this, "使用本地模式(不同步)", Toast.LENGTH_SHORT).show();
startMainActivity();
}
private void startMainActivity() {
Intent intent = new Intent(this, NotesListActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
startActivity(intent);
finish();
}
private void showLoading(boolean show) {
mProgressBar.setVisibility(show ? View.VISIBLE : View.GONE);
mBtnLogin.setEnabled(!show);
mBtnRegister.setEnabled(!show);
}
}

@ -1156,7 +1156,7 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
if (mWorkingNote.getNoteId() > 0) {
Intent intent = new Intent(this, AlarmReceiver.class);
intent.setData(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, mWorkingNote.getNoteId()));
PendingIntent pendingIntent = PendingIntent.getBroadcast(this, 0, intent, 0);
PendingIntent pendingIntent = PendingIntent.getBroadcast(this, 0, intent, PendingIntent.FLAG_IMMUTABLE);
AlarmManager alarmManager = ((AlarmManager) getSystemService(ALARM_SERVICE));
showAlertHeader();
if(!set) {
@ -1421,7 +1421,7 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
}
mWorkingNote.setWorkingText(sb.toString());
} else {
mWorkingNote.setWorkingText(RichTextHelper.toHtml(mNoteEditor.getText()));
mWorkingNote.setWorkingText(mNoteEditor.getText().toString());
}
return hasChecked;
}
@ -1449,10 +1449,33 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
* {@link #RESULT_OK} is used to identify the create/edit state
*/
setResult(RESULT_OK);
// 触发同步(如果用户已登录)
triggerBackgroundSync();
}
return saved;
}
/**
*
*/
private void triggerBackgroundSync() {
net.micode.notes.auth.UserAuthManager authManager = net.micode.notes.auth.UserAuthManager.getInstance(this);
if (authManager.isLoggedIn()) {
net.micode.notes.sync.SyncManager.getInstance().syncNotes(new net.micode.notes.sync.SyncManager.SyncCallback() {
@Override
public void onSuccess() {
Log.d("NoteEditActivity", "Background sync completed after save");
}
@Override
public void onError(String error) {
Log.e("NoteEditActivity", "Background sync failed after save: " + error);
}
});
}
}
/**
*
* <p>

@ -1,22 +1,19 @@
package net.micode.notes.ui;
import android.content.Context;
import android.util.Log;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.cardview.widget.CardView;
import androidx.recyclerview.widget.RecyclerView;
import net.micode.notes.R;
import net.micode.notes.data.Notes;
import net.micode.notes.data.NotesRepository;
import net.micode.notes.tool.ResourceParser;
import net.micode.notes.databinding.NoteItemSwipeBinding;
import net.micode.notes.tool.ResourceParser.NoteItemBgResources;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
@ -25,19 +22,28 @@ import java.util.HashSet;
import java.util.List;
import java.util.Locale;
public class NoteInfoAdapter extends RecyclerView.Adapter<NoteInfoAdapter.NoteViewHolder> {
/**
*
* <p>
* RecyclerView
* 使 ViewBinding 访
* </p>
*/
public class NoteInfoAdapter extends RecyclerView.Adapter<NoteInfoAdapter.ViewHolder> {
private static final String TAG = "NoteInfoAdapter";
private Context context;
private List<NotesRepository.NoteInfo> notes;
private HashSet<Long> selectedIds;
private boolean isSelectionMode;
private OnNoteButtonClickListener buttonClickListener;
// 监听器
private OnNoteItemClickListener itemClickListener;
private OnNoteItemLongClickListener itemLongClickListener;
private OnSwipeMenuClickListener swipeMenuClickListener;
public interface OnNoteButtonClickListener {
void onEditButtonClick(int position, long noteId);
}
// 状态
private boolean isTrashMode = false;
private boolean isMultiSelectMode = false;
public interface OnNoteItemClickListener {
void onNoteItemClick(int position, long noteId);
@ -47,46 +53,42 @@ public class NoteInfoAdapter extends RecyclerView.Adapter<NoteInfoAdapter.NoteVi
void onNoteItemLongClick(int position, long noteId);
}
public interface OnSwipeMenuClickListener {
void onSwipeEdit(long itemId);
void onSwipePin(long itemId);
void onSwipeMove(long itemId);
void onSwipeDelete(long itemId);
void onSwipeRename(long itemId);
void onSwipeRestore(long itemId);
void onSwipePermanentDelete(long itemId);
}
public NoteInfoAdapter(Context context) {
this.context = context;
this.notes = new ArrayList<>();
this.selectedIds = new HashSet<>();
this.isSelectionMode = false;
}
public void setSelectionMode(boolean isSelectionMode) {
this.isSelectionMode = isSelectionMode;
notifyDataSetChanged();
}
public void setNotes(List<NotesRepository.NoteInfo> notes) {
this.notes = notes != null ? notes : new ArrayList<>();
notifyDataSetChanged();
}
public void setSelectedIds(HashSet<Long> selectedIds) {
this.selectedIds = selectedIds != null ? new HashSet<>(selectedIds) : new HashSet<>();
public void setTrashMode(boolean isTrashMode) {
this.isTrashMode = isTrashMode;
notifyDataSetChanged();
}
@NonNull
@Override
public NoteViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(context).inflate(R.layout.note_item, parent, false);
return new NoteViewHolder(view);
public void setSelectionMode(boolean isSelectionMode) {
this.isMultiSelectMode = isSelectionMode;
notifyDataSetChanged();
}
@Override
public void onBindViewHolder(@NonNull NoteViewHolder holder, int position) {
NotesRepository.NoteInfo note = notes.get(position);
holder.bind(note, position);
public void setSelectedIds(HashSet<Long> selectedIds) {
this.selectedIds = selectedIds != null ? new HashSet<>(selectedIds) : new HashSet<>();
notifyDataSetChanged();
}
@Override
public int getItemCount() {
return notes.size();
}
public void setOnNoteItemClickListener(OnNoteItemClickListener listener) {
this.itemClickListener = listener;
}
@ -95,112 +97,118 @@ public class NoteInfoAdapter extends RecyclerView.Adapter<NoteInfoAdapter.NoteVi
this.itemLongClickListener = listener;
}
class NoteViewHolder extends RecyclerView.ViewHolder {
CardView cardView;
TextView title, time, name;
ImageView typeIcon, lockIcon, alertIcon, pinnedIcon;
CheckBox checkBox;
public NoteViewHolder(View itemView) {
super(itemView);
cardView = (CardView) itemView;
title = itemView.findViewById(R.id.tv_title);
time = itemView.findViewById(R.id.tv_time);
name = itemView.findViewById(R.id.tv_name);
checkBox = itemView.findViewById(android.R.id.checkbox);
typeIcon = itemView.findViewById(R.id.iv_type_icon);
lockIcon = itemView.findViewById(R.id.iv_lock_icon);
alertIcon = itemView.findViewById(R.id.iv_alert_icon);
pinnedIcon = itemView.findViewById(R.id.iv_pinned_icon);
itemView.setOnClickListener(v -> {
if (itemClickListener != null) {
int pos = getAdapterPosition();
if (pos != RecyclerView.NO_POSITION) {
// Pass click event to Fragment, which will handle selection toggle if in selection mode
itemClickListener.onNoteItemClick(pos, notes.get(pos).getId());
}
}
});
itemView.setOnLongClickListener(v -> {
if (itemLongClickListener != null) {
int pos = getAdapterPosition();
if (pos != RecyclerView.NO_POSITION) {
itemLongClickListener.onNoteItemLongClick(pos, notes.get(pos).getId());
return true;
}
}
return false;
});
public void setOnSwipeMenuClickListener(OnSwipeMenuClickListener listener) {
this.swipeMenuClickListener = listener;
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
NoteItemSwipeBinding binding = NoteItemSwipeBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
return new ViewHolder(binding);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
NotesRepository.NoteInfo note = notes.get(position);
if (note == null) return;
NoteItemSwipeBinding binding = holder.binding;
SwipeMenuLayout swipeLayout = (SwipeMenuLayout) binding.getRoot();
swipeLayout.setItemId(note.getId());
swipeLayout.setSwipeEnabled(!isMultiSelectMode);
// 设置菜单可见性
if (isTrashMode) {
binding.swipeMenuNormal.getRoot().setVisibility(View.GONE);
binding.swipeMenuTrash.getRoot().setVisibility(View.VISIBLE);
} else {
binding.swipeMenuNormal.getRoot().setVisibility(View.VISIBLE);
binding.swipeMenuTrash.getRoot().setVisibility(View.GONE);
}
public void bind(NotesRepository.NoteInfo note, int position) {
String titleStr = note.title;
String snippet = note.snippet;
if (titleStr == null || titleStr.trim().isEmpty()) {
titleStr = snippet;
if (titleStr != null && titleStr.contains("\n")) {
titleStr = titleStr.substring(0, titleStr.indexOf("\n"));
}
if (titleStr == null || titleStr.trim().isEmpty()) {
titleStr = "无标题";
}
// 绑定内容
if (note.type == Notes.TYPE_FOLDER) {
String folderName = note.snippet;
if (TextUtils.isEmpty(folderName)) {
folderName = "未命名文件夹";
}
// Bind Summary
TextView summaryView = itemView.findViewById(R.id.tv_summary);
if (summaryView != null) {
// Clean up snippet for display (in case it contains HTML or entities)
String cleanSnippet = snippet;
if (cleanSnippet != null && !cleanSnippet.isEmpty()) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
cleanSnippet = android.text.Html.fromHtml(cleanSnippet, android.text.Html.FROM_HTML_MODE_LEGACY).toString();
} else {
cleanSnippet = android.text.Html.fromHtml(cleanSnippet).toString();
}
cleanSnippet = cleanSnippet.trim();
}
summaryView.setText(cleanSnippet);
binding.tvTitle.setText(folderName + " (" + note.notesCount + ")");
binding.tvTime.setVisibility(View.GONE);
binding.ivTypeIcon.setVisibility(View.VISIBLE);
binding.ivTypeIcon.setImageResource(R.drawable.ic_folder);
} else {
String title = note.title;
if (TextUtils.isEmpty(title)) {
title = "无标题";
}
binding.tvTitle.setText(title);
binding.tvTime.setText(formatDate(note.modifiedDate));
binding.tvTime.setVisibility(View.VISIBLE);
binding.ivTypeIcon.setVisibility(View.GONE);
}
if (note.type == Notes.TYPE_FOLDER) {
title.setText(note.getSnippet() + " (" + note.getNotesCount() + ")");
time.setVisibility(View.GONE);
typeIcon.setVisibility(View.VISIBLE);
typeIcon.setImageResource(R.drawable.ic_folder);
cardView.setCardBackgroundColor(context.getColor(R.color.bg_white));
} else {
typeIcon.setVisibility(View.GONE);
time.setVisibility(View.VISIBLE);
title.setText(titleStr);
time.setText(formatDate(note.modifiedDate));
// Color Logic
int bgColorId = note.bgColorId;
int color = ResourceParser.getNoteBgColor(context, bgColorId);
cardView.setCardBackgroundColor(color);
// 背景颜色和圆角卡片样式
int color = net.micode.notes.tool.ResourceParser.getNoteBgColor(context, note.bgColorId);
// 使用 CardView 设置背景颜色
binding.contentCard.setCardBackgroundColor(color);
binding.contentCard.setActivated(selectedIds.contains(note.getId()));
// 图标状态
binding.checkbox.setVisibility(isMultiSelectMode ? View.VISIBLE : View.GONE);
binding.checkbox.setChecked(selectedIds.contains(note.getId()));
binding.ivPinnedIcon.setVisibility(note.isPinned ? View.VISIBLE : View.GONE);
binding.ivLockIcon.setVisibility(note.isLocked ? View.VISIBLE : View.GONE);
// 滑动菜单按钮点击
swipeLayout.setOnMenuButtonClickListener(new SwipeMenuLayout.OnMenuButtonClickListener() {
@Override
public void onEdit(long itemId) { if (swipeMenuClickListener != null) swipeMenuClickListener.onSwipeEdit(itemId); }
@Override
public void onPin(long itemId) { if (swipeMenuClickListener != null) swipeMenuClickListener.onSwipePin(itemId); }
@Override
public void onMove(long itemId) { if (swipeMenuClickListener != null) swipeMenuClickListener.onSwipeMove(itemId); }
@Override
public void onDelete(long itemId) { if (swipeMenuClickListener != null) swipeMenuClickListener.onSwipeDelete(itemId); }
@Override
public void onRename(long itemId) { if (swipeMenuClickListener != null) swipeMenuClickListener.onSwipeRename(itemId); }
@Override
public void onRestore(long itemId) { if (swipeMenuClickListener != null) swipeMenuClickListener.onSwipeRestore(itemId); }
@Override
public void onPermanentDelete(long itemId) { if (swipeMenuClickListener != null) swipeMenuClickListener.onSwipePermanentDelete(itemId); }
});
// 内容区域点击
swipeLayout.setOnContentClickListener(itemId -> {
if (itemClickListener != null) {
itemClickListener.onNoteItemClick(holder.getAdapterPosition(), itemId);
}
});
// Selection Logic
if (isSelectionMode) {
checkBox.setVisibility(View.VISIBLE);
checkBox.setChecked(selectedIds.contains(note.getId()));
} else {
checkBox.setVisibility(View.GONE);
swipeLayout.setOnContentLongClickListener(itemId -> {
if (itemLongClickListener != null) {
itemLongClickListener.onNoteItemLongClick(holder.getAdapterPosition(), itemId);
}
});
}
if (note.isPinned) pinnedIcon.setVisibility(View.VISIBLE);
else pinnedIcon.setVisibility(View.GONE);
if (note.isLocked) lockIcon.setVisibility(View.VISIBLE);
else lockIcon.setVisibility(View.GONE);
}
@Override
public int getItemCount() {
return notes.size();
}
private String formatDate(long timestamp) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault());
return sdf.format(new Date(timestamp));
}
}
public static class ViewHolder extends RecyclerView.ViewHolder {
NoteItemSwipeBinding binding;
public ViewHolder(NoteItemSwipeBinding binding) {
super(binding.getRoot());
this.binding = binding;
}
}
}

@ -1,158 +1,149 @@
package net.micode.notes.ui;
import android.app.AlertDialog;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.view.GravityCompat;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.viewpager2.adapter.FragmentStateAdapter;
import androidx.viewpager2.widget.ViewPager2;
import com.google.android.material.bottomnavigation.BottomNavigationView;
import android.app.AlertDialog;
import androidx.recyclerview.widget.RecyclerView;
import net.micode.notes.R;
import net.micode.notes.auth.UserAuthManager;
import net.micode.notes.data.Notes;
import net.micode.notes.data.NotesRepository;
import net.micode.notes.viewmodel.NotesListViewModel;
import net.micode.notes.databinding.ActivityHomeBinding;
import net.micode.notes.sync.SyncManager;
import net.micode.notes.tool.SecurityManager;
import android.content.Intent;
import net.micode.notes.viewmodel.NotesListViewModel;
/**
* Activity
* <p>
* MVVM
* 使 ViewBinding 访
* </p>
*/
public class NotesListActivity extends AppCompatActivity implements SidebarFragment.OnSidebarItemSelectedListener {
private static final String TAG = "NotesListActivity";
private ViewPager2 viewPager;
private DrawerLayout drawerLayout;
private TextView tvTitle;
private ImageView btnChangeLayout;
private ActivityHomeBinding binding;
private NotesListViewModel viewModel;
private RecyclerView rvFolderTabs;
private View tvSearchBar; // Add member variable
private FolderAdapter folderAdapter;
private View headerContainer;
private View selectionHeader;
private View selectionBottomBar;
private BottomNavigationView bottomNav;
private TextView tvSelectionCount;
private TextView btnSelectionRestore;
private TextView btnSelectionDeleteForever;
private TextView btnActionPin;
private TextView btnActionLock;
private static final int REQUEST_CODE_VERIFY_PASSWORD_FOR_LOCK = 106;
// 同步广播接收器
private BroadcastReceiver syncReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if ("com.micode.notes.ACTION_SYNC".equals(intent.getAction())) {
Log.d(TAG, "Received sync broadcast, triggering sync");
SyncManager.getInstance().syncNotes(new SyncManager.SyncCallback() {
@Override
public void onSuccess() {
Log.d(TAG, "Auto-sync completed successfully");
}
@Override
public void onError(String error) {
Log.e(TAG, "Auto-sync failed: " + error);
}
});
}
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_home);
binding = ActivityHomeBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
drawerLayout = findViewById(R.id.drawer_layout);
viewPager = findViewById(R.id.view_pager);
tvTitle = findViewById(R.id.tv_title);
btnChangeLayout = findViewById(R.id.btn_change_layout);
bottomNav = findViewById(R.id.bottom_navigation);
initViews();
initViewModel();
observeViewModel();
rvFolderTabs = findViewById(R.id.rv_folder_tabs);
headerContainer = findViewById(R.id.header_container);
selectionHeader = findViewById(R.id.selection_header);
selectionBottomBar = findViewById(R.id.selection_bottom_bar);
tvSelectionCount = findViewById(R.id.tv_selection_count);
tvSearchBar = findViewById(R.id.tv_search_bar); // Init tvSearchBar
btnSelectionRestore = findViewById(R.id.btn_selection_restore);
btnSelectionDeleteForever = findViewById(R.id.btn_selection_delete_forever);
btnActionPin = findViewById(R.id.btn_action_pin);
btnActionLock = findViewById(R.id.btn_action_lock);
// 初始化SyncManager
SyncManager.getInstance().initialize(this);
}
// Init Shared ViewModel
NotesRepository repository = new NotesRepository(getContentResolver());
viewModel = new ViewModelProvider(this,
new ViewModelProvider.Factory() {
@Override
public <T extends androidx.lifecycle.ViewModel> T create(Class<T> modelClass) {
return (T) new NotesListViewModel(repository);
}
}).get(NotesListViewModel.class);
// Load initial notes (All Notes by default now)
if (savedInstanceState == null) {
viewModel.loadNotes(Notes.ID_ALL_NOTES_FOLDER);
}
private void initViews() {
// Sidebar Button
binding.btnSidebar.setOnClickListener(v -> {
if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
binding.drawerLayout.closeDrawer(GravityCompat.START);
} else {
binding.drawerLayout.openDrawer(GravityCompat.START);
}
});
// Setup Folder Tabs
folderAdapter = new FolderAdapter(this);
rvFolderTabs.setAdapter(folderAdapter);
folderAdapter.setOnFolderClickListener(folderId -> viewModel.enterFolder(folderId));
// Observe Folder Changes for UI updates
viewModel.getCurrentFolderIdLiveData().observe(this, folderId -> {
updateTrashModeUI(folderId == Notes.ID_TRASH_FOLER);
folderAdapter.setSelectedFolderId(folderId);
// Layout Toggle Button
binding.btnChangeLayout.setOnClickListener(v -> {
NotesListFragment fragment = getNotesListFragment();
if (fragment != null) {
boolean isStaggered = fragment.toggleLayout();
binding.btnChangeLayout.setImageResource(isStaggered ? R.drawable.ic_view_list : R.drawable.ic_view_grid);
}
});
viewModel.getFoldersLiveData().observe(this, folders -> {
folderAdapter.setFolders(folders);
// Search Bar
binding.tvSearchBar.setOnClickListener(v -> {
startActivity(new Intent(this, NoteSearchActivity.class));
});
// Selection Mode Observer
viewModel.getIsSelectionMode().observe(this, isSelection -> {
if (isSelection) {
headerContainer.setVisibility(View.GONE);
selectionHeader.setVisibility(View.VISIBLE);
if (viewModel.isTrashMode()) {
selectionBottomBar.setVisibility(View.GONE);
btnSelectionRestore.setVisibility(View.VISIBLE);
btnSelectionDeleteForever.setVisibility(View.VISIBLE);
// Hide Select All if needed, or keep it. Let's keep it for now but maybe change layout if crowded.
} else {
selectionBottomBar.setVisibility(View.VISIBLE);
btnSelectionRestore.setVisibility(View.GONE);
btnSelectionDeleteForever.setVisibility(View.GONE);
}
// bottomNav.setVisibility(View.GONE); // Overlay covers it
updateSelectionCount(); // Initial update when entering mode
} else {
headerContainer.setVisibility(View.VISIBLE);
selectionHeader.setVisibility(View.GONE);
selectionBottomBar.setVisibility(View.GONE);
// Only show bottom nav if NOT in trash mode (handled by updateTrashModeUI)
if (!viewModel.isTrashMode()) {
bottomNav.setVisibility(View.VISIBLE);
}
// Setup ViewPager
MainPagerAdapter pagerAdapter = new MainPagerAdapter(this);
binding.viewPager.setAdapter(pagerAdapter);
binding.viewPager.setUserInputEnabled(false); // Disable swipe to avoid conflict
// Setup Bottom Navigation
binding.bottomNavigation.setOnItemSelectedListener(item -> {
int itemId = item.getItemId();
if (itemId == R.id.nav_notes) {
binding.viewPager.setCurrentItem(0, false);
binding.tvTitle.setText(R.string.app_name);
binding.btnChangeLayout.setVisibility(View.VISIBLE);
binding.tvSearchBar.setVisibility(View.VISIBLE);
binding.rvFolderTabs.setVisibility(View.VISIBLE);
updateLayoutButtonIcon();
return true;
} else if (itemId == R.id.nav_tasks) {
binding.viewPager.setCurrentItem(1, false);
binding.tvTitle.setText("待办");
binding.btnChangeLayout.setVisibility(View.GONE);
binding.tvSearchBar.setVisibility(View.GONE);
binding.rvFolderTabs.setVisibility(View.GONE);
return true;
}
return false;
});
// Update selection count
viewModel.getSelectedIdsLiveData().observe(this, selectedIds -> {
updateSelectionCount();
updateSelectionActionUI();
});
// Selection Actions
findViewById(R.id.btn_close_selection).setOnClickListener(v -> viewModel.setIsSelectionMode(false));
binding.btnCloseSelection.setOnClickListener(v -> viewModel.setIsSelectionMode(false));
binding.btnSelectAll.setOnClickListener(v -> viewModel.selectAllNotes());
btnActionPin.setOnClickListener(v -> {
binding.btnActionPin.setOnClickListener(v -> {
viewModel.toggleSelectedNotesPin();
viewModel.setIsSelectionMode(false);
});
btnActionLock.setOnClickListener(v -> {
binding.btnActionLock.setOnClickListener(v -> {
SecurityManager securityManager = SecurityManager.getInstance(this);
if (!securityManager.isPasswordSet()) {
new AlertDialog.Builder(this)
.setTitle("设置密码")
.setMessage("使用加锁功能前请先设置密码")
.setPositiveButton("去设置", (d, w) -> {
showSetPasswordDialog();
})
.setPositiveButton("去设置", (d, w) -> showSetPasswordDialog())
.setNegativeButton("取消", null)
.show();
} else {
@ -162,11 +153,7 @@ public class NotesListActivity extends AppCompatActivity implements SidebarFragm
}
});
findViewById(R.id.btn_select_all).setOnClickListener(v -> {
viewModel.selectAllNotes();
});
findViewById(R.id.btn_action_delete).setOnClickListener(v -> {
binding.btnActionDelete.setOnClickListener(v -> {
new AlertDialog.Builder(this)
.setTitle("删除")
.setMessage("确定要删除选中的笔记吗?")
@ -178,8 +165,7 @@ public class NotesListActivity extends AppCompatActivity implements SidebarFragm
.show();
});
// Restore Action
btnSelectionRestore.setOnClickListener(v -> {
binding.btnSelectionRestore.setOnClickListener(v -> {
new AlertDialog.Builder(this)
.setTitle("恢复笔记")
.setMessage("确定要恢复选中的笔记吗?")
@ -191,8 +177,7 @@ public class NotesListActivity extends AppCompatActivity implements SidebarFragm
.show();
});
// Delete Forever Action
btnSelectionDeleteForever.setOnClickListener(v -> {
binding.btnSelectionDeleteForever.setOnClickListener(v -> {
AlertDialog dialog = new AlertDialog.Builder(this)
.setTitle("永久删除")
.setMessage("确定要永久删除选中的笔记吗?此操作不可撤销。")
@ -202,132 +187,115 @@ public class NotesListActivity extends AppCompatActivity implements SidebarFragm
})
.setNegativeButton("再想想", null)
.show();
// Set positive button color to red/dark if possible, but default is fine or need custom view
dialog.setOnShowListener(d -> {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setTextColor(getResources().getColor(android.R.color.holo_red_dark));
});
});
}
// Search Bar
tvSearchBar.setOnClickListener(v -> {
startActivity(new android.content.Intent(this, NoteSearchActivity.class));
});
private void initViewModel() {
NotesRepository repository = new NotesRepository(getContentResolver());
// Share ViewModel with fragments using the activity scope
viewModel = new ViewModelProvider(this,
new ViewModelProvider.Factory() {
@Override
public <T extends androidx.lifecycle.ViewModel> T create(Class<T> modelClass) {
return (T) new NotesListViewModel(repository);
}
}).get(NotesListViewModel.class);
MainPagerAdapter adapter = new MainPagerAdapter(this);
viewPager.setAdapter(adapter);
viewPager.setUserInputEnabled(false); // Disable swipe to avoid conflict
// Setup Folder Tabs
folderAdapter = new FolderAdapter(this);
binding.rvFolderTabs.setAdapter(folderAdapter);
folderAdapter.setOnFolderClickListener(folderId -> viewModel.enterFolder(folderId));
// Initial load
viewModel.loadNotes(Notes.ID_ALL_NOTES_FOLDER);
}
// Setup Bottom Navigation
bottomNav.setOnItemSelectedListener(item -> {
int itemId = item.getItemId();
if (itemId == R.id.nav_notes) {
viewPager.setCurrentItem(0, false);
tvTitle.setText(R.string.app_name);
btnChangeLayout.setVisibility(View.VISIBLE);
// Show Search & Tabs
tvSearchBar.setVisibility(View.VISIBLE);
rvFolderTabs.setVisibility(View.VISIBLE);
updateLayoutButtonIcon();
return true;
} else if (itemId == R.id.nav_tasks) {
viewPager.setCurrentItem(1, false);
tvTitle.setText("待办");
btnChangeLayout.setVisibility(View.GONE);
// Hide Search & Tabs
tvSearchBar.setVisibility(View.GONE);
rvFolderTabs.setVisibility(View.GONE);
return true;
}
return false;
private void observeViewModel() {
viewModel.getCurrentFolderIdLiveData().observe(this, folderId -> {
updateTrashModeUI(folderId == Notes.ID_TRASH_FOLER);
folderAdapter.setSelectedFolderId(folderId);
});
// Sidebar Button
findViewById(R.id.btn_sidebar).setOnClickListener(v -> {
if (drawerLayout.isDrawerOpen(GravityCompat.START)) {
drawerLayout.closeDrawer(GravityCompat.START);
} else {
drawerLayout.openDrawer(GravityCompat.START);
}
viewModel.getFoldersLiveData().observe(this, folders -> {
folderAdapter.setFolders(folders);
});
// Layout Toggle Button
btnChangeLayout.setOnClickListener(v -> {
NotesListFragment fragment = getNotesListFragment();
if (fragment != null) {
boolean isStaggered = fragment.toggleLayout();
btnChangeLayout.setImageResource(isStaggered ? R.drawable.ic_view_list : R.drawable.ic_view_grid);
viewModel.getIsSelectionMode().observe(this, isSelection -> {
if (isSelection) {
binding.headerContainer.setVisibility(View.GONE);
binding.selectionHeader.setVisibility(View.VISIBLE);
if (viewModel.isTrashMode()) {
binding.selectionBottomBar.setVisibility(View.GONE);
binding.btnSelectionRestore.setVisibility(View.VISIBLE);
binding.btnSelectionDeleteForever.setVisibility(View.VISIBLE);
} else {
binding.selectionBottomBar.setVisibility(View.VISIBLE);
binding.btnSelectionRestore.setVisibility(View.GONE);
binding.btnSelectionDeleteForever.setVisibility(View.GONE);
}
} else {
binding.headerContainer.setVisibility(View.VISIBLE);
binding.selectionHeader.setVisibility(View.GONE);
binding.selectionBottomBar.setVisibility(View.GONE);
if (!viewModel.isTrashMode()) {
binding.bottomNavigation.setVisibility(View.VISIBLE);
}
}
});
// Initial icon update (post to ensure fragment is attached)
viewPager.post(this::updateLayoutButtonIcon);
viewModel.getSelectedIdsLiveData().observe(this, selectedIds -> {
updateSelectionCount();
updateSelectionActionUI();
});
}
private void updateTrashModeUI(boolean isTrash) {
if (isTrash) {
tvTitle.setText("回收站");
tvSearchBar.setVisibility(View.GONE);
rvFolderTabs.setVisibility(View.GONE);
bottomNav.setVisibility(View.GONE);
binding.tvTitle.setText("回收站");
binding.tvSearchBar.setVisibility(View.GONE);
binding.rvFolderTabs.setVisibility(View.GONE);
binding.bottomNavigation.setVisibility(View.GONE);
} else {
// Restore normal UI state
if (bottomNav.getSelectedItemId() == R.id.nav_notes) {
tvTitle.setText(R.string.app_name);
tvSearchBar.setVisibility(View.VISIBLE);
rvFolderTabs.setVisibility(View.VISIBLE);
if (binding.bottomNavigation.getSelectedItemId() == R.id.nav_notes) {
binding.tvTitle.setText(R.string.app_name);
binding.tvSearchBar.setVisibility(View.VISIBLE);
binding.rvFolderTabs.setVisibility(View.VISIBLE);
} else {
tvTitle.setText("待办");
tvSearchBar.setVisibility(View.GONE);
rvFolderTabs.setVisibility(View.GONE);
binding.tvTitle.setText("待办");
binding.tvSearchBar.setVisibility(View.GONE);
binding.rvFolderTabs.setVisibility(View.GONE);
}
bottomNav.setVisibility(View.VISIBLE);
}
}
public void updateSelectionCount() {
if (viewModel != null) {
int count = viewModel.getSelectedCount();
tvSelectionCount.setText("已选择 " + count + " 项");
binding.bottomNavigation.setVisibility(View.VISIBLE);
}
}
private void showSetPasswordDialog() {
new AlertDialog.Builder(this)
.setTitle("设置密码")
.setItems(new String[]{"数字锁", "手势锁"}, (dialog, which) -> {
int type = (which == 0) ? SecurityManager.TYPE_PIN : SecurityManager.TYPE_PATTERN;
Intent intent = new Intent(this, PasswordActivity.class);
intent.setAction(PasswordActivity.ACTION_SETUP_PASSWORD);
intent.putExtra(PasswordActivity.EXTRA_PASSWORD_TYPE, type);
startActivity(intent);
})
.show();
private void updateSelectionCount() {
int count = viewModel.getSelectedCount();
binding.tvSelectionCount.setText("已选择 " + count + " 项");
}
private void updateSelectionActionUI() {
if (viewModel != null) {
if (btnActionPin != null) {
boolean isAllPinned = viewModel.isAllSelectedPinned();
if (isAllPinned) {
btnActionPin.setText("取消置顶");
} else {
btnActionPin.setText("置顶");
}
}
if (btnActionLock != null) {
boolean isAllLocked = viewModel.isAllSelectedLocked();
if (isAllLocked) {
btnActionLock.setText("解锁");
} else {
btnActionLock.setText("加锁");
}
}
}
boolean isAllPinned = viewModel.isAllSelectedPinned();
binding.btnActionPin.setText(isAllPinned ? "取消置顶" : "置顶");
boolean isAllLocked = viewModel.isAllSelectedLocked();
binding.btnActionLock.setText(isAllLocked ? "解锁" : "加锁");
}
private void showSetPasswordDialog() {
new AlertDialog.Builder(this)
.setTitle("设置密码")
.setItems(new String[]{"数字锁", "手势锁"}, (dialog, which) -> {
int type = (which == 0) ? SecurityManager.TYPE_PIN : SecurityManager.TYPE_PATTERN;
Intent intent = new Intent(this, PasswordActivity.class);
intent.setAction(PasswordActivity.ACTION_SETUP_PASSWORD);
intent.putExtra(PasswordActivity.EXTRA_PASSWORD_TYPE, type);
startActivity(intent);
})
.show();
}
private NotesListFragment getNotesListFragment() {
@ -343,10 +311,32 @@ public class NotesListActivity extends AppCompatActivity implements SidebarFragm
NotesListFragment fragment = getNotesListFragment();
if (fragment != null) {
boolean isStaggered = fragment.isStaggeredLayout();
btnChangeLayout.setImageResource(isStaggered ? R.drawable.ic_view_list : R.drawable.ic_view_grid);
binding.btnChangeLayout.setImageResource(isStaggered ? R.drawable.ic_view_list : R.drawable.ic_view_grid);
}
}
@Override
protected void onResume() {
super.onResume();
IntentFilter filter = new IntentFilter("com.micode.notes.ACTION_SYNC");
registerReceiver(syncReceiver, filter);
// Auto-sync if needed
UserAuthManager authManager = UserAuthManager.getInstance(this);
if (authManager.isLoggedIn()) {
long lastSync = SyncManager.getInstance().getLastSyncTime();
if (System.currentTimeMillis() - lastSync > 30 * 60 * 1000) {
SyncManager.getInstance().syncNotes(null);
}
}
}
@Override
protected void onPause() {
super.onPause();
unregisterReceiver(syncReceiver);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
@ -356,63 +346,38 @@ public class NotesListActivity extends AppCompatActivity implements SidebarFragm
}
}
private class MainPagerAdapter extends FragmentStateAdapter {
public MainPagerAdapter(@NonNull AppCompatActivity activity) {
super(activity);
}
@NonNull
@Override
public Fragment createFragment(int position) {
if (position == 0) {
return new NotesListFragment();
} else {
return new TaskListFragment();
}
}
@Override
public int getItemCount() {
return 2;
}
}
@Override
public void onBackPressed() {
if (drawerLayout != null && drawerLayout.isDrawerOpen(GravityCompat.START)) {
drawerLayout.closeDrawer(GravityCompat.START);
} else if (viewModel != null && viewModel.navigateUp()) {
// Handled by ViewModel navigation
if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
binding.drawerLayout.closeDrawer(GravityCompat.START);
} else if (Boolean.TRUE.equals(viewModel.getIsSelectionMode().getValue())) {
viewModel.setIsSelectionMode(false);
} else if (viewModel.navigateUp()) {
// Handled
} else {
super.onBackPressed();
}
}
// ==================== Sidebar Callbacks ====================
@Override
public void onFolderSelected(long folderId) {
drawerLayout.closeDrawer(GravityCompat.START);
viewModel.enterFolder(folderId);
}
// Sidebar Callbacks
@Override public void onFolderSelected(long folderId) { binding.drawerLayout.closeDrawer(GravityCompat.START); viewModel.enterFolder(folderId); }
@Override public void onTrashSelected() { binding.drawerLayout.closeDrawer(GravityCompat.START); viewModel.enterFolder(Notes.ID_TRASH_FOLER); }
@Override public void onSyncSelected() { binding.drawerLayout.closeDrawer(GravityCompat.START); SyncManager.getInstance().syncNotes(null); }
@Override public void onLoginSelected() { binding.drawerLayout.closeDrawer(GravityCompat.START); startActivity(new Intent(this, LoginActivity.class)); }
@Override public void onLogoutSelected() { binding.drawerLayout.closeDrawer(GravityCompat.START); viewModel.refreshNotes(); }
@Override public void onExportSelected() { binding.drawerLayout.closeDrawer(GravityCompat.START); Toast.makeText(this, "导出功能待实现", Toast.LENGTH_SHORT).show(); }
@Override public void onTemplateSelected() { binding.drawerLayout.closeDrawer(GravityCompat.START); viewModel.enterFolder(Notes.ID_TEMPLATE_FOLDER); }
@Override public void onSettingsSelected() { binding.drawerLayout.closeDrawer(GravityCompat.START); startActivity(new Intent(this, SettingsActivity.class)); }
@Override public void onCreateFolder() { binding.drawerLayout.closeDrawer(GravityCompat.START); /* Show dialog */ }
@Override public void onCloseSidebar() { binding.drawerLayout.closeDrawer(GravityCompat.START); }
@Override public void onRenameFolder(long folderId) { /* Handle rename */ }
@Override public void onDeleteFolder(long folderId) { /* Handle delete */ }
@Override
public void onTrashSelected() {
drawerLayout.closeDrawer(GravityCompat.START);
viewModel.enterFolder(Notes.ID_TRASH_FOLER);
}
@Override public void onSyncSelected() { drawerLayout.closeDrawer(GravityCompat.START); }
@Override public void onLoginSelected() { drawerLayout.closeDrawer(GravityCompat.START); }
@Override public void onExportSelected() { drawerLayout.closeDrawer(GravityCompat.START); }
@Override public void onTemplateSelected() {
drawerLayout.closeDrawer(GravityCompat.START);
viewModel.enterFolder(Notes.ID_TEMPLATE_FOLDER);
}
@Override public void onSettingsSelected() {
drawerLayout.closeDrawer(GravityCompat.START);
startActivity(new android.content.Intent(this, SettingsActivity.class));
private class MainPagerAdapter extends FragmentStateAdapter {
public MainPagerAdapter(@NonNull AppCompatActivity activity) { super(activity); }
@NonNull @Override public Fragment createFragment(int position) {
return (position == 0) ? new NotesListFragment() : new TaskListFragment();
}
@Override public int getItemCount() { return 2; }
}
@Override public void onCreateFolder() { drawerLayout.closeDrawer(GravityCompat.START); }
@Override public void onCloseSidebar() { drawerLayout.closeDrawer(GravityCompat.START); }
}
}

@ -28,7 +28,8 @@ import net.micode.notes.viewmodel.NotesListViewModel;
public class NotesListFragment extends Fragment implements
NoteInfoAdapter.OnNoteItemClickListener,
NoteInfoAdapter.OnNoteItemLongClickListener {
NoteInfoAdapter.OnNoteItemLongClickListener,
NoteInfoAdapter.OnSwipeMenuClickListener {
private static final String TAG = "NotesListFragment";
private static final String PREF_KEY_IS_STAGGERED = "is_staggered";
@ -81,6 +82,7 @@ public class NotesListFragment extends Fragment implements
adapter.setOnNoteItemClickListener(this);
adapter.setOnNoteItemLongClickListener(this);
adapter.setOnSwipeMenuClickListener(this);
// Fix FAB: Enable creating new notes
binding.btnNewNote.setOnClickListener(v -> {
@ -200,4 +202,66 @@ public class NotesListFragment extends Fragment implements
super.onResume();
viewModel.refreshNotes();
}
}
// Swipe Menu Callbacks
@Override
public void onSwipeEdit(long itemId) {
android.util.Log.d(TAG, "onSwipeEdit called for itemId: " + itemId);
if (viewModel.getNotesLiveData().getValue() != null) {
boolean found = false;
for (NotesRepository.NoteInfo note : viewModel.getNotesLiveData().getValue()) {
if (note.getId() == itemId) {
found = true;
if (note.type == Notes.TYPE_FOLDER) {
onSwipeRename(itemId);
} else {
openNoteEditor(note);
}
break;
}
}
if (!found) {
Toast.makeText(requireContext(), "未找到笔记 (ID: " + itemId + ")", Toast.LENGTH_SHORT).show();
}
} else {
Toast.makeText(requireContext(), "数据未加载", Toast.LENGTH_SHORT).show();
}
}
@Override public void onSwipePin(long itemId) { viewModel.toggleNoteSelection(itemId, true); viewModel.toggleSelectedNotesPin(); viewModel.setIsSelectionMode(false); }
@Override public void onSwipeMove(long itemId) { /* Show move dialog */ }
@Override public void onSwipeDelete(long itemId) { viewModel.deleteNote(itemId); }
@Override
public void onSwipeRename(long itemId) {
// Show rename dialog for folder
final EditText input = new EditText(requireContext());
input.setInputType(InputType.TYPE_CLASS_TEXT);
// Find current name
String currentName = "";
if (viewModel.getNotesLiveData().getValue() != null) {
for (NotesRepository.NoteInfo note : viewModel.getNotesLiveData().getValue()) {
if (note.getId() == itemId) {
currentName = note.snippet;
break;
}
}
}
input.setText(currentName);
new AlertDialog.Builder(requireContext())
.setTitle("重命名文件夹")
.setView(input)
.setPositiveButton("确定", (dialog, which) -> {
String newName = input.getText().toString().trim();
if (!newName.isEmpty()) {
viewModel.renameFolder(itemId, newName);
}
})
.setNegativeButton("取消", null)
.show();
}
@Override public void onSwipeRestore(long itemId) { viewModel.toggleNoteSelection(itemId, true); viewModel.restoreSelectedNotes(); viewModel.setIsSelectionMode(false); }
@Override public void onSwipePermanentDelete(long itemId) { viewModel.toggleNoteSelection(itemId, true); viewModel.deleteSelectedNotesForever(); viewModel.setIsSelectionMode(false); }
}

@ -18,17 +18,19 @@ package net.micode.notes.ui;
import android.app.AlertDialog;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.text.InputFilter;
import android.text.TextUtils;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Animation;
import android.view.animation.TranslateAnimation;
import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.PopupMenu;
import android.widget.TextView;
import android.widget.Toast;
@ -40,21 +42,20 @@ import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import net.micode.notes.R;
import net.micode.notes.auth.UserAuthManager;
import net.micode.notes.data.Notes;
import net.micode.notes.data.NotesRepository;
import net.micode.notes.databinding.SidebarLayoutBinding;
import net.micode.notes.sync.SyncManager;
import net.micode.notes.viewmodel.FolderListViewModel;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Fragment
* Fragment -
* <p>
*
* /
* 使 LinearLayout NavigationView
* "文件夹"
* </p>
*/
public class SidebarFragment extends Fragment {
@ -62,70 +63,53 @@ public class SidebarFragment extends Fragment {
private static final String TAG = "SidebarFragment";
private static final int MAX_FOLDER_NAME_LENGTH = 50;
// ViewBinding
private SidebarLayoutBinding binding;
// 适配器和数据
private FolderTreeAdapter adapter;
// 菜单项
private LinearLayout menuAllNotes;
private LinearLayout menuTrash;
private LinearLayout menuFolders;
private LinearLayout menuSyncSettings;
private LinearLayout menuTemplates;
private LinearLayout menuExport;
private LinearLayout menuSettings;
private LinearLayout menuLogin;
private LinearLayout menuLogout;
// 文件夹树
private LinearLayout folderTreeContainer;
private RecyclerView rvFolderTree;
private ImageButton btnCreateFolder;
private ImageView ivFolderExpand;
// 头部
private LinearLayout headerNotLoggedIn;
private LinearLayout headerLoggedIn;
private View btnLoginPrompt;
private TextView tvUsername;
private TextView tvDeviceId;
// ViewModel
private FolderListViewModel viewModel;
// 单击和双击检测
private long lastClickTime = 0;
private View lastClickedView = null;
private static final long DOUBLE_CLICK_INTERVAL = 300; // 毫秒
private FolderTreeAdapter adapter;
// 回调接口
private OnSidebarItemSelectedListener listener;
/**
*
*/
// 状态
private boolean isFolderTreeExpanded = false;
public interface OnSidebarItemSelectedListener {
/**
*
* @param folderId ID
*/
void onFolderSelected(long folderId);
/**
*
*/
void onTrashSelected();
/**
*
*/
void onSyncSelected();
/**
*
*/
void onLoginSelected();
/**
*
*/
void onLogoutSelected();
void onExportSelected();
/**
*
*/
void onTemplateSelected();
/**
*
*/
void onSettingsSelected();
/**
*
*/
void onCreateFolder();
/**
*
*/
void onCloseSidebar();
void onRenameFolder(long folderId);
void onDeleteFolder(long folderId);
}
@Override
@ -150,136 +134,146 @@ public class SidebarFragment extends Fragment {
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
binding = SidebarLayoutBinding.inflate(inflater, container, false);
return binding.getRoot();
@Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_sidebar, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
initViews();
setupListeners();
initViews(view);
initListeners();
initFolderTree();
observeViewModel();
updateUserState();
}
@Override
public void onDestroyView() {
super.onDestroyView();
binding = null;
public void onResume() {
super.onResume();
updateUserState();
}
/**
*
*/
public void refreshFolderTree() {
if (viewModel != null) {
viewModel.loadFolderTree();
}
}
private void initViews(View view) {
// 头部
headerNotLoggedIn = view.findViewById(R.id.header_not_logged_in);
headerLoggedIn = view.findViewById(R.id.header_logged_in);
btnLoginPrompt = view.findViewById(R.id.btn_login_prompt);
tvUsername = view.findViewById(R.id.tv_username);
tvDeviceId = view.findViewById(R.id.tv_device_id);
/**
*
*/
private void initViews() {
// 设置RecyclerView
binding.rvFolderTree.setLayoutManager(new LinearLayoutManager(requireContext()));
adapter = new FolderTreeAdapter(new ArrayList<>(), viewModel);
adapter.setOnFolderItemClickListener(this::handleFolderItemClick);
binding.rvFolderTree.setAdapter(adapter);
// 菜单项
menuAllNotes = view.findViewById(R.id.menu_all_notes);
menuTrash = view.findViewById(R.id.menu_trash);
menuFolders = view.findViewById(R.id.menu_folders);
menuSyncSettings = view.findViewById(R.id.menu_sync_settings);
menuTemplates = view.findViewById(R.id.menu_templates);
menuExport = view.findViewById(R.id.menu_export);
menuSettings = view.findViewById(R.id.menu_settings);
menuLogin = view.findViewById(R.id.menu_login);
menuLogout = view.findViewById(R.id.menu_logout);
// 文件夹树
folderTreeContainer = view.findViewById(R.id.folder_tree_container);
rvFolderTree = view.findViewById(R.id.rv_folder_tree);
btnCreateFolder = view.findViewById(R.id.btn_create_folder);
ivFolderExpand = view.findViewById(R.id.iv_folder_expand);
}
/**
*
*/
private void setupListeners() {
// 根文件夹(单击展开/收起,双击跳转)
setupFolderClickListener(binding.tvRootFolder, Notes.ID_ROOT_FOLDER);
private void initListeners() {
if (headerNotLoggedIn != null) {
headerNotLoggedIn.setOnClickListener(v -> {
if (listener != null) listener.onLoginSelected();
});
}
if (headerLoggedIn != null) {
headerLoggedIn.setOnClickListener(v -> {
Log.d(TAG, "Logged in header clicked");
});
}
// 关闭侧栏
binding.btnCloseSidebar.setOnClickListener(v -> {
// 菜单项点击
menuAllNotes.setOnClickListener(v -> {
if (listener != null) {
listener.onFolderSelected(Notes.ID_ROOT_FOLDER);
listener.onCloseSidebar();
}
});
// 创建文件夹
binding.btnCreateFolder.setOnClickListener(v -> showCreateFolderDialog());
// 菜单项
binding.menuSync.setOnClickListener(v -> {
menuTrash.setOnClickListener(v -> {
if (listener != null) {
listener.onSyncSelected();
listener.onTrashSelected();
listener.onCloseSidebar();
}
});
binding.menuLogin.setOnClickListener(v -> {
if (listener != null) {
listener.onLoginSelected();
}
menuFolders.setOnClickListener(v -> toggleFolderTree());
menuSyncSettings.setOnClickListener(v -> {
Intent intent = new Intent(requireContext(), SyncActivity.class);
startActivity(intent);
if (listener != null) listener.onCloseSidebar();
});
binding.menuExport.setOnClickListener(v -> {
menuTemplates.setOnClickListener(v -> {
if (listener != null) {
listener.onExportSelected();
listener.onTemplateSelected();
listener.onCloseSidebar();
}
});
binding.menuTemplates.setOnClickListener(v -> {
menuExport.setOnClickListener(v -> {
if (listener != null) {
listener.onTemplateSelected();
listener.onExportSelected();
listener.onCloseSidebar();
}
});
binding.menuSettings.setOnClickListener(v -> {
menuSettings.setOnClickListener(v -> {
if (listener != null) {
listener.onSettingsSelected();
listener.onCloseSidebar();
}
});
binding.menuTrash.setOnClickListener(v -> {
menuLogin.setOnClickListener(v -> {
if (listener != null) {
listener.onTrashSelected();
listener.onLoginSelected();
listener.onCloseSidebar();
}
});
menuLogout.setOnClickListener(v -> showLogoutConfirmDialog());
}
/**
* /
*/
private void setupFolderClickListener(View view, long folderId) {
view.setOnClickListener(v -> {
android.util.Log.d(TAG, "setupFolderClickListener: folderId=" + folderId);
long currentTime = System.currentTimeMillis();
if (lastClickedView == view && (currentTime - lastClickTime) < DOUBLE_CLICK_INTERVAL) {
android.util.Log.d(TAG, "Double click on root folder, jumping to: " + folderId);
// 这是双击,执行跳转
if (listener != null) {
// 根文件夹也可以跳转(回到根)
listener.onFolderSelected(folderId);
}
// 重置双击状态
lastClickTime = 0;
lastClickedView = null;
} else {
android.util.Log.d(TAG, "Single click on root folder, will toggle expand in " + DOUBLE_CLICK_INTERVAL + "ms");
// 可能是单击,延迟处理
lastClickTime = currentTime;
lastClickedView = view;
view.postDelayed(() -> {
// 如果在延迟期间没有发生双击,则执行单击操作(展开/收起)
if (System.currentTimeMillis() - lastClickTime >= DOUBLE_CLICK_INTERVAL) {
android.util.Log.d(TAG, "Toggling root folder expand");
toggleFolderExpand(folderId);
}
}, DOUBLE_CLICK_INTERVAL);
private void toggleFolderTree() {
isFolderTreeExpanded = !isFolderTreeExpanded;
folderTreeContainer.setVisibility(isFolderTreeExpanded ? View.VISIBLE : View.GONE);
// 旋转展开图标
if (ivFolderExpand != null) {
ivFolderExpand.setRotation(isFolderTreeExpanded ? 180 : 0);
}
}
private void initFolderTree() {
rvFolderTree.setLayoutManager(new LinearLayoutManager(requireContext()));
adapter = new FolderTreeAdapter(new ArrayList<>(), viewModel);
adapter.setOnFolderItemClickListener(folderId -> {
if (listener != null) {
listener.onFolderSelected(folderId);
listener.onCloseSidebar();
}
});
adapter.setOnFolderItemLongClickListener(this::handleFolderItemLongClick);
rvFolderTree.setAdapter(adapter);
if (btnCreateFolder != null) {
btnCreateFolder.setOnClickListener(v -> showCreateFolderDialog());
}
}
/**
* ViewModel
*/
private void observeViewModel() {
viewModel.getFolderTree().observe(getViewLifecycleOwner(), folderItems -> {
if (folderItems != null) {
@ -287,56 +281,94 @@ public class SidebarFragment extends Fragment {
adapter.notifyDataSetChanged();
}
});
viewModel.loadFolderTree();
}
/**
* /
*/
private void toggleFolderExpand(long folderId) {
android.util.Log.d(TAG, "toggleFolderExpand: folderId=" + folderId);
viewModel.toggleFolderExpand(folderId);
}
public void updateUserState() {
UserAuthManager authManager = UserAuthManager.getInstance(requireContext());
boolean isLoggedIn = authManager.isLoggedIn();
/**
* /
*/
private void handleFolderItemClick(long folderId) {
android.util.Log.d(TAG, "handleFolderItemClick: folderId=" + folderId);
long currentTime = System.currentTimeMillis();
if (lastClickedFolderId == folderId && (currentTime - lastFolderClickTime) < DOUBLE_CLICK_INTERVAL) {
android.util.Log.d(TAG, "Double click detected, jumping to folder: " + folderId);
// 这是双击,执行跳转
if (listener != null) {
listener.onFolderSelected(folderId);
}
// 重置双击状态
lastFolderClickTime = 0;
lastClickedFolderId = -1;
} else {
android.util.Log.d(TAG, "Single click, will toggle expand in " + DOUBLE_CLICK_INTERVAL + "ms");
// 可能是单击,延迟处理
lastFolderClickTime = currentTime;
lastClickedFolderId = folderId;
new android.os.Handler().postDelayed(() -> {
// 如果在延迟期间没有发生双击,则执行单击操作(展开/收起)
if (System.currentTimeMillis() - lastFolderClickTime >= DOUBLE_CLICK_INTERVAL) {
android.util.Log.d(TAG, "Toggling folder expand: " + folderId);
toggleFolderExpand(folderId);
// 更新头部
if (headerNotLoggedIn != null && headerLoggedIn != null) {
if (isLoggedIn) {
headerNotLoggedIn.setVisibility(View.GONE);
headerLoggedIn.setVisibility(View.VISIBLE);
String username = authManager.getUsername();
String deviceId = authManager.getDeviceId();
if (tvUsername != null) {
tvUsername.setText(username != null ? username : getString(R.string.drawer_default_username));
}
}, DOUBLE_CLICK_INTERVAL);
if (tvDeviceId != null) {
tvDeviceId.setText(deviceId != null ? "Device: " + deviceId.substring(0, Math.min(8, deviceId.length()))
: getString(R.string.drawer_default_device_id));
}
} else {
headerNotLoggedIn.setVisibility(View.VISIBLE);
headerLoggedIn.setVisibility(View.GONE);
}
}
// 更新菜单项
if (menuLogin != null && menuLogout != null) {
menuLogin.setVisibility(isLoggedIn ? View.GONE : View.VISIBLE);
menuLogout.setVisibility(isLoggedIn ? View.VISIBLE : View.GONE);
}
}
private void showLogoutConfirmDialog() {
new AlertDialog.Builder(requireContext())
.setTitle(R.string.dialog_logout_title)
.setMessage(R.string.dialog_logout_message)
.setPositiveButton(R.string.dialog_logout_confirm, (dialog, which) -> performLogout())
.setNegativeButton(android.R.string.cancel, null)
.show();
}
private void performLogout() {
UserAuthManager authManager = UserAuthManager.getInstance(requireContext());
authManager.logout();
// 重置同步状态,清除上次同步时间
SyncManager.getInstance().resetSyncState();
updateUserState();
if (listener != null) {
listener.onLogoutSelected();
listener.onCloseSidebar();
}
Toast.makeText(requireContext(), R.string.toast_logout_success, Toast.LENGTH_SHORT).show();
Log.d(TAG, "User logged out successfully");
}
// 双击检测专用变量(针对文件夹列表项)
private long lastFolderClickTime = 0;
private long lastClickedFolderId = -1;
private void handleFolderItemLongClick(long folderId) {
if (folderId <= 0) return;
PopupMenu popup = new PopupMenu(requireContext(), rvFolderTree);
popup.getMenuInflater().inflate(R.menu.folder_context_menu, popup.getMenu());
popup.setOnMenuItemClickListener(item -> {
int itemId = item.getItemId();
if (itemId == R.id.action_rename && listener != null) {
listener.onRenameFolder(folderId);
return true;
} else if (itemId == R.id.action_delete && listener != null) {
listener.onDeleteFolder(folderId);
return true;
} else if (itemId == R.id.action_move) {
Toast.makeText(requireContext(), "移动功能开发中", Toast.LENGTH_SHORT).show();
return true;
}
return false;
});
popup.show();
}
/**
*
*/
private void showCreateFolderDialog() {
public void showCreateFolderDialog() {
AlertDialog.Builder builder = new AlertDialog.Builder(requireContext());
builder.setTitle(R.string.dialog_create_folder_title);
@ -352,156 +384,153 @@ public class SidebarFragment extends Fragment {
Toast.makeText(requireContext(), R.string.error_folder_name_empty, Toast.LENGTH_SHORT).show();
return;
}
if (folderName.length() > MAX_FOLDER_NAME_LENGTH) {
Toast.makeText(requireContext(), R.string.error_folder_name_too_long, Toast.LENGTH_SHORT).show();
return;
}
createFolder(folderName);
});
// 创建文件夹
NotesRepository repository = new NotesRepository(requireContext().getContentResolver());
long parentId = viewModel.getCurrentFolderId();
if (parentId == 0) {
parentId = Notes.ID_ROOT_FOLDER;
builder.setNegativeButton(android.R.string.cancel, null);
builder.show();
}
private void createFolder(String folderName) {
NotesRepository repository = new NotesRepository(requireContext().getContentResolver());
long parentId = viewModel.getCurrentFolderId();
if (parentId == 0) parentId = Notes.ID_ROOT_FOLDER;
repository.createFolder(parentId, folderName, new NotesRepository.Callback<Long>() {
@Override
public void onSuccess(Long folderId) {
if (getActivity() != null) {
getActivity().runOnUiThread(() -> {
Toast.makeText(requireContext(), R.string.create_folder_success, Toast.LENGTH_SHORT).show();
viewModel.loadFolderTree();
});
}
}
repository.createFolder(parentId, folderName,
new NotesRepository.Callback<Long>() {
@Override
public void onSuccess(Long folderId) {
if (getActivity() != null) {
getActivity().runOnUiThread(() -> {
Toast.makeText(requireContext(), R.string.create_folder_success, Toast.LENGTH_SHORT).show();
// 刷新文件夹列表
viewModel.loadFolderTree();
});
}
}
@Override
public void onError(Exception error) {
if (getActivity() != null) {
getActivity().runOnUiThread(() -> {
Toast.makeText(requireContext(),
getString(R.string.error_folder_name_too_long) + ": " + error.getMessage(),
Toast.LENGTH_SHORT).show();
});
}
}
});
@Override
public void onError(Exception error) {
if (getActivity() != null) {
getActivity().runOnUiThread(() -> {
Toast.makeText(requireContext(),
getString(R.string.error_create_folder) + ": " + error.getMessage(),
Toast.LENGTH_SHORT).show();
});
}
}
});
}
builder.setNegativeButton(android.R.string.cancel, null);
builder.show();
public void refreshFolderTree() {
if (viewModel != null) viewModel.loadFolderTree();
}
/**
* FolderTreeAdapter
* /
*/
private static class FolderTreeAdapter extends RecyclerView.Adapter<FolderTreeAdapter.FolderViewHolder> {
@Override
public void onDetach() {
super.onDetach();
listener = null;
}
private List<FolderTreeItem> folderItems;
private FolderListViewModel viewModel;
private OnFolderItemClickListener folderItemClickListener;
// ==================== FolderTreeAdapter ====================
public FolderTreeAdapter(List<FolderTreeItem> folderItems, FolderListViewModel viewModel) {
this.folderItems = folderItems;
this.viewModel = viewModel;
}
private static class FolderTreeAdapter extends RecyclerView.Adapter<FolderTreeAdapter.FolderViewHolder> {
public void setData(List<FolderTreeItem> folderItems) {
this.folderItems = folderItems;
}
private List<FolderTreeItem> folderItems;
private FolderListViewModel viewModel;
private OnFolderItemClickListener folderItemClickListener;
private OnFolderItemLongClickListener folderItemLongClickListener;
public void setOnFolderItemClickListener(OnFolderItemClickListener listener) {
this.folderItemClickListener = listener;
}
public FolderTreeAdapter(List<FolderTreeItem> folderItems, FolderListViewModel viewModel) {
this.folderItems = folderItems;
this.viewModel = viewModel;
}
@NonNull
@Override
public FolderViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.sidebar_folder_item, parent, false);
return new FolderViewHolder(view, folderItemClickListener);
}
public void setData(List<FolderTreeItem> folderItems) {
this.folderItems = folderItems;
}
@Override
public void onBindViewHolder(@NonNull FolderViewHolder holder, int position) {
FolderTreeItem item = folderItems.get(position);
boolean isExpanded = viewModel != null && viewModel.isFolderExpanded(item.folderId);
holder.bind(item, isExpanded);
}
public void setOnFolderItemClickListener(OnFolderItemClickListener listener) {
this.folderItemClickListener = listener;
}
@Override
public int getItemCount() {
return folderItems.size();
}
public void setOnFolderItemLongClickListener(OnFolderItemLongClickListener listener) {
this.folderItemLongClickListener = listener;
}
static class FolderViewHolder extends RecyclerView.ViewHolder {
private View indentView;
private ImageView ivExpandIcon;
private ImageView ivFolderIcon;
private TextView tvFolderName;
private TextView tvNoteCount;
private FolderTreeItem currentItem;
private final OnFolderItemClickListener folderItemClickListener;
public FolderViewHolder(@NonNull View itemView, OnFolderItemClickListener listener) {
super(itemView);
this.folderItemClickListener = listener;
indentView = itemView.findViewById(R.id.indent_view);
ivExpandIcon = itemView.findViewById(R.id.iv_expand_icon);
ivFolderIcon = itemView.findViewById(R.id.iv_folder_icon);
tvFolderName = itemView.findViewById(R.id.tv_folder_name);
tvNoteCount = itemView.findViewById(R.id.tv_note_count);
}
@NonNull
@Override
public FolderViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.sidebar_folder_item, parent, false);
return new FolderViewHolder(view, folderItemClickListener, folderItemLongClickListener);
}
@Override
public void onBindViewHolder(@NonNull FolderViewHolder holder, int position) {
FolderTreeItem item = folderItems.get(position);
boolean isExpanded = viewModel != null && viewModel.isFolderExpanded(item.folderId);
holder.bind(item, isExpanded);
}
public void bind(FolderTreeItem item, boolean isExpanded) {
this.currentItem = item;
@Override
public int getItemCount() {
return folderItems.size();
}
// 设置缩进
int indent = item.level * 32;
indentView.setLayoutParams(new LinearLayout.LayoutParams(indent, LinearLayout.LayoutParams.MATCH_PARENT));
static class FolderViewHolder extends RecyclerView.ViewHolder {
private View indentView;
private View ivFolderIcon;
private TextView tvFolderName;
private TextView tvNoteCount;
private FolderTreeItem currentItem;
public FolderViewHolder(@NonNull View itemView, OnFolderItemClickListener clickListener,
OnFolderItemLongClickListener longClickListener) {
super(itemView);
indentView = itemView.findViewById(R.id.indent_view);
ivFolderIcon = itemView.findViewById(R.id.iv_folder_icon);
tvFolderName = itemView.findViewById(R.id.tv_folder_name);
tvNoteCount = itemView.findViewById(R.id.tv_note_count);
itemView.setOnClickListener(v -> {
if (clickListener != null && currentItem != null) {
clickListener.onFolderClick(currentItem.folderId);
}
});
// 设置展开/收起图标
if (item.hasChildren) {
ivExpandIcon.setVisibility(View.VISIBLE);
ivExpandIcon.setRotation(isExpanded ? 90 : 0);
} else {
ivExpandIcon.setVisibility(View.INVISIBLE);
itemView.setOnLongClickListener(v -> {
if (longClickListener != null && currentItem != null) {
longClickListener.onFolderLongClick(currentItem.folderId);
return true;
}
return false;
});
}
// 设置文件夹名称
tvFolderName.setText(item.name);
public void bind(FolderTreeItem item, boolean isExpanded) {
this.currentItem = item;
// 设置便签数量
tvNoteCount.setText(String.format(itemView.getContext()
.getString(R.string.folder_note_count), item.noteCount));
int indent = item.level * 32;
indentView.setLayoutParams(new LinearLayout.LayoutParams(indent, LinearLayout.LayoutParams.MATCH_PARENT));
// 设置点击监听器
itemView.setOnClickListener(v -> {
if (folderItemClickListener != null) {
folderItemClickListener.onFolderClick(item.folderId);
}
});
}
tvFolderName.setText(item.name);
tvNoteCount.setText(String.format(itemView.getContext()
.getString(R.string.folder_note_count), item.noteCount));
}
}
}
/**
*
*/
public interface OnFolderItemClickListener {
void onFolderClick(long folderId);
}
public interface OnFolderItemClickListener {
void onFolderClick(long folderId);
}
public interface OnFolderItemLongClickListener {
void onFolderLongClick(long folderId);
}
/**
* FolderTreeItem
*
*/
public static class FolderTreeItem {
public long folderId;
public String name;
public int level; // 层级0表示顶级
public int level;
public boolean hasChildren;
public int noteCount;
@ -513,10 +542,4 @@ public class SidebarFragment extends Fragment {
this.noteCount = noteCount;
}
}
@Override
public void onDetach() {
super.onDetach();
listener = null;
}
}

@ -0,0 +1,155 @@
/*
* Copyright (c) 2025, Modern Notes Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.micode.notes.ui;
import android.content.Context;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
/**
*
* <p>
*
* </p>
*/
public class SwipeDetector extends GestureDetector.SimpleOnGestureListener {
private static final float SWIPE_THRESHOLD = 100f; // 滑动阈值(像素)
// 滑动状态
private float downX;
private float downY;
private boolean isSwiping = false;
// 监听器
private SwipeListener swipeListener;
/**
*
*/
public interface SwipeListener {
/**
*
*/
void onSwipeStart();
/**
*
*
* @param distance
*/
void onSwipeMove(float distance);
/**
*
*
* @param distance
*/
void onSwipeEnd(float distance);
}
/**
*
*
* @param context
* @param listener
*/
public SwipeDetector(Context context, SwipeListener listener) {
this.swipeListener = listener;
}
@Override
public boolean onDown(MotionEvent e) {
downX = e.getX();
downY = e.getY();
isSwiping = false;
return true;
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
if (!isSwiping) {
isSwiping = true;
if (swipeListener != null) {
swipeListener.onSwipeStart();
}
}
if (swipeListener != null) {
swipeListener.onSwipeMove(distanceX);
}
return true;
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
if (swipeListener != null) {
swipeListener.onSwipeEnd(e2.getX() - downX);
}
return true;
}
/**
*
*
* @param currentX X
* @return
*/
public float getSwipeDistance(float currentX) {
return currentX - downX;
}
/**
*
*
* @param distance
* @return true
*/
public boolean isThresholdReached(float distance) {
return Math.abs(distance) >= SWIPE_THRESHOLD;
}
/**
*
*
* @param distance
* @return true
*/
public boolean isSwipeLeft(float distance) {
return distance < 0;
}
/**
*
*
* @param distance
* @return true
*/
public boolean isSwipeRight(float distance) {
return distance > 0;
}
/**
*
*/
public void reset() {
downX = 0;
downY = 0;
isSwiping = false;
}
}

@ -0,0 +1,512 @@
/*
* Copyright (c) 2025, Modern Notes Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.micode.notes.ui;
import android.content.Context;
import android.graphics.Rect;
import android.os.Handler;
import android.os.Looper;
import android.util.AttributeSet;
import android.util.Log;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.OvershootInterpolator;
import android.view.animation.TranslateAnimation;
import android.widget.FrameLayout;
import android.widget.OverScroller;
import androidx.core.view.GestureDetectorCompat;
/**
*
*
*/
public class SwipeMenuLayout extends FrameLayout {
private static final String TAG = "SwipeMenuLayout";
private static final int MENU_WIDTH_DP = 260; // 增加宽度以适应新按钮
private static final int MIN_VELOCITY = 500;
private static final int MAX_OVERSCROLL = 80;
private View contentView;
private View menuView;
private int menuWidth;
private int screenWidth;
private OverScroller scroller;
private VelocityTracker velocityTracker;
private float lastX;
private float downX;
private float downY;
private long downTime;
private int currentState = STATE_CLOSE;
private float currentScrollX = 0;
private boolean isScrolling = false;
private boolean longPressTriggered = false;
private static final int STATE_CLOSE = 0;
private static final int STATE_OPEN = 1;
private static final int STATE_SWIPING = 2;
private static final float TOUCH_SLOP = 10f;
private static final int CLICK_TIME_THRESHOLD = 300;
private static final int LONG_PRESS_TIME_THRESHOLD = 500;
private Handler longPressHandler;
private Runnable longPressRunnable;
private OnMenuButtonClickListener menuButtonClickListener;
private OnContentClickListener contentClickListener;
private OnContentLongClickListener contentLongClickListener;
private long itemId;
private boolean swipeEnabled = true;
public interface OnMenuButtonClickListener {
void onEdit(long itemId);
void onPin(long itemId);
void onMove(long itemId);
void onDelete(long itemId);
void onRename(long itemId);
void onRestore(long itemId);
void onPermanentDelete(long itemId);
}
public interface OnContentLongClickListener {
void onContentLongClick(long itemId);
}
public interface OnContentClickListener {
void onContentClick(long itemId);
}
private boolean isFirstLayout = true;
public SwipeMenuLayout(Context context) {
super(context);
init(context);
}
public SwipeMenuLayout(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);
}
public SwipeMenuLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
private void init(Context context) {
scroller = new OverScroller(context);
velocityTracker = VelocityTracker.obtain();
menuWidth = (int) (MENU_WIDTH_DP * context.getResources().getDisplayMetrics().density);
longPressHandler = new Handler(Looper.getMainLooper());
longPressRunnable = () -> {
if (!isScrolling && !longPressTriggered) {
Log.d(TAG, "Long press triggered via Handler, itemId: " + itemId);
longPressTriggered = true;
if (contentLongClickListener != null) {
contentLongClickListener.onContentLongClick(itemId);
}
}
};
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
if (getChildCount() != 2) {
throw new IllegalStateException("SwipeMenuLayout must have exactly 2 children: content and menu");
}
contentView = getChildAt(0);
menuView = getChildAt(1);
setupMenuButtonListeners();
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
if (contentView != null && menuView != null) {
screenWidth = right - left;
// 动态计算菜单宽度
int visibleMenuWidth = 0;
if (menuView instanceof ViewGroup) {
ViewGroup menuGroup = (ViewGroup) menuView;
for (int i = 0; i < menuGroup.getChildCount(); i++) {
View child = menuGroup.getChildAt(i);
if (child.getVisibility() == View.VISIBLE) {
visibleMenuWidth = Math.max(visibleMenuWidth, child.getMeasuredWidth());
}
}
}
if (visibleMenuWidth > 0) {
menuWidth = visibleMenuWidth;
}
if (isFirstLayout) {
contentView.setTranslationX(0);
int cardMarginEnd = (int) (8 * getContext().getResources().getDisplayMetrics().density);
menuView.setTranslationX(screenWidth - cardMarginEnd);
scroller.startScroll(0, 0, 0, 0);
isFirstLayout = false;
} else if (currentState == STATE_OPEN) {
contentView.setTranslationX(-menuWidth);
int cardMarginEnd = (int) (8 * getContext().getResources().getDisplayMetrics().density);
menuView.setTranslationX(screenWidth - cardMarginEnd - menuWidth);
}
}
}
private void setupMenuButtonListeners() {
if (menuView instanceof ViewGroup) {
ViewGroup menuGroup = (ViewGroup) menuView;
int childCount = menuGroup.getChildCount();
Log.d(TAG, "setupMenuButtonListeners: menuGroup childCount=" + childCount);
// menuView 是 FrameLayout包含两个 include 的布局
// 需要遍历每个菜单布局内部的按钮
for (int i = 0; i < childCount; i++) {
View menuLayout = menuGroup.getChildAt(i);
Log.d(TAG, "Menu layout " + i + ": " + menuLayout.getClass().getSimpleName() + ", visibility=" + menuLayout.getVisibility());
if (menuLayout instanceof ViewGroup) {
ViewGroup menuInnerGroup = (ViewGroup) menuLayout;
int buttonCount = menuInnerGroup.getChildCount();
Log.d(TAG, "Menu " + i + " has " + buttonCount + " buttons");
for (int j = 0; j < buttonCount; j++) {
View button = menuInnerGroup.getChildAt(j);
final View finalButton = button;
String tag = (String) button.getTag();
Log.d(TAG, "Button " + j + ": id=" + button.getId() + ", tag=" + tag);
button.setOnClickListener(v -> {
Log.d(TAG, "Button clicked: id=" + finalButton.getId() + ", tag=" + finalButton.getTag());
if (menuButtonClickListener != null) {
long itemId = getItemId();
Log.d(TAG, "menuButtonClickListener not null, itemId=" + itemId);
handleMenuButtonClick(finalButton, itemId);
} else {
Log.e(TAG, "menuButtonClickListener is NULL!");
}
closeMenu();
});
}
}
}
} else {
Log.e(TAG, "menuView is not a ViewGroup!");
}
}
private void handleMenuButtonClick(View button, long itemId) {
int id = button.getId();
String actionType = (String) button.getTag();
Log.d(TAG, "handleMenuButtonClick: id=" + id + ", actionType=" + actionType + ", itemId=" + itemId);
if (actionType != null && menuButtonClickListener != null) {
switch (actionType) {
case "edit":
Log.d(TAG, "Calling onEdit");
menuButtonClickListener.onEdit(itemId);
break;
case "pin":
Log.d(TAG, "Calling onPin");
menuButtonClickListener.onPin(itemId);
break;
case "move":
Log.d(TAG, "Calling onMove");
menuButtonClickListener.onMove(itemId);
break;
case "delete":
Log.d(TAG, "Calling onDelete");
menuButtonClickListener.onDelete(itemId);
break;
case "rename":
Log.d(TAG, "Calling onRename");
menuButtonClickListener.onRename(itemId);
break;
case "restore":
Log.d(TAG, "Calling onRestore");
menuButtonClickListener.onRestore(itemId);
break;
case "permanent_delete":
Log.d(TAG, "Calling onPermanentDelete");
menuButtonClickListener.onPermanentDelete(itemId);
break;
default:
Log.e(TAG, "Unknown actionType: " + actionType);
}
} else {
if (actionType == null) {
Log.e(TAG, "actionType is NULL!");
}
if (menuButtonClickListener == null) {
Log.e(TAG, "menuButtonClickListener is NULL in handleMenuButtonClick!");
}
}
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (!swipeEnabled) {
return false;
}
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
downX = ev.getX();
downY = ev.getY();
lastX = ev.getX();
downTime = System.currentTimeMillis();
isScrolling = false;
longPressTriggered = false;
// 安排长按检测任务
longPressHandler.removeCallbacks(longPressRunnable);
longPressHandler.postDelayed(longPressRunnable, LONG_PRESS_TIME_THRESHOLD);
break;
case MotionEvent.ACTION_MOVE:
float deltaX = ev.getX() - downX;
float deltaY = ev.getY() - downY;
// 检测滑动
if (Math.abs(deltaX) > TOUCH_SLOP * 2 && Math.abs(deltaX) > Math.abs(deltaY) * 2) {
isScrolling = true;
longPressHandler.removeCallbacks(longPressRunnable);
return true;
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
// 取消长按任务
longPressHandler.removeCallbacks(longPressRunnable);
break;
}
return false;
}
public boolean onTouchEvent(MotionEvent event) {
if (velocityTracker == null) {
velocityTracker = VelocityTracker.obtain();
}
velocityTracker.addMovement(event);
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
downX = event.getX();
downY = event.getY();
lastX = event.getX();
downTime = System.currentTimeMillis();
isScrolling = false;
longPressTriggered = false;
// 安排长按检测任务
longPressHandler.removeCallbacks(longPressRunnable);
longPressHandler.postDelayed(longPressRunnable, LONG_PRESS_TIME_THRESHOLD);
break;
case MotionEvent.ACTION_MOVE:
float dx = event.getX() - lastX;
float deltaX = event.getX() - downX;
float deltaY = event.getY() - downY;
if (Math.abs(deltaX) > TOUCH_SLOP * 2 && Math.abs(deltaX) > Math.abs(deltaY) * 2) {
// 检测到滑动,取消长按任务
isScrolling = true;
longPressHandler.removeCallbacks(longPressRunnable);
currentScrollX += dx;
applyScroll(currentScrollX, true);
}
lastX = event.getX();
break;
case MotionEvent.ACTION_UP:
// 取消长按任务
longPressHandler.removeCallbacks(longPressRunnable);
long upTime = System.currentTimeMillis();
long duration = upTime - downTime;
if (isScrolling) {
handleTouchRelease();
} else if (!longPressTriggered && duration < LONG_PRESS_TIME_THRESHOLD) {
// 短按且未触发长按 = 点击
Log.d(TAG, "Content click detected, itemId: " + itemId);
if (contentClickListener != null) {
contentClickListener.onContentClick(itemId);
}
}
if (velocityTracker != null) {
velocityTracker.recycle();
velocityTracker = null;
}
isScrolling = false;
break;
case MotionEvent.ACTION_CANCEL:
// 取消长按任务
longPressHandler.removeCallbacks(longPressRunnable);
if (isScrolling) {
handleTouchRelease();
}
if (velocityTracker != null) {
velocityTracker.recycle();
velocityTracker = null;
}
isScrolling = false;
break;
}
return true;
}
public void openMenu() {
scroller = new OverScroller(getContext(), new OvershootInterpolator(0.5f));
smoothScrollTo(-menuWidth);
currentState = STATE_OPEN;
}
public void closeMenu() {
scroller = new OverScroller(getContext(), new OvershootInterpolator(0.5f));
smoothScrollTo(0);
currentState = STATE_CLOSE;
}
public void toggleMenu() {
if (currentState == STATE_OPEN) {
closeMenu();
} else {
openMenu();
}
}
private void applyScroll(float scrollX, boolean allowElastic) {
if (!allowElastic) {
if (scrollX > 0) scrollX = 0;
if (scrollX < -menuWidth) scrollX = -menuWidth;
} else {
if (scrollX > MAX_OVERSCROLL) {
scrollX = MAX_OVERSCROLL;
} else if (scrollX < -menuWidth - MAX_OVERSCROLL) {
scrollX = -menuWidth - MAX_OVERSCROLL;
}
}
contentView.setTranslationX(scrollX);
// 优化定位:菜单紧贴卡片右边缘(考虑卡片右边距)
// 假设卡片右边距为 8dp (对应 12dp marginHorizontal 的一半左右,或者从布局获取)
int cardMarginEnd = (int) (8 * getContext().getResources().getDisplayMetrics().density);
menuView.setTranslationX(scrollX + screenWidth - cardMarginEnd);
currentScrollX = scrollX;
}
private void handleTouchRelease() {
velocityTracker.computeCurrentVelocity(1000);
float velocity = velocityTracker.getXVelocity();
int targetX;
if (Math.abs(velocity) > MIN_VELOCITY) {
if (velocity > 0) {
targetX = 0;
} else {
targetX = -menuWidth;
}
} else {
if (Math.abs(currentScrollX) < menuWidth / 2) {
targetX = 0;
} else {
targetX = -menuWidth;
}
}
smoothScrollTo(targetX);
currentState = (targetX == 0) ? STATE_CLOSE : STATE_OPEN;
}
private void smoothScrollTo(int x) {
scroller.startScroll((int) currentScrollX, 0, x - (int) currentScrollX, 0, 300);
invalidate();
postInvalidate();
}
@Override
public void computeScroll() {
if (scroller.computeScrollOffset()) {
currentScrollX = scroller.getCurrX();
applyScroll(currentScrollX, false);
requestAnimationInvalidation();
}
}
private void requestAnimationInvalidation() {
post(this::computeScroll);
}
public void setOnMenuButtonClickListener(OnMenuButtonClickListener listener) {
this.menuButtonClickListener = listener;
}
public void setOnContentClickListener(OnContentClickListener listener) {
this.contentClickListener = listener;
}
public void setOnContentLongClickListener(OnContentLongClickListener listener) {
this.contentLongClickListener = listener;
}
public void setItemId(long itemId) {
this.itemId = itemId;
}
public long getItemId() {
return itemId;
}
public boolean isMenuOpen() {
return currentState == STATE_OPEN;
}
public void setSwipeEnabled(boolean enabled) {
this.swipeEnabled = enabled;
}
public boolean isSwipeEnabled() {
return swipeEnabled;
}
}

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

@ -246,7 +246,16 @@ public class FolderListViewModel extends AndroidViewModel {
while (cursor.moveToNext()) {
Map<String, Object> folder = new HashMap<>();
long id = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID));
String name = cursor.getString(cursor.getColumnIndexOrThrow(NoteColumns.SNIPPET));
// 优先使用TITLEfallback到SNIPPET
String name = "";
int titleIndex = cursor.getColumnIndex(NoteColumns.TITLE);
if (titleIndex != -1) {
name = cursor.getString(titleIndex);
}
if (name == null || name.trim().isEmpty()) {
name = cursor.getString(cursor.getColumnIndexOrThrow(NoteColumns.SNIPPET));
}
// 尝试获取parent_id可能列名不对
int parentIdIndex = cursor.getColumnIndex(NoteColumns.PARENT_ID);
@ -265,6 +274,7 @@ public class FolderListViewModel extends AndroidViewModel {
android.util.Log.d(TAG, "Folder data: id=" + id + ", name=" + name + ", parentId=" + parentId + ", noteCount=" + noteCount);
folder.put(NoteColumns.ID, id);
folder.put(NoteColumns.TITLE, name);
folder.put(NoteColumns.SNIPPET, name);
folder.put(NoteColumns.PARENT_ID, parentId);
folder.put(NoteColumns.NOTES_COUNT, noteCount);

@ -0,0 +1,260 @@
/*
* Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.micode.notes.viewmodel;
import android.app.Application;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import net.micode.notes.auth.AnonymousAuthManager;
import net.micode.notes.auth.UserAuthManager;
import net.micode.notes.data.NotesRepository;
import net.micode.notes.model.WorkingNote;
import net.micode.notes.sync.SyncManager;
import java.util.List;
/**
* ViewModel
*
* <p>
*
* MVVM Activity
* </p>
* <p>
*
* 1. -
* 2. -
* 3. -
* </p>
*/
public class LoginViewModel extends AndroidViewModel {
private static final String TAG = "LoginViewModel";
private final UserAuthManager mAuthManager;
private final AnonymousAuthManager mAnonymousAuthManager;
private final NotesRepository mNotesRepository;
private final MutableLiveData<Boolean> mIsLoading = new MutableLiveData<>(false);
private final MutableLiveData<String> mErrorMessage = new MutableLiveData<>();
private final MutableLiveData<Boolean> mLoginSuccess = new MutableLiveData<>(false);
private final MutableLiveData<Integer> mMigratedNotesCount = new MutableLiveData<>();
private final MutableLiveData<String> mSyncStatus = new MutableLiveData<>();
public LoginViewModel(@NonNull Application application) {
super(application);
mAuthManager = UserAuthManager.getInstance(application);
mAnonymousAuthManager = AnonymousAuthManager.getInstance(application);
mNotesRepository = new NotesRepository(application.getContentResolver());
}
public LiveData<Boolean> getIsLoading() {
return mIsLoading;
}
public LiveData<String> getErrorMessage() {
return mErrorMessage;
}
public LiveData<Boolean> getLoginSuccess() {
return mLoginSuccess;
}
public LiveData<Integer> getMigratedNotesCount() {
return mMigratedNotesCount;
}
public LiveData<String> getSyncStatus() {
return mSyncStatus;
}
/**
*
*/
public boolean isLoggedIn() {
return mAuthManager.isLoggedIn();
}
/**
*
*/
public void login(String username, String password) {
if (username == null || username.isEmpty() || password == null || password.isEmpty()) {
mErrorMessage.setValue("请输入用户名和密码");
return;
}
mIsLoading.setValue(true);
mSyncStatus.setValue("正在登录...");
mAuthManager.login(username, password, new UserAuthManager.AuthCallback() {
@Override
public void onSuccess(String userId, String username) {
mSyncStatus.postValue("登录成功,正在同步数据...");
// 登录成功后执行数据迁移和全量同步
migrateAnonymousDataAndSync(userId);
}
@Override
public void onError(String error) {
mIsLoading.postValue(false);
mSyncStatus.postValue("登录失败");
mErrorMessage.postValue("登录失败: " + error);
}
});
}
/**
*
*/
public void register(String username, String password) {
if (username == null || username.isEmpty() || password == null || password.isEmpty()) {
mErrorMessage.setValue("请输入用户名和密码");
return;
}
if (password.length() < 6) {
mErrorMessage.setValue("密码长度至少6位");
return;
}
mIsLoading.setValue(true);
mSyncStatus.setValue("正在注册...");
mAuthManager.register(username, password, new UserAuthManager.AuthCallback() {
@Override
public void onSuccess(String userId, String username) {
mSyncStatus.postValue("注册成功,正在同步数据...");
// 注册成功后执行数据迁移和全量同步
migrateAnonymousDataAndSync(userId);
}
@Override
public void onError(String error) {
mIsLoading.postValue(false);
mSyncStatus.postValue("注册失败");
mErrorMessage.postValue("注册失败: " + error);
}
});
}
/**
*
*
* <p>
* 1.
* 2.
* 3.
* </p>
*/
private void migrateAnonymousDataAndSync(String newUserId) {
mSyncStatus.postValue("正在接管设备上的笔记...");
// 新用户接管设备上所有笔记
mNotesRepository.takeoverAllNotes(newUserId, new NotesRepository.Callback<Integer>() {
@Override
public void onSuccess(Integer count) {
Log.d(TAG, "Takeover completed: " + count + " notes now belong to " + newUserId);
mMigratedNotesCount.postValue(count);
// 接管完成后执行全量同步(上传所有笔记到云端)
performFullSync();
}
@Override
public void onError(Exception error) {
Log.e(TAG, "Failed to takeover notes", error);
mMigratedNotesCount.postValue(0);
// 即使接管失败也尝试同步
performFullSync();
}
});
}
/**
*
*
* <p>
* 1.
* 2.
*
* </p>
*/
private void performFullSync() {
Log.d(TAG, "Performing full sync after login");
mSyncStatus.postValue("正在上传笔记到云端...");
// 初始化同步管理器
SyncManager.getInstance().initialize(getApplication());
// 重置同步状态
SyncManager.getInstance().resetSyncState();
// 第一步:上传所有本地笔记到云端
SyncManager.getInstance().uploadAllNotes(new SyncManager.SyncCallback() {
@Override
public void onSuccess() {
Log.d(TAG, "Upload all notes completed");
mSyncStatus.postValue("上传完成,正在下载云端笔记...");
// 第二步:下载云端其他笔记(如果有)
downloadCloudNotes();
}
@Override
public void onError(String error) {
Log.e(TAG, "Upload failed: " + error);
// 即使上传失败也尝试下载
downloadCloudNotes();
}
});
}
/**
*
*/
private void downloadCloudNotes() {
SyncManager.getInstance().syncAllNotes(new SyncManager.SyncCallback() {
@Override
public void onSuccess() {
Log.d(TAG, "Download cloud notes completed");
mSyncStatus.postValue("同步完成");
mIsLoading.postValue(false);
mLoginSuccess.postValue(true);
}
@Override
public void onError(String error) {
Log.e(TAG, "Download failed: " + error);
mSyncStatus.postValue("同步完成(下载可能有部分失败)");
mIsLoading.postValue(false);
mLoginSuccess.postValue(true);
}
});
}
/**
*
*/
public void clearError() {
mErrorMessage.setValue(null);
}
}

@ -823,6 +823,121 @@ public class NotesListViewModel extends ViewModel {
repository.applyTemplate(templateId, Notes.ID_ROOT_FOLDER, callback);
}
/**
*
* <p>
*
* </p>
*
* @param folderId ID
* @param newName
*/
public void renameFolder(long folderId, String newName) {
isLoading.postValue(true);
errorMessage.postValue(null);
repository.renameFolder(folderId, newName, new NotesRepository.Callback<Integer>() {
@Override
public void onSuccess(Integer rowsAffected) {
isLoading.postValue(false);
// 触发列表和侧栏刷新
refreshNotes();
sidebarRefreshNeeded.postValue(true);
Log.d(TAG, "Successfully renamed folder: " + folderId);
}
@Override
public void onError(Exception error) {
isLoading.postValue(false);
String message = "重命名文件夹失败: " + error.getMessage();
errorMessage.postValue(message);
Log.e(TAG, message, error);
}
});
}
/**
*
* <p>
*
* </p>
*
* @param noteId ID
* @param newName
*/
public void renameNote(long noteId, String newName) {
isLoading.postValue(true);
errorMessage.postValue(null);
repository.renameNote(noteId, newName, new NotesRepository.Callback<Integer>() {
@Override
public void onSuccess(Integer rowsAffected) {
isLoading.postValue(false);
// 触发列表和侧栏刷新
refreshNotes();
sidebarRefreshNeeded.postValue(true);
Log.d(TAG, "Successfully renamed note: " + noteId);
}
@Override
public void onError(Exception error) {
isLoading.postValue(false);
String message = "重命名笔记失败: " + error.getMessage();
errorMessage.postValue(message);
Log.e(TAG, message, error);
}
});
}
/**
*
* <p>
*
* </p>
*
* @param folderId ID
*/
public void deleteFolder(long folderId) {
isLoading.postValue(true);
errorMessage.postValue(null);
repository.deleteFolder(folderId, new NotesRepository.Callback<Integer>() {
@Override
public void onSuccess(Integer rowsAffected) {
isLoading.postValue(false);
// 触发侧栏刷新
sidebarRefreshNeeded.postValue(true);
Log.d(TAG, "Successfully deleted folder: " + folderId);
}
@Override
public void onError(Exception error) {
isLoading.postValue(false);
String message = "删除文件夹失败: " + error.getMessage();
errorMessage.postValue(message);
Log.e(TAG, message, error);
}
});
}
/**
*
* <p>
*
* </p>
*
* @param folderId ID
* @param callback
*/
public void getFolderInfo(long folderId, NotesRepository.Callback<NotesRepository.NoteInfo> callback) {
try {
NotesRepository.NoteInfo folderInfo = repository.getFolderInfo(folderId);
callback.onSuccess(folderInfo);
} catch (Exception e) {
callback.onError(e);
}
}
/**
* ViewModel
* <p>

@ -36,12 +36,14 @@ public abstract class NoteWidgetProvider extends AppWidgetProvider {
public static final String [] PROJECTION = new String [] {
NoteColumns.ID,
NoteColumns.BG_COLOR_ID,
NoteColumns.SNIPPET
NoteColumns.SNIPPET,
NoteColumns.TITLE
};
public static final int COLUMN_ID = 0;
public static final int COLUMN_BG_COLOR_ID = 1;
public static final int COLUMN_SNIPPET = 2;
public static final int COLUMN_TITLE = 3;
private static final String TAG = "NoteWidgetProvider";
@ -87,7 +89,11 @@ public abstract class NoteWidgetProvider extends AppWidgetProvider {
c.close();
return;
}
snippet = c.getString(COLUMN_SNIPPET);
// 优先使用TITLEfallback到SNIPPET
snippet = c.getColumnCount() > COLUMN_TITLE ? c.getString(COLUMN_TITLE) : "";
if (snippet == null || snippet.trim().isEmpty()) {
snippet = c.getString(COLUMN_SNIPPET);
}
bgId = c.getInt(COLUMN_BG_COLOR_ID);
intent.putExtra(Intent.EXTRA_UID, c.getLong(COLUMN_ID));
intent.setAction(Intent.ACTION_VIEW);

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="300"
android:interpolator="@android:anim/accelerate_interpolator">
<translate
android:fromXDelta="0%"
android:toXDelta="100%" />
</set>

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="300"
android:interpolator="@android:anim/decelerate_interpolator">
<translate
android:fromXDelta="100%"
android:toXDelta="0%" />
</set>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -17,24 +17,29 @@
<!-- 便签项背景:可点击效果 -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_activated="true">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="#BBDEFB"/>
<solid android:color="#E0E0E0"/>
<corners android:radius="16dp"/>
</shape>
</item>
<item android:state_pressed="true">
<item android:state_activated="true">
<shape android:shape="rectangle">
<solid android:color="#E0E0E0"/>
<solid android:color="#D0D0D0"/>
<stroke android:width="2dp" android:color="#2196F3"/>
<corners android:radius="16dp"/>
</shape>
</item>
<item android:state_selected="true">
<shape android:shape="rectangle">
<solid android:color="#BBDEFB"/>
<solid android:color="#D0D0D0"/>
<corners android:radius="16dp"/>
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#FFFFFF"/>
<corners android:radius="16dp"/>
</shape>
</item>
</selector>

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

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="16dp" />
<solid android:color="#F0F0F0" />
</shape>

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

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

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

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tv_delete_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textColor="?android:textColorPrimary"
android:lineSpacingExtra="4dp" />
</LinearLayout>

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<EditText
android:id="@+id/et_folder_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/folder_name_hint"
android:maxLength="50"
android:inputType="text"
android:padding="12dp" />
</LinearLayout>

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

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

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

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

@ -0,0 +1,148 @@
<?xml version="1.0" encoding="utf-8"?>
<net.micode.notes.ui.SwipeMenuLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<!-- 内容视图(使用 CardView 实现圆角卡片样式) -->
<androidx.cardview.widget.CardView
android:id="@+id/content_card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginTop="6dp"
android:layout_marginBottom="6dp"
app:cardCornerRadius="16dp"
app:cardElevation="2dp"
app:cardBackgroundColor="@color/bg_white">
<LinearLayout
android:id="@+id/content_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- 标题行 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<!-- 类型图标(文件夹/便签) -->
<ImageView
android:id="@+id/iv_type_icon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="8dp"
android:visibility="gone" />
<TextView
android:id="@+id/tv_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="16sp"
android:textColor="@android:color/black"
android:textStyle="bold"
android:maxLines="1"
android:ellipsize="end"
android:singleLine="true" />
<ImageView
android:id="@+id/iv_lock_icon"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="4dp"
android:src="@android:drawable/ic_lock_lock"
app:tint="@android:color/darker_gray"
android:visibility="gone" />
<TextView
android:id="@+id/tv_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textSize="12sp"
android:textColor="@android:color/darker_gray" />
</LinearLayout>
<!-- 文件夹名称 -->
<TextView
android:id="@+id/tv_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textSize="14sp"
android:textColor="@android:color/darker_gray"
android:maxLines="1"
android:ellipsize="end"
android:visibility="gone" />
<!-- 底部控制行:复选框和提醒图标 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<CheckBox
android:id="@android:id/checkbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:focusable="false"
android:clickable="false"
android:visibility="gone" />
<ImageView
android:id="@+id/iv_alert_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:visibility="gone" />
<!-- 填充空间 -->
<View
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<ImageView
android:id="@+id/iv_pinned_icon"
android:layout_width="16dp"
android:layout_height="16dp"
android:src="@android:drawable/ic_menu_upload"
app:tint="@android:color/darker_gray"
android:visibility="gone" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- 操作按钮视图(包含普通和回收站两个菜单) -->
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginTop="6dp"
android:layout_marginBottom="6dp">
<!-- 普通模式菜单 -->
<include
android:id="@+id/swipe_menu_normal"
android:layout_width="wrap_content"
android:layout_height="match_parent"
layout="@layout/swipe_menu_note"
android:visibility="gone" />
<!-- 回收站模式菜单 -->
<include
android:id="@+id/swipe_menu_trash"
android:layout_width="wrap_content"
android:layout_height="match_parent"
layout="@layout/swipe_menu_trash"
android:visibility="gone" />
</FrameLayout>
</net.micode.notes.ui.SwipeMenuLayout>

@ -10,7 +10,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="4dp"
android:padding="8dp"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.StaggeredGridLayoutManager"
app:spanCount="2" />

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

@ -145,7 +145,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:drawableStart="@drawable/ic_menu_notes"
android:drawableStart="@android:drawable/ic_menu_edit"
android:drawablePadding="12dp"
android:text="@string/menu_templates"
android:textSize="16sp"

@ -0,0 +1,111 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="horizontal"
android:paddingVertical="6dp"
android:paddingEnd="8dp"
android:gravity="center_vertical">
<LinearLayout
android:id="@+id/btn_edit"
android:tag="edit"
android:layout_width="56dp"
android:layout_height="match_parent"
android:layout_marginHorizontal="4dp"
android:background="@drawable/swipe_button_bg"
android:backgroundTint="#E3F2FD"
android:gravity="center"
android:orientation="vertical"
android:clickable="true"
android:focusable="true">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_edit"
app:tint="#1976D2" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="编辑"
android:textSize="10sp"
android:textColor="#1976D2" />
</LinearLayout>
<LinearLayout
android:id="@+id/btn_pin"
android:tag="pin"
android:layout_width="56dp"
android:layout_height="match_parent"
android:layout_marginHorizontal="4dp"
android:background="@drawable/swipe_button_bg"
android:backgroundTint="#FFF3E0"
android:gravity="center"
android:orientation="vertical"
android:clickable="true"
android:focusable="true">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@android:drawable/ic_menu_upload"
app:tint="#EF6C00" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="置顶"
android:textSize="10sp"
android:textColor="#EF6C00" />
</LinearLayout>
<LinearLayout
android:id="@+id/btn_move"
android:tag="move"
android:layout_width="56dp"
android:layout_height="match_parent"
android:layout_marginHorizontal="4dp"
android:background="@drawable/swipe_button_bg"
android:backgroundTint="#E8F5E9"
android:gravity="center"
android:orientation="vertical"
android:clickable="true"
android:focusable="true">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_folder"
app:tint="#2E7D32" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="移动"
android:textSize="10sp"
android:textColor="#2E7D32" />
</LinearLayout>
<LinearLayout
android:id="@+id/btn_delete"
android:tag="delete"
android:layout_width="56dp"
android:layout_height="match_parent"
android:layout_marginHorizontal="4dp"
android:background="@drawable/swipe_button_bg"
android:backgroundTint="#FFEBEE"
android:gravity="center"
android:orientation="vertical"
android:clickable="true"
android:focusable="true">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_delete"
app:tint="#C62828" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="删除"
android:textSize="10sp"
android:textColor="#C62828" />
</LinearLayout>
</LinearLayout>

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="horizontal"
android:paddingVertical="6dp"
android:paddingEnd="8dp"
android:gravity="center_vertical">
<LinearLayout
android:id="@+id/btn_restore"
android:tag="restore"
android:layout_width="80dp"
android:layout_height="match_parent"
android:layout_marginHorizontal="4dp"
android:background="@drawable/swipe_button_bg"
android:backgroundTint="#E3F2FD"
android:gravity="center"
android:orientation="vertical"
android:clickable="true"
android:focusable="true">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_sync"
app:tint="#1976D2" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="恢复"
android:textSize="10sp"
android:textColor="#1976D2" />
</LinearLayout>
<LinearLayout
android:id="@+id/btn_permanent_delete"
android:tag="permanent_delete"
android:layout_width="80dp"
android:layout_height="match_parent"
android:layout_marginHorizontal="4dp"
android:background="@drawable/swipe_button_bg"
android:backgroundTint="#FFEBEE"
android:gravity="center"
android:orientation="vertical"
android:clickable="true"
android:focusable="true">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_delete"
app:tint="#C62828" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="永久删除"
android:textSize="10sp"
android:textColor="#C62828" />
</LinearLayout>
</LinearLayout>

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

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_rename"
android:title="@string/menu_rename" />
<item
android:id="@+id/action_move"
android:title="@string/menu_move" />
<item
android:id="@+id/action_delete"
android:title="@string/menu_delete" />
</menu>

@ -20,7 +20,7 @@
<item
android:id="@+id/menu_tasks"
android:title="TASKS"
android:title="@string/menu_tasks"
android:icon="@drawable/ic_menu_tasks"
app:showAsAction="always" />

@ -140,4 +140,33 @@
<string name="redo_success">重做成功</string>
<string name="undo_fail">无可撤回</string>
<string name="redo_fail">无可重做</string>
<!-- New translations for missing strings -->
<string name="menu_rename">重命名</string>
<string name="dialog_rename_folder_title">重命名文件夹</string>
<string name="dialog_delete_folder_title">删除文件夹</string>
<string name="dialog_delete_folder_with_notes">删除 "%1$s" 及其 %2$d 条笔记?</string>
<string name="dialog_delete_folder_empty">删除 "%1$s"?</string>
<string name="folder_name_hint">文件夹名称</string>
<string name="error_intent_invalid">无效的意图</string>
<string name="error_intent_unsupported">不支持的意图操作</string>
<string name="empty_notes_hint">暂无便签,点击右下角按钮创建</string>
<string name="empty_notes_icon">空便签图标</string>
<string name="menu_edit_note">编辑便签</string>
<string name="menu_login">登录</string>
<string name="menu_export">导出</string>
<string name="menu_settings">设置</string>
<string name="sidebar_close">关闭侧边栏</string>
<string name="sidebar_create_folder">创建文件夹</string>
<string name="error_folder_name_exists">文件夹已存在</string>
<string name="menu_pin">置顶</string>
<string name="menu_lock">锁定</string>
<string name="lock_confirmation">确定需要为其上锁?</string>
<string name="lock_confirm_button">确定</string>
<string name="lock_cancel_button">再想想</string>
<string name="delete_confirmation">确定要删除选中的便签吗?</string>
<string name="menu_unpin">取消置顶</string>
<string name="menu_unlock">解锁</string>
<string name="menu_restore">恢复</string>
<string name="menu_permanent_delete">永久删除</string>
</resources>

@ -121,7 +121,46 @@
<string name="datetime_dialog_ok">設置</string>
<string name="datetime_dialog_cancel">取消</string>
<plurals name="search_results_title">
<item quantity="other"><xliff:g id="NUMBER">%1$s</xliff:g> 條符合<xliff:g id="SEARCH">%2$s</xliff:g>的搜尋結果</item>
<item quantity="other"><xliff:g id="NUMBER">%1$s</xliff:g> 條符合"<xliff:g id="SEARCH">%2$s</xliff:g>"的搜尋結果</item>
</plurals>
<!-- Sidebar -->
<string name="root_folder_name">我的便籤</string>
<string name="folder_note_count">%d 個便籤</string>
<string name="dialog_create_folder_title">創建文件夾</string>
<string name="dialog_create_folder_hint">文件夾名稱</string>
<string name="error_folder_name_empty">文件夾名稱不能為空</string>
<string name="error_folder_name_too_long">文件夾名稱過長最多50個字符</string>
<string name="menu_trash">回收站</string>
<string name="create_folder_success">創建文件夾成功</string>
<!-- New translations for missing strings -->
<string name="menu_rename">重命名</string>
<string name="dialog_rename_folder_title">重命名文件夾</string>
<string name="dialog_delete_folder_title">刪除文件夾</string>
<string name="dialog_delete_folder_with_notes">刪除 "%1$s" 及其 %2$d 條筆記?</string>
<string name="dialog_delete_folder_empty">刪除 "%1$s"?</string>
<string name="folder_name_hint">文件夾名稱</string>
<string name="error_intent_invalid">無效的意圖</string>
<string name="error_intent_unsupported">不支持的意圖操作</string>
<string name="empty_notes_hint">暫無便籤,點擊右下角按鈕創建</string>
<string name="empty_notes_icon">空便籤圖標</string>
<string name="menu_edit_note">編輯便籤</string>
<string name="menu_login">登錄</string>
<string name="menu_export">導出</string>
<string name="menu_settings">設置</string>
<string name="sidebar_close">關閉側邊欄</string>
<string name="sidebar_create_folder">創建文件夾</string>
<string name="error_folder_name_exists">文件夾已存在</string>
<string name="menu_pin">置頂</string>
<string name="menu_lock">鎖定</string>
<string name="lock_confirmation">確定需要為其上鎖?</string>
<string name="lock_confirm_button">確定</string>
<string name="lock_cancel_button">再想想</string>
<string name="delete_confirmation">確定要刪除選中的便籤嗎?</string>
<string name="menu_unpin">取消置頂</string>
<string name="menu_unlock">解鎖</string>
<string name="menu_restore">恢復</string>
<string name="menu_permanent_delete">永久刪除</string>
</resources>

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

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 滑动按钮颜色 -->
<color name="swipe_edit_bg">#2196F3</color> <!-- 蓝色 -->
<color name="swipe_pin_bg">#FF9800</color> <!-- 橙色 -->
<color name="swipe_move_bg">#4CAF50</color> <!-- 绿色 -->
<color name="swipe_delete_bg">#F44336</color> <!-- 红色 -->
<color name="swipe_restore_bg">#2196F3</color> <!-- 蓝色 -->
<color name="swipe_permanent_bg">#F44336</color> <!-- 红色 -->
</resources>

@ -36,19 +36,20 @@
<string name="note_link_web">Browse web</string>
<string name="note_link_other">Open map</string>
<!-- Text export file information -->
<string name="file_path">/MIUI/notes/</string>
<string name="file_name_txt_format">notes_%s.txt</string>
<!-- notes list string -->
<string name="format_folder_files_count">(%d)</string>
<string name="menu_create_folder">New Folder</string>
<string name="menu_export_text">Export text</string>
<string name="menu_sync">Sync</string>
<string name="menu_sync_cancel">Cancel syncing</string>
<string name="menu_setting">Settings</string>
<string name="menu_search">Search</string>
<string name="menu_delete">Delete</string>
<string name="menu_move">Move to folder</string>
<string name="menu_select_title">%d selected</string>
<string name="file_path" translatable="false">/MIUI/notes/</string>
<string name="file_name_txt_format" translatable="false">notes_%s.txt</string>
<!-- notes list string -->
<string name="format_folder_files_count" translatable="false">(%d)</string>
<string name="menu_create_folder">New Folder</string>
<string name="menu_export_text">Export text</string>
<string name="menu_sync">Sync</string>
<string name="menu_sync_cancel">Cancel syncing</string>
<string name="menu_setting">Settings</string>
<string name="menu_search">Search</string>
<string name="menu_delete">Delete</string>
<string name="menu_move">Move to folder</string>
<string name="menu_rename">Rename</string>
<string name="menu_select_title">%d selected</string>
<string name="menu_select_none">Nothing selected, the operation is invalid</string>
<string name="menu_select_all">Select all</string>
<string name="menu_deselect_all">Deselect all</string>
@ -59,10 +60,18 @@
<string name="menu_font_super">Super</string>
<string name="menu_list_mode">Enter check list</string>
<string name="menu_normal_mode">Leave check list</string>
<string name="menu_folder_view">View folder</string>
<string name="menu_folder_delete">Delete folder</string>
<string name="menu_folder_change_name">Change folder name</string>
<string name="folder_exist">The folder %1$s exist, please rename</string>
<string name="menu_folder_view">View folder</string>
<string name="menu_folder_delete">Delete folder</string>
<string name="menu_folder_change_name">Change folder name</string>
<string name="folder_exist">The folder %1$s exist, please rename</string>
<string name="menu_tasks">Tasks</string>
<!-- Folder operation dialogs -->
<string name="dialog_rename_folder_title">Rename folder</string>
<string name="dialog_delete_folder_title">Delete folder</string>
<string name="dialog_delete_folder_with_notes">Delete \"%1$s\" and its %2$d notes?</string>
<string name="dialog_delete_folder_empty">Delete \"%1$s\"?</string>
<string name="folder_name_hint">Folder name</string>
<string name="menu_share">Share</string>
<string name="menu_send_to_desktop">Send to home</string>
<string name="menu_alert">Remind me</string>
@ -75,15 +84,15 @@
<string name="alert_message_delete_notes">Confirm to delete the selected %d notes?</string>
<string name="alert_message_delete_note">Confirm to delete this note?</string>
<string name="format_move_notes_to_folder">Have moved selected %1$d notes to %2$s folder</string>
<!-- Error information -->
<string name="error_sdcard_unmounted">SD card busy, not available now</string>
<string name="error_sdcard_export">Export failed, please check SD card</string>
<string name="error_note_not_exist">The note is not exist</string>
<string name="error_note_empty_for_clock">Sorry, can not set clock on empty note</string>
<string name="error_note_empty_for_send_to_desktop">Sorry, can not send and empty note to home</string>
<string name="error_intent_invalid">Invalid intent</string>
<string name="error_intent_unsupported">Unsupported intent action</string>
<string name="success_sdcard_export">Export successful</string>
<!-- Error information -->
<string name="error_sdcard_unmounted">SD card busy, not available now</string>
<string name="error_sdcard_export">Export failed, please check SD card</string>
<string name="error_note_not_exist">The note is not exist</string>
<string name="error_note_empty_for_clock">Sorry, can not set clock on empty note</string>
<string name="error_note_empty_for_send_to_desktop">Sorry, can not send and empty note to home</string>
<string name="error_intent_invalid">Invalid intent</string>
<string name="error_intent_unsupported">Unsupported intent action</string>
<string name="success_sdcard_export">Export successful</string>
<string name="failed_sdcard_export">Export fail</string>
<string name="format_exported_file_location">Export text file (%1$s) to SD (%2$s) directory</string>
<!-- Sync -->
@ -103,7 +112,7 @@
<string name="preferences_account_title">Sync account</string>
<string name="preferences_account_summary">Sync notes with google task</string>
<string name="preferences_last_sync_time">Last sync time %1$s</string>
<string name="preferences_last_sync_time_format">yyyy-MM-dd hh:mm:ss</string>
<string name="preferences_last_sync_time_format" translatable="false">yyyy-MM-dd hh:mm:ss</string>
<string name="preferences_add_account">Add account</string>
<string name="preferences_menu_change_account">Change sync account</string>
<string name="preferences_menu_remove_account">Remove sync account</string>
@ -188,4 +197,61 @@
<string name="capsule_permission_accessibility_message">全局速记胶囊需要无障碍服务权限来监听剪贴板和获取来源应用。</string>
<string name="preferences_capsule_title">全局速记胶囊</string>
<string name="preferences_capsule_summary">开启侧边悬浮胶囊,支持跨应用拖拽和剪贴板速记</string>
<string name="menu_restore">Restore</string>
<string name="menu_permanent_delete">Delete Forever</string>
<!-- Drawer Navigation -->
<string name="drawer_login_prompt">点击登录/注册</string>
<string name="drawer_default_username">用户</string>
<string name="drawer_default_device_id">Device ID: Unknown</string>
<string name="drawer_sync_status_synced">已同步</string>
<string name="drawer_sync_status_syncing">同步中...</string>
<string name="drawer_sync_status_failed">同步失败</string>
<string name="drawer_group_notes">笔记</string>
<string name="drawer_group_sync">云同步</string>
<string name="drawer_group_other">其他</string>
<string name="drawer_all_notes">全部笔记</string>
<string name="drawer_favorites">收藏</string>
<string name="drawer_reminders">提醒</string>
<string name="drawer_sync_now">立即同步</string>
<string name="drawer_sync_settings">同步设置</string>
<string name="drawer_help">帮助与反馈</string>
<string name="drawer_login">登录</string>
<string name="drawer_logout">退出登录</string>
<!-- Cloud Sync -->
<string name="sync_title">Cloud Sync</string>
<string name="sync_login_status">Login Status</string>
<string name="sync_device_id_default">Device ID: Not initialized</string>
<string name="sync_auto_sync">Auto Sync</string>
<string name="sync_last_time">Last Sync Time:</string>
<string name="sync_never">Never</string>
<string name="sync_status_label">Sync Status:</string>
<string name="sync_status_idle">Idle</string>
<string name="sync_status_syncing">Syncing...</string>
<string name="sync_status_success">Sync Successful</string>
<string name="sync_status_failed">Sync Failed</string>
<string name="sync_button_now">Sync Now</string>
<string name="sync_toast_started">Sync started</string>
<string name="sync_toast_success">Sync completed successfully</string>
<string name="sync_toast_failed">Sync failed: %1$s</string>
<!-- Logout -->
<string name="dialog_logout_title">退出登录</string>
<string name="dialog_logout_message">确定要退出登录吗?退出后本地笔记将保留,但无法同步到云端。</string>
<string name="dialog_logout_confirm">退出</string>
<string name="toast_logout_success">已退出登录</string>
<!-- Folder -->
<string name="folder_title">文件夹</string>
<string name="error_create_folder">创建文件夹失败</string>
<!-- Conflict Resolution -->
<string name="conflict_title">Note Conflict</string>
<string name="conflict_local_version">Local Version</string>
<string name="conflict_cloud_version">Cloud Version</string>
<string name="conflict_use_local">Use Local</string>
<string name="conflict_use_cloud">Use Cloud</string>
<string name="conflict_merge">Merge</string>
<string name="conflict_merge_hint">Merge feature coming soon</string>
</resources>

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

@ -0,0 +1,70 @@
package net.micode.notes.model;
import org.json.JSONException;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import static org.junit.Assert.*;
/**
* CloudNote
*/
@RunWith(RobolectricTestRunner.class)
public class CloudNoteTest {
@Test
public void testCloudNoteFromJson() throws JSONException {
// 测试从JSON解析cloudNoteId
JSONObject json = new JSONObject();
json.put("cloudNoteId", "test-uuid-123");
json.put("noteId", "100");
json.put("title", "测试标题");
json.put("content", "测试内容");
json.put("parentId", "0");
json.put("type", 0);
json.put("modifiedTime", 1234567890L);
CloudNote cloudNote = new CloudNote(json);
assertEquals("test-uuid-123", cloudNote.getCloudNoteId());
assertEquals("100", cloudNote.getNoteId());
assertEquals("测试标题", cloudNote.getTitle());
}
@Test
public void testCloudNoteToJson() throws JSONException {
// 创建模拟的WorkingNote
WorkingNote note = WorkingNote.createEmptyNote(
RuntimeEnvironment.getApplication(),
0,
0,
-1,
0
);
note.setTitle("测试标题");
note.setContent("测试内容");
note.setCloudNoteId("test-uuid-456");
CloudNote cloudNote = new CloudNote(note, "device-123");
JSONObject json = cloudNote.toJson();
assertEquals("test-uuid-456", json.getString("cloudNoteId"));
assertEquals("测试标题", json.getString("title"));
}
@Test
public void testCloudNoteWithoutCloudNoteId() throws JSONException {
// 测试没有cloudNoteId的情况首次上传
JSONObject json = new JSONObject();
json.put("noteId", "200");
json.put("title", "新笔记");
CloudNote cloudNote = new CloudNote(json);
assertEquals("", cloudNote.getCloudNoteId());
assertEquals("200", cloudNote.getNoteId());
}
}

@ -16,6 +16,9 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://maven.aliyun.com/repository/public") }
maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") }
maven { url = uri("https://maven.aliyun.com/repository/releases") }
}
}

Loading…
Cancel
Save