Compare commits

...

25 Commits

Author SHA1 Message Date
蒋天翔 ae90690fd9 团队自评报告、汇报PPT等
2 months ago
蒋天翔 c0aa8be65c 最终完善版的报告文档等
2 months ago
mbls3xqnp 1edd837ed9 Merge pull request '进行导出、语言设置等功能完善' (#34) from baoerjun_branch into master
2 months ago
包尔俊 f01729f8f8 feat: 添加笔记导出功能支持文本、图片和PDF格式
2 months ago
包尔俊 86d39b7a95 feat: 新增多语言支持和基础Activity重构
2 months ago
mbls3xqnp 50a82e52bb Merge pull request '胶囊功能完善' (#33) from baoerjun_branch into master
2 months ago
mbls3xqnp 394354710b Merge pull request '便签编辑UI优化、背景自定义和时间、地点智能识别' (#32) from baoerjun_branch into master
2 months ago
包尔俊 16e249516a 胶囊功能完善
2 months ago
包尔俊 f8c785f348 便签编辑页面UI优化和时间、地点智能识别
2 months ago
包尔俊 574573cad8 笔记编辑页面优化
2 months ago
包尔俊 48344838f1 胶囊功能调整
2 months ago
包尔俊 a2dc685b07 Merge branch 'baoerjun_branch'
2 months ago
包尔俊 87b5daed82 ui界面现代化补全
2 months ago
包尔俊 1b7d196fe6 修改了title和snippet的逻辑
2 months ago
包尔俊 70197d0faa 文档更新
2 months ago
包尔俊 802f828318 云同步功能实现
2 months ago
p7tupf26b 2e7dc5d079 Merge pull request 'ui界面现代化优化以及功能迁移' (#31) from jiangtianxiang_branch into master
2 months ago
包尔俊 aff9ac63d1 Merge https://bdgit.educoder.net/p7tupf26b/git into merge-branch
2 months ago
包尔俊 4c3e4cae68 merge
2 months ago
p7tupf26b 795bee106a Merge pull request '将之前没有提交的代码文件提交' (#30) from jiangtianxiang_branch into master
2 months ago
包尔俊 573add61f1 Merge branch 'baoerjun_branch' into merge-branch
2 months ago
包尔俊 2a59a2aa01 实现左滑操作
2 months ago
p7tupf26b b2a4fbb5ed Merge pull request '新增笔记模板和图片插入功能' (#29) from jiangtianxiang_branch into master
2 months ago
p7tupf26b c871709b17 Merge pull request '待办功能' (#28) from jiangtianxiang_branch into master
2 months ago
p7tupf26b 0eb9a179f6 Merge pull request '新增功能' (#27) from jiangtianxiang_branch into master
2 months ago

BIN
.gitignore vendored

Binary file not shown.

@ -1,757 +0,0 @@
# Xiaomi Notes 功能扩展规划(精简版)
## 概述
本文档规划了小米笔记应用的潜在功能扩展,按优先级和时间线组织。核心功能优先,高级功能作为后续迭代。
## 项目当前状态2026-01-21
### 已实现的核心功能 ✅
**基础功能**
- ✅ 笔记创建和编辑
- ✅ 笔记列表显示
- ✅ 文件夹管理(树形结构、展开收起、面包屑导航)
- ✅ 笔记提醒(闹钟功能)
- ✅ 笔记背景颜色5种颜色
- ✅ 笔记字体样式4种大小
- ✅ 本地数据存储SQLite + ContentProvider
**高级功能**
- ✅ MVVM架构重构ViewModel + Repository Pattern
- ✅ Google Tasks云同步
- ✅ 密码保护(图案锁 + 密码验证)
- ✅ 笔记锁定功能
- ✅ 笔记置顶功能
- ✅ 数据备份和恢复
- ✅ 桌面小部件2x2, 4x4
- ✅ 搜索功能ContentProvider支持
- ✅ 回收站功能
- ✅ 多语言支持(简体中文、繁体中文、英文)
- ✅ 材料设计UIMaterial Design
**技术架构**
- ✅ MVVM架构模式
- ✅ Repository数据访问层
- ✅ LiveData响应式数据更新
- ✅ ContentProvider标准API
- ✅ SQLiteOpenHelper数据库管理
- ✅ ExecutorService异步操作
- ✅ 48个Java源文件
- ✅ 135个资源文件
- ✅ 数据库版本5含10个触发器
### 项目统计
| 类别 | 数量 | 说明 |
|------|-------|------|
| 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 |
| 测试文件 | 4个 | 1个单元测试、2个数据层测试、1个集成测试 |
| 数据库表 | 2个 | note表(21字段)、data表(11字段) |
| 系统文件夹 | 4个 | 根(0)、临时(-1)、通话记录(-2)、回收站(-3) |
## 功能分类
### 核心功能 (Phase 1 - 已完成)
- ✅ 笔记创建和编辑
- ✅ 笔记列表显示
- ✅ 文件夹管理(树形结构、面包屑导航)
- ✅ 笔记提醒(闹钟)
- ✅ 笔记背景颜色(黄/红/蓝/绿/白)
- ✅ 笔记字体样式(小/中/大/超大)
- ✅ 本地数据存储SQLite + ContentProvider
- ✅ 笔记锁定功能
- ✅ 笔记置顶功能
- ✅ 回收站功能
- ✅ Google Tasks同步
## 短期扩展 (Phase 2 - 1-2个月)
### P0 - 必须实现
#### 2.1 搜索功能增强 ⚠️ 部分实现
**描述**: 提供强大的搜索功能,支持全文搜索、筛选和排序
**当前状态**:
- ✅ 基础搜索功能ContentProvider支持search URI
- ✅ 搜索建议功能
- ✅ 搜索历史记录
- ✅ 高级筛选选项
- ✅ 搜索结果高亮
**待实现功能点**:
- ✅ 搜索历史记录(本地存储常用搜索词)
- ✅ 搜索结果高亮显示
- ✅ 搜索频率排序
**技术方案**:
- 使用 SharedPreferences 存储搜索历史
- 扩展 NotesRepository 搜索逻辑
- 实现搜索 UI 筛选面板
**优先级**: 高
**工作量**: 2-3天
#### 2.2 导入导出功能增强 ✅ 已实现基础版本
**描述**: 支持笔记的导入导出,便于数据迁移和分享
**用户需求**: 希望可以实现与没有应用的人分享也能有应用类似的便签显示效果
**当前状态**:
- ✅ 数据备份功能BackupUtils.java
- ✅ 数据恢复功能
- ❌ 导出为便签图片格式(便于分享给非用户)
- ❌ 导出为 Markdown/TXT/JSON
**待实现功能点**:
- [ ] 导出为便签图片格式(类似应用内笔记卡片样式)
- [ ] 支持自定义背景颜色
- [ ] 支持字体大小选择
- [ ] 支持水印(可选)
- [ ] 高分辨率导出(分享清晰)
- [ ] 导出为 Markdown 格式
- [ ] 导出为 TXT 格式
- [ ] 导出为 JSON 格式
- [ ] 批量导出选择界面
- [ ] 导入 JSON 格式
- [ ] 导入验证和冲突处理
**技术方案**:
- 使用现有的 BackupUtils 基础
- 使用 Canvas 绘制便签卡片并保存为图片
- 集成 Markdown 处理库
- 实现文件选择器Storage Access Framework
- 添加分享功能Intent.ACTION_SEND
**优先级**: 高
**工作量**: 3-4天
#### 2.3 撤回功能
**描述**: 支持简单的笔记撤回操作
**功能点**:
- ✅ 撤回上一次编辑
- ✅ 撤回历史栈可连续撤回10-20次
- ✅ 重做功能
- ✅ 撤回/重做状态提示
- ✅ 清空撤回历史
**技术方案**:
- 实现 UndoStack 数据结构
- 在 NoteEditText 中记录编辑历史
- 使用 Command Pattern 实现撤回逻辑
- 添加撤回/重做 UI 按钮
**优先级**: 中
**工作量**: 2-3天
### P1 - 应该实现
#### 2.4 标签系统
**描述**: 为笔记添加标签,便于分类和筛选
**用户疑问**: 是否与文件夹功能重叠?
**功能定位**:
- **文件夹**: 层级分类(父文件夹 → 子文件夹),一个笔记只能属于一个文件夹
- **标签**: 扁平分类,一个笔记可以有多个标签,支持交叉分类
**场景对比**:
| 功能 | 文件夹 | 标签 |
|------|-------|------|
| 结构 | 树形层级 | 扁平列表 |
| 归属关系 | 一个笔记 → 一个文件夹 | 一个笔记 ↔ 多个标签 |
| 查询 | 按文件夹筛选 | 按标签筛选、多标签组合 |
| 适用场景 | 主要分类、项目分组 | 辅助分类、主题标记、状态标记 |
| 示例 | "工作/项目A/会议记录" | "重要"、"待办"、"学习"、"会议" |
**功能点**:
- [ ] 创建标签
- [ ] 编辑标签(名称、颜色)
- [ ] 删除标签
- [ ] 为笔记添加/移除标签(多选支持)
- [ ] 按标签筛选笔记
- [ ] 按多标签组合筛选AND/OR 逻辑)
- [ ] 标签颜色自定义
- [ ] 标签统计(每个标签的笔记数)
- [ ] 标签云视图
**技术方案**:
- 设计 Tag 数据表id, name, color, created_date
- 设计 NoteTag 关联表note_id, tag_id
- 实现标签选择器 UI类似文件夹树
- 扩展 NotesRepository 标签查询逻辑
- 添加标签管理 ViewModel
**优先级**: 高
**工作量**: 3-4天
#### 2.5 笔记锁定 ✅ 已实现
**描述**: 为敏感笔记添加密码保护
**用户需求**: 只需要密码保护就行了
**当前状态**:
- ✅ 图案锁保护LockPatternView.java
- ✅ 密码保护PasswordActivity.java
- ✅ SecurityManager.java 加密工具
**优先级**: 已完成
**工作量**: 0天
#### 2.6 笔记模板
**描述**: 提供常用笔记模板,快速创建标准化笔记
**功能点**:
- [ ] 预置模板(会议记录、日记、待办事项、读书笔记等)
- [ ] 创建自定义模板
- [ ] 编辑模板
- [ ] 删除模板
- [ ] 应用模板到新笔记
- [ ] 模板分类(工作、生活、学习)
- [ ] 模板预览
**技术方案**:
- 设计 Template 数据表id, name, content, category, created_date
- 实现模板管理 UI
- 创建模板应用逻辑(复制模板内容到新笔记)
- 添加模板管理 Activity/Fragment
**优先级**: 中
**工作量**: 2-3天
### P2 - 可以实现
#### 2.7 笔记统计和分析
**描述**: 提供笔记使用情况的统计信息
**功能点**:
- [ ] 笔记数量统计(总数、本周新增、本月新增)
- [ ] 笔记创建频率图表
- [ ] 常用词分析
- [ ] 文件夹分布(饼图)
- [ ] 标签使用统计
- [ ] 笔记长度统计
- [ ] 活跃时间段分析
- [ ] 周/月/年报告
**技术方案**:
- 扩展 NotesRepository 统计查询
- 集成图表库MPAndroidChart 或 AnyChart
- 实现统计数据计算(基于现有表)
- 创建统计图表 UI Activity
**优先级**: 低
**工作量**: 3-4天
#### 2.8 快捷操作优化 ⚠️ 部分实现
**描述**: 添加快捷操作,提高效率
**当前状态**:
- ✅ 长按笔记快捷菜单(复制、分享、删除、移动、设置提醒)
- ✅ 桌面小组件2x2, 4x4
- ❌ 通知栏快捷操作
- ❌ 文本格式快捷工具栏
**待实现功能点**:
- [ ] 通知栏快捷操作(创建笔记、语音输入)
- [ ] 快捷方式Launcher Shortcuts API
- [ ] 快捷手势(左滑删除、右滑置顶)
**优先级**: 低
**工作量**: 2天
## 中期扩展 (Phase 3 - 3-4个月)
### P1 - 应该实现
#### 3.1 富文本编辑
**描述**: 增强文本编辑功能,支持多种格式
**功能点**:
- ✅ 粗体、斜体、下划线
- ✅ 删除线
- ✅ 标题层级 (H1-H6)
- ✅ 列表(无序、有序、检查列表)
- ✅ 引用块
- ✅ 代码块
- ✅ 链接
- ✅ 分割线
- ✅ 文本颜色
- ✅ 文本背景色
**技术方案**:
- 集成富文本编辑库(如 RichEditor、SpannableStringBuilder
- 或使用 Markdown 渲染器(如 Markwon
- 实现格式工具栏
- 扩展 NoteEditText.java 支持富文本
**优先级**: 高
**工作量**: 4-5天
#### 3.2 图片附件
**描述**: 支持在笔记中插入图片
**功能点**:
- [ ] 从相册选择图片
- [ ] 拍照插入
- [ ] 图片裁剪
- [ ] 图片压缩
- [ ] 图片预览(全屏查看)
- [ ] 图片删除
- [ ] 图片大小调整
- [ ] 图片旋转
- [ ] 图片备注
**技术方案**:
- 使用 ContentResolver 访问图片
- 集成图片裁剪库UCrop 或 Android Image Cropper
- 集成图片加载库Glide 或 Coil
- 扩展 data 表支持图片存储mime_type = "image/*"
- 创建图片查看器 Activity
**优先级**: 高
**工作量**: 4-5天
### P2 - 可以实现
#### 3.3 智能识别功能
**描述**: 智能识别笔记中的地址和时间,支持快速跳转
**用户需求**: 希望可以添加智能识别地址、时间功能,跳转导航和闹钟设置
**功能点**:
- [ ] 智能识别地址信息
- [ ] 自动检测地址模式(省市区、街道地址)
- [ ] 高亮显示识别的地址
- [ ] 点击地址跳转地图应用高德地图、百度地图、Google Maps
- [ ] 支持地址标注(在地址旁显示位置图标)
- [ ] 智能识别时间信息
- [ ] 自动检测日期时间模式今天14:30、明天上午9点、2025年1月21日
- [ ] 高亮显示识别的时间
- [ ] 点击时间快速设置闹钟/提醒
- [ ] 支持时间相对表达(下周三、后天)
- [ ] 智能识别电话号码
- [ ] 自动检测电话号码
- [ ] 点击电话号码直接拨号
- [ ] 智能识别URL链接
- [ ] 自动检测URL并使其可点击
- [ ] 长按URL显示预览或复制选项
**技术方案**:
- 使用正则表达式识别地址、时间、电话、URL模式
- 实现智能解析器SmartParser.java
- 扩展 NoteEditText 支持高亮显示
- 集成 Intent 跳转地图、电话、闹钟
- 使用 SpannableString 设置可点击区域
**优先级**: 中
**工作量**: 5-6天
#### 3.4 链接笔记
**描述**: 支持笔记之间的链接引用
**功能点**:
- [ ] 创建笔记链接([[笔记标题]]语法)
- [ ] 自动检测笔记标题链接
- [ ] 反向链接查看(哪些笔记引用了当前笔记)
- [ ] 链接预览(悬浮显示笔记摘要)
- [ ] 链接计数
- [ ] 图谱视图(可视化笔记关系)
**技术方案**:
- 设计 NoteLink 数据表id, source_note_id, target_note_id, position
- 实现链接语法解析(正则表达式)
- 创建链接管理 UI
- 集成图表库绘制笔记关系图谱
**优先级**: 中
**工作量**: 4-5天
#### 3.5 任务清单
**描述**: 在笔记中创建待办事项清单
**功能点**:
- ✅ 添加任务项(- [ ] 语法)
- ✅ 标记完成/未完成
- ✅ 任务优先级(高/中/低)
- ✅ 任务截止日期
- ✅ 任务提醒
- ❌ 任务统计(完成率)
- ✅ 过滤已完成任务
- ❌ 任务拖拽排序
**技术方案**:
- 扩展 data 表支持任务类型mime_type = "text/x-todo"
- 实现任务 UI 组件TodoItemView
- 扩展 NoteEditText 解析任务语法
- 集成现有提醒功能
**优先级**: 中
**工作量**: 3-4天
## 长期扩展 (Phase 4 - 6个月+)
### P2 - 可以实现
#### 4.1 主题和自定义 ⚠️ 部分实现
**描述**: 提供更多主题和界面自定义选项
**用户需求**: 自定义壁纸
**当前状态**:
- ✅ 笔记背景颜色5种
- ✅ 字体大小4种
- ✅ 夜间主题values-night
- ❌ 完整主题系统
- ❌ 自定义主题颜色
- ❌ 自定义壁纸
**待实现功能点**:
- [ ] 多种预设主题
- [ ] 简约白
- [ ] 深夜黑
- [ ] 护眼绿
- [ ] 暖色系
- [ ] 冷色系
- [ ] 自定义主题颜色
- [ ] 自定义应用壁纸
- [ ] 从相册选择壁纸
- [ ] 预设壁纸库
- [ ] 瓷片平铺/居中/拉伸
- [ ] 磨砂玻璃效果(可选)
- [ ] 自定义字体家族(使用 Google Fonts
- [ ] 自定义卡片样式
- [ ] 自定义图标包
- [ ] 动态取色(基于壁纸)
**技术方案**:
- 使用 Material You (Material 3) 动态颜色
- 实现主题管理 ViewModel
- 创建主题编辑器 Activity
- 集成 Google Fonts API
- 实现壁纸设置和管理
**优先级**: 低
**工作量**: 5-6天
#### 4.2 智能功能
**描述**: 利用 AI 提供智能辅助功能
**功能点**:
- [ ] 自动摘要AI生成笔记摘要
- [ ] 智能分类建议(基于内容推荐文件夹/标签)
- [ ] 关键词提取
- [ ] 相关笔记推荐(基于内容相似度)
- [ ] 语法检查
- [ ] 翻译功能
- [ ] 智能纠错
**技术方案**:
- 集成本地 NLP 模型TensorFlow Lite
- 或使用第三方 AI APIOpenAI、百度文心等
- 实现智能分析逻辑
- 创建智能功能 UI 组件
**优先级**: 低
**工作量**: 7-10天
#### 4.3 云同步和账号系统 ✅ 已实现基础版本
**描述**: 完善的数据备份和云同步机制
**用户需求**: 实现注册、登录和云同步
**当前状态**:
- ✅ Google Tasks 同步(已有账号系统)
- ✅ 手动备份BackupUtils.java
- ✅ 手动恢复
- ❌ 自定义账号系统(注册/登录)
- ❌ 云备份到自建服务器
- ❌ 多设备同步
**待实现功能点**:
- [ ] 自定义账号系统
- [ ] 用户注册(手机号/邮箱)
- [ ] 用户登录(密码 + 验证码)
- [ ] 密码找回
- [ ] 账号注销
- [ ] 账号安全设置(修改密码、绑定手机号)
- [ ] 云同步到自建服务器
- [ ] 自动同步(实时或定时)
- [ ] 手动同步
- [ ] 同步冲突解决
- [ ] 同步状态显示
- [ ] 多设备管理
- [ ] 查看已登录设备
- [ ] 远程登出设备
- [ ] 设备命名
- [ ] 备份文件加密
- [ ] 端到端加密
- [ ] 数据传输加密HTTPS + 证书固定)
**技术方案**:
- 设计服务端Node.js + MongoDB/PostgreSQL
- 实现 REST API用户认证、数据同步
- 客户端集成 Retrofit + OkHttp
- 使用 JWT 或 OAuth 进行认证
- 实现增量同步算法
- 扩展现有 BackupUtils.java
**优先级**: 中
**工作量**: 10-15天含服务端开发
## 功能实现时间线(精简版)
### Month 1-2: 核心功能增强
- ✅ Week 1: 搜索功能增强 (2.1) - 搜索历史、高级筛选
- [ ] Week 2: 导入导出功能增强 (2.2) - 便签图片导出、Markdown/TXT
- ✅ Week 3: 撤回功能 (2.3) - 撤回/重做
- [ ] Week 4: 标签系统 (2.4) - 标签分类和筛选
### Month 3-4: 用户体验提升
- [ ] Week 5: 笔记模板 (2.6) - 模板管理
- ✅ Week 6-8: 富文本编辑 (3.1) - 完整格式支持
### Month 5-6: 功能扩展
- [ ] Week 9-10: 图片附件 (3.2) - 图片管理和预览
- [ ] Week 11-12: 智能识别功能 (3.3) - 地址、时间识别
### Month 7-8: 高级功能
- [ ] 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) 等
## 技术栈规划(基于当前架构)
### 当前技术栈
- **UI 框架**: Material Design Components
- **架构**: MVVM + Repository Pattern
- **数据库**: SQLite + SQLiteOpenHelper
- **异步**: ExecutorService
- **视图绑定**: findViewById传统方式
- **依赖管理**: Gradle Version Catalog (libs.versions.toml)
- **网络**: Apache HttpClient 4.5.14
### 建议技术演进
#### 短期演进保持Java
- **UI 框架**: 升级到 Material Design 3
- **架构**: 保持 MVVM + Repository Pattern
- **数据库**: 保持 SQLiteOpenHelper考虑迁移到 Room
- **异步**: 保持 ExecutorService考虑迁移到 Kotlin Coroutines
- **依赖注入**: 考虑引入 Hilt可选
#### 中期演进可选Kotlin迁移
- **语言**: 逐步迁移到 Kotlin100% 互操作)
- **UI**: 迁移到 Jetpack Compose可选
- **异步**: 迁移到 Kotlin Coroutines + Flow
- **依赖注入**: Hilt
- **数据库**: Room + Coroutines
#### 可能引入的技术栈
- **富文本编辑**:
- RichEditor (Java)
- Markwon (Markdown渲染Kotlin优先)
- **图片处理**:
- Glide / Coil (图片加载)
- UCrop / Android Image Cropper (图片裁剪)
- **图表**: MPAndroidChart / AnyChart / ECharts
- **文件压缩**: Zip4j
- **加密**: Android Keystore + AES (已有 SecurityManager)
- **智能识别**:
- 正则表达式(地址、时间、电话识别)
- 第三方 API地址解析、时间解析
- **服务端开发**:
- Node.js + Express/Koa
- MongoDB / PostgreSQL
- Redis缓存
- Nginx反向代理
### 技术债务清理
#### 高优先级
- [ ] 迁移从 findViewById 到 ViewBinding / Jetpack Compose
- [ ] 迁移从 ExecutorService 到 Kotlin Coroutines / Flow
- [ ] 迁移从 SQLiteOpenHelper 到 Room
- [ ] 重构 Apache HttpClient 依赖(使用 Retrofit + OkHttp
#### 中优先级
- [ ] 引入依赖注入框架Hilt
- [ ] 升级到 Material Design 3
- [ ] 迁移到 Kotlin逐步
- [ ] 统一异常处理机制
#### 低优先级
- [ ] Jetpack Compose 迁移
- [ ] 单向数据流UDF架构优化
- [ ] 模块化架构Dynamic Feature Modules
## 性能优化
### 短期优化
- [ ] RecyclerView 性能优化DiffUtil、预加载
- [ ] 图片加载和缓存优化(使用 Glide/Coil
- [ ] 数据库查询优化(添加索引、避免 N+1 查询)
- [ ] 内存泄漏检测和修复LeakCanary
- [ ] 启动速度优化Application 初始化优化)
### 长期优化
- [ ] 启用 ProGuard/R8 混淆和优化
- [ ] APK 体积优化(资源压缩、动态下发)
- [ ] 启动速度优化(延迟加载、线程优化)
- [ ] 电池使用优化WorkManager 替代 AlarmManager
- [ ] 网络请求优化(缓存策略、请求合并)
## 安全性考虑
### 数据安全
- [x] 数据加密存储(已有 SecurityManager
- [ ] 敏感信息保护(日志脱敏)
- [x] 权限最小化原则(运行时权限请求)
- [ ] 安全的文件存储Scoped Storage
- [ ] 密码保护(已有)
### 隐私保护
- [x] 本地数据处理优先
- [ ] 明确的隐私政策
- [ ] 用户数据控制权(数据导出、删除)
- [ ] 安全的数据传输HTTPS + 证书固定)
### 代码安全
- [ ] 代码混淆ProGuard/R8
- [ ] 防止重打包(签名校验)
- [ ] 日志脱敏(不记录敏感信息)
- [ ] SQL 注入防护(使用参数化查询,已实现)
## 测试策略
### 单元测试
- [x] 数据库操作测试(已有 FolderDatabaseTest
- [x] 仓库层测试(已有 NotesRepositoryTest
- [ ] 业务逻辑测试ViewModel 单元测试)
- [ ] 工具类测试Utils 单元测试)
### 集成测试
- [ ] 主要功能流程测试(笔记创建、编辑、删除)
- [ ] 数据迁移测试(数据库版本升级)
- [ ] ContentProvider 接口测试
- [ ] 同步功能测试
### UI 测试
- [ ] Espresso UI 测试(关键流程)
- [ ] 用户交互测试(手势、快捷操作)
- [ ] 兼容性测试(不同 Android 版本、屏幕尺寸)
### 性能测试
- [ ] 大数据量测试1000+ 笔记)
- [ ] 内存使用测试(内存泄漏、峰值)
- [ ] 启动时间测试(冷启动、热启动)
- [ ] 电池使用测试(长时间运行)
## 用户反馈和迭代
### 收集反馈
- [ ] 应用内反馈功能(反馈表单)
- [ ] 应用商店评论监控
- [ ] 用户调研(问卷调查)
- [ ] 数据分析(崩溃率、使用频率、功能使用率)
- [ ] Beta 测试计划Play Console
### 迭代流程
1. 收集用户反馈(多渠道)
2. 分析需求优先级(影响力 vs 成本)
3. 制定开发计划Sprint Planning
4. 开发和测试Code Review + CI/CD
5. 发布新版本(灰度发布)
6. 收集新反馈(持续监控)
### 数据驱动决策
- [ ] 功能使用率统计
- [ ] 用户留存率分析
- [ ] 崩溃率监控
- [ ] 性能指标追踪
- [ ] A/B 测试(新功能)
## 风险评估
### 技术风险
- [ ] 第三方库兼容性(版本冲突、废弃)
- [ ] Android 版本兼容性(碎片化)
- [ ] 性能瓶颈(大数据量、复杂查询)
- [ ] 数据迁移风险(版本升级失败)
- [ ] Kotlin 迁移风险(互操作问题)
- [ ] 服务端开发风险(并发、安全、扩展性)
### 产品风险
- [ ] 功能过度复杂(用户体验下降)
- [ ] 用户学习成本高(功能过多)
- [ ] 功能使用率低(开发浪费)
- [ ] 维护成本高(技术债务积累)
- [ ] 竞品功能差距(市场竞争力)
### 缓解措施
- [ ] 渐进式功能发布Feature Flags
- [ ] 充分的测试覆盖(单元测试、集成测试)
- [ ] 用户教育文档(帮助中心、教程)
- [ ] 灵活的架构设计(解耦、可扩展)
- [ ] 持续重构(技术债管理)
- [ ] 可配置功能(用户可选)
## 成功指标
### 技术指标
- [ ] 崩溃率 < 0.5%
- [ ] ANR 率 < 0.1%
- [ ] 启动时间 < 2
- [ ] 代码覆盖率 > 60%
- [ ] 技术债务指数降低
### 产品指标
- [ ] 日活跃用户DAU增长
- [ ] 用户留存率7日、30日
- [ ] 功能使用率(每个功能的用户比例)
- [ ] 应用商店评分 > 4.5
- [ ] 用户反馈响应时间 < 24h
## 总结
本规划基于当前项目实际状态MVVM架构、Repository Pattern、48个Java文件、135个资源文件并根据用户反馈精简了功能列表提供了从核心功能增强到高级功能扩展的完整路线图。
**项目优势**
1. ✅ 完整的 MVVM 架构ViewModel + Repository
2. ✅ 响应式数据更新LiveData
3. ✅ 标准化数据访问ContentProvider
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
**维护者**: Sisyphus AI Agent
**更新说明**: 根据用户反馈精简功能列表,移除不必要的生物识别和复杂版本历史,强调便签图片导出、智能识别和云同步功能

6
package-lock.json generated

@ -1,6 +0,0 @@
{
"name": "git",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/src.iml" filepath="$PROJECT_DIR$/.idea/src.iml" />
</modules>
</component>
</project>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

@ -13,8 +13,3 @@
.externalNativeBuild
.cxx
local.properties
build.gradle.kts
gradle.properties
gradlew
gradlew.bat
settings.gradle.kts

@ -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,117 @@
# 小米便签 (MiNotes) 项目深度分析与拓展计划书
## 1. 项目概览与架构分析 (Project Overview & Architecture)
### 1.1 总体架构
本项目采用了 **混合架构 (Hybrid Architecture)**,处于从传统的 Android 开发模式向现代 MVVM 模式过渡的阶段。
* **Legacy 部分**: 核心编辑页面 `NoteEditActivity` 及其对应的逻辑类 `WorkingNote` 采用了一种类似于 MVP/MVC 的变体,其中 `WorkingNote` 充当了“上帝类”的角色承担了数据加载、状态管理、UI 回调等过多职责,导致耦合度较高。
* **Modern 部分**: 列表页面 `NotesListActivity` 引入了 `ViewModel` (`NotesListViewModel`) 和 `Repository` (`NotesRepository`),利用 `LiveData` 进行数据驱动的 UI 更新,体现了较好的分层设计思想。
### 1.2 数据流向
* **核心数据源**: `NotesProvider` (继承自 `ContentProvider`) 是应用的数据中枢。它不仅为应用内部提供数据(通过 `ContentResolver`),还设计用于跨进程/跨应用的数据共享。
* **数据访问**:
* **读操作**: 大部分通过 `NotesRepository` -> `ContentResolver` -> `NotesProvider` -> `SQLiteDatabase` 的链路。
* **写操作**: 编辑页直接通过 `WorkingNote` 操作 `ContentResolver`,绕过了 Repository 层。
* **同步机制**: `GTaskManager` 负责与 Google Tasks 的同步,它直接操作本地数据库并进行网络请求。
### 1.3 关键组件
* **WorkingNote**: 负责单条笔记的生命周期管理(加载、保存、转换模式)。
* **NotesDatabaseHelper**: 管理 SQLite 数据库创建、升级及复杂的触发器Trigger逻辑。
* **GTaskSyncService**: 后台同步服务。
---
## 2. 现有功能分析 (Feature Analysis)
### 2.1 核心功能
* **笔记编辑**: 支持普通文本模式和清单模式Checklist
* **文件夹管理**: 支持多层级文件夹(虽然 UI 上主要体现为单层),包含系统预置文件夹(通话记录、回收站)。
* **通话记录笔记**: 特色功能,能够根据来电号码自动关联或创建笔记。
### 2.2 辅助功能
* **桌面小部件 (Widgets)**: 提供 2x2 和 4x4 两种规格,通过 `RemoteViews` 更新。
* **定时提醒**: 基于 `AlarmManager` 实现。
* **云同步**: 仅支持 Google Tasks 同步。
---
## 3. 可扩展性与兼容性评估 (Scalability & Compatibility)
### 3.1 可扩展性 (Scalability)
* **优势**:
* **ContentProvider**: 数据结构标准,易于被外部模块访问。
* **MVVM 雏形**: 新增模块若遵循 MVVM 模式,将具有良好的测试性和维护性。
* **劣势 (瓶颈)**:
* **数据库耦合**: 大量硬编码的 SQL 语句和触发器逻辑,难以迁移到 Room 等现代 ORM 框架。
* **WorkingNote 臃肿**: 修改编辑逻辑牵一发而动全身。
* **同步接口缺失**: 缺乏通用的同步接口抽象,难以扩展其他云服务(如 WebDAV
### 3.2 兼容性 (Compatibility)
当前代码库存在显著的兼容性风险,尤其是在 Android 10+ 设备上:
* **AsyncTask**: `GTaskASyncTask` 使用了已废弃的 API可能导致内存泄漏或崩溃。
* **存储权限**: 未适配 Android 10+ 的分区存储 (Scoped Storage),直接文件路径访问将失效。
* **精确闹钟**: Android 12+ 需要 `SCHEDULE_EXACT_ALARM` 权限。
* **通知渠道**: Android 8.0+ 必须适配 Notification Channels。
---
## 4. 项目拓展计划 (Extension Plan)
本计划旨在将小米便签打造为一款现代化、高效率、高颜值的笔记应用。
### Phase 1: 现代化重构与基石搭建 (Modernization)
**目标**: 消除技术债务,确保在 Android 14/15 上稳定运行。
1. **并发模型升级**:
* 废弃 `AsyncTask`
* 引入 **Kotlin Coroutines** (协程) 处理异步任务(数据库读写、网络请求)。
2. **系统适配**:
* **权限**: 适配动态权限申请,特别是闹钟和通知权限。
* **存储**: 使用 `MediaStore` API 或 SAF (Storage Access Framework) 替代直接文件操作。
* **服务**: 将后台同步服务适配为 WorkManager 或 Foreground Service。
3. **代码解耦**:
* 重构 `NoteEditActivity`,引入 `NoteEditViewModel`
* 拆分 `WorkingNote`,将其纯化为数据模型,逻辑移入 ViewModel/UseCase。
### Phase 2: 核心体验升级 (Core Experience)
**目标**: 提升笔记的编辑与组织能力。
1. **富文本支持 (Markdown)**:
* 数据库 `data` 表新增 `mime_type` 类型支持。
* 集成 Markdown 解析器(如 Markwon支持标题、列表、代码块渲染。
2. **多媒体附件**:
* 新增 `attachments` 数据表。
* 支持插入图片(压缩存储或引用 URI、音频文件。
3. **标签系统 (Tagging)**:
* 实现笔记与标签的多对多关系。
* UI 上支持按标签筛选、颜色标记。
### Phase 3: 云服务与互联 (Cloud & Connectivity)
**目标**: 打破数据孤岛,提供数据安全保障。
1. **同步架构抽象**:
* 定义 `ISyncProvider` 接口。
* 将 `GTaskManager` 降级为一种实现。
2. **WebDAV 支持**:
* 实现通用的 WebDAV 协议允许用户连接坚果云、Nextcloud、NAS 等私有云。
3. **本地备份**:
* 支持导出为 JSON/Zip 格式。
* 实现自动定时本地备份。
### Phase 4: UI/UX 焕新 (Design Refresh)
**目标**: 拥抱 Material Design 3 (Material You)。
1. **动态取色**: 支持 Android 12+ 的动态主题色。
2. **深色模式**: 完善的 Dark Mode 资源适配。
3. **现代交互**:
* 列表页支持侧滑删除/归档。
* 笔记拖拽排序。
* 沉浸式状态栏与导航栏。
## 5. 关键决策与建议
* **开发语言**: 建议新功能完全使用 **Kotlin** 开发,旧 Java 代码按需迁移。
* **数据库**: 虽然迁移到 Room 工作量大,但建议在 Phase 2 开始尝试引入 Room与现有 Helper 共存,逐步替换。
* **同步策略**: 鉴于网络环境,建议将 **WebDAV** 作为首推的同步方式Google Tasks 作为可选插件。
---
*Generated by Trae AI Assistant*

@ -0,0 +1,122 @@
# 小米便签 (MiNotes) 功能拓展详细规划书 (v3.0 - 全面增强版)
本规划书在 v2.0 离线优先的基础上进一步汲取了市场主流笔记应用Notion, Obsidian, Flomo 等)的精华,旨在打造一款**全能型、离线优先、极具差异化**的笔记应用。我们从效率、知识管理、创意、安全四个维度进行了深度发散和规划。
## 1. 效率与工作流 (Productivity & Workflow)
*定位:让笔记不仅是记录,更是行动的开始。*
### 1.1 专注模式 (Focus Mode / Pomodoro)
* **功能描述**: 在编辑笔记时,提供一个沉浸式的“专注模式”。开启后,隐藏所有无关 UI状态栏、工具栏并可叠加一个倒计时番茄钟。
* **用户价值**: 帮助用户进入心流状态,适合深度写作、思考或复习。
* **离线适配**: 纯本地逻辑。
* **开发难度**: 低。
### 1.2 悬浮窗速记 (Floating Quick Note)
* **功能描述**: 在其他应用之上显示一个半透明悬浮球。点击后展开一个小窗口,支持快速输入文字、语音或粘贴剪贴板内容,无需切换应用。
* **用户价值**: 随时随地捕捉灵感,阅读/看视频时的绝佳伴侣。
* **离线适配**: 本地 WindowManager 实现。
* **开发难度**: 中(需处理悬浮窗权限适配)。
### 1.3 每日回顾 (Daily Review / Journaling)
* **功能描述**:
* **自动日志**: 每天自动创建一个以“日期”命名的笔记,聚合当天的所有操作(新建笔记、完成待办、通话记录)。
* **那年今日**: 首页顶部展示往年今天的笔记回顾。
* **用户价值**: 自动化的生活记录与复盘,无需手动整理。
* **开发难度**: 中。
### 1.4 模板中心 (Template Center)
* **功能描述**: 提供预设模板库如康奈尔笔记法、SWOT 分析、会议纪要、周报),并允许用户将现有笔记保存为自定义模板。
* **用户价值**: 快速开始标准化记录,降低启动门槛。
* **开发难度**: 低。
---
## 2. 知识管理与可视化 (Knowledge & Visualization)
*定位:将碎片化信息重组为结构化知识。*
### 2.1 看板视图 (Kanban View)
* **功能描述**: 将文件夹内的笔记或待办事项以卡片形式展示在“未开始”、“进行中”、“已完成”等列中,支持拖拽流转状态。
* **用户价值**: 轻量级的项目管理工具,直观掌控任务进度。
* **开发难度**: 高。
### 2.2 日历视图 (Calendar View)
* **功能描述**: 在日历上以圆点或标题形式展示笔记(按创建/修改时间或提醒时间分布)。支持点击日期直接创建笔记。
* **用户价值**: 从时间维度回顾历史,规划未来。
* **开发难度**: 中。
### 2.3 知识图谱 (Knowledge Graph)
* **功能描述**: 基于双向链接(`[[Link]]`),用节点和连线可视化展示笔记之间的引用关系。支持缩放、拖拽节点,点击节点跳转。
* **用户价值**: 上帝视角俯瞰知识结构,发现知识盲区与隐性联系。
* **开发难度**: 极高(需引入图形渲染库如 MPAndroidChart 修改版或自定义 View
### 2.4 标签墙与嵌套标签 (Tag Hierarchy)
* **功能描述**: 支持多级标签系统(如 `#工作/项目A/会议`),并提供一个动态的标签云或标签墙视图进行筛选。
* **用户价值**: 突破文件夹的单一维度限制,实现多维度灵活管理。
* **开发难度**: 中。
---
## 3. 创意与多媒体 (Creativity & Media)
*定位:释放表达欲,记录不设限。*
### 3.1 手写与绘图 (Handwriting & Sketch)
* **功能描述**: 提供画笔工具,支持在笔记中插入手写区域或涂鸦。支持压感(如果设备支持),支持橡皮擦、套索工具。
* **用户价值**: 还原纸笔体验,适合绘制草图、数学公式、手写签名。
* **开发难度**: 高。
### 3.2 语音笔记与时间戳 (Audio Note with Timestamp)
* **功能描述**: 录音的同时可以打字记录。回放录音时,点击文字可跳转到对应的录音进度(类似 Notability 的功能)。
* **用户价值**: 会议记录、课堂录音的神器,不再错过任何细节。
* **开发难度**: 高。
### 3.3 闪念胶囊 (Flash Card / Flomo-like)
* **功能描述**: 一个类似于聊天界面的输入框,发送即保存为一张卡片。支持随机漫游回顾,支持标签分类。
* **用户价值**: 极速捕捉转瞬即逝的灵感,无压力记录。
* **开发难度**: 低。
---
## 4. 隐私与安全 (Privacy & Security)
*定位:数据主权,绝对安全。*
### 4.1 生物识别锁 (Biometric Lock)
* **功能描述**: 支持指纹或面部解锁进入应用,或锁定特定私密文件夹/笔记。
* **用户价值**: 保护日记、账号密码等敏感信息,防止他人窥探。
* **开发难度**: 低(使用 Android BiometricPrompt API
### 4.2 本地加密存储 (Local Encryption)
* **功能描述**: 使用 AES-256 算法对数据库文件和附件进行加密存储。即使手机丢失,导出文件也无法被暴力破解。
* **用户价值**: 满足极高安全需求场景(如商业机密、个人隐私)。
* **开发难度**: 中(需引入 SQLCipher
### 4.3 隐身模式 (Stealth Mode)
* **功能描述**: 在多任务界面模糊显示应用内容;提供一个“伪装密码”,输入后进入一个空的或预设的伪装空间。
* **用户价值**: 极致的隐私保护。
* **开发难度**: 中。
---
## 5. 推荐实施路线 (Roadmap v3.0) - 离线优先策略
### Phase 1: 基础体验增强 (Foundation)
* **目标**: 夯实基础,提升易用性和安全性。
* **任务**:
1. **回收站** (现有计划)
2. **深色模式** (现有计划)
3. **生物识别锁**: 增加安全感,实现简单,价值高。
4. **模板功能**: 提升记录效率。
### Phase 2: 核心生产力 (Core Productivity)
* **目标**: 丰富记录形式,提升编辑效率。
* **任务**:
1. **本地备份与导出** (现有计划)
2. **Markdown 支持** (现有计划)
3. **悬浮窗速记**: 扩展记录场景。
4. **日历视图**: 提供新的时间维度视角。
### Phase 3: 高级与创新 (Innovation)
* **目标**: 打造差异化壁垒。
* **任务**:
1. **离线 OCR** (现有计划)
2. **看板视图**: 引入项目管理能力。
3. **双向链接 & 知识图谱**: 打造知识库神器的核心。

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

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

@ -1,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,7 +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")
// 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
}

@ -4,6 +4,8 @@
xmlns:tools="http://schemas.android.com/tools">
<!-- ==================== 对于功能的声明 ==================== -->
<!-- 允许显示悬浮窗 -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- 允许写入外部存储,用于保存笔记数据 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<!-- 允许在桌面创建快捷方式 -->
@ -22,6 +24,32 @@
<uses-permission android:name="android.permission.USE_CREDENTIALS" />
<!-- 允许接收系统启动完成广播,用于初始化闹钟 -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- 允许设置闹钟 -->
<uses-permission android:name="com.android.alarm.permission.SET_ALARM" />
<uses-permission android:name="android.permission.SET_ALARM" />
<!-- 允许在 Android 11+ 上查询外部应用 -->
<queries>
<intent>
<action android:name="android.intent.action.SET_ALARM" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="geo" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
<!-- 阿里云推送服务权限 -->
<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
@ -34,6 +62,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Notesmaster"
android:requestLegacyExternalStorage="true"
tools:targetApi="31">
<!-- ==================== 笔记列表活动 ==================== -->
@ -102,6 +131,12 @@
android:exported="false" />
<!-- ==================== 待办任务列表活动 ==================== -->
<activity
android:name=".ui.CapsuleListActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:screenOrientation="portrait"
android:theme="@style/Theme.Notesmaster" />
<activity
android:name=".ui.TaskListActivity"
android:label="Tasks"
@ -121,6 +156,16 @@
android:authorities="micode_notes"
android:multiprocess="true" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="net.micode.notes.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- ==================== 桌面小部件接收器 ==================== -->
<!-- 2x2大小的笔记桌面小部件 -->
<receiver
@ -179,6 +224,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 +269,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 +326,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 -->

@ -5,6 +5,7 @@ import android.os.Bundle;
import android.util.Log;
import android.view.Gravity;
import android.view.View;
import android.widget.Toast;
import androidx.activity.EdgeToEdge;
import androidx.appcompat.app.AppCompatActivity;
@ -15,6 +16,8 @@ import androidx.drawerlayout.widget.DrawerLayout;
import net.micode.notes.data.Notes;
import net.micode.notes.databinding.ActivityMainBinding;
import net.micode.notes.tool.BackupUtils;
import net.micode.notes.ui.BaseActivity;
import net.micode.notes.ui.SidebarFragment;
/**
@ -24,7 +27,7 @@ import net.micode.notes.ui.SidebarFragment;
*
* </p>
*/
public class MainActivity extends AppCompatActivity implements SidebarFragment.OnSidebarItemSelectedListener {
public class MainActivity extends BaseActivity implements SidebarFragment.OnSidebarItemSelectedListener {
private static final String TAG = "MainActivity";
private ActivityMainBinding binding;
@ -116,13 +119,36 @@ 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
public void onExportSelected() {
Log.d(TAG, "Export selected");
// TODO: 实现导出功能
BackupUtils backupUtils = BackupUtils.getInstance(this);
int state = backupUtils.exportToText();
switch (state) {
case BackupUtils.STATE_SUCCESS:
Toast.makeText(this, getString(R.string.format_exported_file_location,
backupUtils.getExportedTextFileName(),
backupUtils.getExportedTextFileDir()), Toast.LENGTH_SHORT).show();
break;
case BackupUtils.STATE_SD_CARD_UNMOUONTED:
Toast.makeText(this, R.string.error_sdcard_unmounted, Toast.LENGTH_SHORT).show();
break;
case BackupUtils.STATE_SYSTEM_ERROR:
default:
Toast.makeText(this, R.string.error_sdcard_export, Toast.LENGTH_SHORT).show();
break;
}
closeSidebar();
}
@Override
@ -142,6 +168,14 @@ public class MainActivity extends AppCompatActivity implements SidebarFragment.O
closeSidebar();
}
@Override
public void onCapsuleSelected() {
Log.d(TAG, "Capsule selected");
Intent intent = new Intent(this, net.micode.notes.ui.CapsuleListActivity.class);
startActivity(intent);
closeSidebar();
}
@Override
public void onCreateFolder() {
Log.d(TAG, "Create folder");
@ -154,6 +188,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,58 @@
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 net.micode.notes.capsule.CapsuleService;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Build;
import androidx.preference.PreferenceManager;
import android.provider.Settings;
import com.google.android.material.color.DynamicColors;
import net.micode.notes.tool.LocaleHelper;
import android.content.Context;
public class NotesApplication extends Application {
private static final String TAG = "NotesApplication";
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(LocaleHelper.onAttach(base));
}
@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);
// Start CapsuleService if enabled
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
boolean capsuleEnabled = prefs.getBoolean("pref_key_capsule_enable", false);
if (capsuleEnabled && Settings.canDrawOverlays(this)) {
Intent intent = new Intent(this, CapsuleService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent);
} else {
startService(intent);
}
}
}
}

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

@ -0,0 +1,394 @@
package net.micode.notes.capsule;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.graphics.PixelFormat;
import android.os.Build;
import android.os.IBinder;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.WindowManager;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import android.os.Handler;
import android.util.Log;
import android.view.DragEvent;
import android.content.ClipData;
import android.content.ClipDescription;
import net.micode.notes.R;
import net.micode.notes.model.Note;
import net.micode.notes.data.Notes;
import android.view.GestureDetector;
public class CapsuleService extends Service {
private static final String TAG = "CapsuleService";
private WindowManager mWindowManager;
private View mCollapsedView;
private View mExpandedView;
private EditText mEtContent;
private WindowManager.LayoutParams mCollapsedParams;
private WindowManager.LayoutParams mExpandedParams;
private GestureDetector mGestureDetector;
private Handler mHandler = new Handler();
public static String currentSourcePackage = "";
private static final String CHANNEL_ID = "CapsuleServiceChannel";
public static final String ACTION_SAVE_SUCCESS = "net.micode.notes.capsule.ACTION_SAVE_SUCCESS";
private final android.content.BroadcastReceiver mSaveReceiver = new android.content.BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (ACTION_SAVE_SUCCESS.equals(intent.getAction())) {
highlightCapsule();
}
}
};
public static void setCurrentSourcePackage(String pkg) {
currentSourcePackage = pkg;
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
return START_STICKY;
}
@Override
public void onCreate() {
super.onCreate();
mWindowManager = (WindowManager) getSystemService(WINDOW_SERVICE);
createNotificationChannel();
startForeground(1, createNotification());
initViews();
}
private void initViews() {
// Collapsed View
mCollapsedView = LayoutInflater.from(this).inflate(R.layout.layout_capsule_collapsed, null);
int layoutFlag;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
layoutFlag = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
} else {
layoutFlag = WindowManager.LayoutParams.TYPE_PHONE;
}
mCollapsedParams = new WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
layoutFlag,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT);
mCollapsedParams.gravity = Gravity.TOP | Gravity.START;
mCollapsedParams.x = 0;
mCollapsedParams.y = 100;
// Expanded View
mExpandedView = LayoutInflater.from(this).inflate(R.layout.layout_capsule_expanded, null);
mExpandedParams = new WindowManager.LayoutParams(
dp2px(320),
dp2px(400),
layoutFlag,
WindowManager.LayoutParams.FLAG_DIM_BEHIND | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
PixelFormat.TRANSLUCENT);
mExpandedParams.dimAmount = 0.5f;
mExpandedParams.gravity = Gravity.CENTER;
// Setup Fields
mCollapsedView.setClickable(true);
mEtContent = mExpandedView.findViewById(R.id.et_content);
// Setup Listeners
setupCollapsedListener();
setupExpandedListener();
// Add Collapsed View initially
try {
mWindowManager.addView(mCollapsedView, mCollapsedParams);
Log.d(TAG, "initViews: Collapsed view added");
} catch (Exception e) {
Log.e(TAG, "initViews: Failed to add collapsed view", e);
}
}
private void setupCollapsedListener() {
mGestureDetector = new GestureDetector(this, new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onSingleTapConfirmed(MotionEvent e) {
Log.d(TAG, "onSingleTapConfirmed: Triggering showExpandedView");
showExpandedView();
return true;
}
});
mCollapsedView.setOnTouchListener(new View.OnTouchListener() {
private int initialX;
private int initialY;
private float initialTouchX;
private float initialTouchY;
@Override
public boolean onTouch(View v, MotionEvent event) {
// Let GestureDetector handle taps
if (mGestureDetector.onTouchEvent(event)) {
return true;
}
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
v.setPressed(true);
initialX = mCollapsedParams.x;
initialY = mCollapsedParams.y;
initialTouchX = event.getRawX();
initialTouchY = event.getRawY();
return true;
case MotionEvent.ACTION_MOVE:
int Xdiff = (int) (event.getRawX() - initialTouchX);
int Ydiff = (int) (event.getRawY() - initialTouchY);
// Move if dragged
if (Math.abs(Xdiff) > 10 || Math.abs(Ydiff) > 10) {
mCollapsedParams.x = initialX + Xdiff;
mCollapsedParams.y = initialY + Ydiff;
mWindowManager.updateViewLayout(mCollapsedView, mCollapsedParams);
}
return true;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
v.setPressed(false);
return true;
}
return false;
}
});
mCollapsedView.setOnDragListener((v, event) -> {
switch (event.getAction()) {
case DragEvent.ACTION_DRAG_STARTED:
Log.d(TAG, "onDrag: ACTION_DRAG_STARTED");
if (event.getClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) ||
event.getClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML)) {
v.setAlpha(1.0f);
return true;
}
return false;
case DragEvent.ACTION_DRAG_ENTERED:
Log.d(TAG, "onDrag: ACTION_DRAG_ENTERED");
v.animate().scaleX(1.2f).scaleY(1.2f).setDuration(200).start();
return true;
case DragEvent.ACTION_DRAG_EXITED:
Log.d(TAG, "onDrag: ACTION_DRAG_EXITED");
v.animate().scaleX(1.0f).scaleY(1.0f).setDuration(200).start();
return true;
case DragEvent.ACTION_DROP:
Log.d(TAG, "onDrag: ACTION_DROP");
ClipData.Item item = event.getClipData().getItemAt(0);
CharSequence text = item.getText();
if (text != null) {
saveNote(text.toString());
}
v.animate().scaleX(1.0f).scaleY(1.0f).setDuration(200).start();
return true;
case DragEvent.ACTION_DRAG_ENDED:
Log.d(TAG, "onDrag: ACTION_DRAG_ENDED");
v.setAlpha(0.8f);
return true;
}
return false;
});
}
private void setupExpandedListener() {
Button btnCancel = mExpandedView.findViewById(R.id.btn_cancel);
Button btnSave = mExpandedView.findViewById(R.id.btn_save);
btnCancel.setOnClickListener(v -> showCollapsedView());
btnSave.setOnClickListener(v -> {
String content = mEtContent.getText().toString();
if (!content.isEmpty()) {
saveNote(content);
mEtContent.setText("");
showCollapsedView();
}
});
}
private void showExpandedView() {
mHandler.post(() -> {
try {
Log.d(TAG, "showExpandedView: Attempting to show expanded view");
if (mCollapsedView != null && mCollapsedView.getParent() != null) {
Log.d(TAG, "showExpandedView: Removing collapsed view");
mWindowManager.removeViewImmediate(mCollapsedView);
}
if (mExpandedView != null && mExpandedView.getParent() == null) {
Log.d(TAG, "showExpandedView: Adding expanded view");
mWindowManager.addView(mExpandedView, mExpandedParams);
TextView tvSource = mExpandedView.findViewById(R.id.tv_source);
if (currentSourcePackage != null && !currentSourcePackage.isEmpty()) {
tvSource.setText("Source: " + currentSourcePackage);
tvSource.setVisibility(View.VISIBLE);
} else {
tvSource.setVisibility(View.GONE);
}
if (mEtContent != null) {
mEtContent.requestFocus();
}
Log.d(TAG, "showExpandedView: Expanded view added successfully");
} else {
Log.w(TAG, "showExpandedView: Expanded view already has a parent or is null");
}
} catch (Exception e) {
Log.e(TAG, "showExpandedView: Error", e);
}
});
}
private void showCollapsedView() {
mHandler.post(() -> {
try {
Log.d(TAG, "showCollapsedView: Attempting to show collapsed view");
if (mExpandedView != null && mExpandedView.getParent() != null) {
Log.d(TAG, "showCollapsedView: Removing expanded view");
mWindowManager.removeViewImmediate(mExpandedView);
}
if (mCollapsedView != null && mCollapsedView.getParent() == null) {
Log.d(TAG, "showCollapsedView: Adding collapsed view");
mWindowManager.addView(mCollapsedView, mCollapsedParams);
Log.d(TAG, "showCollapsedView: Collapsed view added successfully");
} else {
Log.w(TAG, "showCollapsedView: Collapsed view already has a parent or is null");
}
} catch (Exception e) {
Log.e(TAG, "showCollapsedView: Error", e);
}
});
}
private void saveNote(String content) {
new Thread(() -> {
try {
// 1. Create new note in CAPSULE folder
long noteId = Note.getNewNoteId(this, Notes.ID_CAPSULE_FOLDER);
// 2. Create Note object
Note note = new Note();
note.setTextData(Notes.DataColumns.CONTENT, content);
// Generate Summary (First 20 chars or first line)
String summary = content.length() > 20 ? content.substring(0, 20) + "..." : content;
int firstLineEnd = content.indexOf('\n');
if (firstLineEnd > 0 && firstLineEnd < 20) {
summary = content.substring(0, firstLineEnd);
}
note.setNoteValue(Notes.NoteColumns.SNIPPET, summary);
// Add Source Info if available
if (currentSourcePackage != null && !currentSourcePackage.isEmpty()) {
note.setTextData(Notes.DataColumns.DATA3, currentSourcePackage);
}
boolean success = note.syncNote(this, noteId);
mHandler.post(() -> {
if (success) {
Log.d(TAG, "saveNote: Success");
Toast.makeText(this, "Saved to Notes", Toast.LENGTH_SHORT).show();
} else {
Log.e(TAG, "saveNote: Failed");
Toast.makeText(this, "Failed to save", Toast.LENGTH_SHORT).show();
}
});
} catch (Exception e) {
e.printStackTrace();
Log.e(TAG, "saveNote: Exception", e);
mHandler.post(() -> Toast.makeText(this, "Error: " + e.getMessage(), Toast.LENGTH_SHORT).show());
}
}).start();
}
private int dp2px(int dp) {
return (int) (dp * getResources().getDisplayMetrics().density);
}
private void createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel serviceChannel = new NotificationChannel(
CHANNEL_ID,
"Capsule Service Channel",
NotificationManager.IMPORTANCE_DEFAULT
);
NotificationManager manager = getSystemService(NotificationManager.class);
if (manager != null) {
manager.createNotificationChannel(serviceChannel);
}
}
}
private Notification createNotification() {
Notification.Builder builder;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
builder = new Notification.Builder(this, CHANNEL_ID);
} else {
builder = new Notification.Builder(this);
}
return builder.setContentTitle("Global Capsule Running")
.setContentText("Tap to configure")
.setSmallIcon(R.mipmap.ic_launcher)
.build();
}
@Override
public void onDestroy() {
super.onDestroy();
try {
unregisterReceiver(mSaveReceiver);
} catch (Exception e) {
Log.e(TAG, "Receiver not registered", e);
}
if (mCollapsedView != null && mCollapsedView.getParent() != null) {
mWindowManager.removeView(mCollapsedView);
}
if (mExpandedView != null && mExpandedView.getParent() != null) {
mWindowManager.removeView(mExpandedView);
}
}
private void highlightCapsule() {
if (mCollapsedView != null && mCollapsedView.getParent() != null) {
mHandler.post(() -> {
mCollapsedView.animate().scaleX(1.5f).scaleY(1.5f).setDuration(200).withEndAction(() -> {
mCollapsedView.animate().scaleX(1.0f).scaleY(1.0f).setDuration(200).start();
}).start();
});
}
}
}

