一个轻量 Android 应用:文本编辑器 + 桌面小组件。在应用里输入文本,退出后内容以可上下滚动的小部件形式展示在桌面,支持自定义外观,且零后台进程、数据永不损坏。
当前版本:v8.5.1(versionCode 37,compileSdk 36 / minSdk 24 / targetSdk 36)
| 特性 | 说明 |
|---|---|
| 📝 文本编辑 | 全屏多行编辑器,内容仅在退出 / 返回 / 切后台时保存(不做编辑自动保存) |
| 🧭 顶部导航栏 | 编辑页顶部一条应用栏,4 个常驻入口一眼可见:设置 / WebDAV 同步 / 立即上传 / 撤回(原先占屏幕底部的两个按钮已上移删除)。导航栏高度固定 56dp,上方那条系统栏(状态栏 / 刘海 / 挖孔)由独立占位条承担高度 —— 系统栏再高也不会挤压标题和按钮(v7.7.1 修复)。顶栏配色与编辑区同色(v8.3.0):底色 = 当前模式下编辑器的背景色、标题与 4 个图标 = 编辑器的文字色,浅色 / 深色模式各用各的那一组,改了编辑器配色顶栏立刻跟着变(此前的固定品牌紫已删除) |
| ↩️ 无限次撤回 | 顶部「撤回」按钮逐步退回每一次编辑,不设次数上限,直到精确变回刚打开应用时的内容;旋转 / 深色模式重建后撤回历史依然可用;撤回后光标自动落在改动处。v8.4.0 起每条撤回记录压缩成「位置 + 长度 + 被删文本」(不再为每次按键分配对象与两个字符串),长会话内存约降一个数量级,撤回次数仍然不设上限 |
| ⬆️ 立即上传 | 顶部「立即上传」按钮:先把编辑器当前内容落盘,再立刻上传到云端(用户按下的当下意图不会被合并,自己走一趟);未启用/未配置同步时会明确提示去设置,而不是点了没反应 |
| ☁️ WebDAV 同步 | 把最新内容纯单向上传到你自己的 WebDAV(坚果云 / Nextcloud / 群晖 NAS):只依赖本机记录判断内容有没有变,变了就写一个新文件 note<本地时间 yyyyMMdd_HHmmss_SSS>.txt(如 note20261018_192437_123.txt,v8.4.0 起从难读的 unix 时间戳改为可读格式),旧文件永不触碰 —— 云端自动保留每一次上传的历史,也就不需要读取、比对或清理远端。同步期间通知栏有进度条(每次上传都保证可见,最短 1.5 秒),上传成功还会弹一个 Toast;结束立即停止,不留常驻后台。退出瞬间若只是网络抖一下,会在前台窗口内快速重试 2 次(通知显示「网络不稳,正在重试(1/2)…」)而不是等到一分钟后的系统补传(v8.0.3)。设置页的状态 / 诊断块可长按选中复制,也可点「复制全部」一次取全(v8.0.2) |
| ⌨️ 进入即输入 | 打开应用后光标自动落在第一行行首,并自动弹出输入法,省掉一次点击(读盘完成后触发,不会先弹键盘后填内容) |
| 🌙 自适应深色模式 | 编辑页随系统深色模式自动切换黑白,切换时自动保存当前内容;小组件颜色不受影响 |
| 🪟 可滚动小组件 | FrameLayout 根容器 + ListView 集合式小组件:短内容时列表只占文字那么高,文字下方的空白也可点(点击由根容器接管);长内容在小组件内原生上下滚动。v8.5.0 起不再把列表项撑到「宿主报告的小组件高度 − 防误触余量」,那个估算链和滑杆一起删除 —— 底部不可点的死区消失,也不会再出现 v5.8 那种「短内容误滚动半行」。尺寸:默认 4×3(v8.5.1 起旧系统也按同一档折算),可缩放到 2×1(所有 Android 版本支持) |
| 🎨 外观自定义 | 设置页可调字体大小(10–34sp)、字体颜色、背景颜色、背景透明度;字体大小同时决定编辑器的文字大小(改一处两处同步);并可分别自定义浅色模式 / 深色模式编辑器的背景色与文字色;实时预览即时生效,支持自定义 RGB 取色 |
| 🔒 原子写入 | 内容存储采用 Jetpack DataStore 官方原子写入(临时文件 + fsync + 原子重命名),任意时刻崩溃都不会产生"写一半"的损坏文件 |
| ✅ CRC32 校验 | 文件格式 [UTF-8 内容][4字节 CRC32],读取时校验;发现损坏自动保留现场文件并重建 |
| 🪫 零后台占用 | 无自动保存、无轮询、无常驻服务。小组件数据服务为绑定式,仅桌面渲染时才临时启动;自动上传只跟在「保存内容」之后(退出 / 返回 / 切后台 / 旋转 / 深色模式等所有保存路径),每次保存都立刻尝试、没有任何节流(v8.1.0 删掉了 1 分钟闸门),结束即停前台服务;同一时刻最多一次上传,保存比上传快时后来者自动合并进正在执行的那一趟(一趟上传吸收它开始前积累的所有保存),而要不要真的发请求只由内容哈希决定——内容没变时一个请求都不发;只有失败才有重试间隔。v7.9 起另有一条系统级兜底:保存成功后若上传没能完成,会给系统排一个(固定 id、只会有一个)一次性持久化重试任务,系统在有网络时拉起进程补传,成功即撤销 —— 不轮询、不常驻、首次延迟 30 秒、失败按指数退避,且最多自动重试 5 次(用完就停,不长期驻留)。打开应用只读盘显示内容:不算哈希、不检测、不联网(v8.2.0 起连同「待上传检测」一起删除) |
| 🔇 静默保存 | 保存成功不弹任何提示(v7.7 起删除全部「已保存」toast);只有写盘失败才提示「保存失败」,旧内容不受影响 |
| 💾 备份与换机迁移 | 走系统的备份通道(v8.2.0 起):allowBackup="true" + 备份规则显式包含 file 与 sharedpref,由 Auto Backup / 厂商换机助手把正文 + 外观配色 + WebDAV 配置(含口令)搬到新设备。代价(刻意接受):WebDAV 口令会随系统备份离机,同步设置页已写明。v8.3.0 删除了 v8.2.0 自加的「导出 / 恢复备份文件」界面 —— 应用里少一块功能就少一处出 bug 的地方(用户要求) |
| 📏 长度上限 | 内容上限 10,000 字符(v8.4.0 从 100,000 降低:以桌面小部件显示为主,小上限换内存安全),超出时给出可见提示;历史遗留的超长内容完整加载、一个字不丢,但只能删减到上限以下才能继续输入(并给出明确提示) |
| ♿ 无障碍 | 色板具备可访问名称、选中状态与 48dp 触控目标;滑杆带 labelFor 关联标签;顶部导航栏的图标按钮均带中文可访问名称 |
| 🌐 中英双语 | 默认中文,values-en 提供英文 |
完整更新日志(含每次改动的起因、真机证据与边界)见 CHANGELOG.md。
当前版本:v8.5.1 —— 小组件尺寸契约对齐:
- 默认大小 4×3 在新旧系统上统一:Android 12+ 本来就按
targetCellWidth=4 / targetCellHeight=3摆 4×3; Android 11 及以下不读targetCell,此前用 180dp/100dp 折算出来约 3×2 —— 现在改成minWidth=250dp / minHeight=180dp(按官方折算表 n 格 ≈ 70n−30dp,即 4 格 / 3 格)。 - 放开缩放下限:新增
minResizeWidth=110dp / minResizeHeight=40dp(约 2 列 × 1 行)。此前没有声明minResize*, 下限默认等于minWidth/minHeight,所以小组件缩不到 2×1/1×1 —— 而 v8.5.0 之后任何尺寸都能正常渲染,这个下限纯属历史值。 - 只动
widget_info.xml的 4 个属性值;渲染、数据、同步、权限均无改动。尺寸行为无法在 CI 观测,请你在桌面上拖一下验收。
上一版:v8.5.0 —— 小组件改成「点击交给根容器」,删掉防误触余量:
- 小组件根布局从 ListView 换成 FrameLayout:列表高度交给内容(
wrap_content),文字下方的空白由根容器接收点击。 「整块可点」不再依赖「把列表项撑到宿主报告的小组件高度 − 余量」这条估算链,于是同时删除: ① 读OPTION_APPWIDGET_MIN_HEIGHT算尺寸的逻辑与setMinimumHeight; ②onAppWidgetOptionsChanged里的重建 / 重算(缩放改由桌面重新布局,文字自动重排); ③ 设置页的「防误触余量」滑杆与相关文案。 直接结果:底部不可点的死区消失,v5.8「短内容误滚动半行」这类由尺寸取整产生的 bug 从结构上消失。 - 行为不变的部分:内容读取与失败三态(空提示 / 读失败提示)、字号与颜色、8dp 内边距、长内容滚动、点击打开编辑页、 无后台常驻、无新增权限、无新增配置入口。数据层与同步完全没有动。
- 新增 8 条 Robolectric 用例(
WidgetBehaviourTest):钉住「短内容时列表只占文字那么高」「文字下方空白由根容器接住点击」 「长内容仍在组件内滚动且不溢出根容器」「Provider 把点击与背景放在根容器上」「列表项不再被撑高」「工厂无需 widget id」。
上一版:v8.4.0 —— 三件用户点名的改动 + 一轮「失败路径」加固:
- 正文上限 100,000 → 10,000 字符(用户决定:以桌面小部件显示为主;撤回次数仍然不设上限)。 历史遗留的超长内容完整加载、不截断,但只能删减到上限以内才能继续输入,并给一次明确提示。 同时撤回记录改成紧凑结构(位置 + 长度 + 被删文本),长会话内存约降一个数量级。
- 云端文件名改成可读的本地时间:
note20261018_192437_123.txt(前缀 + yyyyMMdd_HHmmss_SSS + 扩展名,精确到毫秒),不再是难读的 unix 时间戳; 旧文件保持原样,不清理不改名。 - 失败路径加固(都不改变正常使用行为): ① 读盘失败不再当成「空便签」—— 编辑器保持禁用、所有保存路径拒绝写盘; ② 返回键 / 深色模式切换时写盘失败不再销毁或重建编辑页; ③ 两次保存串行化(旧快照不会覆盖新快照); ④ 同步读盘失败时不再上传空内容; ⑤ CRC 校验失败(内容文件损坏)会在下次打开应用时明确提示一次(并保留损坏现场文件)。
- 顺带:内容没变时不再重写磁盘(每次失焦都整份写盘的浪费),上传触发与「保存即上传」语义不变。
更早:v8.3.0 —— 删除 v8.2.0 自加的「导出 / 恢复备份文件」整块功能(系统备份/换机迁移通道保留), 顶栏从固定品牌紫改为与编辑区同色(底色 = 编辑器背景色、标题与图标 = 编辑器文字色,浅色/深色各用一组)。
v8.1.0 —— 每次保存都立刻尝试上传,不再有 1 分钟节流;同一时刻最多一次上传, 突发保存自动合并(一趟上传吸收它开始前积累的所有保存);只有失败才产生重试间隔(前台窗口内 1 秒 / 3 秒, 之后交给系统兜底任务指数退避,最多 5 次)。防重复上传靠的是内容哈希:内容没变时一个请求都不发。
推送 main 分支或手动触发工作流,自动完成:
- 质量门禁:Android Lint + JVM 单元测试(Robolectric),任一失败即中止
- 编译 Debug + Release(Release 使用 secrets 中的 keystore 签名)
- 仪器化测试:在 CI 的 Android 35 / 36 双档模拟器(KVM 硬件加速)上运行
androidTest。除了「自动弹出输入法」这类只能在真实 Android 运行环境观察的行为,还会在 runner 上启动仓库自带的零依赖 WebDAV 服务器(.github/scripts/webdav_stub_server.py,模拟器经10.0.2.2访问),端到端验证上传、「内容没变则零请求」与补传,并把服务器请求日志打印到 Actions 日志里作为证据 - 上传构建产物(Actions 页面 Artifacts)
- 自动发布 GitHub Release(仅
main分支,且前三步全部通过),附签名 APK,可直接下载:
https://github.com/llllllllqq/hellowidget/releases/latest
- 自动:推送
main分支(会发版);推送qa/**分支(只跑质量门禁与构建,不发版) - 手动:仓库 Actions 页 → Build & Release → Run workflow
Release 签名使用以下仓库 Secrets(已配置)。没有 secrets 时(例如 fork 的 PR)流程不会失败,只是产出未签名的 release 包:
| Secret | 说明 |
|---|---|
KEYSTORE_BASE64 |
签名 keystore 文件的 Base64 |
KEYSTORE_PASSWORD |
keystore 密码 |
KEY_ALIAS |
密钥别名 |
KEY_PASSWORD |
密钥密码 |
只改 gradle.properties 两行,提交并推送 main:
hellowidget.versionName=8.5.1
hellowidget.versionCode=37CI 会自动用 versionName 生成 tag(v8.5.1)与 Release 标题,APK 元数据也取自同一处,三者不会再漂移。
发布后请顺手在 CHANGELOG.md 顶部补一条本版记录(起因、改动、证据、边界)。
# 需要 Android SDK(把 sdk.dir 写入 local.properties,该文件已被 gitignore)
./gradlew assembleDebug # Debug APK
./gradlew assembleRelease # Release APK
# 签名 Release 需要以下环境变量;不设置时产出未签名包(不会构建失败)
export HELLOWIDGET_KEYSTORE=/path/to/hello-release.keystore
export KEYSTORE_PASSWORD=... KEY_ALIAS=... KEY_PASSWORD=...
# 质量门禁(与 CI 完全一致)
./gradlew :app:lintDebug :app:testDebugUnitTest
# 仪器化测试(需要已连接的设备或模拟器;CI 上由 GitHub Actions 自动跑)
./gradlew :app:connectedDebugAndroidTestGradle 版本由仓库内的 wrapper 固定(gradle/wrapper/gradle-wrapper.properties → 9.6.0),无需本机安装 Gradle。
hellowidget/
├── CHANGELOG.md # 完整更新日志(每次改动的起因、真机证据与边界)
├── .github/workflows/build.yml # CI:质量门禁 + 构建 + 仪器化测试 + 发布 Release
├── .github/scripts/webdav_stub_server.py # CI 用的零依赖 WebDAV 测试服务器
├── .github/scripts/release_smoke.sh # CI 用的 release 包冒烟(装进模拟器启动一次)
├── gradlew / gradle/wrapper/ # Gradle wrapper(版本锁定 9.6.0)
├── app/
│ ├── build.gradle.kts # 构建配置(compileSdk 36 / minSdk 24 / targetSdk 36)
│ └── src/
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/moe/hellowidget/
│ │ │ ├── MainActivity.kt # 编辑器 + 顶部导航栏(设置/WebDAV/立即上传/撤回)+ 保存流程
│ │ │ ├── UndoHistory.kt # 无限次撤回:记录每一步文本变更并反向套用
│ │ │ ├── UndoHistoryViewModel.kt# 让撤回历史跨配置变更(旋转/深色模式重建)存活
│ │ │ ├── SettingsActivity.kt # 设置页(小组件外观 + 编辑器浅/深色配色)
│ │ │ ├── SyncActivity.kt # WebDAV 同步设置页(凭据/状态/证书指纹)
│ │ │ ├── SyncService.kt # 同步前台服务(API 34+ 用 shortService,含通知栏进度条)
│ │ │ ├── SyncRetryJobService.kt # 系统级兜底重试任务(JobScheduler 拉起,不显示通知)
│ │ │ ├── sync/ # 同步核心
│ │ │ │ ├── SyncEngine.kt # 决策核心:内容哈希决定要不要发请求(纯函数,可单测)
│ │ │ │ ├── OkHttpWebDavClient.kt # OkHttp 5.4:全流程超时 + 可取消 + TOFU 证书固定
│ │ │ │ ├── TlsPinning.kt # 证书指纹校验(TOFU,只增不减)
│ │ │ │ ├── SyncManager.kt # 编排:单飞 + 合并、上传、状态流
│ │ │ │ ├── SyncSettings.kt # 同步配置与状态存取
│ │ │ │ ├── SyncNotifier.kt # 进度通知与上传成功/失败 Toast
│ │ │ │ ├── SyncConfig.kt # URL 与文件名(前缀+本地时间+扩展名)校验
│ │ │ │ ├── SyncLauncher.kt # 统一触发入口(前台服务,失败降级为进程内)
│ │ │ │ ├── SyncRetry.kt # 失败后的系统级一次性重试任务(排入/撤销/该不该重试)
│ │ │ │ ├── SyncWindowRetry.kt # 前台窗口内的有限次快速重试策略(1s/3s,最多 2 次)
│ │ │ │ ├── SyncDiagnostics.kt # 只读诊断:待机分桶 / 待执行任务原因 / 是否加急
│ │ │ │ └── SyncErrorText.kt # 错误分类 → 本地化文案
│ │ │ ├── EditorSettings.kt # 编辑器颜色存取
│ │ │ ├── HelloWidgetApp.kt # Application:初始化 DataStore
│ │ │ ├── ContentStore.kt # DataStore 原子写入 + CRC32 + 旧数据迁移
│ │ │ ├── TextWidgetProvider.kt # 小组件 Provider
│ │ │ ├── TextWidgetService.kt # 绑定式数据服务
│ │ │ └── WidgetSettings.kt # 小组件外观设置存取
│ │ └── res/ # 布局、字符串(中/英)、主题、图标
│ ├── test/ # JVM 单元测试(Robolectric,云端运行)
│ └── androidTest/ # 仪器化测试(CI 模拟器上运行)
├── build.gradle.kts # 根构建配置(AGP 9.4.1 + AGP 内置 Kotlin)
├── gradle.properties # 全局配置 + 版本号唯一来源
└── settings.gradle.kts
在应用顶部导航栏点「WebDAV 同步」(云朵图标),填写服务器目录地址、文件名、用户名与口令,打开开关后保存。日常上传不必进这一页:编辑页顶部的「立即上传」按钮随时可用。
| 服务 | 目录地址示例 |
|---|---|
| 坚果云 | https://dav.jianguoyun.com/dav/你的目录/(需在「账户信息 → 安全选项」生成应用密码) |
| Nextcloud | https://你的域名/remote.php/dav/files/用户名/目录/ |
| 群晖 / NAS | http://192.168.1.5:5005/dav/目录/(明文 http 会显示风险提示) |
行为约定:
- 纯单向上传,且每次都是新文件:本机内容一变,下一次同步就往目录里写一个
note<本地时间 yyyyMMdd_HHmmss_SSS>.txt;旧文件永远不会被覆盖或删除,云端自动保留全部历史。本机没变则一个请求都不发(省流量、省服务器配额) - 不读云端:客户端接口里根本没有读取方法 —— 不列目录、不下载、不比对,也不清理旧数据;没有冲突与冲突副本。历史文件多起来不需要本应用操心
- 文件名:
<前缀><yyyyMMdd_HHmmss_SSS><扩展名>(本地时间、精确到毫秒),前缀与扩展名来自「文件名」字段(默认note.txt→note20261018_192437_123.txt);同一毫秒内的第二次上传会 +1 毫秒避免撞名,时钟回拨也不会重名(v8.3.0 及更早的旧文件仍是裸 unix 名字,保持原样) - 目标目录不存在时会自动创建(服务器回 409/404 时补一次
MKCOL再重试上传) - 上传时机(v8.1.0):只有「保存内容」才触发自动上传(退出 / 返回、切后台,以及旋转、切换深色模式等触发的自动保存),而每次保存都立刻尝试,没有任何节流;要不要真的发请求只由内容有没有变化决定(内容没变则一个请求都不发,这是防重复上传的唯一机制)。同一时刻最多一次上传,保存比上传快时后来者会合并进正在执行的那一趟(一趟上传吸收它开始前积累的所有保存,且绝不并发 PUT)。只有失败才产生重试间隔:前台窗口内 1 秒 / 3 秒各一次,窗口里没救回来就交给系统兜底任务(首次 30 秒,之后 60 秒起指数退避,最多 5 次)。打开应用不上传也不检测(v8.2.0 起连"顺手算一次哈希"的橙点检测也删了):只把磁盘内容显示出来
- 通知与结果提示:Android 13+ 需要通知权限才能看到进度条;拒绝权限时同步照常工作。每次确实发生上传时,进度通知至少显示 1.5 秒;上传成功弹 Toast「已上传到云端」,上传失败也弹 Toast(v7.8 起失败不再往通知栏留东西,完整原因仍记在同步设置页的「同步状态」里)
- 上传失败会自动补传(v7.9):写盘成功后立刻给系统排一个一次性持久化任务(
JobScheduler,需要网络、按指数退避、进程被杀或重启后依然在),上传成功即撤销;因此「保存了却没上传」最多只是一次延迟,不再需要清后台。(v8.0.1 试过把它改成「加急」以获得绕过待机分桶网络限制的收益,但平台明确禁止「加急 + 延迟」,而立刻执行会抢走前台服务的可见上传,故保持常规 + 延迟;v8.1.0 起首次延迟从 65 秒缩短为 30 秒 —— 65 秒当初只为躲开已删除的 1 分钟闸门,现在的下界只是"别和前台窗口抢锁"。)v8.0.3 起多了一层更早的兜底:退出瞬间若是暂时性失败且失败得很早,会先在已经亮着通知的前台窗口里退避重试 2 次(1 秒、3 秒,通知正文显示「网络不稳,正在重试(1/2)…」),成功就当场看到「已上传到云端」;窗口里没救回来才落到上面那条系统任务。同步设置页会显示「自动重试:已排队(预计 HH:MM:SS 补传)/ 无」、「上次访问云端」与「系统诊断(待机分桶 / 待执行任务原因 / 是否加急)」供排查。只有看起来是暂时性的失败才自动重试(网络 / 超时 / IO / 5xx / 目录缺失 / 锁定 / 配额),凭据错误、证书不受信这类需要你先处理的问题不会反复重试 - 网络请求全程有超时(v7.9):单次同步的上限是 60 秒(覆盖 DNS → 建连 → 写正文 → 读响应),任何一次卡住都不会让后续同步被无限期挡住 —— 旧版没有写超时,一条半开连接就能造成「只能清后台」
- 坚果云:坚果云对「父目录不存在」「目录已存在」「读不存在的文件」都返回非标准的 409,客户端已逐条兼容并降级处理(见 V7.5_ONE_WAY_UPLOAD_REPORT.md、V7.4_NUTSTORE_409_REPORT.md)
- 自签名证书:https 握手失败时同步页会显示证书指纹,点击「信任并记住」后按该指纹固定校验;指纹变化会再次要求确认(不会无条件信任所有证书)
- 用户内容:
filesDir/user_content.dat(DataStore 格式:[UTF-8 内容][CRC32];上限 10,000 字符) - 外观设置:SharedPreferences
hello_prefs(字体大小/颜色/背景等,非关键数据) - 损坏恢复:CRC 校验失败时保留现场文件
corrupt_<时间戳>.dat(只保留最近 3 份)并重建,应用始终可用;v8.4.0 起会在下次打开应用时明确提示一次(损坏可能发生在小组件/后台进程的首次读取里,用户当时不在编辑页) - 读盘失败(v8.4.0):读失败与「空便签」严格区分 —— 读不出来时编辑器保持禁用、所有保存路径拒绝写盘,绝不用空内容覆盖磁盘上唯一那份内容
- 备份与迁移(v8.2.0):
allowBackup="true",且backup_rules.xml/data_extraction_rules.xml显式包含file与sharedpref两个域 —— 云备份与设备间迁移都能带走filesDir/user_content.dat与hello_prefs。规则里写的是 include 而不是"什么都不写",因为它就是"到底备份了什么"的可审计清单 - 同步凭据:WebDAV 地址/用户名/口令同样存在
hello_prefs(因此也会随系统备份一起离机,这是 v8.2.0 的刻意取舍:数据安全优先于隐私);建议使用服务端的「应用专用密码」。开启同步后,笔记内容会按你的设置上传到你自己的 WebDAV 服务器(服务端保存的是明文文本)
本项目基于 MIT License 开源,任何人可自由使用、修改、分发(含商用),仅需保留版权声明与许可证文本,详见 LICENSE。
第三方组件声明(含应用图标出处)见 THIRD_PARTY_NOTICES.md。