From 738cb6cc534bf4ac8f6e58e5c02d6199392d4256 Mon Sep 17 00:00:00 2001
From: limuzi013 <128580527+limuzi013@users.noreply.github.com>
Date: Sat, 3 Oct 2026 03:13:56 +0800
Subject: [PATCH 1/2] Release 0.0.1-beta.1: first public build is a beta, not
1.0
The 1.0.0 release was withdrawn: the first public build is a beta.
- VERSION 0.0.1-beta.1. release_check.py, Gradle and the tests accept
X.Y.Z and X.Y.Z-beta.N; versionCode = (major*10000+minor*100+patch)*100
+ stage (beta N, stable 99), so a stable release outranks its betas.
- release.yml also triggers on vX.Y.Z-beta.N and publishes betas as
pre-releases, never as latest. Release notes carry a Beta notice.
- CHANGELOG, README, release notes and docs/RELEASE.md rewritten for a
user-facing, beta-labelled presentation.
Co-Authored-By: Claude Sonnet 5.5
---
.github/workflows/release.yml | 13 +-
CHANGELOG.md | 81 +++++++------
README.md | 222 ++++++++++------------------------
VERSION | 2 +-
app/build.gradle.kts | 26 ++--
docs/RELEASE.md | 29 +++--
docs/REPOSITORY.md | 2 +-
scripts/release_build.sh | 21 +++-
scripts/release_check.py | 38 ++++--
scripts/test_release_check.py | 41 +++++--
10 files changed, 226 insertions(+), 249 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 45ae6381..1679ba5e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,6 +1,7 @@
name: Android Release
-# A release is a signed arm64 APK built from a vX.Y.Z tag on main. The same build
+# A release is a signed arm64 APK built from a vX.Y.Z or vX.Y.Z-beta.N tag on main
+# (a beta is published as a GitHub pre-release and never as "latest"). The same build
# and verification script (scripts/release_build.sh) is rehearsed with a throwaway
# key on manual runs and on pull requests that touch the release pipeline, so the
# first real release is not the first time it runs. See docs/RELEASE.md.
@@ -8,6 +9,7 @@ on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
+ - 'v[0-9]+.[0-9]+.[0-9]+-beta.[0-9]+'
workflow_dispatch:
pull_request:
branches: [main]
@@ -233,6 +235,12 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
+ channel_flags=()
+ latest_flag=--latest
+ if [[ "$GITHUB_REF_NAME" == *-beta.* ]]; then
+ channel_flags=(--prerelease)
+ latest_flag=--latest=false
+ fi
gh release create "$GITHUB_REF_NAME" \
release-files/openIME-*-arm64-release.apk \
release-files/SHA256SUMS.txt \
@@ -240,6 +248,7 @@ jobs:
--repo "$GITHUB_REPOSITORY" \
--verify-tag \
--draft \
+ "${channel_flags[@]}" \
--title "openIME $GITHUB_REF_NAME" \
--notes-file release-files/RELEASE_NOTES.md
@@ -248,4 +257,4 @@ jobs:
echo "::error::Expected 3 release assets, found $count; the draft was left unpublished."
exit 1
fi
- gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false --latest
+ gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false "$latest_flag"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 57e16297..27c13105 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,58 +4,59 @@
[Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循
[语义化版本](https://semver.org/lang/zh-CN/)。每个 `## [版本] - 日期` 小节就是该版本
GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs/RELEASE.md)。
-1.0.0 之前的开发期记录见 [docs/CHANGELOG_PRE_1.0.md](docs/CHANGELOG_PRE_1.0.md)。
+首个公开版本之前的开发期记录见 [docs/CHANGELOG_PRE_1.0.md](docs/CHANGELOG_PRE_1.0.md)。
## [Unreleased]
-## [1.0.0] - 2026-10-02
+## [0.0.1-beta.1] - 2026-10-03
-首个正式版。openIME 是独立的 Android 系统输入法:拼音候选、用户学习和语音识别都在设备内运行,应用不声明 `INTERNET` 权限。
+openIME 的首个公开测试版。功能已经可以日常试用,但稳定性和兼容性还在验证,请不要把它作为唯一的输入法;欢迎在 Issues 反馈问题。
+
+### 亮点
+- 拼音、用户词库学习和语音识别全部在设备本地完成,应用没有 `INTERNET` 权限。
+- 九键拼音一个字选一个音节:左栏列出下一个字的拼音,选定后自动移到下一个字。
+- 长按空格说话,松手识别并上屏,使用内置的中英双语模型。
+- 在终端、远程桌面、游戏、外接键盘、平板与折叠屏、大字号等环境下都能输入;键盘出现崩溃或卡死时自动进入安全模式。
### 输入
-- 键盘:26 键拼音、九键拼音、英文 26 键、数字与符号;不提供英文九键。
-- 拼音引擎:基于 librime 的全拼、简拼、显式分词、候选读取(上限 96 项)、用户学习和 OpenCC 简繁转换;内置约 90 万条 Rime Ice 词典记录(固定版本,含 8105 字表)。首次部署完整词典期间,高频快速词库即时提供候选。
-- 九键:输入时左栏变成拼音列表,一个字一个拼音——每项是下一个字的一个音节(`ni`、`mi`),点选即锁定,列表随即移到下一个字;锁定的音节在继续输入时保持不变,退格先解锁。预编辑跟随首选词的读法(`9694264244326` → `wo'xiang'chi'fan`「我想吃饭」),数字刚好拼得出的词排在预测词之前。做法与取舍见 [九键参考](https://github.com/Slacker-LLC/openIME/blob/main/docs/NINE_KEY_REFERENCE.md)。
-- 选词只覆盖一部分输入时只上屏该词,剩余输入继续作为预编辑;不会把没选过的整句写进用户词库。
-- 空格提交首选候选,回车(确定)提交已输入的拼音原文;提交后统一清除拼音、候选与 Rime composition,随后删除键只处理目标输入框。上屏后的联想栏为「‹ 联想词 ∨」。
-- 删除键:长按连续删除;上滑 ≥ 32dp 松手清空,清空后 5 秒内下滑撤回。清空与撤回在自绘、Compose、Web 等没有「全选」也没有完整 ExtractedText 的输入框里同样可用,提示统一为一个气泡,清空后顶栏显示「已清空 · 撤销」。
+- 键盘:26 键拼音、九键拼音、英文 26 键、数字与符号。
+- 拼音引擎基于 librime:全拼、简拼、手动分词、候选展开(最多 96 项)、用户学习、OpenCC 简繁转换;内置约 90 万条 Rime Ice 词典记录。首次部署完整词典期间,由高频快速词库提供候选。
+- 九键:点选拼音即锁定该音节,继续输入时保持,退格先解锁;预编辑跟随首选词读法(`9694264244326` → `wo'xiang'chi'fan`「我想吃饭」)。设计取舍见 [九键参考](https://github.com/Slacker-LLC/openIME/blob/main/docs/NINE_KEY_REFERENCE.md)。
+- 选词只覆盖一部分输入时,只上屏该词,其余输入继续作为预编辑,不会把没选过的整句写进用户词库。
+- 空格提交首选,回车提交拼音原文。
+- 删除键:长按连续删除;上滑清空,5 秒内下滑撤回(在自绘、Compose、Web 等输入框里同样可用)。
### 语音
-- 长按空格(达到系统长按时长)开始本地语音输入,松手识别并上屏,上滑取消。使用 APK 内置的 sherpa-onnx 与中英双语模型,没有网络语音服务。
-- 输入框出现时后台预热模型,隐藏 10 秒后释放;预热期间的语音先录音缓存;支持动态热词与本地纠错学习。
-- 麦克风权限被拒绝时可恢复,不影响普通输入;本地模型使用前校验完整性。
+- 长按空格开始本地语音输入,松手识别并上屏,上滑取消。
+- 输入框出现时后台预热模型,隐藏 10 秒后释放;支持热词与本地纠错学习。
+- 麦克风权限被拒绝时可恢复,不影响普通打字。
-### 面板与外观
-- Emoji、符号、剪贴板与常用语、文本编辑、浮动键盘和设置面板。
-- 按输入法窗口实际可用宽度计算布局,宽屏限制内容最大宽度并居中;系统底部区域通过 WindowInsets 处理。
-- 强调色可选可自定义,界面里的强调元素统一从它推导;设置页分段控件、剪贴板清除按钮等可点区域保持 48dp 触控目标;安装引导页的卡片对读屏软件是一个整体,不再逐个朗读装饰元素。
-- 手写目前只保留笔迹采集界面,识别引擎尚未接入,入口默认隐藏。
+### 界面
+- Emoji、符号、剪贴板与常用语、文本编辑、浮动键盘、设置面板;强调色可自定义,可点区域保持 48dp 触控目标。
+- 布局按输入法窗口的实际宽度计算,宽屏限制最大宽度并居中。
### 兼容性
-- 物理键盘(平板、折叠屏键盘套、Chromebook、桌面模式)可以打中文:字母组成拼音,空格选首选,1–9 选候选,回车保留拼音,Esc 取消,`,` `.` 等输出全角标点,数字后的 `,` `.` `:` 保持 ASCII;快捷键、大写字母原样交给应用。
-- 终端、游戏、远程桌面这类没有文本类型(TYPE_NULL)的输入框:默认英文,字母立即送出,退格改用按键事件(此前什么也删不掉);日期时间输入框默认数字键盘。
-- 一次提交超大文本(大段粘贴)会分块,不会因超过 Binder 事务上限而崩溃。
-- 横屏不再进入全屏「提取」模式:应用自己的输入框不会被盖住,键盘始终是底部面板。
-- 横屏、平板和折叠屏上按键行宽度现在跟随内容区,不会再被裁掉一部分;布局在测量前就按可用宽度计算。
-- 系统字体放大到 200% 时按键字符不再变成省略号,「中/英」不再缺字:按键标签最多跟随系统字体放大到 1.3 倍,功能键标签会自动缩小以放入按键。
-- 竖屏、横屏、字体 130% / 200%、深色、小屏、窄屏、平板(竖 / 横)和折叠屏内屏都由 `scripts/display_matrix_regression.py` 逐一验证。
+- 外接键盘(平板、折叠屏键盘套、Chromebook、桌面模式)可以打拼音:空格选首选,1–9 选候选,回车保留拼音,Esc 取消,快捷键交给应用。
+- 终端、游戏、远程桌面这类无文本类型的输入框:默认英文、按键直接送出、退格可用;日期时间输入框默认数字键盘。
+- 横屏不再进入全屏「提取」模式;横屏、平板、折叠屏的按键行宽度跟随内容区;系统字体放大到 200% 时按键字符完整显示。
+- 超大文本粘贴会分块提交,不会因超过 Binder 事务上限而崩溃。
+- 逐环境的处理方式和验证范围见 [兼容性说明](https://github.com/Slacker-LLC/openIME/blob/main/docs/COMPATIBILITY.md)。
### 稳定性
-- 一次按键处理失败不会让键盘退出;崩溃、原生崩溃和卡死(ANR)会记录在本机(只含异常类型和代码位置,不含输入内容),10 分钟内三次进入安全模式(关闭原生词库与语音预加载),「关于与数据」里可以复制诊断信息或退出安全模式。
-- librime 启动时进程原生崩溃会逐级自愈:清理编译产物、备份并重建用户词库、最后不再启动原生引擎。
-- 语音输入静音媒体音量期间进程意外退出,下次启动会恢复音量,并带两分钟看门狗。
-- 词库和九键解码器改在后台线程构建,冷启动不再卡住主线程。
+- 单次按键处理失败不会让键盘退出。崩溃、原生崩溃和卡死会记录在本机(只含异常类型和代码位置,不含输入内容);10 分钟内三次进入安全模式(关闭原生词库与语音预加载),可在「关于与数据」复制诊断信息或退出安全模式。
+- librime 启动时原生崩溃会逐级自愈:清理编译产物、备份并重建用户词库、最后不再启动原生引擎。
+- 语音输入静音媒体音量期间进程意外退出,下次启动会恢复音量。
+- 词库和九键解码器在后台线程构建,冷启动不再卡住主线程。
### 隐私与安全
-- 不声明 `INTERNET` 权限;`allowBackup=false`。
-- 密码输入框不写入候选、剪贴板历史或日志(允许从剪贴板粘贴);语音 PCM 只在当前会话的内存缓冲区中处理,结束、取消或失败时清空。
-- 「设置 → 关于与数据」可导出 / 合并导入用户数据,剪贴板历史不导出;卸载会清除本机全部数据,包括学习的用户词库。
-- 测试用 Activity 与 E2E 接收器只存在于 debug 变体,release APK 不导出。
-- 以 GPL-3.0-only 许可证发布,见仓库根目录 `LICENSE`。
-
-### 工程
-- 版本号单一来源(根目录 `VERSION`),`versionCode = 主 × 10000 + 次 × 100 + 修订`。
-- 发布 APK 为固定签名的 `arm64-v8a`,随附 SHA-256 校验和与第三方许可清单;CI 在 API 29 / 31 上运行全部仪器测试。
-
-[Unreleased]: https://github.com/Slacker-LLC/openIME/compare/v1.0.0...HEAD
-[1.0.0]: https://github.com/Slacker-LLC/openIME/releases/tag/v1.0.0
+- 不声明 `INTERNET` 权限,`allowBackup=false`。
+- 密码输入框不写入候选、剪贴板历史或日志;语音音频只在内存中处理,结束、取消或失败时清空。
+- 「设置 → 关于与数据」可导出和合并导入用户数据(剪贴板历史不导出);卸载会清除本机全部数据。
+- 测试用 Activity 与 E2E 接收器只存在于 debug 变体。
+
+### 许可证与发布
+- 以 GPL-3.0-only 发布,见仓库根目录 `LICENSE`。
+- 发布包为固定签名的 `arm64-v8a` APK,随附 SHA-256 校验和与第三方许可清单;版本号规则见 [发布与版本管理](https://github.com/Slacker-LLC/openIME/blob/main/docs/RELEASE.md)。
+
+[Unreleased]: https://github.com/Slacker-LLC/openIME/compare/v0.0.1-beta.1...HEAD
+[0.0.1-beta.1]: https://github.com/Slacker-LLC/openIME/releases/tag/v0.0.1-beta.1
diff --git a/README.md b/README.md
index 28b1f958..246431bf 100644
--- a/README.md
+++ b/README.md
@@ -4,131 +4,80 @@
-本地优先的 Android 中文输入法。
+本地优先的 Android 中文输入法
-openIME 是独立 Android 系统输入法,包名 `llc.slacker.openime`。拼音候选、用户学习和语音识别均在设备内运行;应用不声明 `INTERNET` 权限。
-
-[](https://github.com/Slacker-LLC/openIME/actions/workflows/android.yml)
-
-## 下载
-
-正式版本从 [GitHub Releases](https://github.com/Slacker-LLC/openIME/releases/latest) 下载,各版本的变化见 [CHANGELOG.md](CHANGELOG.md)。发布 APK 使用固定发布签名,文件名为 `openIME-v{版本}-arm64-release.apk`,同一 Release 同时提供 `SHA256SUMS.txt` 和第三方许可清单。下载后先校验再安装:
-
-```bash
-sha256sum -c SHA256SUMS.txt
-```
-
-当前正式发布包只包含 `arm64-v8a`;开发用 Debug APK 仍保留 `x86_64`,用于模拟器回归。
-
-版本号遵循[语义化版本](https://semver.org/lang/zh-CN/),唯一来源是根目录 `VERSION`;发布流程见 [docs/RELEASE.md](docs/RELEASE.md)。
-
-## 安装四步
-
-1. **启用**:安装 APK 后打开 openIME,进入系统输入法设置并启用 `openIME`。
-2. **切换**:回到引导页,打开系统输入法选择器并切换到 `openIME`。
-3. **授权(可选)**:需要本地语音输入时授权麦克风;不授权可直接跳过。
-4. **试打**:在引导页输入框确认键盘、候选和上屏链路正常。
-
-语音输入长按空格达到系统长按阈值后开始录音,松手后识别并上屏。
-
-## 截图
-
-真实截图由 `scripts/visual_matrix_regression.ps1` 和视觉验收流程生成。仓库不提交占位图;26 键、九键与设置页的浅色/深色截图将在真实视觉矩阵验收后写入 `docs/images/`。
-
-## 项目定位
+
+
+
+
+
-openIME 的界面、输入法引擎和本地语音链路均在同一个独立 APK 内运行:
+> **当前处于 Beta 测试阶段(0.0.x)。** 功能和稳定性仍在验证,接口与数据格式可能调整,
+> 请不要把它作为日常唯一的输入法。欢迎通过 [Issues](https://github.com/Slacker-LLC/openIME/issues) 反馈问题。
-```text
-键盘 View
- ↓
-输入状态与候选栏
- ↓
-高频快速词库(首次部署期间即时可用)
- ↓
-librime / OpenCC(完整拼音、候选、学习、简繁转换)
- ↓
-InputConnection
- ↓
-当前应用的输入框
-```
+openIME 是一款独立的 Android 系统输入法。拼音候选、用户词库学习和语音识别全部在设备本地完成,
+应用**没有 `INTERNET` 权限**,输入内容不会离开手机。
-语音输入使用 APK 内置的 sherpa-onnx runtime 和中英双语模型;短按空格提交空格或
-首选候选,长按空格进入语音输入。没有网络语音服务,也没有 `INTERNET` 权限。
+## 功能
-## 当前能力
+- **键盘**:26 键拼音、九键拼音、英文 26 键、数字与符号;Emoji、符号、剪贴板、文本编辑、浮动键盘。
+- **拼音**:全拼、简拼、手动分词、候选展开、用户词库学习、简繁转换。
+ 引擎为 librime,内置约 90 万条 Rime Ice 词典记录;首次部署完整词典期间,高频快速词库先提供候选。
+- **九键**:输入时左栏列出下一个字的拼音,一个字选一个音节,选定后自动移到下一个字。
+- **语音输入**:长按空格说话,松手后识别并上屏;使用内置的中英双语模型,不联网。
+- **手势**:删除键上滑清空,5 秒内下滑可撤回。
+- **适配**:横竖屏、平板与折叠屏、深色模式、大字号;终端、远程桌面、游戏等原始按键输入框;
+ 外接键盘可直接打拼音。详见 [兼容性说明](docs/COMPATIBILITY.md)。
+- **自我保护**:连续崩溃或卡死后自动进入安全模式,保证仍然可以打字;
+ “设置 → 关于与数据”可复制诊断信息并导出、导入用户数据。
-- 26 键拼音、9 键拼音、英文 26 键、数字与符号输入;不提供英文九键。
-- 基于 librime 的全拼、简拼、显式分词、候选词、用户学习、光标编辑、删除和提交链路。
-- 内置约 90 万条 Rime Ice 基础及扩展词典记录;首次部署完整词典时,高频快速词库仍可即时提供候选。
-- OpenCC 简繁转换与 Rime 词典数据,候选结果通过 `setComposingText()` 更新,选中后
- 通过 `commitText()` 写入当前编辑器。
-- Emoji、符号、剪贴板、文本编辑、浮动键盘和设置面板;手写目前仅保留笔迹采集 UI,识别引擎尚未接入,正式入口默认隐藏。
-- 根据输入法窗口实际可用宽度动态计算列宽与间距;宽屏限制内容最大宽度并居中,
- 系统底部区域通过 WindowInsets 处理。
-- 空格短按输入空格或提交首选,长按达到系统长按时长(`ViewConfiguration.getLongPressTimeout()`)进入唯一的语音输入流程;删除键上滑清空。
-- 选词或首选上屏后统一清除拼音、候选与 Rime composition,随后删除键只处理目标输入框。
-- Android 密码编辑器的隐私边界、麦克风权限失败回退和本地模型校验。
+## 下载与安装
-## 仓库结构
+1. 在 [Releases](https://github.com/Slacker-LLC/openIME/releases) 下载最新的 `openIME-v*-arm64-release.apk` 和
+ `SHA256SUMS.txt`。发布包仅支持 `arm64-v8a` 设备(绝大多数近年的 Android 手机),系统要求 Android 8.0(API 26)及以上。
+2. 校验后再安装:
-```text
-app/ Android APK、IME Service、Rime JNI、内置模型与词典
-scripts/ PowerShell/Bash 构建、回归、性能、视觉检查与发布脚本
-docs/ 架构、适配、测试、发布与仓库管理文档
-gradle/ Gradle Wrapper
-.github/ GitHub Actions(CI 与发布)、Dependabot、Issue 与 PR 模板
-VERSION 版本号的唯一来源(MAJOR.MINOR.PATCH)
-CHANGELOG.md 各版本变更记录,同时是 Release 说明的来源
-```
+ ```bash
+ sha256sum -c SHA256SUMS.txt
+ ```
-第三方 C/C++ 源码位于 `app/src/main/cpp/vendor/`,其上游许可证随源代码保留。
+3. 打开 openIME,按引导启用输入法并切换到 openIME。
+4. 如需语音输入,在引导页授权麦克风;不授权也不影响普通打字。
+5. 在引导页的输入框里试打一下,确认键盘、候选和上屏正常。
-## 环境要求
+所有发布包使用同一把固定密钥签名,证书 SHA-256 见 [docs/release-cert.sha256](docs/release-cert.sha256),
+同一把密钥签名的新版本可以直接覆盖安装。
-- Android Studio 或 JDK 17。
-- Android SDK Platform 36。
-- Android NDK `27.0.12077973`。
-- CMake `3.22.1`。
-- Git LFS(语音模型和 sherpa-onnx AAR 使用 LFS)。
+### 已知限制
-首次克隆后请确认大文件已下载:
+- 手写输入尚未接入识别引擎,入口默认隐藏。
+- 九键暂不支持与外接键盘同时使用。
+- 部分厂商系统对输入法的后台限制不同,尚未在大量真机上验证。
+- 首次安装后需要几十秒完成完整词典部署,期间候选质量略低。
+- 版本号小于此前已装版本时,系统会拒绝覆盖安装;需要先卸载(卸载前可导出用户数据)。
-```bash
-git lfs install
-git lfs pull
-```
+## 隐私与安全
-首次克隆还需恢复锁定版本的 Rime 原生依赖与头文件(Linux / Git Bash):
+- 应用不声明 `INTERNET` 权限,`allowBackup` 关闭;词典、语音模型和 runtime 都随 APK 提供。
+- 语音音频只在内存中处理,结束、取消或失败时清空,不落盘。
+- 密码输入框不写入候选、剪贴板历史或日志;崩溃记录只保存异常类型和调用栈,不含输入内容。
+- 卸载会清除本机全部数据,包括学习到的用户词库。
-```bash
-bash scripts/fetch_rime_deps.sh
-```
+发现安全问题请不要直接创建公开 Issue,按 [SECURITY.md](SECURITY.md) 私下报告。
-## 构建
+## 参与开发
-标准构建:
+环境要求:JDK 17、Android SDK Platform 36、NDK `27.0.12077973`、CMake `3.22.1`、Git LFS。
```bash
-./gradlew :app:assembleDebug
-```
-
-Windows PowerShell:
-
-```powershell
-.\gradlew.bat :app:assembleDebug
-```
-
-如果 Windows 工作区路径包含中文导致 Gradle/JDK 17 的测试 worker 无法解析类路径,
-使用仓库提供的 ASCII 临时构建脚本:
-
-```powershell
-.\scripts\build_ascii.ps1
+git lfs install && git lfs pull # 语音模型与 sherpa-onnx AAR
+bash scripts/fetch_rime_deps.sh # 锁定版本的 librime 原生依赖
+./gradlew :app:assembleDebug # 输出 app/build/outputs/apk/debug/app-debug.apk
+bash scripts/verify_linux.sh # 单元测试、Lint、Debug APK、仪器测试 APK
```
-Debug APK 输出为 `app/build/outputs/apk/debug/app-debug.apk`,仅用于开发与回归;正式分发使用 GitHub Release 中固定签名的 arm64 APK。
-
-开发设备可用 ADB 安装 Debug APK:
+Debug 包仅用于开发与回归(含 `x86_64`),带调试用 Activity 和 Receiver,不会随正式包发布。
+在设备上安装与启用:
```bash
adb install -r app/build/outputs/apk/debug/app-debug.apk
@@ -136,61 +85,24 @@ adb shell ime enable --user 0 llc.slacker.openime/.LocalVoiceImeService
adb shell ime set --user 0 llc.slacker.openime/.LocalVoiceImeService
```
-调试测试 Activity 和 E2E Receiver 只存在于 debug 变体,不会成为正式输入法的公共控制入口。
-
-## 验证
-
-Linux 下可一次执行 JVM 测试、Lint、Debug APK 和仪器测试 APK 构建:
-
-```bash
-bash scripts/verify_linux.sh
-# 仅在明确指定测试设备时运行设备测试:
-bash scripts/verify_linux.sh emulator-5554
-```
-
-首次启动首页会显示启用状态,提供系统输入法设置与切换入口;麦克风在用户选择
-开启本地语音时申请,不影响普通打字。候选展开区按词长分配宽度,相同候选快照
-不会重建列表;常用语与自定义符号编辑保留旋转前的草稿。
-
-本地 JVM 测试与构建:
-
-```powershell
-.\gradlew.bat :app:testDebugUnitTest :app:lintDebug :app:assembleDebug --no-daemon --console=plain
-```
-
-真实 IME 回归需要明确指定设备 Serial,避免误操作其他手机:
+仓库结构:
-```powershell
-.\scripts\test_sop.ps1 -Level L0 -Serial
-.\scripts\core_regression.ps1 -Serial
-.\scripts\typing_engine_regression.ps1 -Serial
-.\scripts\extended_regression.ps1 -Serial
-.\scripts\lifecycle_regression.ps1 -Serial
-.\scripts\visual_check.ps1 -Serial
+```text
+app/ Android 应用、输入法服务、Rime JNI、内置模型与词典
+scripts/ 构建、回归、发布脚本
+docs/ 架构、兼容性、测试、发布与仓库管理文档
+.github/ CI、发布流水线、Dependabot、Issue 与 PR 模板
+VERSION 版本号的唯一来源
+CHANGELOG.md 变更记录,同时是 Release 说明的来源
```
-更多说明见:
-
-- [文档索引](docs/README.md)
-- [正式测试 SOP](docs/TEST_SOP.md)
-- [输入法架构](docs/ARCHITECTURE.md)
-- [本地语音模型接入边界](docs/LOCAL_VOICE_MODEL.md)
-- [适配与坐标规范](docs/COORDINATE_SYSTEM.md)
-- [测试流程 SOP](docs/TEST_SOP.md)
-- [脚本说明](scripts/README.md)
-- [发布与版本管理](docs/RELEASE.md)
-- [仓库管理](docs/REPOSITORY.md)
-- [贡献指南](CONTRIBUTING.md)
-
-## 隐私与安全
-
-**本应用不含联网权限,数据只存在本机。**
-
-语音 PCM 只在当前会话的有界内存缓冲区中处理,结束、取消或失败时清空;模型和词典随 APK 提供。密码输入框不写入候选、剪贴板或日志,但允许用户从剪贴板粘贴。设置中的“关于与数据”可导出/合并导入用户数据;剪贴板历史不导出。卸载会清除本机全部数据,包括学习的用户词库。
+文档入口:
-发现安全问题请不要直接创建公开 Issue,先按 [SECURITY.md](SECURITY.md) 联系维护者。
+- [文档索引](docs/README.md) [输入法架构](docs/ARCHITECTURE.md) [兼容性](docs/COMPATIBILITY.md)
+- [测试 SOP](docs/TEST_SOP.md) [脚本说明](scripts/README.md)
+- [发布与版本管理](docs/RELEASE.md) [仓库管理](docs/REPOSITORY.md) [贡献指南](CONTRIBUTING.md)
## 许可证
-openIME 以 [GPL-3.0-only](LICENSE) 发布。第三方组件的许可证保留在各自目录中,
-详见 [docs/LICENSING.md](docs/LICENSING.md) 和 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
+openIME 以 [GPL-3.0-only](LICENSE) 发布。第三方组件保留各自的许可证,
+见 [docs/LICENSING.md](docs/LICENSING.md) 和 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
diff --git a/VERSION b/VERSION
index 3eefcb9d..7390193c 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.0.0
+0.0.1-beta.1
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 8cc08207..f6648169 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -25,27 +25,27 @@ plugins {
id("org.jetbrains.kotlin.android")
}
-// The root VERSION file (MAJOR.MINOR.PATCH) is the only place a version is
-// written. versionCode is derived from it, so the two cannot drift and every
-// release is strictly greater than the one before. scripts/release_check.py
-// applies the same rules in CI and verifies the built APK.
+// The root VERSION file (MAJOR.MINOR.PATCH, or MAJOR.MINOR.PATCH-beta.N) is the
+// only place a version is written. versionCode is derived from it, so the two
+// cannot drift and every release is strictly greater than the one before; a
+// stable release outranks every beta of the same MAJOR.MINOR.PATCH.
+// scripts/release_check.py applies the same rules in CI and verifies the built APK.
val appVersionName: String = providers
.fileContents(rootProject.layout.projectDirectory.file("VERSION"))
.asText
.get()
.trim()
val appVersionCode: Int = run {
- val parts = Regex("""(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)""")
+ val match = Regex("""(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-beta\.([1-9]\d*))?""")
.matchEntire(appVersionName)
- ?.destructured
- ?.toList()
- ?.map(String::toInt)
- ?: throw GradleException("VERSION must be MAJOR.MINOR.PATCH, found '$appVersionName'")
- val (major, minor, patch) = parts
- if (major < 1 || minor > 99 || patch > 99) {
- throw GradleException("VERSION $appVersionName is out of range (major >= 1, minor and patch <= 99)")
+ ?: throw GradleException("VERSION must be MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH-beta.N, found '$appVersionName'")
+ val (major, minor, patch) = match.groupValues.drop(1).take(3).map(String::toInt)
+ val beta = match.groups[4]?.value?.toInt()
+ val stage = beta ?: 99
+ if ((major == 0 && minor == 0 && patch == 0) || major > 2000 || minor > 99 || patch > 99 || (beta != null && beta > 98)) {
+ throw GradleException("VERSION $appVersionName is out of range (not 0.0.0, major <= 2000, minor and patch <= 99, beta <= 98)")
}
- major * 10_000 + minor * 100 + patch
+ (major * 10_000 + minor * 100 + patch) * 100 + stage
}
android {
diff --git a/docs/RELEASE.md b/docs/RELEASE.md
index 187cb7e1..ca5ec7cc 100644
--- a/docs/RELEASE.md
+++ b/docs/RELEASE.md
@@ -6,13 +6,19 @@
## 版本号
-- 语义化版本 `MAJOR.MINOR.PATCH`,只发布 `X.Y.Z` 稳定版,不使用 `-rc`、`-beta` 等后缀。
-- `VERSION` 是一行文本(例如 `1.0.0`)。`app/build.gradle.kts` 读取它:
- `versionName = VERSION`,`versionCode = MAJOR × 10000 + MINOR × 100 + PATCH`
- (`1.0.0` → `10000`,`1.2.3` → `10203`)。要求 `MAJOR ≥ 1`,`MINOR`、`PATCH` 不超过 99。
- 这样 `versionCode` 不会被忘记升级,也永远严格递增。
+- 语义化版本:稳定版 `MAJOR.MINOR.PATCH`,测试版 `MAJOR.MINOR.PATCH-beta.N`(`N` 从 1 起)。
+ 不使用 `-rc`、`-alpha` 等其他后缀。项目目前处于 `0.0.x` 测试阶段;`1.0.0` 只在功能和稳定性
+ 都达到可以让用户日常依赖时才发布。
+- `VERSION` 是一行文本(例如 `0.0.1-beta.1`)。`app/build.gradle.kts` 读取它:
+ `versionName = VERSION`,`versionCode = (MAJOR × 10000 + MINOR × 100 + PATCH) × 100 + 阶段`,
+ 阶段对测试版是 `N`,对稳定版是 `99`(`0.0.1-beta.1` → `101`,`0.0.1` → `199`,`1.0.0` → `1000099`)。
+ 要求 `MINOR`、`PATCH` 不超过 99,`N` 不超过 98,`MAJOR` 不超过 2000,且不能是 `0.0.0`。
+ 这样 `versionCode` 不会被忘记升级,同一个 `X.Y.Z` 的稳定版永远高于它的测试版。
- `scripts/release_check.py` 用同一公式检查仓库,并用 `aapt2` 校验构建出的 APK 里的
包名、`versionName`、`versionCode`;PR 的 CI 和发布工作流都会运行它。
+- 测试版以 GitHub **pre-release** 发布,不标记为 latest,发布说明顶部有 Beta 提示。
+ 标签是 `vX.Y.Z-beta.N`,发布时由流水线按标签自动识别。
+- 注意:Android 不允许 `versionCode` 变小的覆盖安装。一旦公开发布过某个版本,后续版本必须更大。
什么时候升哪一位:
@@ -21,6 +27,7 @@
| MAJOR | 用户数据格式不兼容或需要用户手动迁移;`minSdk` 提高;包名或签名变化 |
| MINOR | 新功能、新面板或键盘;词库、语音模型、第三方 runtime 的版本变化(需重新核对许可证) |
| PATCH | 缺陷修复、性能、文案、依赖的安全更新 |
+| `-beta.N` | 同一个 `X.Y.Z` 的第 N 个测试快照,修复后递增 N;正式确认后去掉后缀发布 |
Rime 共享数据以 `versionCode` 作为部署标记:每次升级后首次启动都会重新部署共享词典
(用户词库在独立目录,不受影响)。所以 PATCH 版本也会触发一次重新部署。
@@ -32,11 +39,11 @@ Rime 共享数据以 `versionCode` 作为部署标记:每次升级后首次启
[CHANGELOG.md](../CHANGELOG.md) 遵循 Keep a Changelog:
- 日常 PR 把用户可见的改动写进 `## [Unreleased]`,**不改 `VERSION`**。
-- 最新的 `## [X.Y.Z] - YYYY-MM-DD` 小节必须正好等于 `VERSION`,写法上不允许空小节、
+- 最新的 `## [版本] - YYYY-MM-DD` 小节必须正好等于 `VERSION`,写法上不允许空小节、
版本或日期倒序。`release_check.py check` 在 CI 里强制这些规则,所以版本号与
变更记录只能一起变化。
- 这一小节的正文就是 GitHub Release 的发布说明,请按用户能读懂的方式写。
-- 已撤回的版本在标题后加 ` [YANKED]`,并发布更高的 PATCH 版本。
+- 已撤回的版本在标题后加 ` [YANKED]`,并发布更高的版本(测试版递增 `N`)。
## 发布产物
@@ -79,7 +86,7 @@ bash scripts/setup_release_signing.sh
## 发布步骤
1. `main` 上最近一次 CI 全绿(包括 API 29 / 31 兼容测试)。
-2. 发布 PR:把 `[Unreleased]` 整理成 `## [X.Y.Z] - YYYY-MM-DD`,同时修改 `VERSION`。
+2. 发布 PR:把 `[Unreleased]` 整理成 `## [版本] - YYYY-MM-DD`,同时修改 `VERSION`。
本地先运行:
```bash
@@ -91,7 +98,7 @@ bash scripts/setup_release_signing.sh
```bash
git switch main && git pull
- git tag -a vX.Y.Z -m "openIME X.Y.Z"
+ git tag -a vX.Y.Z -m "openIME X.Y.Z" # 测试版:vX.Y.Z-beta.N
git push origin vX.Y.Z
```
@@ -101,8 +108,8 @@ bash scripts/setup_release_signing.sh
- 单元测试、`lintRelease`、`assembleRelease`;
- APK 签名校验(不能是 Debug 证书)、只含 `arm64-v8a`、APK 内版本与 `VERSION` 一致、
签名证书与 `release-cert.sha256` 一致;
- - 生成 SHA-256 和发布说明;
- - 另一个只有写权限、不接触密钥的 job 先建**草稿** Release,确认三个附件齐全后才公开。
+ - 生成 SHA-256 和发布说明(测试版带 Beta 提示);
+ - 另一个只有写权限、不接触密钥的 job 先建**草稿** Release,确认三个附件齐全后才公开(测试版标为 pre-release,不是 latest)。
5. 发布后核对:下载 APK,`sha256sum -c SHA256SUMS.txt`,`apksigner verify --print-certs`,
在真机上安装、启用、试打。
diff --git a/docs/REPOSITORY.md b/docs/REPOSITORY.md
index 44096eef..76abb6a7 100644
--- a/docs/REPOSITORY.md
+++ b/docs/REPOSITORY.md
@@ -33,7 +33,7 @@
## 标签
规则集 `release-tags` 作用于 `v*`:只有仓库管理员能创建,创建后任何人(管理员除外)都不能移动或删除。
-发布标签格式固定为 `vX.Y.Z`,必须与 `VERSION` 一致,详见 [RELEASE.md](RELEASE.md)。
+发布标签格式固定为 `vX.Y.Z` 或 `vX.Y.Z-beta.N`,必须与 `VERSION` 一致,详见 [RELEASE.md](RELEASE.md)。
## 安全
diff --git a/scripts/release_build.sh b/scripts/release_build.sh
index 60959a87..1b840c43 100755
--- a/scripts/release_build.sh
+++ b/scripts/release_build.sh
@@ -94,7 +94,17 @@ cp "$APK" "$OUT/$NAME"
cp THIRD_PARTY_NOTICES.md "$OUT/THIRD_PARTY_NOTICES.release.md"
(cd "$OUT" && sha256sum "$NAME" > SHA256SUMS.txt)
+CHANNEL="$(python3 scripts/release_check.py channel)"
+if [[ "$CHANNEL" == "beta" ]]; then
+ CHANNEL_NOTE="> **Beta 测试版。** 功能完整度和稳定性还在验证中,请不要作为日常唯一输入法;遇到问题请到 Issues 反馈。"
+ PACKAGE_NOTE="固定签名的 arm64-v8a 测试包"
+else
+ CHANNEL_NOTE=""
+ PACKAGE_NOTE="固定签名的 arm64-v8a 正式包"
+fi
+
{
+ [[ -n "$CHANNEL_NOTE" ]] && printf '%s\n\n' "$CHANNEL_NOTE"
python3 scripts/release_check.py notes
cat < "$OUT/RELEASE_NOTES.md"
diff --git a/scripts/release_check.py b/scripts/release_check.py
index b1b56811..9bd380b5 100755
--- a/scripts/release_check.py
+++ b/scripts/release_check.py
@@ -10,6 +10,7 @@
section for it right below [Unreleased], and
(with --tag) the tag is exactly v
release_check.py notes [--version X.Y.Z] print that CHANGELOG section (release notes body)
+ release_check.py channel print "beta" or "stable" for VERSION
release_check.py apk PATH [--aapt2 PATH] versionName / versionCode / package inside the APK
match VERSION
@@ -32,7 +33,9 @@
ROOT = Path(__file__).resolve().parent.parent
APPLICATION_ID = "llc.slacker.openime"
-SEMVER = re.compile(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)")
+# X.Y.Z, or X.Y.Z-beta.N for a pre-release. Nothing else is published.
+SEMVER = re.compile(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-beta\.([1-9]\d*))?")
+STABLE_STAGE = 99 # a stable release sorts above every beta of the same X.Y.Z
UNRELEASED_HEADING = re.compile(r"##\s+\[Unreleased\]\s*")
RELEASE_HEADING = re.compile(
r"##\s+\[(?P[^\]]+)\]\s+-\s+(?P\d{4}-\d{2}-\d{2})(?P\s+\[YANKED\])?\s*"
@@ -50,24 +53,32 @@ class Section(NamedTuple):
body: str
-def parse_version(text: str) -> tuple[int, int, int]:
+def parse_version(text: str) -> tuple[int, int, int, int]:
+ """(major, minor, patch, stage); stage is N for X.Y.Z-beta.N and 99 for X.Y.Z."""
match = SEMVER.fullmatch(text.strip())
if not match:
raise ReleaseCheckError(
- f"version '{text.strip()}' must be MAJOR.MINOR.PATCH without a prefix or suffix"
+ f"version '{text.strip()}' must be MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH-beta.N "
+ "(no 'v' prefix, no other suffix)"
)
- major, minor, patch = (int(part) for part in match.groups())
- if major < 1 or minor > 99 or patch > 99:
+ major, minor, patch = (int(part) for part in match.groups()[:3])
+ beta = match.group(4)
+ stage = int(beta) if beta else STABLE_STAGE
+ if (major, minor, patch) == (0, 0, 0) or major > 2000 or minor > 99 or patch > 99 or stage > 98 and beta:
raise ReleaseCheckError(
- f"version {text.strip()} is out of range (major >= 1, minor and patch <= 99)"
+ f"version {text.strip()} is out of range (not 0.0.0, major <= 2000, minor and patch <= 99, beta <= 98)"
)
- return major, minor, patch
+ return major, minor, patch, stage
+
+
+def is_prerelease(version: str) -> bool:
+ return parse_version(version)[3] != STABLE_STAGE
def version_code(version: str) -> int:
- """Same formula as app/build.gradle.kts: major * 10000 + minor * 100 + patch."""
- major, minor, patch = parse_version(version)
- return major * 10_000 + minor * 100 + patch
+ """Same formula as app/build.gradle.kts: (major * 10000 + minor * 100 + patch) * 100 + stage."""
+ major, minor, patch, stage = parse_version(version)
+ return (major * 10_000 + minor * 100 + patch) * 100 + stage
def read_version(root: Path = ROOT) -> str:
@@ -95,7 +106,7 @@ def parse_changelog(text: str) -> tuple[bool, list[Section]]:
continue
raise ReleaseCheckError(
f"CHANGELOG.md heading '{heading}' must be '## [Unreleased]' or "
- "'## [X.Y.Z] - YYYY-MM-DD'"
+ "'## [X.Y.Z] - YYYY-MM-DD' (or [X.Y.Z-beta.N])"
)
end = headings[position + 1][0] if position + 1 < len(headings) else len(lines)
body_lines = lines[index + 1:end]
@@ -122,7 +133,7 @@ def check_changelog(text: str, version: str) -> None:
if not sections:
raise ReleaseCheckError(f"CHANGELOG.md has no section for {version}")
- previous: Optional[tuple[int, int, int]] = None
+ previous: Optional[tuple[int, int, int, int]] = None
previous_date: Optional[datetime.date] = None
for section in sections:
numbers = parse_version(section.version)
@@ -225,6 +236,7 @@ def main(argv: Optional[list[str]] = None) -> int:
sub.add_parser("version")
check = sub.add_parser("check")
check.add_argument("--tag", help="release tag that must equal v")
+ sub.add_parser("channel")
notes = sub.add_parser("notes")
notes.add_argument("--version")
apk = sub.add_parser("apk")
@@ -242,6 +254,8 @@ def main(argv: Optional[list[str]] = None) -> int:
raise ReleaseCheckError(f"tag {args.tag} does not match VERSION {version} (expected v{version})")
check_changelog(changelog, version)
print(f"OK: {version} (versionCode {version_code(version)})")
+ elif args.command == "channel":
+ print("beta" if is_prerelease(version) else "stable")
elif args.command == "notes":
print(changelog_notes(changelog, args.version or version))
elif args.command == "apk":
diff --git a/scripts/test_release_check.py b/scripts/test_release_check.py
index 06f0c178..42753e0a 100644
--- a/scripts/test_release_check.py
+++ b/scripts/test_release_check.py
@@ -33,17 +33,28 @@
class VersionTests(unittest.TestCase):
def test_version_code_formula(self) -> None:
- self.assertEqual(rc.version_code("1.0.0"), 10000)
- self.assertEqual(rc.version_code("1.2.3"), 10203)
- self.assertEqual(rc.version_code("2.10.99"), 21099)
+ self.assertEqual(rc.version_code("0.0.1-beta.1"), 101)
+ self.assertEqual(rc.version_code("0.0.1"), 199)
+ self.assertEqual(rc.version_code("1.0.0"), 1000099)
+ self.assertEqual(rc.version_code("1.2.3-beta.7"), 1020307)
+ self.assertEqual(rc.version_code("2000.99.99"), 2000999999)
+ self.assertLess(rc.version_code("2000.99.99"), 2**31)
def test_every_release_is_greater_than_the_one_before(self) -> None:
- order = ["1.0.0", "1.0.1", "1.0.99", "1.1.0", "1.99.99", "2.0.0"]
+ order = ["0.0.1-beta.1", "0.0.1-beta.2", "0.0.1-beta.98", "0.0.1", "0.0.2-beta.1", "0.0.2",
+ "0.1.0-beta.1", "0.1.0", "0.99.99", "1.0.0-beta.1", "1.0.0", "1.0.1", "1.99.99", "2.0.0"]
codes = [rc.version_code(version) for version in order]
self.assertEqual(codes, sorted(set(codes)))
+ keys = [rc.parse_version(version) for version in order]
+ self.assertEqual(keys, sorted(set(keys)))
- def test_rejects_anything_but_plain_semver(self) -> None:
- for bad in ["1.0", "v1.0.0", "1.0.0-rc1", "1.0.0+build", "01.0.0", "0.9.0", "1.100.0", "1.0.100", ""]:
+ def test_channel(self) -> None:
+ self.assertTrue(rc.is_prerelease("0.0.1-beta.1"))
+ self.assertFalse(rc.is_prerelease("1.0.0"))
+
+ def test_rejects_anything_else(self) -> None:
+ for bad in ["1.0", "v1.0.0", "1.0.0-rc1", "1.0.0-beta", "1.0.0-beta.0", "0.0.1-beta.99", "1.0.0-alpha.1",
+ "1.0.0+build", "01.0.0", "0.0.0", "0.0.0-beta.1", "1.100.0", "1.0.100", "2001.0.0", ""]:
with self.subTest(bad=bad), self.assertRaises(rc.ReleaseCheckError):
rc.parse_version(bad)
@@ -97,12 +108,12 @@ def test_notes_are_exactly_the_section_body(self) -> None:
class BadgingTests(unittest.TestCase):
SAMPLE = (
- "package: name='llc.slacker.openime' versionCode='10000' versionName='1.0.0' "
+ "package: name='llc.slacker.openime' versionCode='101' versionName='0.0.1-beta.1' "
"platformBuildVersionName='16' compileSdkVersion='36'\nsdkVersion:'26'\n"
)
def test_parses_the_package_line(self) -> None:
- self.assertEqual(rc.parse_badging(self.SAMPLE), ("llc.slacker.openime", 10000, "1.0.0"))
+ self.assertEqual(rc.parse_badging(self.SAMPLE), ("llc.slacker.openime", 101, "0.0.1-beta.1"))
def test_rejects_output_without_a_package_line(self) -> None:
with self.assertRaises(rc.ReleaseCheckError):
@@ -126,7 +137,7 @@ def fixture(self, version: str) -> Path:
def test_check_passes_and_prints_the_version_code(self) -> None:
status, output = self.run_main(self.fixture("1.1.0"), "check")
self.assertEqual(status, 0, output)
- self.assertIn("10100", output)
+ self.assertIn("1010099", output)
def test_check_enforces_the_tag(self) -> None:
root = self.fixture("1.1.0")
@@ -141,6 +152,18 @@ def test_a_version_bump_without_a_changelog_section_fails(self) -> None:
self.assertEqual(status, 1)
self.assertIn("newest CHANGELOG", output)
+ def test_a_beta_checks_and_reports_its_channel(self) -> None:
+ root = self.fixture("1.1.0")
+ (root / "CHANGELOG.md").write_text(
+ CHANGELOG.replace("## [Unreleased]\n", "## [Unreleased]\n\n## [1.2.0-beta.1] - 2026-11-02\n\n- beta.\n", 1),
+ encoding="utf-8",
+ )
+ (root / "VERSION").write_text("1.2.0-beta.1\n", encoding="utf-8")
+ self.assertEqual(self.run_main(root, "check", "--tag", "v1.2.0-beta.1")[0], 0)
+ self.assertEqual(self.run_main(root, "channel"), (0, "beta\n"))
+ (root / "VERSION").write_text("1.1.0\n", encoding="utf-8")
+ self.assertEqual(self.run_main(root, "channel"), (0, "stable\n"))
+
def test_notes_default_to_the_current_version(self) -> None:
status, output = self.run_main(self.fixture("1.1.0"), "notes")
self.assertEqual(status, 0)
From 82910fde9981f6bc49314b9c2701bc47cf2854fb Mon Sep 17 00:00:00 2001
From: limuzi013 <128580527+limuzi013@users.noreply.github.com>
Date: Sat, 3 Oct 2026 03:28:34 +0800
Subject: [PATCH 2/2] Allow voice input and clipboard history in password
fields
Owner decision for 0.0.1-beta.1. In password fields (password, visible,
web and numeric password):
- voice input works; only the final result is committed, in one
commitText call: no composing text, no partial display, no hotword or
correction learning
- clipboard history is available (clips the source app marks sensitive
and editors that disable personalized learning stay excluded)
Unchanged: no pinyin composition, no personalized learning, no reading
of the field contents (so copy/cut/select-all/paste buttons stay off).
Tests and docs (COMPATIBILITY, ARCHITECTURE, MAPPING, TEST_SOP,
LOCAL_VOICE_MODEL, README, CHANGELOG) updated to match.
Co-Authored-By: Claude Sonnet 5.5
---
CHANGELOG.md | 3 +-
README.md | 3 +-
.../AuditInteractionInstrumentedTest.kt | 39 +++++---------
.../openime/ClipboardHistoryRepository.kt | 6 ++-
.../llc/slacker/openime/ImeKeyboardView.kt | 54 ++-----------------
.../llc/slacker/openime/ImePanelRenderer.kt | 3 +-
.../slacker/openime/LocalVoiceImeService.kt | 9 ++--
.../llc/slacker/openime/VoiceFinalPolicy.kt | 11 +++-
.../openime/VoiceModelLifecycleManager.kt | 6 ---
.../openime/ClipboardSensitivityPolicyTest.kt | 4 +-
.../slacker/openime/VoiceFinalPolicyTest.kt | 24 +++++++--
docs/ARCHITECTURE.md | 2 +-
docs/COMPATIBILITY.md | 2 +-
docs/LOCAL_VOICE_MODEL.md | 2 +-
docs/MAPPING.md | 2 +-
docs/TEST_SOP.md | 2 +-
16 files changed, 69 insertions(+), 103 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 27c13105..27bba519 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -50,7 +50,8 @@ openIME 的首个公开测试版。功能已经可以日常试用,但稳定性
### 隐私与安全
- 不声明 `INTERNET` 权限,`allowBackup=false`。
-- 密码输入框不写入候选、剪贴板历史或日志;语音音频只在内存中处理,结束、取消或失败时清空。
+- 密码输入框:不组合拼音、不学习词库和纠错、不读取输入框内容、不写日志;剪贴板历史和语音输入可用。语音只在结束时把最终结果一次性上屏,不显示中间结果;来源应用标记为敏感的剪贴板内容和要求关闭个性化学习的输入框仍不进入历史。
+- 语音音频只在内存中处理,结束、取消或失败时清空。
- 「设置 → 关于与数据」可导出和合并导入用户数据(剪贴板历史不导出);卸载会清除本机全部数据。
- 测试用 Activity 与 E2E 接收器只存在于 debug 变体。
diff --git a/README.md b/README.md
index 246431bf..06a66271 100644
--- a/README.md
+++ b/README.md
@@ -60,7 +60,8 @@ openIME 是一款独立的 Android 系统输入法。拼音候选、用户词库
- 应用不声明 `INTERNET` 权限,`allowBackup` 关闭;词典、语音模型和 runtime 都随 APK 提供。
- 语音音频只在内存中处理,结束、取消或失败时清空,不落盘。
-- 密码输入框不写入候选、剪贴板历史或日志;崩溃记录只保存异常类型和调用栈,不含输入内容。
+- 密码输入框不组合拼音、不学习词库、不读取输入框内容、不写日志;可使用剪贴板历史(来源应用标记为敏感的内容和要求关闭个性化学习的输入框除外);语音可用,识别结果只在结束时一次性上屏。
+- 崩溃记录只保存异常类型和调用栈,不含输入内容。
- 卸载会清除本机全部数据,包括学习到的用户词库。
发现安全问题请不要直接创建公开 Issue,按 [SECURITY.md](SECURITY.md) 私下报告。
diff --git a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt
index b04d6e6d..a629e233 100644
--- a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt
+++ b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt
@@ -655,54 +655,39 @@ class AuditInteractionInstrumentedTest {
}
@Test
- fun passwordFieldsDoNotExposeClipboardHistory() = withKeyboard { harness, _, keyboard ->
+ fun passwordFieldsKeepClipboardHistoryAvailable() = withKeyboard { harness, _, keyboard ->
harness.awaitMain {
keyboard.renderState(ImeState(passwordField = true))
val clipboard = keyboard.findViewWithTag("clipboard-toolbar")
- assertFalse("Password fields must disable clipboard history", clipboard.isEnabled)
- assertTrue(
- "Password clipboard action must use the shared disabled alpha",
- clipboard.alpha == ImeSurfacePolicy.DISABLED_ALPHA,
- )
- assertTrue(clipboard.contentDescription.toString().contains("密码输入中不可用"))
- assertTrue("Sensitive editors must not list clipboard in tools", run {
- keyboard.showPanel(Panel.TOOLS)
- keyboard.findViewWithTag("tool:剪贴板") == null
- })
- keyboard.renderState(ImeState(passwordField = false))
- assertTrue("Clipboard must return when the editor is safe", clipboard.isEnabled)
+ assertTrue("Password fields use clipboard history like any other field", clipboard.isEnabled)
+ keyboard.showPanel(Panel.TOOLS)
+ assertTrue("Clipboard stays in tools for password fields", keyboard.findViewWithTag("tool:剪贴板") != null)
+ keyboard.showPanel(Panel.CLIPBOARD)
+ assertEquals(Panel.CLIPBOARD, keyboard.currentPanel())
true
}
}
@Test
- fun switchingIntoPasswordFieldClosesAnOpenClipboardPanel() = withKeyboard { harness, _, keyboard ->
+ fun switchingIntoPasswordFieldKeepsAnOpenClipboardPanel() = withKeyboard { harness, _, keyboard ->
harness.awaitMain {
keyboard.renderState(ImeState(passwordField = false))
keyboard.showPanel(Panel.CLIPBOARD)
assertEquals(Panel.CLIPBOARD, keyboard.currentPanel())
keyboard.renderState(ImeState(passwordField = true))
- assertEquals("Sensitive editor must close persistent clipboard history immediately", Panel.NONE, keyboard.currentPanel())
- assertTrue(keyboard.findViewWithTag("main-dock").isShown)
+ assertEquals(Panel.CLIPBOARD, keyboard.currentPanel())
true
}
}
@Test
- fun passwordFieldsDisableVoiceGestureButKeepSpaceKey() = withKeyboard { harness, _, keyboard ->
+ fun passwordFieldsKeepTheVoiceGesture() = withKeyboard { harness, _, keyboard ->
harness.awaitMain {
keyboard.renderState(ImeState(passwordField = true))
val space = keyboard.findViewWithTag("key-space")
- assertFalse("Password fields must not expose the voice long-press", space.isLongClickable)
- assertTrue(space.contentDescription.toString().contains("语音不可用"))
- keyboard.startVoiceFromSpace()
- assertFalse("Password fields must not start recording", keyboard.isVoiceActive())
- assertTrue("Password fields must not open the voice panel", run {
- keyboard.showPanel(Panel.VOICE)
- keyboard.currentPanel() != Panel.VOICE
- })
- keyboard.renderState(ImeState(passwordField = false))
- assertTrue("Voice gesture must return for ordinary editors", space.isLongClickable)
+ assertTrue("Password fields expose the voice long-press", space.isLongClickable)
+ keyboard.showPanel(Panel.VOICE)
+ assertEquals(Panel.VOICE, keyboard.currentPanel())
true
}
}
diff --git a/app/src/main/java/llc/slacker/openime/ClipboardHistoryRepository.kt b/app/src/main/java/llc/slacker/openime/ClipboardHistoryRepository.kt
index 37c67253..173ce5a6 100644
--- a/app/src/main/java/llc/slacker/openime/ClipboardHistoryRepository.kt
+++ b/app/src/main/java/llc/slacker/openime/ClipboardHistoryRepository.kt
@@ -30,8 +30,10 @@ internal object ClipboardPrivacyPolicy {
editorKind: EditorInfoAdapter.EditorKind,
imeOptions: Int,
): Boolean =
- editorKind != EditorInfoAdapter.EditorKind.PASSWORD &&
- (imeOptions and EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING) == 0
+ // Password fields use the history like any other field (their owner's choice).
+ // Editors that ask for no personalized learning, and clips the source app
+ // marks sensitive (see ClipboardSensitivityPolicy), stay out of it.
+ (imeOptions and EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING) == 0
}
internal object ClipboardRetentionPolicy {
diff --git a/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt b/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt
index d5d1713b..76988f79 100644
--- a/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt
+++ b/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt
@@ -160,7 +160,7 @@ open class ImeKeyboardView(
}
private val spaceVoiceGestureController = SpaceVoiceGestureController(
toPx = ::dp,
- canStartVoice = { voiceAllowed },
+ canStartVoice = { true },
onArmFeedback = ::hapticFeedback,
onVoiceStart = { listener.onVoicePressChanged(true) },
onVoiceStop = { listener.onVoicePressChanged(false) },
@@ -187,7 +187,7 @@ open class ImeKeyboardView(
},
)
},
- canStartVoice = { voiceAllowed },
+ canStartVoice = { true },
markWhiteKey = { key -> key.setTag(MARK_WHITE_KEY, true) },
onFeedback = ::feedback,
onAccessibilityLongPress = {
@@ -315,7 +315,6 @@ open class ImeKeyboardView(
private var lastNineSegmentPrefix = ""
private var lastNinePinyinPaths = emptyList()
private var currentCandidates = emptyList()
- private var voiceAllowed = true
private var voiceGestureSession = false
// Floating mode changes only the IME window bounds. The keyboard surface
// itself remains the same normal keyboard used in portrait mode.
@@ -517,7 +516,6 @@ open class ImeKeyboardView(
createEmojiCell = emojiCellFactory::create,
gridCellParams = ::gridCellParams,
currentMode = { mode },
- isPasswordField = { passwordField },
onModeSelected = { selected -> setMode(selected) },
onShowPanel = ::showPanel,
onEnableFloatingKeyboard = ::enableFloatingKeyboard,
@@ -1266,7 +1264,6 @@ open class ImeKeyboardView(
fun showPanel(newPanel: Panel) {
if (newPanel == Panel.NONE || newPanel == Panel.CANDIDATE_EXPANDED) return
- if (passwordField && newPanel in setOf(Panel.CLIPBOARD, Panel.VOICE)) return
if (panel == Panel.CLIPBOARD && newPanel != Panel.CLIPBOARD) {
clipboardPanelController.invalidatePendingLoad()
}
@@ -1402,18 +1399,12 @@ open class ImeKeyboardView(
fun renderState(state: ImeState) {
val passwordStateChanged = passwordField != state.passwordField
passwordField = state.passwordField
- voiceAllowed = !passwordField
if (passwordStateChanged) {
- if (!voiceAllowed && (voiceGestureSession || voicePanelController.active || voicePanelController.pending)) {
+ // A recording started in the previous editor must not end in this one.
+ if (voiceGestureSession || voicePanelController.active || voicePanelController.pending) {
cancelVoiceForManualInput()
}
- if (passwordField && panel == Panel.CLIPBOARD) {
- // A retained IME view can survive a focus change into a
- // password field. Persistent history must disappear
- // immediately instead of remaining visible until the user
- // manually backs out of the panel.
- closePanelToKeyboard()
- } else if (panel == Panel.TOOLS) {
+ if (panel == Panel.TOOLS) {
renderPanel(Panel.TOOLS)
} else if (panel == Panel.TEXT_EDITOR) {
// The same IME view can survive an editor switch. Rebuild the
@@ -1421,8 +1412,6 @@ open class ImeKeyboardView(
// password/privacy boundary immediately.
renderPanel(Panel.TEXT_EDITOR)
}
- syncSensitiveToolbar()
- syncSensitiveVoice()
}
val sameComposition = composition.text.toString() == state.composition
setCompositionText(
@@ -1479,37 +1468,6 @@ open class ImeKeyboardView(
enter.contentDescription = label
}
- /** Keep sensitive editors from exposing persistent clipboard history. */
- private fun syncSensitiveToolbar() {
- val clipboardButton = toolbarRow.findViewWithTag("clipboard-toolbar") ?: return
- val available = !passwordField
- clipboardButton.isEnabled = available
- clipboardButton.isClickable = available
- clipboardButton.alpha = if (available) 1f else ImeSurfacePolicy.DISABLED_ALPHA
- clipboardButton.contentDescription = if (available) {
- "剪贴板"
- } else {
- "剪贴板,密码输入中不可用"
- }
- if (Build.VERSION.SDK_INT >= 30) {
- clipboardButton.stateDescription = if (available) "可用" else "密码输入中不可用"
- }
- }
-
- /** Password editors keep ordinary space input but remove the recording gesture. */
- private fun syncSensitiveVoice() {
- val space = findViewWithTag("key-space") ?: return
- space.isLongClickable = voiceAllowed
- space.contentDescription = if (voiceAllowed) {
- "空格,点击空格,长按语音输入"
- } else {
- "空格,密码输入中语音不可用"
- }
- if (Build.VERSION.SDK_INT >= 30) {
- space.stateDescription = if (voiceAllowed) "可长按语音" else "语音不可用"
- }
- }
-
fun setAssociationCandidates(candidates: List) {
associationRow.removeAllViews()
candidates.distinct().take(8).forEach { candidate ->
@@ -1910,7 +1868,6 @@ open class ImeKeyboardView(
applyTheme()
onViewHierarchyRebuilt()
renderedMode = mode
- syncSensitiveVoice()
syncModeAccessibility()
}
@@ -2092,7 +2049,6 @@ open class ImeKeyboardView(
/** Starts recording after the combined space key crosses the long-press threshold. */
fun startVoiceFromSpace() {
- if (!voiceAllowed) return
voiceGestureSession = true
voicePanelController.lockLanguageForGesture()
inlineVoicePresenter.invalidateGeneration()
diff --git a/app/src/main/java/llc/slacker/openime/ImePanelRenderer.kt b/app/src/main/java/llc/slacker/openime/ImePanelRenderer.kt
index 222f2e5b..ed0ea93c 100644
--- a/app/src/main/java/llc/slacker/openime/ImePanelRenderer.kt
+++ b/app/src/main/java/llc/slacker/openime/ImePanelRenderer.kt
@@ -41,7 +41,6 @@ internal class ImePanelRenderer(
private val createEmojiCell: (String) -> View,
private val gridCellParams: (Int, Int, Int) -> LinearLayout.LayoutParams,
private val currentMode: () -> KeyboardMode,
- private val isPasswordField: () -> Boolean,
private val onModeSelected: (KeyboardMode) -> Unit,
private val onShowPanel: (Panel) -> Unit,
private val onEnableFloatingKeyboard: () -> Unit,
@@ -118,7 +117,7 @@ internal class ImePanelRenderer(
}
val grid = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL }
val cards = listOf(
- ToolEntry("剪贴板", Panel.CLIPBOARD, R.drawable.ic_clipboard, enabled = !isPasswordField()),
+ ToolEntry("剪贴板", Panel.CLIPBOARD, R.drawable.ic_clipboard),
ToolEntry("表情", Panel.EMOJI, R.drawable.ic_emoji),
ToolEntry("符号", Panel.SYMBOLS, R.drawable.ic_symbols),
ToolEntry("语音输入", Panel.VOICE, R.drawable.ic_mic),
diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt
index 664cbbf5..541e797e 100644
--- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt
+++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt
@@ -559,7 +559,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
java.util.Base64.getDecoder().decode(command.substringAfter("voice-simulate64:")),
Charsets.UTF_8,
)
- if (text.isBlank() || state.passwordField) {
+ if (text.isBlank()) {
false
} else {
VoicePerformanceTrace.abandon()
@@ -578,7 +578,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
java.util.Base64.getDecoder().decode(command.substringAfter("voice-final-only64:")),
Charsets.UTF_8,
)
- if (text.isBlank() || state.passwordField) {
+ if (text.isBlank()) {
false
} else {
VoicePerformanceTrace.abandon()
@@ -821,14 +821,12 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
}
override fun onVoiceToggle() {
- if (state.passwordField) return
voiceMediaMute.mute()
keyboardView?.startVoiceFromSpace()
}
override fun onVoicePressChanged(pressed: Boolean) {
if (pressed) {
- if (state.passwordField) return
// Mute before model startup is posted so media cannot leak through
// during the preparation window shown to the user.
voiceMediaMute.mute()
@@ -875,6 +873,8 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
}
override fun onVoicePartial(text: String) {
+ // Password fields get only the final text, as one direct commit: nothing
+ // is shown in the editor or on the keyboard while the user is speaking.
if (state.passwordField || text.isBlank()) return
voiceComposing = true
gateway.setComposingText(text)
@@ -898,6 +898,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
autoCommit = voiceAutoCommitOnFinal,
finalText = text,
)
+ if (plan.commitDirect) gateway.commitText(text)
if (plan.setFinalText) {
gateway.setComposingText(text)
VoicePerformanceTrace.markFirstDisplay()
diff --git a/app/src/main/java/llc/slacker/openime/VoiceFinalPolicy.kt b/app/src/main/java/llc/slacker/openime/VoiceFinalPolicy.kt
index 209572ae..c143b3a9 100644
--- a/app/src/main/java/llc/slacker/openime/VoiceFinalPolicy.kt
+++ b/app/src/main/java/llc/slacker/openime/VoiceFinalPolicy.kt
@@ -4,6 +4,8 @@ internal data class VoiceFinalPlan(
val setFinalText: Boolean,
val finishComposing: Boolean,
val composingAfter: Boolean,
+ /** Password fields get the final text as one direct commit: no composing text, no correction tracking. */
+ val commitDirect: Boolean = false,
)
/** Pure decision layer so final-only ASR callbacks remain regression-testable. */
@@ -14,7 +16,14 @@ internal object VoiceFinalPolicy {
autoCommit: Boolean,
finalText: String,
): VoiceFinalPlan {
- if (passwordField) return VoiceFinalPlan(false, false, false)
+ if (passwordField) {
+ return VoiceFinalPlan(
+ setFinalText = false,
+ finishComposing = false,
+ composingAfter = false,
+ commitDirect = finalText.isNotBlank(),
+ )
+ }
val setFinal = finalText.isNotBlank()
val hasComposition = hadPartialComposition || setFinal
return VoiceFinalPlan(
diff --git a/app/src/main/java/llc/slacker/openime/VoiceModelLifecycleManager.kt b/app/src/main/java/llc/slacker/openime/VoiceModelLifecycleManager.kt
index 08d9785d..bbcee55b 100644
--- a/app/src/main/java/llc/slacker/openime/VoiceModelLifecycleManager.kt
+++ b/app/src/main/java/llc/slacker/openime/VoiceModelLifecycleManager.kt
@@ -333,12 +333,6 @@ class VoiceModelLifecycleManager(
}
fun start(languageTag: String, events: VoiceRecognitionEvents) {
- val editorKind = inputMethodService?.currentInputEditorInfo?.let(EditorInfoAdapter::kind)
- if (editorKind != null && EditorInfoAdapter.isPassword(editorKind)) {
- events.onError("密码输入框不支持语音输入")
- return
- }
-
mainHandler.removeCallbacks(unloadRunnable)
preload(automaticPreload = false)
val token = sessionGeneration.incrementAndGet()
diff --git a/app/src/test/java/llc/slacker/openime/ClipboardSensitivityPolicyTest.kt b/app/src/test/java/llc/slacker/openime/ClipboardSensitivityPolicyTest.kt
index 0dc77755..b8be1369 100644
--- a/app/src/test/java/llc/slacker/openime/ClipboardSensitivityPolicyTest.kt
+++ b/app/src/test/java/llc/slacker/openime/ClipboardSensitivityPolicyTest.kt
@@ -35,8 +35,8 @@ class ClipboardSensitivityPolicyTest {
}
@Test
- fun passwordEditorsCannotExposePersistentHistory() {
- assertFalse(
+ fun passwordEditorsCanUsePersistentHistory() {
+ assertTrue(
ClipboardPrivacyPolicy.canUsePersistentHistory(
EditorInfoAdapter.EditorKind.PASSWORD,
imeOptions = 0,
diff --git a/app/src/test/java/llc/slacker/openime/VoiceFinalPolicyTest.kt b/app/src/test/java/llc/slacker/openime/VoiceFinalPolicyTest.kt
index 541315e6..9d67df2f 100644
--- a/app/src/test/java/llc/slacker/openime/VoiceFinalPolicyTest.kt
+++ b/app/src/test/java/llc/slacker/openime/VoiceFinalPolicyTest.kt
@@ -39,14 +39,32 @@ class VoiceFinalPolicyTest {
}
@Test
- fun passwordFieldNeverReceivesVoiceText() {
+ fun passwordFieldGetsTheFinalTextAsOneDirectCommit() {
assertEquals(
- VoiceFinalPlan(false, false, false),
+ VoiceFinalPlan(
+ setFinalText = false,
+ finishComposing = false,
+ composingAfter = false,
+ commitDirect = true,
+ ),
+ VoiceFinalPolicy.resolve(
+ passwordField = true,
+ hadPartialComposition = false,
+ autoCommit = true,
+ finalText = "直接上屏",
+ ),
+ )
+ }
+
+ @Test
+ fun passwordFieldWithBlankResultCommitsNothing() {
+ assertEquals(
+ VoiceFinalPlan(false, false, false, commitDirect = false),
VoiceFinalPolicy.resolve(
passwordField = true,
hadPartialComposition = false,
autoCommit = true,
- finalText = "不应写入",
+ finalText = " ",
),
)
}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index e657aafa..349469ce 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -62,7 +62,7 @@ LocalVoiceImeService
3. 删除优先处理 openIME 自己的 composition,再处理目标编辑器文本。
4. 候选提交使用已经渲染的 `CandidateSnapshot`/native identity,避免旧异步结果提交到新 composition。
5. 切换输入框、模式或结束会话时必须使旧 generation/session 失效。
-6. 密码及隐私编辑器不暴露持久剪贴板历史、语音和个性化学习路径。
+6. 密码编辑器不组合、不读取正文、不进入个性化学习和热词/纠错学习;剪贴板历史和语音可用,语音只一次性上屏最终结果。要求关闭个性化学习的编辑器和来源应用标记为敏感的剪贴板内容不进入持久历史。
## 候选与 Rime
diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md
index f30460b8..94b2179a 100644
--- a/docs/COMPATIBILITY.md
+++ b/docs/COMPATIBILITY.md
@@ -8,7 +8,7 @@
|---|---|---|
| 普通 / 多行 / 搜索 / 聊天(EditText、WebView、Compose) | 拼音预编辑 + 候选;回车按 IME action 或原始回车 | `core_regression.sh`、`ImeTestLabActivity` |
| 自绘 / Compose / Web,没有「全选」也没有 ExtractedText | 清空 / 撤回改用光标前后文本,答案长度等于请求长度时拒绝删除 | `InputConnectionGatewayTest`,`CustomEditorTestActivity` |
-| 密码(含可见密码、网页密码、数字密码) | 不组合、不学习、不进剪贴板历史、禁用语音 | `security_regression.ps1` |
+| 密码(含可见密码、网页密码、数字密码) | 不组合(字母逐个直接上屏)、不学习词库和纠错、不读取输入框正文;可以使用剪贴板历史;语音可用,只把最终结果一次性上屏(不显示中间结果) | `security_regression.ps1`;`VoiceFinalPolicyTest`、`ClipboardSensitivityPolicyTest` |
| 数字 / 电话 / 日期时间 | 起始键盘为数字 | `EditorInfoAdapterTest` |
| 邮箱 / URL | 起始键盘为英文 | `EditorInfoAdapterTest` |
| TYPE_NULL(终端、游戏、远程桌面) | 起始英文;每个字母立即以真实按键事件送出;退格 / 前删用按键事件(它们的 InputConnection 多半是 dummy 模式的 BaseInputConnection,`deleteSurroundingText` 返回 true 却什么也没删) | `InputConnectionGatewayTest` |
diff --git a/docs/LOCAL_VOICE_MODEL.md b/docs/LOCAL_VOICE_MODEL.md
index 2bc1edb8..e20b7882 100644
--- a/docs/LOCAL_VOICE_MODEL.md
+++ b/docs/LOCAL_VOICE_MODEL.md
@@ -88,7 +88,7 @@ app/src/main/assets/models/voice/
的识别后纠正仍保留。
- 用户在语音上屏后立即删除并改正的文本会形成私有 `VoiceCorrectionRepository` 对;
后续相同 ASR 原结果先应用本地纠正,改正目标也会回流动态热词。
-- 密码框不进入热词或纠错学习;日志不记录 PCM、转写、热词、纠错内容。
+- 密码框可以使用语音,但只在结束时一次性上屏最终结果,不显示中间结果,也不进入热词或纠错学习;日志不记录 PCM、转写、热词、纠错内容。
- `VoicePerformanceTrace` 只记录模型准备、麦克风启动、首 PCM、首解码、首 partial、
首次上屏、final、标点、丢弃样本数和总耗时。`droppedPcmSamples > 0` 会标记 degraded。
- `VoiceAudioRouteManager` 独立管理 Android 12+ 的 BLE/SCO/有线/USB 通信设备并在
diff --git a/docs/MAPPING.md b/docs/MAPPING.md
index 749d9ce6..54e52f86 100644
--- a/docs/MAPPING.md
+++ b/docs/MAPPING.md
@@ -13,7 +13,7 @@
| 长按 Popup | `KeyPopupController` | 定位、边缘限制、入场动画 |
| 符号 | `ImeData.symbols` + `CustomSymbolRepository` | 生产可用 |
| Emoji | `ImeData` + `EmojiRecentRepository` + Fluent assets | 当前正式 UI 仍以表情类为主,待扩展完整分类 |
-| 剪贴板 | `ClipboardHistoryRepository` + `InputConnectionGateway` | 普通编辑器可用;密码编辑器不暴露持久历史/粘贴入口 |
+| 剪贴板 | `ClipboardHistoryRepository` + `InputConnectionGateway` | 普通和密码编辑器均可用;来源应用标记为敏感的内容、要求关闭个性化学习的编辑器不进入持久历史;密码编辑器的复制/剪切/全选/粘贴按钮仍不可用(不读取输入框正文) |
| 常用语 | `QuickPhraseRepository` / `QuickPhraseEditActivity` | 生产可用 |
| 文本编辑 | `InputConnectionGateway` | 生产可用,能力随目标 EditorInfo/选区变化 |
| 语音 | `VoiceModelLifecycleManager` + sherpa-onnx | 本地语音;长按空格跟随 Android touch-and-hold timeout |
diff --git a/docs/TEST_SOP.md b/docs/TEST_SOP.md
index ae674602..3302ab25 100644
--- a/docs/TEST_SOP.md
+++ b/docs/TEST_SOP.md
@@ -161,7 +161,7 @@ OpenIME
状态、波形和实时文本;松手停止并上屏;上滑进入取消,松手丢弃。
固定语料包含中文问句、提醒句、英文句、中英混合、连续 30 秒、环境噪音、静音和口述标点。
-离线必须可用,权限拒绝可恢复,密码框禁用或隔离语音结果,不保留无必要 PCM/临时文本。
+离线必须可用,权限拒绝可恢复,密码框可用语音,但只在结束时一次性上屏最终结果(不显示中间结果、不进入热词与纠错学习),不保留无必要 PCM/临时文本。
按键气泡必须锚定当前键,宽约 1~1.2 倍键宽、高 70~80dp,边缘自动内收。震动在按下
瞬间仅触发一次,默认轻,尊重系统触觉开关,快速打字不形成沉重连续震动。