@ -0,0 +1,67 @@
package net.micode.notes.capsule;
import android.accessibilityservice.AccessibilityService;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.view.accessibility.AccessibilityEvent;
import android.widget.Toast;
public class ClipboardMonitorService extends AccessibilityService {
private ClipboardManager mClipboardManager;
private ClipboardManager.OnPrimaryClipChangedListener mClipListener;
private long mLastClipTime = 0;
private static final long MERGE_THRESHOLD = 2000; // 2 seconds
@Override
public void onCreate() {
super.onCreate();
mClipboardManager = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
}
@Override
protected void onServiceConnected() {
super.onServiceConnected();
// Register clipboard listener
if (mClipboardManager != null) {
mClipListener = () -> {
handleClipChanged();
};
mClipboardManager.addPrimaryClipChangedListener(mClipListener);
}
}
private void handleClipChanged() {
long now = System.currentTimeMillis();
if (now - mLastClipTime < MERGE_THRESHOLD) {
// Notify CapsuleService to show "Merge" bubble
// For now just show a toast or log
// Intent intent = new Intent("net.micode.notes.capsule.ACTION_MERGE_SUGGESTION");
// sendBroadcast(intent);
}
mLastClipTime = now;
}
@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
if (event.getPackageName() != null) {
// Store current package name in CapsuleService
CapsuleService.setCurrentSourcePackage(event.getPackageName().toString());
}
}
}
@Override
public void onInterrupt() {
}
@Override
public void onDestroy() {
super.onDestroy();
if (mClipboardManager != null && mClipListener != null) {
mClipboardManager.removePrimaryClipChangedListener(mClipListener);
}
}
}

