Files
yenharvey 31aa4abe5e refactor(android): 游戏键盘与云端服务配置从输入法服务里拆出
之前 QingjianInputMethodService 有 820 行,超过单文件 800 行的上限;游戏键盘的判断、浮层与按钮,
以及云联想、释义兜底、快捷翻译、语音识别的配置下发都写在服务里。
- GameMode:哪个应用用游戏键盘、建浮层、触摸区、拖动栏按钮与横屏应用登记;服务经 GameMode.Host 提供包名、写设置与重建键盘
- CloudServices:云端与语音识别配置交给引擎,配置页改了跟着换(与剪贴板的局域网同步无关)
- 服务里「写设置时不重建键盘」统一成 writeSettings,服务现在 726 行
不改行为;拖动栏「换回普通键盘」重建前也先收起面板,与功能面板里的开关一致。
测试:真机(小米,MIUI)试打框弹键盘正常;浏览器里功能面板开、关游戏键盘,设置里的应用列表随之增删、勾选状态正确;横屏浮层没在游戏里验
2026-11-05 13:59:39 +08:00

190 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Android 实现要点
`apps/android` 的功能怎么接到 Core、各处的取舍。目录与构建见 [apps/android/README.md](../../apps/android/README.md),用户怎么用见 [user/platforms/android.md](../user/platforms/android.md)。
## 键盘
- 布局:拼音 26 键 / 九宫格 / 双拼 / 五笔 86 / 注音大千,加上英文 26 键与日语罗马音,在键盘选择面板里切。英文只切到英文(全键盘),中文布局不变。
- 全键盘:字母键角标(首排数字、二三排符号,中文 H J 是中文引号),长按弹 大写 / 角标符号 / 小写;中文底排 符 / 123,英文底排 !?# / 123(常用符号页)。
按住空格左右滑挪光标(`space_cursor`,缺省开;横向挪过 14dp 且比竖向多才算,起手在空格上,与字母键起手的滑行、上滑分得开;组句中不响应:先 `commitRaw` 再挪会把没选的字母上屏;拼音里的光标靠点拼音行挪,见下条)、按住不动进语音(见下文「语音输入」);回车键按输入框的动作写 发送 / 搜索 / 前往 / 下一项 / 完成(`EnterLabel`),多行框画回车图标。
- 点拼音行挪光标(#881):渲染器给出拼音行各字之间的位置(`StripMeta.preedit_edges`),候选条点在拼音行里按最近的缝走 `Native.moveCaret`,
Core `Engine::move_cursor_to_marked` 换回缓冲区光标(全拼按字母、双拼展开全拼时按最近的音节边界、显示敲的键时按键;纠错 / 辅码 / 注音 / 九宫格对不上,不挪);`Frame.preedit_cursor` 让候选条把光标画在中间。光标在开头时 `Session::backspace` 先挪到末尾再删(没有方向键挪回去,不然退格就卡住了)。
- 九宫格拼音行的下划线:`Frame.preedit_pinned` 是拼音行里定下的音节(字符起止,前面留着的词 + `Engine::nine_key_pinned`),`strip::preedit` 把它切成 `PreeditStyle::Pinned` 一段。
- 删除键上划清空(`flick_clear`,缺省开):删除键按下照旧先删一个字并起连删,同时 `onBackspaceDown` 记下按下那一刻光标前的文字(最多 `MAX_CLEAR` = 5000);往上划够上滑距离就停掉连删,抬手 `Composer.clearBefore`;连删已经起来(`REPEAT_DELAY_MS` 之后)这一笔就作废,不当清空。
按下时在组句只清拼音;否则 `deleteSurroundingText` 删光标前的文字,`Native.cleared` 让会话在补全里放「撤销」(光标前还是空的就一直留着),撤销放回按下那一刻的文字,所以按下时删的那个字(按键事件可能还没到应用)也回来。
- 上滑(`flick_symbols`,缺省开,#882):只看方向不看列。手指离开按下那一点 10dp 起就得一直在往上的扇形里(横向不超过往上的 1.2 倍,约偏离竖直 50°,含历史点),
往上过 18dp 抬手出 `Key.flick`(长按不滑直接抬手那一项:字母键是角标,九宫格是数字),出的总是按下的键;中途偏出扇形(横走、往下、拐弯)这一笔就作废,交给滑行或点按。
两者同时成立时上滑优先(一路往斜上走的滑行词很少)。手指一进扇形就撤掉等着的长按:慢慢往斜上划时长按先到点弹气泡、手指再一歪就选上了气泡左边的大写,这是 #882 说的「误触大写」。
- 长按出大写(`long_press_upper`,缺省关):字母键长按不滑直接抬手出气泡第 0 项(大写)而不是角标;上滑仍是角标,两条手势各管各的,不随状态换含义。
- Shift 是三态 `ShiftState`:点一下单次,350 ms 内再点锁定(单次键亮着,锁定再加右上角一个品牌色小点;微软双拼的 `;` 同样显示),锁定时再点解锁;单次的出过一个字母就熄。拼音、日语组句中 Shift 不响应(大写字母在 Core 另有含义),英文组句中可以。
- 英文键盘组句时字母按 Shift 带大小写进引擎(Core 的英文模式保留大小写,`Compa` 出 Company);空格、数字、标点前 `commitRaw` 落下敲的字母,不顶首选——首选可能是补全或纠错出的词,验证码、型号会被换掉。要词点候选。
- 键位排法 `Settings.Arrangement`:缺省 `STANDARD` 一直不动(肌肉记忆),改了键位置的进 `LEFT_THUMB`(「左手便捷」):符号面板分类竖排在左(`Tabs.Style.COLUMN`)、左下返回、右列只剩删除 / 锁定。
- 「123」进数字键盘:左列可拖的符号,中间 3×3 数字,右列 删除 / . / @。输入框是电话、数字、日期时间类(`EditorInfo.wantsNumbers`)时 `onStartInput` 直接换到这一页;`abandon` 每次换输入框都回字母页,离开时不用另记原来的键盘。
- 九宫格是 Core 的 `nine_key` 方案([design/nine-key.md](../design/nine-key.md)):左列是同码的拼音,点一下定下音节,不在组句时放标点。
数字键长按弹 `abc2ABC`(`1` 键是 `1@-_/`),组句中选小写字母就把这一位定成这个字母。九宫格的查询不走模糊音。
- 注音大千按电脑大千键位排四排,键面画注音、进引擎的是大千的 ASCII 键(Core 的 `zhuyin::map_key`)。空格走 `Session::space`:缺声调时是一声,否则上屏首选。
- 双拼九套方案与「输入时显示按键」(`shuangpin_raw_preedit`)同 PC;键上印的声韵母从 Core 的方案表取(`Native.shuangpinKeycaps`),用 `;` 当韵母的方案二排末尾多一个 `;` 键;声韵母小字可关(`shuangpin_hints`)。
微软双拼照手机键盘的习惯(搜狗等)把 `;` 放在 Shift 位(`Key.Letter.flickShift`):点按永远是 `;`、上滑是 Shift,不按组句状态换含义;二排回到九个键。
- 五笔走 Core 的形码管线,码表是随包的 `assets/schemes/wubi86/table.tsv`(随包时改名 `wubi86.tsv`,第一次切到五笔时读);「五笔拼音混输」开着时拼音也能打。
- 日语罗马音(测试版)走 Core 的 `RomajiComposer`,不经拼音引擎;候选是小词表汉字、平假名、片假名、原样罗马音,回车上屏平假名。
- 中英键按一下在一对语言间切(缺省 中 ↔ 英),长按竖排弹出全部语言。「换语言时保留中文」开着时另一边总是中文;当前语言与另一边存在设置里。
- 全面屏适配开着时手势条那一截左右各放一个键(切换输入法 / 语音 / 剪贴板 / 收起键盘 / 不放),两边不能一样。
- 底边让出的导航栏高度在建视图时量一次(`InputPanel`),用户换了手势 / 三键导航时输入法进程不一定重建视图,三键的导航栏会盖住最下一排;所以每次弹键盘(`onStartInputView`)重量一次,变了就重建。
- 日志:Rust 的 tracing 同时写 logcat 与 `files/logs/ime.<日期>.log`(按天、留 7 天,不经后台线程,没接住的异常记完再退);Kotlin 经 `AppLog` → `Native.log` 写同一份。`QingjianApp` 一起来就装上,并补记 `getHistoricalProcessExitReasons` 里上回之后的退出原因(Android 11 起)。文件缺省 info,开了详细日志才记 debug(带打的字);开关存在单独的 `diagnostics` prefs,放设置那份会触发键盘重建。「数据 → 日志 → 打包日志」见 `LogPack`:日志 + 设置清单(抹掉像密钥的项)+ 机型、省电、输入法、随包数据清单。
- 密码、网址、邮箱与非文本输入框不过引擎。`inputType` 整个是 0 才算非文本;类别是 0 却带文本标志的(抖音直播间报 `0x20000`)照常组句。
## 候选条
- 由 Rust 渲染器(`qingjian-render` 的 `Layout::Strip`)按设置里的主题画成位图贴上(见下文「外观」),与桌面候选窗同一套主题;emoji 渲染器画不出(系统 emoji 是 COLRv1),壳在词的位置上补画;位图里 emoji 候选只用全角空格占位,渲染器也画得出彩色 emoji 时才不会叠成两份(#902,报在 Android 17 上)。
真机结果见 [design/rendering.md](../design/rendering.md)「Android 候选条」。
- 长按候选弹气泡:左右排开译词,滑到哪条抬手上屏,对应桌面端修饰键 + 数字。删得掉的候选(Core `CandidateKind::forgettable`)末尾多一项「删除」(`Session::forget`,对应桌面端 ⇧ + 数字),结果用 toast 提示;
没有译文时气泡里只有「删除」,不预选,得往上滑进气泡才算,免得长按松手误删。
- 右端 ⌄ 展开全部候选:盖住键盘,按字宽排、每行撑满、上下滑,右列 返回 / 删除 / 重输。
- 分次选词留在组句里(Core 的 `hold_selections`,键盘设置开关,缺省开):选词没吃完拼音时 `commit` 回空串,留着的词经 `Query::marked_text` 显示在拼音前面,退格由 Core 撤回、回车 `take_raw` 带上留着的词。
空格在有候选时只选首选(留着就接着选),没有候选才原样上屏;标点、符号这类「顶首选」要结束组句,壳选首选后若还在组句就 `commitRaw`,留着的词连同剩下的拼音一起落下。
- 不在组句时光标前是「用户名@」,候选条列常见邮箱后缀(Core 的 `email_completions`,经 `Session::complete`);中文组句中按 @,敲的字母原样落下当用户名。
- 光标前末尾是一道算式(数字键盘上直接上屏的 `3+4`、`3×4=`)时,同一条路给结果(Core 的 `calculation_completions`):帧的 `replace` 是上屏前要删掉的字符数(换成结果那条),壳 `deleteSurroundingText` 后再上屏。壳只在光标前最后一个字是数字、`)` 或 `=` 时才问。
## 快捷输入
- `rq` / `sj` / `xq`、`v1+2`、`u4e00` 都是 Core 的快捷候选。手机上数字与运算符不在字母键盘上:帧带 `expression`(`Engine::expression_mode` 或 `unicode_entry`),为真时「123」「符号页」不顶首选,数字键盘上的数字与 `+ - * / ( ) . ^` 进缓冲区(× ÷ 换成 * /),`=` 上屏「算式=结果」那条。
- 九宫格是数字串,`77` 不认 `rq`;拼音列或长按把字母都定下来(缓冲区成了 `r'q`)才出。全拼出了「日期 / 时间 / 星期」时 Core 把同一组写法插在这个词后面、它的 emoji 之前(九宫格 `7474` 的「日期」常排第四,所以看前五个词)。
## 输入设置
键盘设置「输入」一节与 PC 的同名项对应,整份 `InputOptions` 经 `Session::set_options` 交给 Core,名字同 PC 配置:
- 模糊音:`[fuzzy]` 九条。
- 拼写纠错:`spelling_correction`(同 PC 的 `[general]`),`Engine::set_spelling_correction`。
- 中文用全角标点:中文键盘的逗号句号与拼音列 ,。?!: 键带着对应的半角字符,上屏前经 `Engine::punctuate` 转换(引号成对、数字后的点保持半角);关了出半角并 `note_passthrough`。
符号页里的标点是明确挑的字符,不转换。
- 打拼音时也给英文词 / 中文排在英文词前面:`english_in_chinese` / `chinese_first`。
- 英文键盘给候选:关了英文字母直接上屏,壳里判断,Core 没有对应项。
- 学习输入习惯:`learning`,即 `Engine::set_learning`;私密输入另走 `Session::set_private`,由输入框的 `IME_FLAG_NO_PERSONALIZED_LEARNING` 决定(浏览器无痕标签页会带),对应 PC 上应用声明的私密输入框。Core 的私密输入不学习、不记生词与例句、不发云端,输入统计照记(同 PC)。
- 繁体字形:`traditional_variant`(`taiwan` / `hongkong` / `standard`)。功能面板的「繁体」仍是开关,开着时按这里的字形出(`Session::set_traditional` 用会话记着的字形)。
- 附加词库:`domains` / `disabled_dictionaries`,同 PC 的 `[dictionaries]`。会话每次交设置时按开关列出该挂的文件(路径、修改时间、大小),与上次一样就不动,变了重新 `set_extra_dictionaries`。
- 学习语言:`learning_language`(`off` 不显示译文)。装配时不装学习语言的释义表,壳交设置时 `Session::set_options` 按语言装(随包表叠个人释义表),语言变了才换,换前先 `flush_learning`。
几门语言的释义表全部随包(`glossary-*.qj` 共约 65 MB),离线即换;等级表 `levels-en.tsv` / `levels-ja.tsv` 也随包。
PC 上只对实体键盘有意义的项(Shift + 字母、切换键、翻页键、高亮移动键、状态条、标点先上屏候选的开关)不搬到手机。
## 面板
面板盖住候选条与键盘那一整块,高度跟键盘走:功能面板(常用功能宫格 + 就地改的开关,「繁体」在这里,字形按键盘设置里选的)、定制工具栏(`ToolbarTool` 名字逗号分隔存 `toolbar`,没存过是缺省那七个,空串是全移除;面板里改的先留在面板上,点 ✓ 才写设置,免得每点一下都重建键盘)、键盘选择、分类符号、表情(Emoji / 颜文字,能接肤色的排出五档滑选)。
符号与表情的内容是 Core 的面板目录(`Native.panelCatalog`),与桌面端符号面板同一份;「常用」按用过的排(Core 的 `common_items` + `PanelUsage`),每次打开取一次。
符号一行排偶数格(一字一格六格,网格窄于 6 × 40dp 排四格;宽的四格或两格):目录里成对的括号引号从偶数位起,偶数列就不会把一对拆到两行。
## 词库
- 领域词库是 `data/generated/dicts/` 的 11 本 `.qj`(约 7.5 MB),`stageData` 放进 `assets/data/dicts/`,`BundledData` 递归解到 `files/data/dicts/`;缺省只开成语,同 PC。
- 导入走 SAF(`ACTION_OPEN_DOCUMENT`,`*/*` 多选:TSV 与 Rime 文件的 MIME 各家文件管理器给得不一样)。壳按原文件名拷到缓存目录,Rust 的 `store::dictionaries` 交 `qingjian_dictionary::import` 转成 `.qj` 放进 `files/user/dicts/`,再删掉拷贝。导入与删除后改设置里的 `dictionaries_stamp`,开着的键盘重建、重交选项、会话按文件重挂。
- 「删除」直接删文件:PC 的「移除」是挪进 `removed/`,手机上用户碰不到这个目录,挪过去等于留垃圾。
- 有的厂商文件选择器按自己的文件索引列文件,`adb push` 进去的要先发 `MEDIA_SCANNER_SCAN_FILE` 广播才搜得到。
- 通讯录(#876,设计见 `docs/design/contacts.md`):`contacts/ContactsSync` 在设置开着、有 `READ_CONTACTS` 时后台读 `Contacts` 与 `Data`(电话、邮箱)两张表,回主线程交 `Native.setContacts`;55 个联系人读加建词库约 20 ms。
MIUI 上只给「仅在使用中允许」时,输入法服务在后台启动那次读到空结果(权限仍显示允许),所以读到零个不算读过,`onStartInputView` 里再读(`retryIfEmpty`)。
shell 的 `content delete` 在 MIUI 上删不掉联系人(不报错、不生效),真机测试加的联系人要在通讯录 App 里删。
## 剪贴板与常用语
- 系统剪贴板复制了文字或图片由 `ClipboardWatcher` 记进历史:默认输入法读剪贴板不要权限;标了敏感的不记;可关、可设最多 100 / 500 / 1000 / 5000 条。
监听在输入法没起来时收不到,所以键盘每次弹出也读一遍;同一份剪贴板按 `ClipDescription.timestamp` 只记一次(时间戳存在 `clipboard` prefs,进程重起也认得),不然用户删掉的那条只要还在系统剪贴板里,下次弹键盘又被记回来。
存取在 Rust 侧 `qingjian_learning::ClipboardHistory`(`clipboard.jsonl`,图片另存 `clipboard-images/`)。
- 像代码的文字(Core `code::looks_like_code`)用等宽字体原样显示,「代码高亮」按 Core `code::highlight` 上色。
- 图片走 `commitContent` 发出去(`ClipboardImageProvider` 只读临时授权,`query` 要给 `OpenableColumns`,否则有的应用把 content:// 地址当文字插进去);输入框不收图片就放回系统剪贴板。
- 拆词(Core `text_pieces` 切块)可点选或横滑连选,选着的实时放进输入框、处在编辑中,返回或完成确认上屏。
- 常用语(`PhraseBook`,`phrases.json`)按分组列,卡片带输入码;带输入码的挂成 Core 自定义短语,打码直接出。输入码最多 32 个字母,跟 Core 自定义短语的上限一致。
## 翻译选中的文字
- PC 的「翻译选中文字」(快捷键读选区)在手机上改成系统文字选择菜单里的「青简翻译」:`ProcessTextActivity` 接 `ACTION_PROCESS_TEXT`,透明窗口 + 底部卡片(主题 `Theme.Qingjian.Sheet`),不进最近任务。
原应用没给 `EXTRA_PROCESS_TEXT_READONLY` 时多一个「替换」,译文经 `setResult` 的 `EXTRA_PROCESS_TEXT` 交回去。
- 翻译走 `Engine::request_translation`,方向由 Core 按文字判断。Activity 不借输入法的引擎(输入法不一定是青简,也不该打断它的会话),另装一个轻量 Engine(`assemble_translation`:主词库 mmap + 学习语言释义表,几毫秒),用同一套 `setCloud` / `translate` / `pollTranslation` 出口。
- 失败(出错、回包解析不了、模型没给内容)由 `qingjian-predict` 的 worker 回一条空结果,壳当场显示红字「翻译失败」并只给「重试」,与译文分开,不必等超时;快捷翻译条的失败状态同样是红字。
- 自家 App 的输入框要列出文字处理项得在清单声明 `<queries>`(Android 11 起的包可见性)。有的厂商系统在青简试打框的选择菜单里只列「搜索 / 分享」,别的翻译应用也不出,真机上用 `am start -a android.intent.action.PROCESS_TEXT` 验的。
## 浮动编辑条与快捷翻译
- 常用语编辑框与快捷翻译条都是 `FloatingEditor`:开着时 Composer 的输入连接换成它的,键盘打的字进编辑条;打输入码时换英文全键盘。
编辑条里打的字不进应用,开着时 `Engine::set_metered(false)`,不计今日字数。
- 快捷翻译:原文(不含组句中的拼音)停顿 450 ms 后经 `Engine::request_translation` 发云端(汉字为主译成学习语言、外文译成中文),每 100 ms 取一次;
译文用组合文字放进外面的输入框,改原文跟着换,确定或回车落定,取消撤掉。
- 云端是 `qingjian-predict` 的 `CloudPredictor`,开 `rustls` feature(ring + `webpki-roots`,不用平台证书库);云联想关着时配置只开翻译,平时打字不联网。
`rustls` 的 crypto provider 要在 async-openai 建客户端之前装好,见 [crate-notes.md](crate-notes.md) 的 predict 一节。不记接口用量:用量文件靠文件锁合并,安卓上 `lock()` 不支持。
- 密钥由 `SecretStore` 用 Android Keystore 里生成的 AES-256 钥匙(不可导出、卸载即毁)做 AES-GCM 加密后只存密文;明文只在调 JNI 时临时拼进 JSON,Rust 侧不派生 `Debug`、不进日志。
应用关了系统备份;快捷翻译、翻译选中的文字、释义兜底与云联想要联网(`INTERNET` 权限),「只在 Wi-Fi 下使用」读网络类型要 `ACCESS_NETWORK_STATE`。
- 释义兜底(键盘设置「云端大模型」里的开关,缺省关,同 PC 的 `CloudGlossFiller`):会话 `set_gloss_fill` 用同一份云端配置起 `CloudGlossFiller`,Core 在上屏时判断「释义表查不到、不是私密输入」才发那个词;
`Handle::operate` 每次操作前 `poll_glosses`,补好的释义当场进个人释义表(`user-glossary-<语言>.tsv`),下一帧就有译词。
PC 上它随云联想一起开,手机上单独一个开关;PC 的「生词本整句翻译」(`vocabulary_translation`)会发整句,手机不开。
服务在键盘重建、引擎装好、云端配置(`cloud` 那份 prefs)改了时同步;`Engine.setGlossFill` 按配置摘要去重,同一份不重交(每交一次 Rust 侧都另起一条线程)。
云端没填好时设置页不放开关,换成一行说明。
## 云联想与整句补全
- 键盘设置「云端大模型」里的开关,缺省关,同 PC 的自动联想:`CloudSettings.predict` 打开 `PredictConfig` 的 `automatic` 与 `sentence`,两个云端词槽,停键 500 ms,两次请求至少隔 1 s,超时 10 s;生词本整句翻译不开。
- 会话 `refresh` 每次查完候选就 `request_prediction`(防抖在 worker 里),壳在一段组句的第一帧之后读光标前 64、后 32 个字交 `setSurrounding`,交了按带上下文的再要一次。
组句中 Composer 每 150 ms `pollPrediction`,结果插在本地首选之后:先整句补全,再云端词,与本地重复的不要;选中整句走 `accept_prediction`。
- 九宫格敲的是数字,Core 用这次查询的拼音行(`QuerySnapshot.pinyin`,九宫格时取 `typed_display`)问云端,没查过这段就不发。
- 候选条的 `strip_cell` 给 `cloud` 候选前加小云朵、字用 `@cloud` 色(同 PC 候选窗);展开的网格里同样画云朵(`PanelIcons.Icon.CLOUD`),字用 `Palette.cloud`。
- 「只在 Wi-Fi 下使用」缺省开:`service/CloudServices.sync` 在键盘建起、每次开始输入和云端配置改了时看 `Network.onWifi`(Wi-Fi 或有线,开 VPN 也认得出),不在就退回只翻译。
打字途中换网络要到下次开始输入才跟上。快捷翻译与释义兜底不看这个开关。
- 工具框(快捷翻译、常用语编辑)开着时会话不发、不收联想(`set_metered(false)` 时 `tools_open`),快捷翻译的译文不被组句联想作废。
## App
启动页底部 tab 切 首页 / 生词本 / 我的,配色 `AppColors` 与键盘分开。首页是没切到青简时的切换卡、输入统计看板(`Native.statsDetail` 传 `off` 不读生词本:今天 / 近 7 天 / 累计、最近 30 天柱状图、四个比例)、常用功能与试打框;
二级页「键盘设置」「输入统计」(点看板进,「我的」里不再单列入口)「关于」。「我的」页的累计卡仍用 `Native.usageStats`。
- 生词本页(`Native.vocabulary`)一次交 `{language, filter, level, change}`:Rust 侧先改(标认识 / 删除,写回 `user-vocab.tsv`)再按筛选与等级列,最多 500 条,另给符合的总数。
输入法进程里的生词本落盘前会合并文件(`VocabularyBook::refresh`),App 这边的改动不会被盖掉。搜索同 PC(`search`:不分大小写,词、带它上屏的中文、例句里有就算),在 Rust 侧筛;搜索框常驻,打字停 250 ms 后只重填下面的词表,不丢焦点。PC 生词本的时间线、导出不做。
- 「看到」次数:候选条每画一帧,`renderStrip` 把视口里(不算 ⌄ 那一格)看得见的候选与译文交给 `Engine::note_displayed`,横滑后才露出来的不算,同桌面端只算当前一页。
- 输入统计(`Native.statsDetail`)的派生数与 PC 设置程序统计页调同一套函数(`qingjian_learning::figures`,见 crate-notes 的 `UsageStats`),两端「近 7 天」一致;PC 的一年日历改成最近 30 天柱状图,云服务用量不做(用量文件靠文件锁,安卓上不支持)。
## 高级
PC 设置程序的「高级」页(配置文件、日志、输入日志)在手机上改成「我的 → 高级」,只有导出数据与清空学习数据,不露文件路径;配置文件编辑与输入日志开关不做。
- 导出:`ACTION_CREATE_DOCUMENT`(`application/zip`)让用户选位置,壳用 `ZipOutputStream` 把文件平铺写进去;打包哪些由 Rust 的 `store::learning::export_files` 定:
学习数据目录顶层的 `.tsv` 与 `.json`(输入习惯各表、生词本与时间线、输入统计、常用语、面板常用、个人释义表)。
剪贴板历史可能有密码、验证码,不带(同 PC 导出日志时抹掉密钥);导入的词库与设置也不带。导出前 `Engine.flushAll` 让开着的引擎落盘。
- 清空:先勾输入习惯(`FrequencyLearner::files` 列出的词频与各侧车表)和 / 或生词本(`user-vocab.tsv` 与 `vocab-log.tsv`,全部语言,同 PC 生词本页的「清空」),再确认一次。
输入统计、常用语、剪贴板、词库不动(PC 也没有清它们的入口)。
- App 与输入法同进程,引擎内存里的学习数据下次落盘会把删掉的写回去,所以 `Engine.clearLearning` 先把进程里开着的引擎(`Engine` 伴生对象里登记)落盘卸下,删文件,再重新装配;
装配到一半被卸下的那次结果按 `generation` 作废。重装好之前按键原样上屏,`onEngineReady` 重交方案与选项。
## 外观
「我的 → 外观」对应 PC 设置程序的「外观」页,只搬主题、深浅色与字号;拼音显示位置、渲染引擎、字体、排布、过渡动画是桌面候选窗的事,不做(字体以后有人要再说)。
- 主题:列的是 `qingjian-render` 的全部内置主题(`Native.themes`),与 PC 同一份,PC 新加主题手机自动跟上;存 `theme`(同 PC 的 `[general] theme`)。卡片上的候选条是渲染器用样例帧画的(`appearance::preview`,`Native.themePreview`),下面几排键按该主题的键盘配色画。
预览在 App 进程里画,渲染器不能跨线程,放在 `thread_local` 里,壳只在主线程调,画过的按(主题, 深浅, 倍数, 字号档)缓存。
- 键盘配色:`Palette` 不写颜色,整份由 Rust 推(`qingjian_render::KeyboardColors`,规则见 [design/theme.md](../design/theme.md)「手机键盘」),`Native.keyboardPalette` 交 JSON,壳按(主题, 深浅)缓存。
键盘、面板、浮动编辑条、长按气泡都吃这一份;候选条由会话的画笔按同一主题画(`Native.setAppearance`,引擎重装后 `Engine` 记着重交)。
- 深浅色:`appearance` 三选一(跟随系统 / 浅色 / 深色,同 PC),跟随系统时看 `uiMode`,系统切换时服务的 `onConfigurationChanged` 重建键盘。锁定浅色的主题(四个角色主题)以主题为准,`Palette.dark` 用 Rust 判断后的结果,候选条也按它画。
- 字号:四档(小 0.88 / 标准 / 大 1.15 / 特大 1.3),对应 PC 的候选字号与译文字号,手机上合成一项、按比例调(用户选的)。Rust 侧把主题的 `candidate` / `annotation` 字号乘上倍数交 `Theme::with_text_sizes`;
候选条在样式上写死的 19 点跟着缩放(渲染器的 `font_ref` 改动),译文截断宽度同比放大;条高按 `max(1, 倍数)` 放大,展开的候选网格同比缩放。键面上的字不跟(用户选的),另有「键面字号」(`key_text_size`,同样四档,`KeyPainter` 画字时乘倍数)。
- 震动强度(`haptic_strength`):标准是原来的预置 TICK / CLICK,重一档是 CLICK / HEAVY_CLICK;预置效果不能缩放,轻一档用合成振动按 0.5 缩小 tick 原语,不支持时退回小振幅短震。
## 语音输入
设计见 [design/voice-and-swipe.md](../design/voice-and-swipe.md)。现在的识别方是 Rust 侧的火山引擎豆包流式识别(`crates/qingjian-voice`);「语音识别」页没配好时退回占位的 `UnavailableRecognizer`,三处入口都在工具栏的位置放一行可点的提示(`CandidateBarView.showNotice`,5 秒或出候选后收起),点了进设置页;快捷翻译没配云端同样。原来只弹 toast,一闪就过、也没有去设置的路,用户反馈像点了没反应。
- 入口都进 `voice/VoiceInput`:`KeyboardView` 按住空格 400 ms 不动问 `onVoiceHold`(开着「长按空格说话」时),开不起来退回挪光标;横向滑开一格(14 dp)先到就是挪光标。工具栏话筒与全面屏底栏语音键是点按进。
- 识别方式是 `VoiceRecognizer`(是否可用、要不要录音权限、开始、说完、取消,回调中间结果、最终结果、出错、音量)。现有 Rust 侧识别方 `EngineRecognizer`:`AudioRecord`(`VOICE_RECOGNITION` 源)录 16 kHz 单声道,每 100 ms 一截经 `Native.voiceFeed` 交 Rust、每 100 ms `pollVoice` 取结果;`voiceAvailable` 为真(配好了)时 `VoiceInput` 才选它。收尾(说完、出错、取消)一律在 `stop()` 里停录音线程,出错时也得停,不然麦克风一直开着。以后可加系统 `SpeechRecognizer`、切到系统语音输入法。
- 中间结果用 `setComposingText` 放进输入框(走 `Composer.Host.connection`,快捷翻译、常用语编辑条开着时进编辑条);最终结果经 `Session::commit_voice` → `Engine::commit_voice` 上屏,替换编辑中的那段。开始前组句先落下(同回车)。
- `VoicePanel`(`PanelKind.VOICE`)由 `InputPanel.openVoice` 盖上,不经 `openPanel`;返回键、换输入框、收起键盘、键盘重建都取消这一段。两块弧是面板下方一个大圆(半径 = 面板宽)顶上离正上方 4°–28° 的两段,按着空格时 `KeyboardView` 把手指的屏幕坐标交 `VoicePanel.track` 判落在哪块(判定比画出来的宽一些),松手时按它撤回、上屏并执行动作或只上屏。右块写 `enterLabel`(快捷翻译条开着时用它的),执行动作走 `sendDefaultEditorAction`;多行框写「完成」。
- 录音权限:`MicrophonePermissionActivity` 透明窗口代为 `requestPermissions`(自己的任务、不进最近任务,免得把 App 带到前台),结果回调给入口,给了直接开始。拒绝了入口提示没有权限。
- 配置:「我的 → 语音识别」(`VoiceSettingsActivity`)与键盘设置里的云端大模型分开,存在 `voice` 那份 prefs(`VoiceConfig`:服务、资源 ID、密钥密文);密钥同样经 `SecretStore.seal` 用 Keystore 那把钥匙加密。服务监听这份 prefs,`Engine.setVoice` 交 `{provider, api_key, resource_id}` 的 JSON 给 `Session::set_voice`(同一份不重交),「测试连接」走 `Native.voiceTest`(识别半秒静音,阻塞,后台线程调)。
## 滑行输入
设计见 [design/voice-and-swipe.md](../design/voice-and-swipe.md)「滑行」。解码在 Core(`ShapeDecoder` 按词表打分),壳只判定、记点、画线。`Session` 按在打什么给候选词:中文是主词库的音节串、英文模式与 `decodeSwipe`(字母直接上屏)是英文词表,两份都在第一次滑行时建(`OnceLock`)。
- `KeyboardView.swipeLayout` 由服务在 `showKeyboard` 里按当前键盘设(字母页,且是拼音全键盘、英文键盘或常用语编辑条的英文键盘);还要设置里 `swipe_input` 开着。
- `SwipeTrail` 管一笔:第一根手指落在 `Key.Letter` 上就 `begin`,`ACTION_MOVE` 把历史点一起记上(快划时两帧之间隔得远,不记历史点拐角会被抹平),离起点 0.6 键宽转成滑行,这时撤掉这根手指的按下态(不画气泡)与长按计时。
- 抬手时 `toJson(slots)` 拼 `{points, keys}`(像素;键是所有字母键的中心与宽高),`Composer.onSwipe`:组句或该组句时 `Native.swipe` → `Session::swipe`(字母逐个 `engine.push`,最后刷一帧),英文直接上屏时 `Native.decodeSwipe` 拿字母串上屏,Shift 亮着首字母大写。
- 真机用 `adb shell input motionevent DOWN / MOVE / UP` 模拟(同一条 `adb shell` 里串起来,不然每步间隔太长会先触发长按)。
## 还没做
本机离线语音识别、神经重排、用户主题(PC 主题目录里自己写的主题)。