@ -62,6 +62,11 @@ public class Notes {
*/
public static final int TYPE_TASK = 3;
/**
*
*/
public static final int TYPE_TEMPLATE = 4;
/**
* ID
* {@link Notes#ID_ROOT_FOLDER }
@ -295,6 +300,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 = 14;
/**
*
@ -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" +
")";
/**
@ -433,6 +437,15 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper {
*/
// 创建模板文件夹
createTemplateFolder(db);
/**
* create capsule folder
*/
// 创建速记胶囊文件夹
values.clear();
values.put(NoteColumns.ID, Notes.ID_CAPSULE_FOLDER);
values.put(NoteColumns.TYPE, Notes.TYPE_SYSTEM);
db.insert(TABLE.NOTE, null, values);
}
/**
@ -578,6 +591,30 @@ 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++;
}
// 从V13升级到V14
if (oldVersion == 13) {
upgradeToV14(db);
oldVersion++;
}
// 如果需要,重新创建触发器
if (reCreateTriggers) {
reCreateNoteTableTriggers(db);
@ -674,13 +711,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 +896,106 @@ 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);
}
}
/**
* V14
* <p>
*
* </p>
*
* @param db SQLiteDatabase
*/
private void upgradeToV14(SQLiteDatabase db) {
try {
ContentValues values = new ContentValues();
values.put(NoteColumns.ID, Notes.ID_CAPSULE_FOLDER);
values.put(NoteColumns.TYPE, Notes.TYPE_SYSTEM);
db.insert(TABLE.NOTE, null, values);
Log.i(TAG, "Upgraded database to V14: Created Capsule system folder");
} catch (Exception e) {
Log.e(TAG, "Failed to create Capsule system folder in V14 upgrade", e);
}
}
/**
*
*
@ -861,21 +1017,21 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper {
// 工作模板
long workFolderId = insertFolder(db, Notes.ID_TEMPLATE_FOLDER, "工作");
if (workFolderId > 0) {
insertNote(db, workFolderId, "会议记录", "会议主题:\n时间\n地点\n参会人\n\n会议内容\n\n行动项\n");
insertNote(db, workFolderId, "周报", "本周工作总结:\n1. \n2. \n\n下周工作计划\n1. \n2. \n\n需要协调的问题\n");
insertNote(db, workFolderId, "会议记录", "会议主题:\n时间\n地点\n参会人\n\n会议内容\n\n行动项\n", Notes.TYPE_TEMPLATE);
insertNote(db, workFolderId, "周报", "本周工作总结:\n1. \n2. \n\n下周工作计划\n1. \n2. \n\n需要协调的问题\n", Notes.TYPE_TEMPLATE);
}
// 生活模板
long lifeFolderId = insertFolder(db, Notes.ID_TEMPLATE_FOLDER, "生活");
if (lifeFolderId > 0) {
insertNote(db, lifeFolderId, "日记", "日期:\n天气\n心情\n\n正文\n");
insertNote(db, lifeFolderId, "购物清单", "1. \n2. \n3. \n");
insertNote(db, lifeFolderId, "日记", "日期:\n天气\n心情\n\n正文\n", Notes.TYPE_TEMPLATE);
insertNote(db, lifeFolderId, "购物清单", "1. \n2. \n3. \n", Notes.TYPE_TEMPLATE);
}
// 学习模板
long studyFolderId = insertFolder(db, Notes.ID_TEMPLATE_FOLDER, "学习");
if (studyFolderId > 0) {
insertNote(db, studyFolderId, "读书笔记", "书名:\n作者\n\n核心观点\n\n精彩摘录\n\n读后感\n");
insertNote(db, studyFolderId, "读书笔记", "书名:\n作者\n\n核心观点\n\n精彩摘录\n\n读后感\n", Notes.TYPE_TEMPLATE);
}
}
@ -884,16 +1040,17 @@ 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);
return db.insert(TABLE.NOTE, null, values);
}
private void insertNote(SQLiteDatabase db, long parentId, String title, String content) {
private void insertNote(SQLiteDatabase db, long parentId, String title, String content, int type) {
ContentValues values = new ContentValues();
values.put(NoteColumns.PARENT_ID, parentId);
values.put(NoteColumns.TYPE, Notes.TYPE_NOTE);
values.put(NoteColumns.TYPE, type);
values.put(NoteColumns.CREATED_DATE, System.currentTimeMillis());
values.put(NoteColumns.MODIFIED_DATE, System.currentTimeMillis());
values.put(NoteColumns.SNIPPET, content); // SNIPPET acts as content preview or full content for simple notes

@ -168,7 +168,7 @@ public class NotesProvider extends ContentProvider {
+ " FROM " + TABLE.NOTE
+ " WHERE " + NoteColumns.SNIPPET + " LIKE ?"
+ " AND " + NoteColumns.PARENT_ID + "<>" + Notes.ID_TRASH_FOLER
+ " AND " + NoteColumns.TYPE + "=" + Notes.TYPE_NOTE;
+ " AND (" + NoteColumns.TYPE + "=" + Notes.TYPE_NOTE + " OR " + NoteColumns.TYPE + "=" + Notes.TYPE_TEMPLATE + ")";
/**
* Content Provider

@ -30,6 +30,9 @@ 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 net.micode.notes.tool.ResourceParser;
import java.util.ArrayList;
import java.util.HashMap;
@ -86,6 +89,10 @@ public class NotesRepository {
return parentId;
}
public void setParentId(long parentId) {
this.parentId = parentId;
}
public String getNoteDataValue() {
return snippet;
}
@ -102,6 +109,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 +152,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 +174,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 +212,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");
@ -296,11 +313,17 @@ public class NotesRepository {
selection = NoteColumns.TYPE + "=" + Notes.TYPE_NOTE + " AND " +
NoteColumns.PARENT_ID + "<>" + Notes.ID_TRASH_FOLER;
selectionArgs = null;
} else if (folderId == Notes.ID_TEMPLATE_FOLDER) {
// Special case for template folder: show all templates regardless of category
selection = NoteColumns.TYPE + "=" + Notes.TYPE_TEMPLATE;
selectionArgs = null;
} else if (folderId == Notes.ID_ROOT_FOLDER) {
selection = "(" + NoteColumns.TYPE + "=" + Notes.TYPE_NOTE + " AND " + NoteColumns.PARENT_ID + "=?)";
selectionArgs = new String[]{String.valueOf(Notes.ID_ROOT_FOLDER)};
} else {
selection = NoteColumns.PARENT_ID + "=? AND " + NoteColumns.TYPE + "=" + Notes.TYPE_NOTE;
// In a sub-folder, show both normal notes and templates if they exist there
selection = NoteColumns.PARENT_ID + "=? AND (" + NoteColumns.TYPE + "=" + Notes.TYPE_NOTE +
" OR " + NoteColumns.TYPE + "=" + Notes.TYPE_TEMPLATE + ")";
selectionArgs = new String[]{String.valueOf(folderId)};
}
@ -406,6 +429,23 @@ public class NotesRepository {
return notes;
}
/**
*
*
* @param noteId ID
* @param callback
*/
public void getNoteInfo(long noteId, Callback<NoteInfo> callback) {
executor.execute(() -> {
try {
NoteInfo noteInfo = getFolderInfo(noteId);
callback.onSuccess(noteInfo);
} catch (Exception e) {
callback.onError(e);
}
});
}
/**
*
*
@ -552,6 +592,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);
@ -592,13 +633,29 @@ public class NotesRepository {
ContentValues values = new ContentValues();
long currentTime = System.currentTimeMillis();
int type = Notes.TYPE_NOTE;
if (folderId == Notes.ID_TEMPLATE_FOLDER) {
type = Notes.TYPE_TEMPLATE;
} else if (folderId > 0) {
// Check if folder is under templates
NoteInfo folder = getFolderInfo(folderId);
if (folder != null && folder.parentId == Notes.ID_TEMPLATE_FOLDER) {
type = Notes.TYPE_TEMPLATE;
}
}
values.put(NoteColumns.PARENT_ID, folderId);
values.put(NoteColumns.TYPE, Notes.TYPE_NOTE);
values.put(NoteColumns.TYPE, type);
values.put(NoteColumns.CREATED_DATE, currentTime);
values.put(NoteColumns.MODIFIED_DATE, currentTime);
values.put(NoteColumns.LOCAL_MODIFIED, 1);
values.put(NoteColumns.SNIPPET, "");
// 设置默认背景颜色(支持随机背景设置)
if (context != null) {
values.put(NoteColumns.BG_COLOR_ID, ResourceParser.getDefaultBgId(context));
}
Uri uri = contentResolver.insert(Notes.CONTENT_NOTE_URI, values);
Long noteId = 0L;
@ -1215,6 +1272,176 @@ 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保持一致性文件夹名存储在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.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>
@ -1299,7 +1526,7 @@ public class NotesRepository {
long currentTime = System.currentTimeMillis();
values.put(NoteColumns.PARENT_ID, categoryId);
values.put(NoteColumns.TYPE, Notes.TYPE_NOTE);
values.put(NoteColumns.TYPE, Notes.TYPE_TEMPLATE);
values.put(NoteColumns.CREATED_DATE, currentTime);
values.put(NoteColumns.MODIFIED_DATE, currentTime);
values.put(NoteColumns.LOCAL_MODIFIED, 1);
@ -1332,6 +1559,23 @@ public class NotesRepository {
});
}
/**
*
*
* @param noteId ID
* @param callback
*/
public void getNoteContent(long noteId, Callback<String> callback) {
executor.execute(() -> {
try {
String content = getNoteContent(noteId);
callback.onSuccess(content);
} catch (Exception e) {
callback.onError(e);
}
});
}
private String getNoteContent(long noteId) {
String content = "";
Cursor cursor = contentResolver.query(
@ -1375,4 +1619,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);
@ -409,8 +425,9 @@ public class Note {
try {
ContentProviderResult[] results = context.getContentResolver().applyBatch(
Notes.AUTHORITY, operationList);
return (results == null || results.length == 0 || results[0] == null) ? null
: ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId);
if (results == null || results.length == 0 || results[0] == null) {
return null;
}
} catch (RemoteException e) {
Log.e(TAG, String.format("%s: %s", e.toString(), e.getMessage()));
return null;
@ -419,7 +436,52 @@ public class Note {
return null;
}
}
return null;
return ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId);
}
// ==================== 云同步相关方法 ====================
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";
@ -94,6 +115,7 @@ public class WorkingNote {
DataColumns.DATA2,
DataColumns.DATA3,
DataColumns.DATA4,
DataColumns.DATA5,
};
/** 数据查询投影 - 笔记元数据 */
@ -103,8 +125,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 +160,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 +190,37 @@ public class WorkingNote {
mIsDeleted = false;
mMode = 0;
mWidgetType = Notes.TYPE_WIDGET_INVALIDE;
mLocalModified = 1; // 新建笔记需要同步
// Determine type based on folder
if (folderId == Notes.ID_TEMPLATE_FOLDER) {
mType = Notes.TYPE_TEMPLATE;
} else if (folderId > 0) {
// Check if parent is template folder
int parentType = net.micode.notes.tool.DataUtils.getNoteTypeById(context.getContentResolver(), folderId);
if (parentType == Notes.TYPE_FOLDER) {
// We need to check the folder's parent
long parentId = 0;
android.database.Cursor c = context.getContentResolver().query(
android.content.ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, folderId),
new String[] { NoteColumns.PARENT_ID }, null, null, null);
if (c != null) {
if (c.moveToFirst()) {
parentId = c.getLong(0);
}
c.close();
}
if (parentId == Notes.ID_TEMPLATE_FOLDER) {
mType = Notes.TYPE_TEMPLATE;
} else {
mType = Notes.TYPE_NOTE;
}
} else {
mType = Notes.TYPE_NOTE;
}
} else {
mType = Notes.TYPE_NOTE;
}
}
/**
@ -200,6 +262,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 +271,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 {
@ -238,6 +317,17 @@ public class WorkingNote {
mContent = cursor.getString(DATA_CONTENT_COLUMN);
mMode = cursor.getInt(DATA_MODE_COLUMN);
mNote.setTextDataId(cursor.getLong(DATA_ID_COLUMN));
// 加载壁纸路径
int wallpaperIndex = cursor.getColumnIndex(DataColumns.DATA5);
if (wallpaperIndex != -1) {
String path = cursor.getString(wallpaperIndex);
if (!TextUtils.isEmpty(path)) {
mWallpaperPath = path;
} else {
mWallpaperPath = null;
}
}
} else if (DataConstants.CALL_NOTE.equals(type)) {
// 加载通话记录数据
mNote.setCallDataId(cursor.getLong(DATA_ID_COLUMN));
@ -258,7 +348,7 @@ public class WorkingNote {
* <p>
*
* </p>
*
*
* @param context
* @param folderId ID
* @param widgetId Widget ID
@ -275,6 +365,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>
@ -302,9 +407,9 @@ public class WorkingNote {
public synchronized boolean saveNote() {
if (isWorthSaving()) {
if (!existInDatabase()) {
// 创建新笔记
if ((mNoteId = Note.getNewNoteId(mContext, mFolderId)) == 0) {
Log.e(TAG, "Create new note fail with id:" + mNoteId);
// 创建新笔记
if ((mNoteId = Note.getNewNoteId(mContext, mFolderId, mType)) == 0) {
Log.e(TAG, "Create new note fail with id:" + mNoteId);
return false;
}
}
@ -349,7 +454,7 @@ public class WorkingNote {
* @return true false
*/
private boolean isWorthSaving() {
if (mIsDeleted || (!existInDatabase() && TextUtils.isEmpty(mContent))
if (mIsDeleted || (!existInDatabase() && TextUtils.isEmpty(mContent) && TextUtils.isEmpty(mWallpaperPath))
|| (existInDatabase() && !mNote.isLocalModified())) {
return false;
} else {
@ -377,6 +482,12 @@ public class WorkingNote {
*/
public void setTitle(String title) {
mTitle = title;
mNote.setNoteValue(NoteColumns.TITLE, mTitle);
// 只有文件夹需要将标题同步到SNIPPET字段文件夹名存储在SNIPPET中以保持兼容性
// 普通便签的SNIPPET应由内容触发器自动维护
if (mType == Notes.TYPE_FOLDER) {
mNote.setNoteValue(NoteColumns.SNIPPET, mTitle);
}
}
public String getTitle() {
@ -431,16 +542,12 @@ public class WorkingNote {
private String mWallpaperPath;
public void setWallpaper(String path) {
mWallpaperPath = path;
// Ideally we should save this to DB, but for now we might use shared prefs or a separate table
// Or reuse bg_color_id with a special flag if we want to stick to existing schema strictly?
// Better: store in a new column or reuse a data column if possible.
// Given existing schema, let's use DataColumns.DATA5 if available? No DATA5.
// Let's use a SharedPreference for mapping noteId -> wallpaperPath for now to avoid schema migration complexity in this step.
// Or just use a special negative color ID range for wallpapers?
// Actually, let's use a separate storage for wallpapers map: note_id -> uri string
if (mNoteSettingStatusListener != null) {
mNoteSettingStatusListener.onBackgroundColorChanged(); // Reuse this to trigger refresh
if (!TextUtils.equals(mWallpaperPath, path)) {
mWallpaperPath = path;
mNote.setTextData(DataColumns.DATA5, mWallpaperPath);
if (mNoteSettingStatusListener != null) {
mNoteSettingStatusListener.onBackgroundColorChanged(); // Reuse this to trigger refresh
}
}
}
@ -621,13 +728,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);
}
}

@ -116,6 +116,27 @@ public class BackupUtils {
return mTextExport.exportToText();
}
/**
*
*
* @param noteId ID
* @param title
* @return
*/
public int exportNoteToText(String noteId, String title) {
return mTextExport.exportNoteToText(noteId, title);
}
/**
*
*
* @param noteIds ID
* @return
*/
public int exportNotesToText(java.util.List<Long> noteIds) {
return mTextExport.exportNotesToText(noteIds);
}
/**
*
*
@ -310,6 +331,72 @@ public class BackupUtils {
}
}
/**
*
*
* @param noteId ID
* @param title
* @return
*/
public int exportNoteToText(String noteId, String title) {
if (!externalStorageAvailable()) {
Log.d(TAG, "Media was not mounted");
return STATE_SD_CARD_UNMOUONTED;
}
PrintStream ps = getExportNotePrintStream(title);
if (ps == null) {
Log.e(TAG, "get print stream error");
return STATE_SYSTEM_ERROR;
}
exportNoteToText(noteId, ps);
ps.close();
return STATE_SUCCESS;
}
/**
*
*
* @param noteIds ID
* @return
*/
public int exportNotesToText(java.util.List<Long> noteIds) {
if (!externalStorageAvailable()) {
Log.d(TAG, "Media was not mounted");
return STATE_SD_CARD_UNMOUONTED;
}
PrintStream ps = getExportToTextPrintStream();
if (ps == null) {
Log.e(TAG, "get print stream error");
return STATE_SYSTEM_ERROR;
}
for (Long noteId : noteIds) {
Cursor noteCursor = mContext.getContentResolver().query(
net.micode.notes.data.Notes.CONTENT_NOTE_URI,
NOTE_PROJECTION,
net.micode.notes.data.Notes.NoteColumns.ID + "=?",
new String[]{String.valueOf(noteId)},
null);
if (noteCursor != null) {
if (noteCursor.moveToFirst()) {
ps.println(String.format(getFormat(FORMAT_NOTE_DATE), DateFormat.format(
mContext.getString(R.string.format_datetime_mdhm),
noteCursor.getLong(NOTE_COLUMN_MODIFIED_DATE))));
exportNoteToText(String.valueOf(noteId), ps);
}
noteCursor.close();
}
}
ps.close();
return STATE_SUCCESS;
}
/**
*
* <p>
@ -383,6 +470,31 @@ public class BackupUtils {
return STATE_SUCCESS;
}
/**
*
*
* @param title
* @return PrintStream null
*/
private PrintStream getExportNotePrintStream(String title) {
File file = generateFileWithTitle(mContext, R.string.file_path, title);
if (file == null) {
Log.e(TAG, "create file to exported failed");
return null;
}
mFileName = file.getName();
mFileDirectory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getAbsolutePath();
PrintStream ps = null;
try {
FileOutputStream fos = new FileOutputStream(file);
ps = new PrintStream(fos);
} catch (FileNotFoundException e) {
e.printStackTrace();
return null;
}
return ps;
}
/**
*
* <p>
@ -399,7 +511,7 @@ public class BackupUtils {
return null;
}
mFileName = file.getName();
mFileDirectory = mContext.getString(R.string.file_path);
mFileDirectory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getAbsolutePath();
PrintStream ps = null;
try {
FileOutputStream fos = new FileOutputStream(file);
@ -416,31 +528,62 @@ public class BackupUtils {
}
/**
* SD
*
*
* @param context
* @param filePathResId ID (使)
* @param title
* @return null
*/
private static File generateFileWithTitle(Context context, int filePathResId, String title) {
// 清理文件名中的非法字符
String fileName = title.replaceAll("[\\\\/:*?\"<>|]", "_");
if (TextUtils.isEmpty(fileName)) {
fileName = "untitled";
}
fileName = fileName + "_" + System.currentTimeMillis() + ".txt";
File filedir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
File file = new File(filedir, fileName);
try {
if (!filedir.exists()) {
filedir.mkdirs();
}
if (!file.exists()) {
file.createNewFile();
}
return file;
} catch (SecurityException | IOException e) {
e.printStackTrace();
}
return null;
}
/**
*
* <p>
*
* </p>
*
* @param context
* @param filePathResId ID
* @param filePathResId ID (使)
* @param fileNameFormatResId ID
* @return null
*/
private static File generateFileMountedOnSDcard(Context context, int filePathResId, int fileNameFormatResId) {
StringBuilder sb = new StringBuilder();
sb.append(Environment.getExternalStorageDirectory());
sb.append(context.getString(filePathResId));
File filedir = new File(sb.toString());
sb.append(context.getString(
File filedir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
String fileName = context.getString(
fileNameFormatResId,
DateFormat.format(context.getString(R.string.format_date_ymd),
System.currentTimeMillis())));
File file = new File(sb.toString());
System.currentTimeMillis()));
File file = new File(filedir, fileName);
try {
if (!filedir.exists()) {
// 创建目录
filedir.mkdir();
filedir.mkdirs();
}
if (!file.exists()) {
// 创建文件

@ -182,10 +182,22 @@ public class DataUtils {
* @return true false
*/
public static boolean visibleInNoteDatabase(ContentResolver resolver, long noteId, int type) {
String selection;
String[] selectionArgs;
if (type == Notes.TYPE_NOTE) {
// If checking for a regular note, also allow templates as they are essentially notes
selection = "(" + NoteColumns.TYPE + "=? OR " + NoteColumns.TYPE + "=?) AND " + NoteColumns.PARENT_ID + "<>" + Notes.ID_TRASH_FOLER;
selectionArgs = new String[] {String.valueOf(Notes.TYPE_NOTE), String.valueOf(Notes.TYPE_TEMPLATE)};
} else {
selection = NoteColumns.TYPE + "=? AND " + NoteColumns.PARENT_ID + "<>" + Notes.ID_TRASH_FOLER;
selectionArgs = new String [] {String.valueOf(type)};
}
Cursor cursor = resolver.query(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId),
null,
NoteColumns.TYPE + "=? AND " + NoteColumns.PARENT_ID + "<>" + Notes.ID_TRASH_FOLER,
new String [] {String.valueOf(type)},
selection,
selectionArgs,
null);
boolean exist = false;

@ -0,0 +1,117 @@
package net.micode.notes.tool;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.net.Uri;
import android.view.View;
import android.widget.ScrollView;
import androidx.core.widget.NestedScrollView;
import androidx.core.content.FileProvider;
import android.os.Environment;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
/**
*
* <p>
* View
* </p>
*/
public class ImageExportHelper {
private static final String TAG = "ImageExportHelper";
/**
* View Bitmap
*
* @param view View
* @return Bitmap
*/
public static Bitmap viewToBitmap(View view) {
if (view instanceof ScrollView || view instanceof NestedScrollView) {
int height = 0;
if (view instanceof ScrollView) {
ScrollView scrollView = (ScrollView) view;
for (int i = 0; i < scrollView.getChildCount(); i++) {
height += scrollView.getChildAt(i).getHeight();
}
} else {
NestedScrollView nestedScrollView = (NestedScrollView) view;
for (int i = 0; i < nestedScrollView.getChildCount(); i++) {
height += nestedScrollView.getChildAt(i).getHeight();
}
}
Bitmap bitmap = Bitmap.createBitmap(view.getWidth(), height, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
canvas.drawColor(Color.WHITE);
view.draw(canvas);
return bitmap;
} else {
Bitmap bitmap = Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
canvas.drawColor(Color.WHITE);
view.draw(canvas);
return bitmap;
}
}
/**
* Bitmap
*
* @param context
* @param bitmap Bitmap
* @param title
* @return Uri null
*/
public static Uri saveBitmapToExternal(Context context, Bitmap bitmap, String title) {
String fileName = title.replaceAll("[\\\\/:*?\"<>|]", "_");
if (fileName.isEmpty()) {
fileName = "untitled";
}
fileName += "_" + System.currentTimeMillis() + ".png";
File filedir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
File file = new File(filedir, fileName);
try {
if (!filedir.exists()) {
filedir.mkdirs();
}
FileOutputStream stream = new FileOutputStream(file);
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream);
stream.close();
return FileProvider.getUriForFile(context, context.getPackageName() + ".fileprovider", file);
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
/**
* Bitmap Uri
*
* @param context
* @param bitmap Bitmap
* @return Uri null
*/
public static Uri saveBitmapToCache(Context context, Bitmap bitmap) {
File cachePath = new File(context.getCacheDir(), "images");
cachePath.mkdirs();
try {
File file = new File(cachePath, "note_export_" + System.currentTimeMillis() + ".png");
FileOutputStream stream = new FileOutputStream(file);
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream);
stream.close();
return FileProvider.getUriForFile(context, context.getPackageName() + ".fileprovider", file);
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}

@ -0,0 +1,92 @@
package net.micode.notes.tool;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.os.Build;
import androidx.preference.PreferenceManager;
import java.util.Locale;
public class LocaleHelper {
private static final String SELECTED_LANGUAGE = "Locale.Helper.Selected.Language";
public static Context onAttach(Context context) {
String lang = getPersistedData(context, Locale.getDefault().getLanguage());
return setLocale(context, lang);
}
public static Context onAttach(Context context, String defaultLanguage) {
String lang = getPersistedData(context, defaultLanguage);
return setLocale(context, lang);
}
public static String getLanguage(Context context) {
return getPersistedData(context, Locale.getDefault().getLanguage());
}
public static Context setLocale(Context context, String language) {
persist(context, language);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
return updateResources(context, language);
}
return updateResourcesLegacy(context, language);
}
private static String getPersistedData(Context context, String defaultLanguage) {
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
// 默认语言改为中文 (zh-CN)
return preferences.getString(SELECTED_LANGUAGE, "zh-CN");
}
private static void persist(Context context, String language) {
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
SharedPreferences.Editor editor = preferences.edit();
editor.putString(SELECTED_LANGUAGE, language);
editor.apply();
}
private static Context updateResources(Context context, String language) {
Locale locale = getLocale(language);
Locale.setDefault(locale);
Configuration configuration = context.getResources().getConfiguration();
configuration.setLocale(locale);
configuration.setLayoutDirection(locale);
return context.createConfigurationContext(configuration);
}
private static Context updateResourcesLegacy(Context context, String language) {
Locale locale = getLocale(language);
Locale.setDefault(locale);
Resources resources = context.getResources();
Configuration configuration = resources.getConfiguration();
configuration.locale = locale;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
configuration.setLayoutDirection(locale);
}
resources.updateConfiguration(configuration, resources.getDisplayMetrics());
return context;
}
private static Locale getLocale(String language) {
if (language.equals("zh-CN")) {
return Locale.SIMPLIFIED_CHINESE;
} else if (language.equals("zh-TW")) {
return Locale.TRADITIONAL_CHINESE;
} else if (language.equals("en")) {
return Locale.ENGLISH;
} else if (language.equals("system")) {
return Locale.getDefault();
}
return new Locale(language);
}
}

@ -0,0 +1,98 @@
package net.micode.notes.tool;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.pdf.PdfDocument;
import android.os.Environment;
import android.text.Layout;
import android.text.StaticLayout;
import android.text.TextPaint;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
/**
* PDF
* <p>
* 使 Android PdfDocument API PDF
* </p>
*/
public class PdfExportHelper {
private static final String TAG = "PdfExportHelper";
/**
* PDF
*
* @param context
* @param title
* @param content
* @return PDF null
*/
public static File exportToPdf(Context context, String title, String content) {
PdfDocument document = new PdfDocument();
// A4 纸张大小 (595 x 842 points)
PdfDocument.PageInfo pageInfo = new PdfDocument.PageInfo.Builder(595, 842, 1).create();
PdfDocument.Page page = document.startPage(pageInfo);
Canvas canvas = page.getCanvas();
TextPaint titlePaint = new TextPaint();
titlePaint.setColor(Color.BLACK);
titlePaint.setTextSize(24);
titlePaint.setFakeBoldText(true);
TextPaint contentPaint = new TextPaint();
contentPaint.setColor(Color.BLACK);
contentPaint.setTextSize(14);
int x = 50;
int y = 50;
// 绘制标题
canvas.drawText(title, x, y, titlePaint);
y += 40;
// 绘制正文(自动换行)
StaticLayout staticLayout;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
staticLayout = StaticLayout.Builder.obtain(content, 0, content.length(), contentPaint, pageInfo.getPageWidth() - 100)
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setLineSpacing(0.0f, 1.2f)
.setIncludePad(false)
.build();
} else {
staticLayout = new StaticLayout(content, contentPaint, pageInfo.getPageWidth() - 100,
Layout.Alignment.ALIGN_NORMAL, 1.2f, 0.0f, false);
}
canvas.save();
canvas.translate(x, y);
staticLayout.draw(canvas);
canvas.restore();
document.finishPage(page);
// 生成文件名
String fileName = title.replaceAll("[\\\\/:*?\"<>|]", "_");
if (fileName.isEmpty()) {
fileName = "untitled";
}
fileName += "_" + System.currentTimeMillis() + ".pdf";
File file = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), fileName);
try {
FileOutputStream fos = new FileOutputStream(file);
document.writeTo(fos);
fos.close();
} catch (IOException e) {
e.printStackTrace();
file = null;
} finally {
document.close();
}
return file;
}
}

@ -51,6 +51,12 @@ public class ResourceParser {
public static final int EYE_CARE_GREEN = 6;
public static final int WARM = 7;
public static final int COOL = 8;
// Gradient Presets
public static final int SUNSET = 9;
public static final int OCEAN = 10;
public static final int FOREST = 11;
public static final int LAVENDER = 12;
/** 自定义颜色按钮 ID (用于 UI 显示) */
public static final int CUSTOM_COLOR_BUTTON_ID = -100;
@ -108,7 +114,15 @@ public class ResourceParser {
R.drawable.edit_blue,
R.drawable.edit_white,
R.drawable.edit_green,
R.drawable.edit_red
R.drawable.edit_red,
R.color.bg_midnight_black,
R.color.bg_eye_care_green,
R.color.bg_warm,
R.color.bg_cool,
R.drawable.preset_sunset,
R.drawable.preset_ocean,
R.drawable.preset_forest,
R.drawable.preset_lavender
};
/** 标题栏背景资源数组 */

@ -0,0 +1,167 @@
package net.micode.notes.tool;
import android.content.Context;
import android.os.Build;
import android.text.Spannable;
import android.text.style.URLSpan;
import android.view.textclassifier.TextClassificationManager;
import android.view.textclassifier.TextClassifier;
import android.view.textclassifier.TextLinks;
import java.util.Collection;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
*
* <p>
* Android AI (TextClassifier)
* URL
* </p>
*/
public class SmartParser {
// 时间 Scheme 前缀
public static final String SCHEME_TIME = "smarttime:";
// 地点 Scheme 前缀
public static final String SCHEME_GEO = "smartgeo:";
// 优化的时间正则表达式(作为补充)
private static final String TIME_REGEX =
"((今天|明天|后天|下周[一二三四五六日])?\\s*([上下]午)?\\s*(\\d{1,2})[:点](\\d{0,2})分?)" +
"|(\\b\\d{1,2}:\\d{2}\\b)";
// 优化的地点正则表达式:匹配 1-10 个中文字符/数字 + 常见的地点后缀
private static final String GEO_REGEX =
"([一-龥0-9]{1,10}(?:省|市|区|县|街道|路|弄|巷|楼|院|场|店|里|广场|大厦|中心|医院|学校|大学|公园|车站|机场|酒店|宾馆|超市|商场))";
// 扩展噪音词列表:包含动词、代词、时间单位和方位词
private static final String NOISE_PREFIXES = "我在去到从的地了你他们这那点分时上下午";
/**
*
*
* @param context
* @param text
*/
public static void parse(Context context, Spannable text) {
if (text == null || text.length() == 0) return;
// 1. 清除旧的智能链接
URLSpan[] allSpans = text.getSpans(0, text.length(), URLSpan.class);
for (URLSpan span : allSpans) {
String url = span.getURL();
if (url != null && (url.startsWith(SCHEME_TIME) || url.startsWith(SCHEME_GEO))) {
text.removeSpan(span);
}
}
// 2. 识别逻辑
// 步骤 A: 优先识别时间(因为时间格式相对固定,误报率低)
applyRegexLinks(text, Pattern.compile(TIME_REGEX, Pattern.CASE_INSENSITIVE), SCHEME_TIME);
// 步骤 B: 识别地点(地点正则较宽松,需要避开已识别的时间)
applyRegexLinks(text, Pattern.compile(GEO_REGEX), SCHEME_GEO);
// 3. 使用系统级 AI (TextClassifier) 作为增强
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
try {
TextClassificationManager tcm = context.getSystemService(TextClassificationManager.class);
if (tcm != null) {
TextClassifier classifier = tcm.getTextClassifier();
TextLinks.Request request = new TextLinks.Request.Builder(text).build();
TextLinks links = classifier.generateLinks(request);
for (TextLinks.TextLink link : links.getLinks()) {
String entityType = getTopEntity(link);
if ("address".equals(entityType) || "location".equals(entityType) || "place".equals(entityType)) {
applySmartSpan(text, link.getStart(), link.getEnd(), SCHEME_GEO);
} else if ("date".equals(entityType) || "datetime".equals(entityType)) {
applySmartSpan(text, link.getStart(), link.getEnd(), SCHEME_TIME);
}
}
}
} catch (Exception e) {
// 静默回退
}
}
}
/**
*
*/
private static String getTopEntity(TextLinks.TextLink link) {
float maxConfidence = -1;
String topType = null;
for (int i = 0; i < link.getEntityCount(); i++) {
String type = link.getEntity(i);
float confidence = link.getConfidenceScore(type);
if (confidence > maxConfidence) {
maxConfidence = confidence;
topType = type;
}
}
return topType;
}
/**
* Span
*/
private static void applySmartSpan(Spannable text, int start, int end, String scheme) {
// 1. 噪音修剪(特别是地点识别)
if (SCHEME_GEO.equals(scheme)) {
while (start < end && NOISE_PREFIXES.indexOf(text.charAt(start)) != -1) {
start++;
}
}
if (start >= end) return;
// 2. 处理重叠冲突
URLSpan[] existing = text.getSpans(start, end, URLSpan.class);
if (existing.length > 0) {
for (URLSpan span : existing) {
int spanStart = text.getSpanStart(span);
int spanEnd = text.getSpanEnd(span);
// 如果当前识别结果完全落在已有 span 内部,则跳过
if (start >= spanStart && end <= spanEnd) {
return;
}
// 如果当前识别结果包含了已有 span尝试修剪当前结果的起始位置
if (start < spanEnd && end > spanStart) {
// 如果重叠发生在开头,将起始位置移动到已有 span 之后
if (start < spanEnd) {
start = spanEnd;
}
}
}
}
// 再次检查修剪后的合法性
if (start >= end) return;
// 针对地点识别,修剪后可能剩下的是噪音或过短
if (SCHEME_GEO.equals(scheme)) {
// 再次修剪新起点处的噪音
while (start < end && NOISE_PREFIXES.indexOf(text.charAt(start)) != -1) {
start++;
}
// 如果剩下的文本太短(如只有 1 个字且不是后缀),则放弃
if (end - start < 2) return;
}
text.setSpan(new SmartURLSpan(scheme + text.subSequence(start, end)),
start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
/**
*
*/
private static void applyRegexLinks(Spannable text, Pattern pattern, String scheme) {
Matcher m = pattern.matcher(text);
while (m.find()) {
applySmartSpan(text, m.start(), m.end(), scheme);
}
}
}

@ -0,0 +1,122 @@
package net.micode.notes.tool;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.provider.AlarmClock;
import android.text.style.URLSpan;
import android.util.Log;
import android.view.View;
import android.widget.Toast;
import net.micode.notes.R;
import java.util.Calendar;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* URLSpan
*/
public class SmartURLSpan extends URLSpan {
private static final String TAG = "SmartURLSpan";
public SmartURLSpan(String url) {
super(url);
}
@Override
public void onClick(View widget) {
String url = getURL();
Context context = widget.getContext();
if (url.startsWith(SmartParser.SCHEME_TIME)) {
handleTimeClick(context, url.substring(SmartParser.SCHEME_TIME.length()));
} else if (url.startsWith(SmartParser.SCHEME_GEO)) {
handleGeoClick(context, url.substring(SmartParser.SCHEME_GEO.length()));
} else {
super.onClick(widget);
}
}
/**
*
*/
private void handleTimeClick(Context context, String timeStr) {
try {
int hour = -1;
int minute = 0;
// 尝试解析小时和分钟
// 支持格式如14:30, 10点30分, 9点
Pattern p = Pattern.compile("(\\d{1,2})[:点](\\d{0,2})");
Matcher m = p.matcher(timeStr);
if (m.find()) {
hour = Integer.parseInt(m.group(1));
String minStr = m.group(2);
if (minStr != null && !minStr.isEmpty()) {
minute = Integer.parseInt(minStr);
}
}
// 处理上下午
if (timeStr.contains("下午") && hour < 12) {
hour += 12;
} else if (timeStr.contains("上午") && hour == 12) {
hour = 0;
}
if (hour == -1) {
// 如果没解析出来,默认打开闹钟主界面
Intent intent = new Intent(AlarmClock.ACTION_SET_ALARM);
context.startActivity(intent);
return;
}
// 设置闹钟意图
Intent intent = new Intent(AlarmClock.ACTION_SET_ALARM)
.putExtra(AlarmClock.EXTRA_HOUR, hour)
.putExtra(AlarmClock.EXTRA_MINUTES, minute)
.putExtra(AlarmClock.EXTRA_SKIP_UI, false);
if (intent.resolveActivity(context.getPackageManager()) != null) {
context.startActivity(intent);
} else {
Log.w(TAG, "No activity found to handle set alarm, trying without resolveActivity");
try {
context.startActivity(intent);
} catch (Exception e2) {
Toast.makeText(context, "无法打开闹钟应用", Toast.LENGTH_SHORT).show();
}
}
} catch (Exception e) {
Log.e(TAG, "Failed to set alarm", e);
Toast.makeText(context, "解析时间失败", Toast.LENGTH_SHORT).show();
}
}
/**
*
*/
private void handleGeoClick(Context context, String location) {
try {
// 使用 geo:0,0?q=location 格式打开地图
Uri gmmIntentUri = Uri.parse("geo:0,0?q=" + Uri.encode(location));
Intent mapIntent = new Intent(Intent.ACTION_VIEW, gmmIntentUri);
if (mapIntent.resolveActivity(context.getPackageManager()) != null) {
context.startActivity(mapIntent);
} else {
Log.w(TAG, "No activity found to handle geo intent, trying web fallback");
// 如果没有地图应用支持 geo 协议,尝试搜索
Uri webUri = Uri.parse("https://www.google.com/maps/search/" + Uri.encode(location));
Intent webIntent = new Intent(Intent.ACTION_VIEW, webUri);
context.startActivity(webIntent);
}
} catch (Exception e) {
Log.e(TAG, "Failed to open map", e);
Toast.makeText(context, "无法打开地图应用", Toast.LENGTH_SHORT).show();
}
}
}

@ -39,6 +39,8 @@ import net.micode.notes.tool.DataUtils;
import java.io.IOException;
import net.micode.notes.tool.LocaleHelper;
/**
*
*
@ -55,6 +57,11 @@ import java.io.IOException;
* @see net.micode.notes.tool.DataUtils
*/
public class AlarmAlertActivity extends Activity implements OnClickListener, OnDismissListener {
@Override
protected void attachBaseContext(Context newBase) {
super.attachBaseContext(LocaleHelper.onAttach(newBase));
}
// 当前提醒的笔记ID
private long mNoteId;
// 笔记内容摘要
@ -218,8 +225,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,12 @@
package net.micode.notes.ui;
import android.content.Context;
import androidx.appcompat.app.AppCompatActivity;
import net.micode.notes.tool.LocaleHelper;
public class BaseActivity extends AppCompatActivity {
@Override
protected void attachBaseContext(Context newBase) {
super.attachBaseContext(LocaleHelper.onAttach(newBase));
}
}

@ -0,0 +1,71 @@
package net.micode.notes.ui;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.widget.Toast;
import android.util.Log;
import net.micode.notes.data.Notes;
import net.micode.notes.model.Note;
import net.micode.notes.capsule.CapsuleService;
public class CapsuleActionActivity extends Activity {
private static final String TAG = "CapsuleActionActivity";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
CharSequence text = getIntent().getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT);
String sourcePackage = null;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP_MR1) {
if (getReferrer() != null) {
sourcePackage = getReferrer().getAuthority(); // or getHost()
}
}
if (text != null) {
saveNote(text.toString(), sourcePackage);
// Notify CapsuleService to animate (if running)
Intent intent = new Intent("net.micode.notes.capsule.ACTION_SAVE_SUCCESS");
sendBroadcast(intent);
}
finish();
}
private void saveNote(String content, String source) {
new Thread(() -> {
try {
long noteId = Note.getNewNoteId(this, Notes.ID_CAPSULE_FOLDER);
Note note = new Note();
note.setTextData(Notes.DataColumns.CONTENT, content);
String summary = content.length() > 20 ? content.substring(0, 20) + "..." : content;
int firstLineEnd = content.indexOf('\n');
if (firstLineEnd > 0 && firstLineEnd < 20) {
summary = content.substring(0, firstLineEnd);
}
note.setNoteValue(Notes.NoteColumns.SNIPPET, summary);
if (source != null) {
note.setTextData(Notes.DataColumns.DATA3, source);
}
boolean success = note.syncNote(this, noteId);
runOnUiThread(() -> {
if (success) {
Toast.makeText(this, "已保存到胶囊", Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(this, "保存失败", Toast.LENGTH_SHORT).show();
}
});
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}

@ -0,0 +1,33 @@
package net.micode.notes.ui;
import android.os.Bundle;
import android.view.MenuItem;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import net.micode.notes.R;
public class CapsuleListActivity extends BaseActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_capsule_list);
if (savedInstanceState == null) {
getSupportFragmentManager().beginTransaction()
.replace(R.id.container, new CapsuleListFragment())
.commit();
}
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == android.R.id.home) {
finish();
return true;
}
return super.onOptionsItemSelected(item);
}
}

@ -0,0 +1,191 @@
package net.micode.notes.ui;
import android.content.Intent;
import android.database.Cursor;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;
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.model.Note;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
public class CapsuleListFragment extends Fragment {
private RecyclerView mRecyclerView;
private CapsuleAdapter mAdapter;
private TextView mEmptyView;
private Toolbar mToolbar;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_capsule_list, container, false);
mRecyclerView = view.findViewById(R.id.capsule_list);
mEmptyView = view.findViewById(R.id.tv_empty);
mToolbar = view.findViewById(R.id.toolbar);
setupToolbar();
mRecyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
mAdapter = new CapsuleAdapter();
mRecyclerView.setAdapter(mAdapter);
return view;
}
private void setupToolbar() {
if (mToolbar != null && getActivity() instanceof AppCompatActivity) {
AppCompatActivity activity = (AppCompatActivity) getActivity();
activity.setSupportActionBar(mToolbar);
if (activity.getSupportActionBar() != null) {
activity.getSupportActionBar().setDisplayHomeAsUpEnabled(true);
activity.getSupportActionBar().setTitle("速记胶囊");
}
}
}
@Override
public void onResume() {
super.onResume();
loadCapsules();
}
private void loadCapsules() {
new Thread(() -> {
if (getContext() == null) return;
// Query notes in CAPSULE folder.
// Join with Data table to get DATA3 (source package)
Cursor cursor = getContext().getContentResolver().query(
Notes.CONTENT_NOTE_URI,
null,
Notes.NoteColumns.PARENT_ID + "=?",
new String[]{String.valueOf(Notes.ID_CAPSULE_FOLDER)},
Notes.NoteColumns.MODIFIED_DATE + " DESC"
);
List<CapsuleItem> items = new ArrayList<>();
if (cursor != null) {
while (cursor.moveToNext()) {
long id = cursor.getLong(cursor.getColumnIndexOrThrow(Notes.NoteColumns.ID));
String snippet = cursor.getString(cursor.getColumnIndexOrThrow(Notes.NoteColumns.SNIPPET));
long modifiedDate = cursor.getLong(cursor.getColumnIndexOrThrow(Notes.NoteColumns.MODIFIED_DATE));
// Try to get source from projection (if joined) or query separately
String source = "";
try {
int sourceIdx = cursor.getColumnIndex(Notes.DataColumns.DATA3);
if (sourceIdx != -1) {
source = cursor.getString(sourceIdx);
}
} catch (Exception e) {
// Not joined, ignore for now or lazy load
}
items.add(new CapsuleItem(id, snippet, modifiedDate, source));
}
cursor.close();
}
// Update UI
if (getActivity() != null) {
getActivity().runOnUiThread(() -> {
mAdapter.setItems(items);
mEmptyView.setVisibility(items.isEmpty() ? View.VISIBLE : View.GONE);
});
}
}).start();
}
private void openNoteEditor(long noteId) {
Intent intent = new Intent(getActivity(), NoteEditActivity.class);
intent.setAction(Intent.ACTION_VIEW);
intent.putExtra(Intent.EXTRA_UID, noteId);
startActivity(intent);
}
private static class CapsuleItem {
long id;
String summary;
long time;
String source;
public CapsuleItem(long id, String summary, long time, String source) {
this.id = id;
this.summary = summary;
this.time = time;
this.source = source;
}
}
private class CapsuleAdapter extends RecyclerView.Adapter<CapsuleAdapter.ViewHolder> {
private List<CapsuleItem> mItems = new ArrayList<>();
public void setItems(List<CapsuleItem> items) {
mItems = items;
notifyDataSetChanged();
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_capsule, parent, false);
return new ViewHolder(v);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
CapsuleItem item = mItems.get(position);
holder.tvSummary.setText(item.summary);
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault());
holder.tvTime.setText(sdf.format(new Date(item.time)));
if (item.source != null && !item.source.isEmpty()) {
holder.tvSource.setText(item.source);
holder.tvSource.setVisibility(View.VISIBLE);
} else {
holder.tvSource.setVisibility(View.GONE);
}
holder.itemView.setOnClickListener(v -> {
openNoteEditor(item.id);
});
}
@Override
public int getItemCount() {
return mItems.size();
}
class ViewHolder extends RecyclerView.ViewHolder {
TextView tvSummary, tvTime, tvSource;
public ViewHolder(@NonNull View itemView) {
super(itemView);
tvSummary = itemView.findViewById(R.id.tv_summary);
tvTime = itemView.findViewById(R.id.tv_time);
tvSource = itemView.findViewById(R.id.tv_source);
}
}
}
}

@ -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,91 @@
package net.micode.notes.ui;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import net.micode.notes.R;
import net.micode.notes.data.NotesRepository;
import java.util.ArrayList;
import java.util.List;
public class FolderAdapter extends RecyclerView.Adapter<FolderAdapter.FolderViewHolder> {
private Context context;
private List<NotesRepository.NoteInfo> folders;
private long selectedFolderId = -1;
private OnFolderClickListener listener;
public interface OnFolderClickListener {
void onFolderClick(long folderId);
}
public FolderAdapter(Context context) {
this.context = context;
this.folders = new ArrayList<>();
}
public void setFolders(List<NotesRepository.NoteInfo> folders) {
this.folders = folders != null ? folders : new ArrayList<>();
notifyDataSetChanged();
}
public void setSelectedFolderId(long folderId) {
this.selectedFolderId = folderId;
notifyDataSetChanged();
}
public void setOnFolderClickListener(OnFolderClickListener listener) {
this.listener = listener;
}
@NonNull
@Override
public FolderViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(context).inflate(R.layout.folder_tab_item, parent, false);
return new FolderViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull FolderViewHolder holder, int position) {
NotesRepository.NoteInfo folder = folders.get(position);
holder.bind(folder);
}
@Override
public int getItemCount() {
return folders.size();
}
class FolderViewHolder extends RecyclerView.ViewHolder {
TextView tvName;
public FolderViewHolder(View itemView) {
super(itemView);
tvName = itemView.findViewById(R.id.tv_folder_name);
itemView.setOnClickListener(v -> {
if (listener != null) {
int pos = getAdapterPosition();
if (pos != RecyclerView.NO_POSITION) {
listener.onFolderClick(folders.get(pos).getId());
}
}
});
}
public void bind(NotesRepository.NoteInfo folder) {
String name = folder.snippet; // Folder name is stored in snippet
if (name == null || name.isEmpty()) {
name = "Folder";
}
tvName.setText(name);
tvName.setSelected(folder.getId() == selectedFolderId);
}
}
}

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

@ -53,6 +53,8 @@ import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import android.graphics.Bitmap;
import android.net.Uri;
import net.micode.notes.R;
import net.micode.notes.data.Notes;
@ -61,7 +63,10 @@ import net.micode.notes.model.NoteCommand;
import net.micode.notes.model.UndoRedoManager;
import net.micode.notes.model.WorkingNote;
import net.micode.notes.model.WorkingNote.NoteSettingChangedListener;
import net.micode.notes.tool.BackupUtils;
import net.micode.notes.tool.DataUtils;
import net.micode.notes.tool.ImageExportHelper;
import net.micode.notes.tool.PdfExportHelper;
import net.micode.notes.tool.ResourceParser;
import net.micode.notes.tool.ResourceParser.TextAppearanceResources;
import net.micode.notes.ui.DateTimePickerDialog.OnDateTimeSetListener;
@ -77,14 +82,24 @@ import java.util.regex.Pattern;
import androidx.appcompat.app.AppCompatActivity;
import com.google.android.material.appbar.MaterialToolbar;
import com.google.android.material.bottomsheet.BottomSheetDialog;
import net.micode.notes.databinding.DialogBackgroundSelectorBinding;
import net.micode.notes.databinding.DialogColorPickerBinding;
import net.micode.notes.databinding.NoteEditBinding;
import net.micode.notes.tool.RichTextHelper;
import net.micode.notes.tool.SmartParser;
import net.micode.notes.data.FontManager;
public class NoteEditActivity extends AppCompatActivity implements OnClickListener,
import android.widget.RadioButton;
import android.widget.RadioGroup;
import java.io.File;
import java.util.ArrayList;
public class NoteEditActivity extends BaseActivity implements OnClickListener,
NoteSettingChangedListener, OnTextViewChangeListener {
/**
*
@ -162,7 +177,6 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
private UndoRedoManager mUndoRedoManager;
private boolean mInUndoRedo = false;
private androidx.recyclerview.widget.RecyclerView mColorSelectorRv;
private NoteColorAdapter mColorAdapter;
/**
@ -331,7 +345,7 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
* </p>
*/
private void initResources() {
mHeadViewPanel = binding.noteTitle;
mHeadViewPanel = binding.cvEditorSurface;
mNoteHeaderHolder = new HeadViewHolder();
mNoteHeaderHolder.tvModified = binding.tvModifiedDate;
mNoteHeaderHolder.ivAlertIcon = binding.ivAlertIcon;
@ -342,9 +356,8 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
mNoteHeaderHolder.etTitle = binding.etTitle;
mNoteEditor = binding.noteEditView;
mNoteEditorPanel = binding.svNoteEdit;
mNoteEditorPanel = binding.cvEditorSurface;
mNoteBgColorSelector = binding.noteBgColorSelector;
mColorSelectorRv = binding.rvBgColorSelector;
mNoteEditor.addTextChangedListener(new TextWatcher() {
private CharSequence mBeforeText;
@ -406,6 +419,10 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
ResourceParser.EYE_CARE_GREEN,
ResourceParser.WARM,
ResourceParser.COOL,
ResourceParser.SUNSET,
ResourceParser.OCEAN,
ResourceParser.FOREST,
ResourceParser.LAVENDER,
ResourceParser.CUSTOM_COLOR_BUTTON_ID,
ResourceParser.WALLPAPER_BUTTON_ID
);
@ -418,11 +435,11 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
pickWallpaper();
} else {
mWorkingNote.setBgColorId(colorId);
mWorkingNote.setWallpaper(null);
mNoteBgColorSelector.setVisibility(View.GONE);
}
}
});
mColorSelectorRv.setAdapter(mColorAdapter);
mFontSizeSelector = binding.fontSizeSelector;
for (int id : sFontSizeBtnsMap.keySet()) {
@ -447,7 +464,12 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
}
mSharedPrefs = PreferenceManager.getDefaultSharedPreferences(this);
mFontSizeId = mSharedPrefs.getInt(PREFERENCE_FONT_SIZE, ResourceParser.BG_DEFAULT_FONT_SIZE);
try {
String fontSizeStr = mSharedPrefs.getString(PREFERENCE_FONT_SIZE, String.valueOf(ResourceParser.BG_DEFAULT_FONT_SIZE));
mFontSizeId = Integer.parseInt(fontSizeStr);
} catch (ClassCastException e) {
mFontSizeId = mSharedPrefs.getInt(PREFERENCE_FONT_SIZE, ResourceParser.BG_DEFAULT_FONT_SIZE);
}
/**
* HACKME: Fix bug of store the resource id in shared preference.
* The id may larger than the length of resources, in this case,
@ -680,8 +702,7 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
public void onClick(View v) {
int id = v.getId();
if (id == R.id.btn_set_bg_color) {
mNoteBgColorSelector.setVisibility(View.VISIBLE);
// Note: Adapter selection is already set in onBackgroundColorChanged or init
showBackgroundSelector();
} else if (sFontSizeBtnsMap.containsKey(id)) {
View fontView = getFontSelectorView(sFontSelectorSelectionMap.get(mFontSizeId));
if (fontView != null) {
@ -764,27 +785,14 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
String wallpaperPath = mWorkingNote.getWallpaperPath();
if (wallpaperPath != null) {
// Load wallpaper
binding.ivNoteWallpaper.setVisibility(View.VISIBLE);
binding.viewBgMask.setVisibility(View.VISIBLE);
android.net.Uri uri = android.net.Uri.parse(wallpaperPath);
try {
java.io.InputStream inputStream = getContentResolver().openInputStream(uri);
android.graphics.Bitmap bitmap = android.graphics.BitmapFactory.decodeStream(inputStream);
android.graphics.drawable.BitmapDrawable drawable = new android.graphics.drawable.BitmapDrawable(getResources(), bitmap);
// Tiling mode (can be configurable later)
drawable.setTileModeXY(android.graphics.Shader.TileMode.REPEAT, android.graphics.Shader.TileMode.REPEAT);
// Add Blur Effect for Android 12+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
mNoteEditorPanel.setBackground(drawable);
mNoteEditorPanel.setRenderEffect(android.graphics.RenderEffect.createBlurEffect(
20f, 20f, android.graphics.Shader.TileMode.CLAMP));
} else {
mNoteEditorPanel.setBackground(drawable);
}
// Header always uses original wallpaper (or maybe slightly darker?)
mHeadViewPanel.setBackground(drawable.getConstantState().newDrawable());
binding.ivNoteWallpaper.setImageBitmap(bitmap);
// Dynamic Coloring with Palette
androidx.palette.graphics.Palette.from(bitmap).generate(palette -> {
@ -795,12 +803,14 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
} catch (Exception e) {
Log.e(TAG, "Failed to load wallpaper", e);
// Fallback to color
binding.ivNoteWallpaper.setVisibility(View.GONE);
binding.viewBgMask.setVisibility(View.GONE);
applyColorBackground(colorId);
}
} else {
binding.ivNoteWallpaper.setVisibility(View.GONE);
binding.viewBgMask.setVisibility(View.GONE);
applyColorBackground(colorId);
// Reset toolbar colors to default/theme
resetToolbarColors();
}
updateTextColor(colorId);
@ -809,30 +819,47 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
private void applyPaletteColors(androidx.palette.graphics.Palette palette) {
int primaryColor = palette.getDominantColor(getResources().getColor(R.color.primary_color));
int onPrimaryColor = getResources().getColor(R.color.on_primary_color);
int mutedColor = palette.getMutedColor(android.graphics.Color.WHITE);
// Ensure contrast for onPrimaryColor
if (androidx.core.graphics.ColorUtils.calculateContrast(onPrimaryColor, primaryColor) < 3.0) {
onPrimaryColor = android.graphics.Color.WHITE;
onPrimaryColor = isColorDark(primaryColor) ? android.graphics.Color.WHITE : android.graphics.Color.BLACK;
}
binding.toolbar.setBackgroundColor(primaryColor);
binding.toolbar.setTitleTextColor(onPrimaryColor);
if (binding.toolbar.getNavigationIcon() != null) {
binding.toolbar.getNavigationIcon().setTint(onPrimaryColor);
}
getWindow().setStatusBarColor(primaryColor);
// Update Card Surface - semi-transparent glass effect
int surfaceColor = androidx.core.graphics.ColorUtils.setAlphaComponent(mutedColor, 230); // 90% opacity
binding.cvEditorSurface.setCardBackgroundColor(surfaceColor);
// Update input text color based on surface color
int textColor = isColorDark(surfaceColor) ? android.graphics.Color.WHITE : android.graphics.Color.BLACK;
mNoteEditor.setTextColor(textColor);
if (mNoteHeaderHolder != null && mNoteHeaderHolder.etTitle != null) {
mNoteHeaderHolder.etTitle.setTextColor(textColor);
mNoteHeaderHolder.etTitle.setHintTextColor(androidx.core.graphics.ColorUtils.setAlphaComponent(textColor, 128));
}
binding.tvCharCount.setTextColor(textColor);
binding.tvModifiedDate.setTextColor(textColor);
}
private void resetToolbarColors() {
int primaryColor = getResources().getColor(R.color.primary_color);
int onPrimaryColor = getResources().getColor(R.color.on_primary_color);
binding.toolbar.setBackgroundColor(primaryColor);
binding.toolbar.setBackgroundColor(android.graphics.Color.TRANSPARENT);
binding.toolbar.setTitleTextColor(onPrimaryColor);
if (binding.toolbar.getNavigationIcon() != null) {
binding.toolbar.getNavigationIcon().setTint(onPrimaryColor);
}
getWindow().setStatusBarColor(primaryColor);
// Reset Card Surface
binding.cvEditorSurface.setCardBackgroundColor(android.graphics.Color.parseColor("#CCFFFFFF"));
}
private void updateTextColor(int colorId) {
@ -843,20 +870,27 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
textColor = android.graphics.Color.WHITE;
} else if (colorId < 0) {
// Custom color: Calculate luminance
// colorId is the ARGB value for custom colors
if (isColorDark(colorId)) {
textColor = android.graphics.Color.WHITE;
}
}
// For wallpaper, we might want to check palette, but for now default to black or keep current
// If wallpaper is set, this method is called with the underlying colorId.
// We should probably rely on the underlying color or default to white/black.
mNoteEditor.setTextColor(textColor);
// Also update title color if needed
if (mNoteHeaderHolder != null && mNoteHeaderHolder.etTitle != null) {
mNoteHeaderHolder.etTitle.setTextColor(textColor);
// If wallpaper is set, applyPaletteColors already handled text color.
if (mWorkingNote.getWallpaperPath() == null) {
mNoteEditor.setTextColor(textColor);
if (mNoteHeaderHolder != null && mNoteHeaderHolder.etTitle != null) {
mNoteHeaderHolder.etTitle.setTextColor(textColor);
mNoteHeaderHolder.etTitle.setHintTextColor(androidx.core.graphics.ColorUtils.setAlphaComponent(textColor, 128));
}
binding.tvCharCount.setTextColor(textColor);
binding.tvModifiedDate.setTextColor(textColor);
// Adjust card surface opacity for pure colors
if (colorId == ResourceParser.WHITE) {
binding.cvEditorSurface.setCardBackgroundColor(android.graphics.Color.WHITE);
} else {
binding.cvEditorSurface.setCardBackgroundColor(android.graphics.Color.parseColor("#CCFFFFFF"));
}
}
}
@ -868,27 +902,10 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
}
private void applyColorBackground(int colorId) {
mNoteEditorPanel.setBackgroundResource(mWorkingNote.getBgColorResId());
mHeadViewPanel.setBackgroundResource(mWorkingNote.getTitleBgResId());
if (colorId >= ResourceParser.MIDNIGHT_BLACK || colorId < 0) {
int color = ResourceParser.getNoteBgColor(this, colorId);
if (mNoteEditorPanel.getBackground() != null) {
mNoteEditorPanel.getBackground().setTint(color);
mNoteEditorPanel.getBackground().setTintMode(android.graphics.PorterDuff.Mode.MULTIPLY);
}
if (mHeadViewPanel.getBackground() != null) {
mHeadViewPanel.getBackground().setTint(color);
mHeadViewPanel.getBackground().setTintMode(android.graphics.PorterDuff.Mode.MULTIPLY);
}
if (colorId < 0) {
binding.noteEditRoot.setBackgroundColor(colorId);
} else {
// Clear tint for legacy resources
if (mNoteEditorPanel.getBackground() != null) {
mNoteEditorPanel.getBackground().clearColorFilter();
}
if (mHeadViewPanel.getBackground() != null) {
mHeadViewPanel.getBackground().clearColorFilter();
}
binding.noteEditRoot.setBackgroundResource(ResourceParser.NoteBgResources.getNoteBgResource(colorId));
}
}
@ -1029,6 +1046,9 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
getWorkingText();
sendTo(this, mWorkingNote.getContent());
break;
case R.id.menu_export:
showExportDialog();
break;
case R.id.menu_send_to_desktop:
sendToDesktop();
break;
@ -1076,6 +1096,135 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
context.startActivity(intent);
}
/**
*
*/
private void showExportDialog() {
if (mWorkingNote.getNoteId() == 0) {
saveNote();
}
getWorkingText();
String content = mWorkingNote.getContent();
if (TextUtils.isEmpty(content)) {
showToast(R.string.error_note_empty_for_send_to_desktop);
return;
}
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setTitle("导出便签");
LinearLayout layout = new LinearLayout(this);
layout.setOrientation(LinearLayout.VERTICAL);
layout.setPadding(50, 20, 50, 20);
final RadioGroup group = new RadioGroup(this);
RadioButton rbText = new RadioButton(this);
rbText.setText("导出为文本 (.txt)");
rbText.setId(View.generateViewId());
group.addView(rbText);
RadioButton rbImage = new RadioButton(this);
rbImage.setText("导出为图片 (.png)");
rbImage.setId(View.generateViewId());
group.addView(rbImage);
RadioButton rbPdf = new RadioButton(this);
rbPdf.setText("导出为 PDF (.pdf)");
rbPdf.setId(View.generateViewId());
group.addView(rbPdf);
group.check(rbText.getId());
layout.addView(group);
final CheckBox cbShare = new CheckBox(this);
cbShare.setText("导出后立即分享");
layout.addView(cbShare);
builder.setView(layout);
builder.setPositiveButton("开始导出", (dialog, which) -> {
int checkedId = group.getCheckedRadioButtonId();
boolean share = cbShare.isChecked();
if (checkedId == rbText.getId()) {
performExport(0, share);
} else if (checkedId == rbImage.getId()) {
performExport(1, share);
} else if (checkedId == rbPdf.getId()) {
performExport(2, share);
}
});
builder.setNegativeButton("取消", null);
builder.show();
}
private void performExport(int format, boolean share) {
String content = mWorkingNote.getContent();
String title = getTitleFromContent(content);
String noteId = String.valueOf(mWorkingNote.getNoteId());
switch (format) {
case 0: // Text
BackupUtils backupUtils = BackupUtils.getInstance(this);
int state = backupUtils.exportNoteToText(noteId, title);
if (state == BackupUtils.STATE_SUCCESS) {
File file = new File(backupUtils.getExportedTextFileDir(), backupUtils.getExportedTextFileName());
showToast("已导出至下载目录: " + file.getName());
if (share) shareFile(file, "text/plain");
} else {
showToast("导出文本失败");
}
break;
case 1: // Image
binding.svNoteEditScroll.post(() -> {
Bitmap bitmap = ImageExportHelper.viewToBitmap(binding.cvEditorSurface);
Uri uri = ImageExportHelper.saveBitmapToExternal(NoteEditActivity.this, bitmap, title);
if (uri != null) {
showToast("已导出图片至下载目录");
if (share) shareUri(uri, "image/png");
} else {
showToast("生成图片失败");
}
});
break;
case 2: // PDF
File pdfFile = PdfExportHelper.exportToPdf(this, title, content);
if (pdfFile != null) {
showToast("已导出 PDF 至下载目录: " + pdfFile.getName());
if (share) shareFile(pdfFile, "application/pdf");
} else {
showToast("导出 PDF 失败");
}
break;
}
}
private String getTitleFromContent(String content) {
String title = content.trim();
int firstNewLine = title.indexOf('\n');
if (firstNewLine > 0) {
title = title.substring(0, firstNewLine);
}
if (title.length() > 30) {
title = title.substring(0, 30);
}
return title;
}
private void shareFile(File file, String mimeType) {
Uri uri = androidx.core.content.FileProvider.getUriForFile(this, getPackageName() + ".fileprovider", file);
shareUri(uri, mimeType);
}
private void shareUri(Uri uri, String mimeType) {
Intent intent = new Intent(Intent.ACTION_SEND);
intent.setType(mimeType);
intent.putExtra(Intent.EXTRA_STREAM, uri);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
startActivity(Intent.createChooser(intent, "分享便签"));
}
/**
*
* <p>
@ -1156,7 +1305,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) {
@ -1285,6 +1434,10 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
*/
private Spannable getHighlightQueryResult(String fullText, String userQuery) {
SpannableString spannable = new SpannableString(fullText == null ? "" : fullText);
// 应用智能解析(时间、地点识别)
SmartParser.parse(this, spannable);
if (!TextUtils.isEmpty(userQuery)) {
mPattern = Pattern.compile(userQuery);
Matcher m = mPattern.matcher(fullText);
@ -1421,7 +1574,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 +1602,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>
@ -1512,13 +1688,50 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
SHORTCUT_ICON_TITLE_MAX_LEN) : content;
}
private void showColorPickerDialog() {
final View dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_color_picker, null);
final View colorPreview = dialogView.findViewById(R.id.view_color_preview);
android.widget.SeekBar sbRed = dialogView.findViewById(R.id.sb_red);
android.widget.SeekBar sbGreen = dialogView.findViewById(R.id.sb_green);
android.widget.SeekBar sbBlue = dialogView.findViewById(R.id.sb_blue);
private void showBackgroundSelector() {
BottomSheetDialog dialog = new BottomSheetDialog(this);
DialogBackgroundSelectorBinding dialogBinding = DialogBackgroundSelectorBinding.inflate(getLayoutInflater());
java.util.List<Integer> colors = java.util.Arrays.asList(
ResourceParser.YELLOW,
ResourceParser.BLUE,
ResourceParser.WHITE,
ResourceParser.GREEN,
ResourceParser.RED,
ResourceParser.MIDNIGHT_BLACK,
ResourceParser.EYE_CARE_GREEN,
ResourceParser.WARM,
ResourceParser.COOL,
ResourceParser.SUNSET,
ResourceParser.OCEAN,
ResourceParser.FOREST,
ResourceParser.LAVENDER
);
NoteColorAdapter adapter = new NoteColorAdapter(colors, mWorkingNote.getBgColorId(), colorId -> {
mWorkingNote.setBgColorId(colorId);
mWorkingNote.setWallpaper(null); // Clear wallpaper when color selected
dialog.dismiss();
});
dialogBinding.rvBackgroundOptions.setAdapter(adapter);
dialogBinding.btnPickWallpaper.setOnClickListener(v -> {
pickWallpaper();
dialog.dismiss();
});
dialogBinding.btnCustomColor.setOnClickListener(v -> {
showColorPickerDialog();
dialog.dismiss();
});
dialog.setContentView(dialogBinding.getRoot());
dialog.show();
}
private void showColorPickerDialog() {
DialogColorPickerBinding dialogBinding = DialogColorPickerBinding.inflate(getLayoutInflater());
int currentColor = android.graphics.Color.WHITE;
if (mWorkingNote.getBgColorId() < 0) {
currentColor = mWorkingNote.getBgColorId();
@ -1530,10 +1743,10 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
android.graphics.Color.blue(currentColor)
};
colorPreview.setBackgroundColor(android.graphics.Color.rgb(rgb[0], rgb[1], rgb[2]));
sbRed.setProgress(rgb[0]);
sbGreen.setProgress(rgb[1]);
sbBlue.setProgress(rgb[2]);
dialogBinding.viewColorPreview.setBackgroundColor(android.graphics.Color.rgb(rgb[0], rgb[1], rgb[2]));
dialogBinding.sbRed.setProgress(rgb[0]);
dialogBinding.sbGreen.setProgress(rgb[1]);
dialogBinding.sbBlue.setProgress(rgb[2]);
android.widget.SeekBar.OnSeekBarChangeListener listener = new android.widget.SeekBar.OnSeekBarChangeListener() {
@Override
@ -1541,7 +1754,7 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
if (seekBar.getId() == R.id.sb_red) rgb[0] = progress;
else if (seekBar.getId() == R.id.sb_green) rgb[1] = progress;
else if (seekBar.getId() == R.id.sb_blue) rgb[2] = progress;
colorPreview.setBackgroundColor(android.graphics.Color.rgb(rgb[0], rgb[1], rgb[2]));
dialogBinding.viewColorPreview.setBackgroundColor(android.graphics.Color.rgb(rgb[0], rgb[1], rgb[2]));
}
@Override
@ -1551,21 +1764,18 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
public void onStopTrackingTouch(android.widget.SeekBar seekBar) {}
};
sbRed.setOnSeekBarChangeListener(listener);
sbGreen.setOnSeekBarChangeListener(listener);
sbBlue.setOnSeekBarChangeListener(listener);
dialogBinding.sbRed.setOnSeekBarChangeListener(listener);
dialogBinding.sbGreen.setOnSeekBarChangeListener(listener);
dialogBinding.sbBlue.setOnSeekBarChangeListener(listener);
new AlertDialog.Builder(this)
.setTitle("Custom Color")
.setView(dialogView)
.setView(dialogBinding.getRoot())
.setPositiveButton(android.R.string.ok, (dialog, which) -> {
int newColor = android.graphics.Color.rgb(rgb[0], rgb[1], rgb[2]);
// Use negative integer for custom color. Ensure it's negative.
// ARGB color with alpha 255 is negative in Java int.
// If alpha is 0, it might be positive. We assume full opacity.
newColor |= 0xFF000000;
mWorkingNote.setBgColorId(newColor);
mNoteBgColorSelector.setVisibility(View.GONE);
mWorkingNote.setWallpaper(null);
})
.setNegativeButton(android.R.string.cancel, null)
.show();
@ -1624,21 +1834,53 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
}).start();
}
private void saveWallpaperToPrivateStorage(android.net.Uri uri) {
new Thread(() -> {
try {
java.io.InputStream is = getContentResolver().openInputStream(uri);
if (is == null) return;
// Create wallpapers directory if not exists
java.io.File wallpapersDir = new java.io.File(getFilesDir(), "wallpapers");
if (!wallpapersDir.exists()) {
wallpapersDir.mkdirs();
}
// Create a unique file name
String fileName = "wp_" + System.currentTimeMillis() + ".jpg";
java.io.File destFile = new java.io.File(wallpapersDir, fileName);
java.io.FileOutputStream fos = new java.io.FileOutputStream(destFile);
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = is.read(buffer)) != -1) {
fos.write(buffer, 0, bytesRead);
}
fos.close();
is.close();
final String filePath = "file://" + destFile.getAbsolutePath();
runOnUiThread(() -> {
mWorkingNote.setWallpaper(filePath);
mNoteBgColorSelector.setVisibility(View.GONE);
});
} catch (Exception e) {
Log.e(TAG, "Failed to copy wallpaper", e);
runOnUiThread(() -> {
showToast(R.string.failed_sdcard_export);
});
}
}).start();
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_CODE_PICK_WALLPAPER && resultCode == RESULT_OK && data != null) {
android.net.Uri uri = data.getData();
if (uri != null) {
// Take persistent permissions
try {
getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
} catch (SecurityException e) {
Log.e(TAG, "Failed to take persistable uri permission", e);
}
mWorkingNote.setWallpaper(uri.toString());
mNoteBgColorSelector.setVisibility(View.GONE);
saveWallpaperToPrivateStorage(uri);
}
} else if (requestCode == REQUEST_CODE_PICK_IMAGE && resultCode == RESULT_OK && data != null) {
android.net.Uri uri = data.getData();
@ -1648,6 +1890,17 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
}
}
/**
* Toast
* <p>
* Toast
* </p>
* @param text
*/
private void showToast(String text) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show();
}
/**
* Toast
* <p>
@ -1672,27 +1925,26 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
}
private void initRichTextToolbar() {
mRichTextSelector = findViewById(R.id.rich_text_selector);
findViewById(R.id.btn_bold).setOnClickListener(new OnClickListener() {
mRichTextSelector = binding.richTextSelector;
binding.btnBold.setOnClickListener(new OnClickListener() {
public void onClick(View v) { RichTextHelper.applyBold(mNoteEditor); }
});
findViewById(R.id.btn_italic).setOnClickListener(new OnClickListener() {
binding.btnItalic.setOnClickListener(new OnClickListener() {
public void onClick(View v) { RichTextHelper.applyItalic(mNoteEditor); }
});
findViewById(R.id.btn_underline).setOnClickListener(new OnClickListener() {
binding.btnUnderline.setOnClickListener(new OnClickListener() {
public void onClick(View v) { RichTextHelper.applyUnderline(mNoteEditor); }
});
findViewById(R.id.btn_strikethrough).setOnClickListener(new OnClickListener() {
binding.btnStrikethrough.setOnClickListener(new OnClickListener() {
public void onClick(View v) { RichTextHelper.applyStrikethrough(mNoteEditor); }
});
findViewById(R.id.btn_header).setOnClickListener(new OnClickListener() {
binding.btnHeader.setOnClickListener(new OnClickListener() {
public void onClick(View v) {
final CharSequence[] items = {"H1 (Largest)", "H2", "H3", "H4", "H5", "H6 (Smallest)", "Normal"};
AlertDialog.Builder builder = new AlertDialog.Builder(NoteEditActivity.this);
builder.setTitle("Header Level");
builder.setItems(items, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int item) {
// item index maps to level: 0->1, 1->2, ..., 5->6, 6->0 (Normal)
int level = (item == 6) ? 0 : (item + 1);
RichTextHelper.applyHeading(mNoteEditor, level);
}
@ -1700,22 +1952,22 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
builder.show();
}
});
findViewById(R.id.btn_list).setOnClickListener(new OnClickListener() {
binding.btnList.setOnClickListener(new OnClickListener() {
public void onClick(View v) { RichTextHelper.applyBullet(mNoteEditor); }
});
findViewById(R.id.btn_quote).setOnClickListener(new OnClickListener() {
binding.btnQuote.setOnClickListener(new OnClickListener() {
public void onClick(View v) { RichTextHelper.applyQuote(mNoteEditor); }
});
findViewById(R.id.btn_code).setOnClickListener(new OnClickListener() {
binding.btnCode.setOnClickListener(new OnClickListener() {
public void onClick(View v) { RichTextHelper.applyCode(mNoteEditor); }
});
findViewById(R.id.btn_link).setOnClickListener(new OnClickListener() {
binding.btnLink.setOnClickListener(new OnClickListener() {
public void onClick(View v) { RichTextHelper.insertLink(NoteEditActivity.this, mNoteEditor); }
});
findViewById(R.id.btn_divider).setOnClickListener(new OnClickListener() {
binding.btnDivider.setOnClickListener(new OnClickListener() {
public void onClick(View v) { RichTextHelper.insertDivider(mNoteEditor); }
});
findViewById(R.id.btn_color_text).setOnClickListener(new OnClickListener() {
binding.btnColorText.setOnClickListener(new OnClickListener() {
public void onClick(View v) {
final CharSequence[] items = {"Black", "Red", "Blue"};
final int[] colors = {android.graphics.Color.BLACK, android.graphics.Color.RED, android.graphics.Color.BLUE};
@ -1729,7 +1981,7 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
builder.show();
}
});
findViewById(R.id.btn_color_fill).setOnClickListener(new OnClickListener() {
binding.btnColorFill.setOnClickListener(new OnClickListener() {
public void onClick(View v) {
final CharSequence[] items = {"None", "Yellow", "Green", "Cyan"};
final int[] colors = {android.graphics.Color.TRANSPARENT, android.graphics.Color.YELLOW, android.graphics.Color.GREEN, android.graphics.Color.CYAN};

@ -59,6 +59,12 @@ import android.view.ScaleGestureDetector;
import android.view.GestureDetector;
import android.text.style.ImageSpan;
import net.micode.notes.tool.RichTextHelper;
import net.micode.notes.tool.SmartParser;
import net.micode.notes.tool.SmartURLSpan;
import android.text.TextWatcher;
import android.text.Editable;
import android.text.Spannable;
import android.text.style.ClickableSpan;
import android.app.AlertDialog;
import android.widget.SeekBar;
import android.widget.TextView;
@ -93,6 +99,8 @@ public class NoteEditText extends EditText implements ScaleGestureDetector.OnSca
sSchemaActionResMap.put(SCHEME_TEL, R.string.note_link_tel);
sSchemaActionResMap.put(SCHEME_HTTP, R.string.note_link_web);
sSchemaActionResMap.put(SCHEME_EMAIL, R.string.note_link_email);
sSchemaActionResMap.put(SmartParser.SCHEME_TIME, R.string.note_link_time);
sSchemaActionResMap.put(SmartParser.SCHEME_GEO, R.string.note_link_geo);
}
@Override
@ -213,6 +221,20 @@ public class NoteEditText extends EditText implements ScaleGestureDetector.OnSca
private void init(Context context) {
mScaleDetector = new ScaleGestureDetector(context, this);
setLinkTextColor(getResources().getColor(R.color.primary_color)); // 设置链接颜色
addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {}
@Override
public void afterTextChanged(Editable s) {
// 触发智能解析
SmartParser.parse(getContext(), s);
}
});
mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onDoubleTap(MotionEvent e) {
@ -396,6 +418,16 @@ public class NoteEditText extends EditText implements ScaleGestureDetector.OnSca
int off = layout.getOffsetForHorizontal(line, x);
// 设置文本选择光标位置
Selection.setSelection(getText(), off);
// 检查是否有 ClickableSpan如智能链接
if (getText() instanceof Spannable) {
Spannable spannable = (Spannable) getText();
ClickableSpan[] links = spannable.getSpans(off, off, ClickableSpan.class);
if (links.length != 0) {
links[0].onClick(this);
return true;
}
}
break;
}

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

@ -196,8 +196,8 @@ public class NoteItemData {
mIsMultiNotesFollowingFolder = false;
mIsOneNoteFollowingFolder = false;
// 如果是普通笔记且不是第一项,检查前一项是否为文件夹
if (mType == Notes.TYPE_NOTE && !mIsFirstItem) {
// 如果是普通笔记或模板且不是第一项,检查前一项是否为文件夹
if ((mType == Notes.TYPE_NOTE || mType == Notes.TYPE_TEMPLATE) && !mIsFirstItem) {
int position = cursor.getPosition();
if (cursor.moveToPrevious()) {
// 前一项是文件夹或系统文件夹

@ -21,7 +21,7 @@ import net.micode.notes.tool.SearchHistoryManager;
import java.util.ArrayList;
import java.util.List;
public class NoteSearchActivity extends AppCompatActivity implements SearchView.OnQueryTextListener, NoteSearchAdapter.OnItemClickListener {
public class NoteSearchActivity extends BaseActivity implements SearchView.OnQueryTextListener, NoteSearchAdapter.OnItemClickListener {
private SearchView mSearchView;
private RecyclerView mRecyclerView;
@ -154,14 +154,36 @@ public class NoteSearchActivity extends AppCompatActivity implements SearchView.
mHistoryManager.addHistory(query);
}
Intent intent = new Intent(this, NoteEditActivity.class);
intent.setAction(Intent.ACTION_VIEW);
intent.putExtra(Intent.EXTRA_UID, note.getId());
// Pass search keyword for highlighting in editor
// NoteEditActivity uses SearchManager.EXTRA_DATA_KEY for ID and USER_QUERY for keyword
intent.putExtra(android.app.SearchManager.EXTRA_DATA_KEY, String.valueOf(note.getId()));
intent.putExtra(android.app.SearchManager.USER_QUERY, mSearchView.getQuery().toString());
startActivity(intent);
if (note.type == Notes.TYPE_TEMPLATE) {
// Apply template: create a new note based on this template
mRepository.applyTemplate(note.getId(), Notes.ID_ROOT_FOLDER, new NotesRepository.Callback<Long>() {
@Override
public void onSuccess(Long newNoteId) {
runOnUiThread(() -> {
Intent intent = new Intent(NoteSearchActivity.this, NoteEditActivity.class);
intent.setAction(Intent.ACTION_VIEW);
intent.putExtra(Intent.EXTRA_UID, newNoteId);
startActivity(intent);
Toast.makeText(NoteSearchActivity.this, "已根据模板创建新笔记", Toast.LENGTH_SHORT).show();
});
}
@Override
public void onError(Exception e) {
runOnUiThread(() -> {
Toast.makeText(NoteSearchActivity.this, "应用模板失败: " + e.getMessage(), Toast.LENGTH_SHORT).show();
});
}
});
} else {
Intent intent = new Intent(this, NoteEditActivity.class);
intent.setAction(Intent.ACTION_VIEW);
intent.putExtra(Intent.EXTRA_UID, note.getId());
// Pass search keyword for highlighting in editor
intent.putExtra(android.app.SearchManager.EXTRA_DATA_KEY, String.valueOf(note.getId()));
intent.putExtra(android.app.SearchManager.USER_QUERY, query);
startActivity(intent);
}
}
@Override

@ -1,158 +1,173 @@
package net.micode.notes.ui;
import android.app.AlertDialog;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.IntentFilter;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.RadioButton;
import android.widget.RadioGroup;
import android.widget.Toast;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import net.micode.notes.tool.ImageExportHelper;
import net.micode.notes.tool.PdfExportHelper;
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.BackupUtils;
import net.micode.notes.tool.SecurityManager;
import android.content.Intent;
import net.micode.notes.viewmodel.NotesListViewModel;
public class NotesListActivity extends AppCompatActivity implements SidebarFragment.OnSidebarItemSelectedListener {
/**
* Activity
* <p>
* MVVM
* 使 ViewBinding 访
* </p>
*/
public class NotesListActivity extends BaseActivity 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.btnActionExport.setOnClickListener(v -> {
exportSelectedNotes();
viewModel.setIsSelectionMode(false);
});
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 +177,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 +189,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 +201,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 +211,124 @@ 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(getApplication(), 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);
viewModel.getFoldersLiveData().observe(this, folders -> {
folderAdapter.setFolders(folders);
});
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 {
drawerLayout.openDrawer(GravityCompat.START);
binding.headerContainer.setVisibility(View.VISIBLE);
binding.selectionHeader.setVisibility(View.GONE);
binding.selectionBottomBar.setVisibility(View.GONE);
if (!viewModel.isTrashMode()) {
binding.bottomNavigation.setVisibility(View.VISIBLE);
}
}
});
viewModel.getSelectedIdsLiveData().observe(this, selectedIds -> {
updateSelectionCount();
updateSelectionActionUI();
});
// 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.getSidebarRefreshNeeded().observe(this, needed -> {
if (needed) {
SidebarFragment fragment = (SidebarFragment) getSupportFragmentManager().findFragmentById(R.id.sidebar_fragment);
if (fragment != null) {
fragment.refreshFolderTree();
}
}
});
// Initial icon update (post to ensure fragment is attached)
viewPager.post(this::updateLayoutButtonIcon);
}
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 +344,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 +379,282 @@ public class NotesListActivity extends AppCompatActivity implements SidebarFragm
}
}
private class MainPagerAdapter extends FragmentStateAdapter {
public MainPagerAdapter(@NonNull AppCompatActivity activity) {
super(activity);
@Override
public void onBackPressed() {
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();
}
}
@NonNull
@Override
public Fragment createFragment(int position) {
if (position == 0) {
return new NotesListFragment();
// 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);
viewModel.setIsSelectionMode(true);
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 onCapsuleSelected() {
binding.drawerLayout.closeDrawer(GravityCompat.START);
startActivity(new Intent(this, CapsuleListActivity.class));
}
@Override public void onCreateFolder() {
binding.drawerLayout.closeDrawer(GravityCompat.START);
SidebarFragment fragment = (SidebarFragment) getSupportFragmentManager().findFragmentById(R.id.sidebar_fragment);
if (fragment != null) {
fragment.showCreateFolderDialog();
}
}
@Override public void onCloseSidebar() { binding.drawerLayout.closeDrawer(GravityCompat.START); }
@Override public void onRenameFolder(long folderId) {
binding.drawerLayout.closeDrawer(GravityCompat.START);
// Show rename dialog
viewModel.getFolderInfo(folderId, new NotesRepository.Callback<NotesRepository.NoteInfo>() {
@Override
public void onSuccess(NotesRepository.NoteInfo folderInfo) {
runOnUiThread(() -> {
if (folderInfo != null) {
showRenameFolderDialog(folderId, folderInfo.snippet);
}
});
}
@Override
public void onError(Exception error) {
runOnUiThread(() -> Toast.makeText(NotesListActivity.this, "获取文件夹信息失败", Toast.LENGTH_SHORT).show());
}
});
}
private void exportSelectedNotes() {
java.util.List<Long> selectedIds = viewModel.getSelectedNoteIds();
if (selectedIds.isEmpty()) {
Toast.makeText(this, "请先选择要导出的便签", Toast.LENGTH_SHORT).show();
return;
}
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setTitle("批量导出便签");
LinearLayout layout = new LinearLayout(this);
layout.setOrientation(LinearLayout.VERTICAL);
layout.setPadding(50, 20, 50, 20);
final RadioGroup group = new RadioGroup(this);
RadioButton rbText = new RadioButton(this);
rbText.setText("导出为文本 (.txt)");
rbText.setId(View.generateViewId());
group.addView(rbText);
RadioButton rbImage = new RadioButton(this);
rbImage.setText("导出为图片 (.png)");
rbImage.setId(View.generateViewId());
group.addView(rbImage);
RadioButton rbPdf = new RadioButton(this);
rbPdf.setText("导出为 PDF (.pdf)");
rbPdf.setId(View.generateViewId());
group.addView(rbPdf);
group.check(rbText.getId());
layout.addView(group);
final CheckBox cbShare = new CheckBox(this);
cbShare.setText("导出后立即分享");
layout.addView(cbShare);
builder.setView(layout);
builder.setPositiveButton("开始导出", (dialog, which) -> {
int checkedId = group.getCheckedRadioButtonId();
boolean share = cbShare.isChecked();
if (checkedId == rbText.getId()) {
performBatchExport(0, selectedIds, share);
} else if (checkedId == rbImage.getId()) {
performBatchExport(1, selectedIds, share);
} else if (checkedId == rbPdf.getId()) {
performBatchExport(2, selectedIds, share);
}
});
builder.setNegativeButton("取消", null);
builder.show();
}
private void performBatchExport(int format, java.util.List<Long> selectedIds, boolean share) {
if (format == 0) { // Text (Combined)
BackupUtils backupUtils = BackupUtils.getInstance(this);
int state = backupUtils.exportNotesToText(selectedIds);
if (state == BackupUtils.STATE_SUCCESS) {
File file = new File(backupUtils.getExportedTextFileDir(), backupUtils.getExportedTextFileName());
Toast.makeText(this, "已导出至下载目录: " + file.getName(), Toast.LENGTH_SHORT).show();
if (share) shareFile(file, "text/plain");
} else {
return new TaskListFragment();
Toast.makeText(this, "导出失败", Toast.LENGTH_SHORT).show();
}
} else {
final ArrayList<Uri> uris = new ArrayList<>();
final int total = selectedIds.size();
final int[] successCount = {0};
final String mimeType = (format == 1) ? "image/png" : "application/pdf";
for (Long id : selectedIds) {
viewModel.getNoteContent(id, new NotesRepository.Callback<String>() {
@Override
public void onSuccess(String content) {
if (!TextUtils.isEmpty(content)) {
String title = getTitleFromContent(content);
File exportedFile = null;
if (format == 1) { // Image
Bitmap bitmap = renderTextToBitmap(content);
Uri uri = ImageExportHelper.saveBitmapToExternal(NotesListActivity.this, bitmap, title);
if (uri != null) {
successCount[0]++;
uris.add(uri);
}
} else { // PDF
exportedFile = PdfExportHelper.exportToPdf(NotesListActivity.this, title, content);
if (exportedFile != null) {
successCount[0]++;
uris.add(androidx.core.content.FileProvider.getUriForFile(NotesListActivity.this, getPackageName() + ".fileprovider", exportedFile));
}
}
}
checkBatchFinished(successCount[0], total, uris, mimeType, share);
}
@Override
public void onError(Exception error) {
checkBatchFinished(successCount[0], total, uris, mimeType, share);
}
});
}
}
}
@Override
public int getItemCount() {
return 2;
private Bitmap renderTextToBitmap(String text) {
android.widget.TextView textView = new android.widget.TextView(this);
textView.setText(text);
textView.setTextColor(Color.BLACK);
textView.setBackgroundColor(Color.WHITE);
textView.setTextSize(16);
textView.setPadding(40, 40, 40, 40);
textView.setWidth(800);
int widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(800, View.MeasureSpec.EXACTLY);
int heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
textView.measure(widthMeasureSpec, heightMeasureSpec);
textView.layout(0, 0, textView.getMeasuredWidth(), textView.getMeasuredHeight());
Bitmap bitmap = Bitmap.createBitmap(textView.getMeasuredWidth(), textView.getMeasuredHeight(), Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
textView.draw(canvas);
return bitmap;
}
private synchronized void checkBatchFinished(int success, int total, ArrayList<Uri> uris, String mimeType, boolean share) {
// Since we are doing this sequentially or with callbacks, we need to track progress
// For simplicity in this implementation, I'll just check if we have reached total count
if (uris.size() + (total - success) >= total) {
if (success > 0) {
Toast.makeText(this, "成功导出 " + success + " 个文件至下载目录", Toast.LENGTH_SHORT).show();
if (share) shareUris(uris, mimeType);
} else {
Toast.makeText(this, "导出失败", Toast.LENGTH_SHORT).show();
}
}
}
@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
} else {
super.onBackPressed();
private String getTitleFromContent(String content) {
if (TextUtils.isEmpty(content)) return "untitled";
String title = content.trim();
int firstNewLine = title.indexOf('\n');
if (firstNewLine > 0) {
title = title.substring(0, firstNewLine);
}
if (title.length() > 30) {
title = title.substring(0, 30);
}
return title;
}
// ==================== Sidebar Callbacks ====================
@Override
public void onFolderSelected(long folderId) {
drawerLayout.closeDrawer(GravityCompat.START);
viewModel.enterFolder(folderId);
private void shareFile(File file, String mimeType) {
Uri uri = androidx.core.content.FileProvider.getUriForFile(this, getPackageName() + ".fileprovider", file);
Intent intent = new Intent(Intent.ACTION_SEND);
intent.setType(mimeType);
intent.putExtra(Intent.EXTRA_STREAM, uri);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
startActivity(Intent.createChooser(intent, "分享便签"));
}
@Override
public void onTrashSelected() {
drawerLayout.closeDrawer(GravityCompat.START);
viewModel.enterFolder(Notes.ID_TRASH_FOLER);
private void shareUris(ArrayList<Uri> uris, String mimeType) {
Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
intent.setType(mimeType);
intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
startActivity(Intent.createChooser(intent, "分享便签"));
}
@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);
private void showRenameFolderDialog(long folderId, String currentName) {
final EditText input = new EditText(this);
input.setText(currentName);
input.setSelection(currentName.length());
new AlertDialog.Builder(this)
.setTitle(R.string.dialog_rename_folder_title)
.setView(input)
.setPositiveButton(R.string.menu_rename, (dialog, which) -> {
String newName = input.getText().toString().trim();
if (!newName.isEmpty()) {
viewModel.renameFolder(folderId, newName);
}
})
.setNegativeButton(android.R.string.cancel, null)
.show();
}
@Override public void onSettingsSelected() {
drawerLayout.closeDrawer(GravityCompat.START);
startActivity(new android.content.Intent(this, SettingsActivity.class));
@Override public void onDeleteFolder(long folderId) {
binding.drawerLayout.closeDrawer(GravityCompat.START);
// Show delete confirmation
viewModel.getFolderInfo(folderId, new NotesRepository.Callback<NotesRepository.NoteInfo>() {
@Override
public void onSuccess(NotesRepository.NoteInfo folderInfo) {
runOnUiThread(() -> {
if (folderInfo != null) {
new AlertDialog.Builder(NotesListActivity.this)
.setTitle(R.string.dialog_delete_folder_title)
.setMessage(String.format(getString(R.string.dialog_delete_folder_with_notes), folderInfo.snippet, folderInfo.notesCount))
.setPositiveButton(R.string.menu_delete, (dialog, which) -> viewModel.deleteFolder(folderId))
.setNegativeButton(android.R.string.cancel, null)
.show();
}
});
}
@Override
public void onError(Exception error) {
runOnUiThread(() -> Toast.makeText(NotesListActivity.this, "获取文件夹信息失败", Toast.LENGTH_SHORT).show());
}
});
}
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); }
}
}

@ -0,0 +1,291 @@
package net.micode.notes.ui;
import android.content.Intent;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import android.app.AlertDialog;
import android.text.InputType;
import android.widget.EditText;
import android.widget.Toast;
import android.preference.PreferenceManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.StaggeredGridLayoutManager;
import net.micode.notes.R;
import net.micode.notes.data.Notes;
import net.micode.notes.data.NotesRepository;
import net.micode.notes.databinding.NoteListBinding;
import net.micode.notes.viewmodel.NotesListViewModel;
public class NotesListFragment extends Fragment implements
NoteInfoAdapter.OnNoteItemClickListener,
NoteInfoAdapter.OnNoteItemLongClickListener,
NoteInfoAdapter.OnSwipeMenuClickListener {
private static final String TAG = "NotesListFragment";
private static final String PREF_KEY_IS_STAGGERED = "is_staggered";
private NotesListViewModel viewModel;
private NoteListBinding binding;
private NoteInfoAdapter adapter;
private static final int REQUEST_CODE_OPEN_NODE = 102;
private static final int REQUEST_CODE_NEW_NODE = 103;
private static final int REQUEST_CODE_VERIFY_PASSWORD_FOR_OPEN = 107;
private NotesRepository.NoteInfo pendingNote;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
binding = NoteListBinding.inflate(inflater, container, false);
return binding.getRoot();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
initViewModel();
initViews(view);
observeViewModel();
}
private void initViewModel() {
NotesRepository repository = new NotesRepository(requireContext().getContentResolver());
// Use requireActivity() to share ViewModel with Activity (for Sidebar filtering)
viewModel = new ViewModelProvider(requireActivity(),
new ViewModelProvider.Factory() {
@Override
public <T extends androidx.lifecycle.ViewModel> T create(Class<T> modelClass) {
return (T) new NotesListViewModel(requireActivity().getApplication(), repository);
}
}).get(NotesListViewModel.class);
}
private void initViews(View view) {
adapter = new NoteInfoAdapter(requireContext());
binding.notesList.setAdapter(adapter);
// Restore layout preference
boolean isStaggered = PreferenceManager.getDefaultSharedPreferences(requireContext())
.getBoolean(PREF_KEY_IS_STAGGERED, true);
setLayoutManager(isStaggered);
adapter.setOnNoteItemClickListener(this);
adapter.setOnNoteItemLongClickListener(this);
adapter.setOnSwipeMenuClickListener(this);
// Fix FAB: Enable creating new notes
binding.btnNewNote.setOnClickListener(v -> {
Intent intent = new Intent(getActivity(), NoteEditActivity.class);
intent.setAction(Intent.ACTION_INSERT_OR_EDIT);
intent.putExtra(Notes.INTENT_EXTRA_FOLDER_ID, viewModel.getCurrentFolderId());
startActivityForResult(intent, REQUEST_CODE_NEW_NODE);
});
}
private void setLayoutManager(boolean isStaggered) {
if (isStaggered) {
StaggeredGridLayoutManager layoutManager = new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL);
layoutManager.setGapStrategy(StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS);
binding.notesList.setLayoutManager(layoutManager);
} else {
binding.notesList.setLayoutManager(new LinearLayoutManager(requireContext()));
}
}
public boolean toggleLayout() {
boolean isStaggered = binding.notesList.getLayoutManager() instanceof StaggeredGridLayoutManager;
boolean newIsStaggered = !isStaggered;
setLayoutManager(newIsStaggered);
PreferenceManager.getDefaultSharedPreferences(requireContext())
.edit()
.putBoolean(PREF_KEY_IS_STAGGERED, newIsStaggered)
.apply();
return newIsStaggered;
}
public boolean isStaggeredLayout() {
return binding.notesList.getLayoutManager() instanceof StaggeredGridLayoutManager;
}
private void observeViewModel() {
viewModel.getNotesLiveData().observe(getViewLifecycleOwner(), notes -> {
adapter.setNotes(notes);
});
viewModel.getIsSelectionMode().observe(getViewLifecycleOwner(), isSelection -> {
adapter.setSelectionMode(isSelection);
});
viewModel.getSelectedIdsLiveData().observe(getViewLifecycleOwner(), selectedIds -> {
adapter.setSelectedIds(selectedIds);
});
}
@Override
public void onNoteItemClick(int position, long noteId) {
if (Boolean.TRUE.equals(viewModel.getIsSelectionMode().getValue())) {
boolean isSelected = viewModel.getSelectedIdsLiveData().getValue() != null &&
viewModel.getSelectedIdsLiveData().getValue().contains(noteId);
viewModel.toggleNoteSelection(noteId, !isSelected);
return;
}
if (viewModel.getNotesLiveData().getValue() != null && position < viewModel.getNotesLiveData().getValue().size()) {
NotesRepository.NoteInfo note = viewModel.getNotesLiveData().getValue().get(position);
if (note.type == Notes.TYPE_FOLDER) {
viewModel.enterFolder(note.getId());
} else if (note.type == Notes.TYPE_TEMPLATE) {
// Apply template: create a new note based on this template
viewModel.applyTemplate(note.getId(), new net.micode.notes.data.NotesRepository.Callback<Long>() {
@Override
public void onSuccess(Long newNoteId) {
// Create a temporary NoteInfo to open the editor
net.micode.notes.data.NotesRepository.NoteInfo newNote = new net.micode.notes.data.NotesRepository.NoteInfo();
newNote.setId(newNoteId);
newNote.setParentId(Notes.ID_ROOT_FOLDER);
newNote.type = Notes.TYPE_NOTE;
requireActivity().runOnUiThread(() -> {
openNoteEditor(newNote);
Toast.makeText(requireContext(), "已根据模板创建新笔记", Toast.LENGTH_SHORT).show();
});
}
@Override
public void onError(Exception e) {
requireActivity().runOnUiThread(() -> {
Toast.makeText(requireContext(), "应用模板失败: " + e.getMessage(), Toast.LENGTH_SHORT).show();
});
}
});
} else {
if (note.isLocked) {
pendingNote = note;
Intent intent = new Intent(getActivity(), PasswordActivity.class);
intent.setAction(PasswordActivity.ACTION_CHECK_PASSWORD);
startActivityForResult(intent, REQUEST_CODE_VERIFY_PASSWORD_FOR_OPEN);
} else {
openNoteEditor(note);
}
}
}
}
@Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_CODE_VERIFY_PASSWORD_FOR_OPEN && resultCode == android.app.Activity.RESULT_OK) {
if (pendingNote != null) {
openNoteEditor(pendingNote);
pendingNote = null;
}
}
}
@Override
public void onNoteItemLongClick(int position, long noteId) {
if (!Boolean.TRUE.equals(viewModel.getIsSelectionMode().getValue())) {
viewModel.setIsSelectionMode(true);
viewModel.toggleNoteSelection(noteId, true);
} else {
boolean isSelected = viewModel.getSelectedIdsLiveData().getValue() != null &&
viewModel.getSelectedIdsLiveData().getValue().contains(noteId);
viewModel.toggleNoteSelection(noteId, !isSelected);
}
}
// Deprecated Context Menu
private void showContextMenu(NotesRepository.NoteInfo note) {
// ... kept for reference or removed
}
private void openNoteEditor(NotesRepository.NoteInfo note) {
Intent intent = new Intent(getActivity(), NoteEditActivity.class);
intent.setAction(Intent.ACTION_VIEW);
intent.putExtra(Notes.INTENT_EXTRA_FOLDER_ID, note.getParentId());
intent.putExtra(Intent.EXTRA_UID, note.getId());
startActivityForResult(intent, REQUEST_CODE_OPEN_NODE);
}
@Override
public void onResume() {
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); }
}

@ -69,7 +69,7 @@ public class NotesListItem extends LinearLayout {
* @param checked
*/
public void bind(Context context, NoteItemData data, boolean choiceMode, boolean checked) {
if (choiceMode && data.getType() == Notes.TYPE_NOTE) {
if (choiceMode && (data.getType() == Notes.TYPE_NOTE || data.getType() == Notes.TYPE_TEMPLATE)) {
mCheckBox.setVisibility(View.VISIBLE);
mCheckBox.setChecked(checked);
} else {
@ -136,7 +136,7 @@ public class NotesListItem extends LinearLayout {
private void setBackground(NoteItemData data) {
int id = data.getBgColorId();
int resId;
if (data.getType() == Notes.TYPE_NOTE) {
if (data.getType() == Notes.TYPE_NOTE || data.getType() == Notes.TYPE_TEMPLATE) {
if (data.isSingle() || data.isOneFollowingFolder()) {
resId = NoteItemBgResources.getNoteBgSingleRes(id);
} else if (data.isLast()) {
@ -153,7 +153,7 @@ public class NotesListItem extends LinearLayout {
setBackgroundResource(resId);
// Apply tint for new colors
if (data.getType() == Notes.TYPE_NOTE && (id >= net.micode.notes.tool.ResourceParser.MIDNIGHT_BLACK || id < 0)) {
if ((data.getType() == Notes.TYPE_NOTE || data.getType() == Notes.TYPE_TEMPLATE) && (id >= net.micode.notes.tool.ResourceParser.MIDNIGHT_BLACK || id < 0)) {
int color = net.micode.notes.tool.ResourceParser.getNoteBgColor(getContext(), id);
if (getBackground() != null) {
getBackground().setTint(color);

@ -11,7 +11,7 @@ import androidx.appcompat.app.AppCompatActivity;
import net.micode.notes.R;
public class NotesPreferenceActivity extends AppCompatActivity {
public class NotesPreferenceActivity extends BaseActivity {
public static final String PREFERENCE_NAME = "notes_preferences";
public static final String PREFERENCE_SYNC_ACCOUNT_NAME = "pref_key_account_name";
@ -33,20 +33,6 @@ public class NotesPreferenceActivity extends AppCompatActivity {
.replace(R.id.settings_container, new SettingsFragment())
.commit();
}
loadSyncButton();
}
private void loadSyncButton() {
Button syncButton = findViewById(R.id.preference_sync_button);
TextView lastSyncTimeView = findViewById(R.id.prefenerece_sync_status_textview);
// Google Tasks同步功能已禁用
syncButton.setEnabled(false);
syncButton.setText("同步功能已禁用");
lastSyncTimeView.setText("Google Tasks同步功能已禁用");
lastSyncTimeView.setVisibility(View.VISIBLE);
}
public static String getSyncAccountName(android.content.Context context) {

@ -0,0 +1,140 @@
package net.micode.notes.ui;
import android.content.Context;
import android.database.Cursor;
import android.text.format.DateUtils;
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.tool.ResourceParser;
public class NotesRecyclerAdapter extends RecyclerView.Adapter<NotesRecyclerAdapter.NoteViewHolder> {
private Context mContext;
private Cursor mCursor;
private OnNoteItemClickListener mListener;
private boolean mChoiceMode;
public interface OnNoteItemClickListener {
void onNoteClick(int position, long noteId);
boolean onNoteLongClick(int position, long noteId);
}
public NotesRecyclerAdapter(Context context) {
mContext = context;
}
public void setOnNoteItemClickListener(OnNoteItemClickListener listener) {
mListener = listener;
}
public void swapCursor(Cursor newCursor) {
if (mCursor == newCursor) return;
if (mCursor != null) {
mCursor.close();
}
mCursor = newCursor;
notifyDataSetChanged();
}
public Cursor getCursor() {
return mCursor;
}
@NonNull
@Override
public NoteViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(mContext).inflate(R.layout.note_item, parent, false);
return new NoteViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull NoteViewHolder holder, int position) {
if (mCursor == null || !mCursor.moveToPosition(position)) {
return;
}
NoteItemData itemData = new NoteItemData(mContext, mCursor);
holder.bind(itemData, mChoiceMode, false); // Checked logic omitted for now
holder.itemView.setOnClickListener(v -> {
if (mListener != null) {
mListener.onNoteClick(position, itemData.getId());
}
});
holder.itemView.setOnLongClickListener(v -> {
if (mListener != null) {
return mListener.onNoteLongClick(position, itemData.getId());
}
return false;
});
}
@Override
public int getItemCount() {
return mCursor == null ? 0 : mCursor.getCount();
}
class NoteViewHolder extends RecyclerView.ViewHolder {
CardView cardView;
TextView title, time, name;
ImageView typeIcon, lockIcon, alertIcon;
CheckBox checkBox;
public NoteViewHolder(View itemView) {
super(itemView);
// Since root is CardView
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);
}
public void bind(NoteItemData data, boolean choiceMode, boolean checked) {
if (choiceMode && (data.getType() == Notes.TYPE_NOTE || data.getType() == Notes.TYPE_TEMPLATE)) {
checkBox.setVisibility(View.VISIBLE);
checkBox.setChecked(checked);
} else {
checkBox.setVisibility(View.GONE);
}
if (data.getType() == Notes.TYPE_FOLDER) {
String snippet = data.getSnippet();
if (snippet == null) snippet = "";
title.setText(snippet + " (" + data.getNotesCount() + ")");
time.setVisibility(View.GONE);
typeIcon.setVisibility(View.VISIBLE);
typeIcon.setImageResource(R.drawable.ic_folder);
cardView.setCardBackgroundColor(mContext.getColor(R.color.bg_white));
} else {
typeIcon.setVisibility(View.GONE);
time.setVisibility(View.VISIBLE);
String titleStr = data.getTitle();
if (titleStr == null || titleStr.isEmpty()) {
titleStr = data.getSnippet();
}
title.setText(titleStr);
time.setText(DateUtils.getRelativeTimeSpanString(data.getModifiedDate()));
// Background Color
int colorId = data.getBgColorId();
int color = ResourceParser.getNoteBgColor(mContext, colorId);
cardView.setCardBackgroundColor(color);
}
}
}
}

@ -0,0 +1,30 @@
package net.micode.notes.ui;
import android.os.Bundle;
import androidx.appcompat.app.AppCompatActivity;
import androidx.preference.PreferenceFragmentCompat;
import net.micode.notes.R;
public class SettingsActivity extends BaseActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_settings);
if (savedInstanceState == null) {
getSupportFragmentManager()
.beginTransaction()
.replace(R.id.settings_container, new SettingsFragment())
.commit();
}
if (getSupportActionBar() != null) {
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setTitle(R.string.menu_settings);
}
}
@Override
public boolean onSupportNavigateUp() {
finish();
return true;
}
}

@ -29,10 +29,15 @@ import net.micode.notes.capsule.ClipboardMonitorService;
import static android.app.Activity.RESULT_OK;
public class SettingsFragment extends PreferenceFragmentCompat {
public static final String PREFERENCE_SYNC_ACCOUNT_KEY = "pref_sync_account_key";
public static final String PREFERENCE_SECURITY_KEY = "pref_key_security";
public static final String PREFERENCE_THEME_MODE = "pref_theme_mode";
public static final String PREFERENCE_CAPSULE_ENABLE = "pref_key_capsule_enable";
public static final String PREFERENCE_FONT_SIZE = "pref_font_size";
public static final String PREFERENCE_BG_RANDOM_APPEAR = "pref_key_bg_random_appear";
public static final String PREFERENCE_LANGUAGE = "pref_language";
public static final String PREFERENCE_CLOUD_SYNC_KEY = "pref_key_cloud_sync";
public static final String PREFERENCE_EXPORT_NOTES_KEY = "pref_key_export_notes";
public static final int REQUEST_CODE_CHECK_PASSWORD = 104;
public static final int REQUEST_CODE_OVERLAY_PERMISSION = 105;
@ -41,10 +46,77 @@ public class SettingsFragment extends PreferenceFragmentCompat {
setPreferencesFromResource(R.xml.preferences, rootKey);
loadThemePreference();
loadSecurityPreference();
loadAccountPreference();
loadCapsulePreference();
loadCloudSyncPreference();
loadExportPreference();
loadFontSizePreference();
loadLanguagePreference();
}
private void loadLanguagePreference() {
ListPreference languagePref = findPreference(PREFERENCE_LANGUAGE);
if (languagePref != null) {
languagePref.setOnPreferenceChangeListener((preference, newValue) -> {
String language = (String) newValue;
net.micode.notes.tool.LocaleHelper.setLocale(getContext(), language);
// 重新启动应用或 Activity 以应用更改
getActivity().recreate();
return true;
});
}
}
private void loadFontSizePreference() {
ListPreference fontSizePref = findPreference(PREFERENCE_FONT_SIZE);
if (fontSizePref != null) {
fontSizePref.setOnPreferenceChangeListener((preference, newValue) -> {
// ListPreference 默认存储字符串NoteEditActivity 需要整数
// 这里我们只需确保它能保存NoteEditActivity 稍后会修改读取逻辑
return true;
});
}
}
private void loadCloudSyncPreference() {
Preference syncPref = findPreference(PREFERENCE_CLOUD_SYNC_KEY);
if (syncPref != null) {
syncPref.setOnPreferenceClickListener(preference -> {
Intent intent = new Intent(getActivity(), SyncActivity.class);
startActivity(intent);
return true;
});
}
}
private void loadExportPreference() {
Preference exportPref = findPreference(PREFERENCE_EXPORT_NOTES_KEY);
if (exportPref != null) {
exportPref.setOnPreferenceClickListener(preference -> {
exportNotes();
return true;
});
}
}
private void exportNotes() {
net.micode.notes.tool.BackupUtils backupUtils = net.micode.notes.tool.BackupUtils.getInstance(getContext());
int state = backupUtils.exportToText();
String message;
switch (state) {
case net.micode.notes.tool.BackupUtils.STATE_SUCCESS:
message = getString(R.string.success_sdcard_export) + ": " + backupUtils.getExportedTextFileDir() + backupUtils.getExportedTextFileName();
break;
case net.micode.notes.tool.BackupUtils.STATE_SD_CARD_UNMOUONTED:
message = getString(R.string.error_sdcard_unmounted);
break;
case net.micode.notes.tool.BackupUtils.STATE_SYSTEM_ERROR:
default:
message = getString(R.string.error_sdcard_export);
break;
}
Toast.makeText(getContext(), message, Toast.LENGTH_LONG).show();
}
private void loadCapsulePreference() {
SwitchPreferenceCompat capsulePref = findPreference(PREFERENCE_CAPSULE_ENABLE);
if (capsulePref != null) {
@ -183,21 +255,6 @@ public class SettingsFragment extends PreferenceFragmentCompat {
}
}
private void loadAccountPreference() {
androidx.preference.PreferenceCategory accountCategory = findPreference(PREFERENCE_SYNC_ACCOUNT_KEY);
if (accountCategory != null) {
accountCategory.removeAll();
Preference accountPref = new Preference(getContext());
accountPref.setTitle(getString(R.string.preferences_account_title));
accountPref.setSummary(getString(R.string.preferences_account_summary));
accountPref.setOnPreferenceClickListener(preference -> {
Toast.makeText(getActivity(), "Google Tasks同步功能已禁用", Toast.LENGTH_SHORT).show();
return true;
});
accountCategory.addPreference(accountPref);
}
}
private void showSetPasswordDialog() {
new AlertDialog.Builder(getActivity())
.setTitle("设置密码")

@ -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,55 @@ 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 menuCapsule;
private LinearLayout menuLogout;
private View logoutDivider;
// 文件夹树
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 onCapsuleSelected();
void onCreateFolder();
/**
*
*/
void onCloseSidebar();
void onRenameFolder(long folderId);
void onDeleteFolder(long folderId);
}
@Override
@ -150,136 +136,149 @@ 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);
menuCapsule = view.findViewById(R.id.menu_capsule);
menuLogout = view.findViewById(R.id.menu_logout);
logoutDivider = view.findViewById(R.id.logout_divider);
// 文件夹树
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());
binding.menuExport.setOnClickListener(v -> {
if (listener != null) {
listener.onExportSelected();
}
menuSyncSettings.setOnClickListener(v -> {
Intent intent = new Intent(requireContext(), SyncActivity.class);
startActivity(intent);
if (listener != null) listener.onCloseSidebar();
});
binding.menuTemplates.setOnClickListener(v -> {
menuTemplates.setOnClickListener(v -> {
if (listener != null) {
listener.onTemplateSelected();
listener.onCloseSidebar();
}
});
binding.menuSettings.setOnClickListener(v -> {
menuExport.setOnClickListener(v -> {
if (listener != null) {
listener.onSettingsSelected();
listener.onExportSelected();
listener.onCloseSidebar();
}
});
binding.menuTrash.setOnClickListener(v -> {
menuSettings.setOnClickListener(v -> {
if (listener != null) {
listener.onTrashSelected();
listener.onSettingsSelected();
listener.onCloseSidebar();
}
});
}
/**
* /
*/
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 (menuCapsule != null) {
menuCapsule.setOnClickListener(v -> {
if (listener != null) {
// 根文件夹也可以跳转(回到根)
listener.onFolderSelected(folderId);
listener.onCapsuleSelected();
listener.onCloseSidebar();
}
// 重置双击状态
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);
});
}
menuLogout.setOnClickListener(v -> showLogoutConfirmDialog());
}
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 +286,96 @@ 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 (menuLogout != null) {
menuLogout.setVisibility(isLoggedIn ? View.VISIBLE : View.GONE);
}
if (logoutDivider != null) {
logoutDivider.setVisibility(isLoggedIn ? View.VISIBLE : View.GONE);
}
}
// 双击检测专用变量(针对文件夹列表项)
private long lastFolderClickTime = 0;
private long lastClickedFolderId = -1;
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();
/**
*
*/
private void showCreateFolderDialog() {
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 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();
}
public void showCreateFolderDialog() {
AlertDialog.Builder builder = new AlertDialog.Builder(requireContext());
builder.setTitle(R.string.dialog_create_folder_title);
@ -352,156 +391,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();
}
@Override
public void onDetach() {
super.onDetach();
listener = null;
}
/**
* FolderTreeAdapter
* /
*/
private static class FolderTreeAdapter extends RecyclerView.Adapter<FolderTreeAdapter.FolderViewHolder> {
// ==================== FolderTreeAdapter ====================
private List<FolderTreeItem> folderItems;
private FolderListViewModel viewModel;
private OnFolderItemClickListener folderItemClickListener;
private static class FolderTreeAdapter extends RecyclerView.Adapter<FolderTreeAdapter.FolderViewHolder> {
public FolderTreeAdapter(List<FolderTreeItem> folderItems, FolderListViewModel viewModel) {
this.folderItems = folderItems;
this.viewModel = viewModel;
}
private List<FolderTreeItem> folderItems;
private FolderListViewModel viewModel;
private OnFolderItemClickListener folderItemClickListener;
private OnFolderItemLongClickListener folderItemLongClickListener;
public void setData(List<FolderTreeItem> folderItems) {
this.folderItems = folderItems;
}
public FolderTreeAdapter(List<FolderTreeItem> folderItems, FolderListViewModel viewModel) {
this.folderItems = folderItems;
this.viewModel = viewModel;
}
public void setOnFolderItemClickListener(OnFolderItemClickListener listener) {
this.folderItemClickListener = listener;
}
public void setData(List<FolderTreeItem> folderItems) {
this.folderItems = folderItems;
}
@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 setOnFolderItemClickListener(OnFolderItemClickListener listener) {
this.folderItemClickListener = listener;
}
@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 setOnFolderItemLongClickListener(OnFolderItemLongClickListener listener) {
this.folderItemLongClickListener = listener;
}
@Override
public int getItemCount() {
return folderItems.size();
}
@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);
}
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);
}
@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 +549,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 BaseActivity {
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));
}
}

@ -24,7 +24,7 @@ import net.micode.notes.model.Task;
import java.util.Calendar;
public class TaskEditActivity extends AppCompatActivity {
public class TaskEditActivity extends BaseActivity {
private EditText contentEdit;
private ImageView alarmBtn;

@ -22,7 +22,7 @@ import net.micode.notes.model.Task;
import java.util.ArrayList;
import java.util.List;
public class TaskListActivity extends AppCompatActivity implements TaskListAdapter.OnTaskItemClickListener {
public class TaskListActivity extends BaseActivity implements TaskListAdapter.OnTaskItemClickListener {
private RecyclerView recyclerView;
private TaskListAdapter adapter;

@ -0,0 +1,123 @@
package net.micode.notes.ui;
import android.content.Intent;
import android.database.Cursor;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import net.micode.notes.R;
import net.micode.notes.data.Notes;
import net.micode.notes.data.Notes.NoteColumns;
import net.micode.notes.model.Task;
import java.util.ArrayList;
import java.util.List;
public class TaskListFragment extends Fragment implements TaskListAdapter.OnTaskItemClickListener {
private RecyclerView recyclerView;
private TaskListAdapter adapter;
private FloatingActionButton fab;
private static final int REQUEST_EDIT_TASK = 1001;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.activity_task_list, container, false);
// Hide Toolbar in fragment if Activity has one or Tabs
// For now, let's keep it but remove navigation logic or hide it if needed
View toolbar = view.findViewById(R.id.toolbar);
if (toolbar != null) {
// toolbar.setVisibility(View.GONE); // Optional: Hide if using main tabs
}
recyclerView = view.findViewById(R.id.task_list_view);
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
adapter = new TaskListAdapter(getContext(), this);
recyclerView.setAdapter(adapter);
fab = view.findViewById(R.id.btn_new_task);
fab.setOnClickListener(v -> {
Intent intent = new Intent(getActivity(), TaskEditActivity.class);
startActivityForResult(intent, REQUEST_EDIT_TASK);
});
return view;
}
@Override
public void onResume() {
super.onResume();
loadTasks();
}
private void loadTasks() {
new Thread(() -> {
if (getContext() == null) return;
Cursor cursor = getContext().getContentResolver().query(
Notes.CONTENT_NOTE_URI,
null,
NoteColumns.TYPE + "=?",
new String[]{String.valueOf(Notes.TYPE_TASK)},
null
);
List<Task> tasks = new ArrayList<>();
if (cursor != null) {
while (cursor.moveToNext()) {
tasks.add(Task.fromCursor(cursor));
}
cursor.close();
}
if (getActivity() != null) {
getActivity().runOnUiThread(() -> adapter.setTasks(tasks));
}
}).start();
}
@Override
public void onItemClick(Task task) {
Intent intent = new Intent(getActivity(), TaskEditActivity.class);
intent.putExtra(Intent.EXTRA_UID, task.id);
startActivityForResult(intent, REQUEST_EDIT_TASK);
}
@Override
public void onCheckBoxClick(Task task) {
task.status = (task.status == Task.STATUS_ACTIVE) ? Task.STATUS_COMPLETED : Task.STATUS_ACTIVE;
if (task.status == Task.STATUS_COMPLETED) {
task.finishedTime = System.currentTimeMillis();
} else {
task.finishedTime = 0;
}
new Thread(() -> {
if (getContext() != null) {
task.save(getContext());
if (getActivity() != null) {
getActivity().runOnUiThread(() -> loadTasks());
}
}
}).start();
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_EDIT_TASK && resultCode == android.app.Activity.RESULT_OK) {
loadTasks();
}
}
}

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

@ -16,11 +16,15 @@
package net.micode.notes.viewmodel;
import android.app.Application;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import net.micode.notes.R;
import net.micode.notes.data.Notes;
import net.micode.notes.data.NotesRepository;
@ -38,7 +42,7 @@ import java.util.List;
* @see NotesRepository
* @see net.micode.notes.model.Note
*/
public class NotesListViewModel extends ViewModel {
public class NotesListViewModel extends AndroidViewModel {
private static final String TAG = "NotesListViewModel";
private final NotesRepository repository;
@ -80,9 +84,11 @@ public class NotesListViewModel extends ViewModel {
/**
*
*
* @param application
* @param repository
*/
public NotesListViewModel(NotesRepository repository) {
public NotesListViewModel(@NonNull Application application, NotesRepository repository) {
super(application);
this.repository = repository;
Log.d(TAG, "ViewModel created");
}
@ -160,44 +166,60 @@ public class NotesListViewModel extends ViewModel {
@Override
public void onSuccess(List<NotesRepository.NoteInfo> path) {
folderPathLiveData.postValue(path);
}
@Override
public void onError(Exception error) {
Log.e(TAG, "Failed to load folder path", error);
}
});
// 加载子文件夹 (Category Tabs) - Always load root folders to keep tabs visible
repository.getSubFolders(Notes.ID_ROOT_FOLDER, new NotesRepository.Callback<List<NotesRepository.NoteInfo>>() {
@Override
public void onSuccess(List<NotesRepository.NoteInfo> folders) {
// Construct the display list with "All" and "Uncategorized"
List<NotesRepository.NoteInfo> displayFolders = new ArrayList<>();
// 1. "All" Folder (Virtual)
NotesRepository.NoteInfo allFolder = new NotesRepository.NoteInfo();
allFolder.setId(Notes.ID_ALL_NOTES_FOLDER);
allFolder.snippet = "所有"; // Name
displayFolders.add(allFolder);
// 2. Real Folders (from DB)
if (folders != null) {
displayFolders.addAll(folders);
// Determine if we are in template mode
boolean isTemplate = (folderId == Notes.ID_TEMPLATE_FOLDER);
if (!isTemplate && path != null) {
for (NotesRepository.NoteInfo info : path) {
if (info.getId() == Notes.ID_TEMPLATE_FOLDER) {
isTemplate = true;
break;
}
}
}
// 3. "Uncategorized" Folder (Actually Root Folder)
NotesRepository.NoteInfo uncategorizedFolder = new NotesRepository.NoteInfo();
uncategorizedFolder.setId(Notes.ID_ROOT_FOLDER);
uncategorizedFolder.snippet = "未分类"; // Custom Name for Root
displayFolders.add(uncategorizedFolder);
foldersLiveData.postValue(displayFolders);
final boolean templateMode = isTemplate;
long tabParentId = templateMode ? Notes.ID_TEMPLATE_FOLDER : Notes.ID_ROOT_FOLDER;
// 加载子文件夹 (Category Tabs)
repository.getSubFolders(tabParentId, new NotesRepository.Callback<List<NotesRepository.NoteInfo>>() {
@Override
public void onSuccess(List<NotesRepository.NoteInfo> folders) {
// Construct the display list with "All" and "Uncategorized"
List<NotesRepository.NoteInfo> displayFolders = new ArrayList<>();
// 1. "All" / "All Templates" Folder (Virtual)
NotesRepository.NoteInfo allFolder = new NotesRepository.NoteInfo();
allFolder.setId(templateMode ? Notes.ID_TEMPLATE_FOLDER : Notes.ID_ALL_NOTES_FOLDER);
allFolder.snippet = getApplication().getString(templateMode ? R.string.folder_all_templates : R.string.folder_all); // Name
displayFolders.add(allFolder);
// 2. Real Folders (from DB)
if (folders != null) {
displayFolders.addAll(folders);
}
// 3. "Uncategorized" Folder (only for normal notes)
if (!templateMode) {
NotesRepository.NoteInfo uncategorizedFolder = new NotesRepository.NoteInfo();
uncategorizedFolder.setId(Notes.ID_ROOT_FOLDER);
uncategorizedFolder.snippet = getApplication().getString(R.string.folder_uncategorized); // Custom Name for Root
displayFolders.add(uncategorizedFolder);
}
foldersLiveData.postValue(displayFolders);
}
@Override
public void onError(Exception error) {
Log.e(TAG, "Failed to load sub-folders", error);
}
});
}
@Override
public void onError(Exception error) {
Log.e(TAG, "Failed to load sub-folders", error);
Log.e(TAG, "Failed to load folder path", error);
}
});
@ -823,6 +845,141 @@ 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);
}
});
}
/**
*
*
* @param noteId ID
* @param callback
*/
public void getNoteInfo(long noteId, NotesRepository.Callback<NotesRepository.NoteInfo> callback) {
repository.getNoteInfo(noteId, callback);
}
/**
*
*
* @param noteId ID
* @param callback
*/
public void getNoteContent(long noteId, NotesRepository.Callback<String> callback) {
repository.getNoteContent(noteId, callback);
}
/**
*
* <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,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true" android:color="@color/text_color_primary"/>
<item android:color="@color/text_color_secondary"/>
</selector>

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/white" />
<corners
android:topLeftRadius="24dp"
android:topRightRadius="24dp" />
</shape>

@ -0,0 +1,14 @@
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape>
<solid android:color="#A0000000"/>
<corners android:radius="25dp"/>
</shape>
</item>
<item>
<shape>
<solid android:color="#80000000"/>
<corners android:radius="25dp"/>
</shape>
</item>
</selector>

@ -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,15 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true">
<shape>
<solid android:color="#FFD54F"/> <!-- Yellow -->
<corners android:radius="16dp"/>
</shape>
</item>
<item>
<shape>
<solid android:color="@android:color/transparent"/>
<corners android:radius="16dp"/>
</shape>
</item>
</selector>

@ -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,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFC107"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
</vector>

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#00000000"
android:strokeColor="#757575"
android:strokeWidth="2"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
</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>

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save