# Android 输入宿主 ## 目录结构与验证入口 Java/Kotlin 宿主按 `java/app/msime/android//` 分为 `account`、`candidate`、`clipboard`、`core`、`dictionary`、`handwriting`、`keyboard`、`policy` 和 `voice`;JNI/C++ 适配位于 `native/`,资源位于 `res/`,按职责组织的回归位于 `tests//`,设备脚本位于 `tests/device/`。Tauri/React 设置仍复用 `packages/ui` 和 `apps/desktop/src-tauri/src/platform/android/`,不会在 Android 复制一套页面或 Rust 业务。 验证分三层,各有对应入口:`check-host.sh` 做契约守卫与 JVM 冒烟(CI 的 `ci-platforms.yml` android job 跑的就是这条),`build-native.sh` + `verify-native.sh` 做 arm64-v8a(按需加 x86_64)的原生构建与导出校验(包括在线候选的五个 host 导出与五个 JNI 方法),`tests/device/smoke.sh` 在固定的 API 35 arm64 专用 AVD 上跑 instrumentation,覆盖原生输入、Tauri/IME 合包、共享设置、统计与手写流程。 本目录的正式 Android applicationId 与原生类所在的 namespace 都是 `app.msime.android`。设备 smoke 使用独立的 `app.msime.android.test` instrumentation APK。 ## 产品版本 版本表 `shared/contracts/editions.json` 里每个有 Android 段的版本(full、pinyin、wubi、japanese、vietnamese、tibetan)在 `gradle-app` 里是一个同名的 productFlavor,可以同时装在一台设备上,彼此完全隔离: - full 就是现有产品:applicationId `app.msime.android`,清单、资源和发布的 `msime-android.apk` 与引入版本之前相同; - 其他版本的 applicationId 是 `app.msime.android.`(Gradle 的 `applicationIdSuffix`),APK 叫 `msime-android-.apk`。清单里的 ContentProvider authority 一律写成 `${applicationId}.<名字>`,随之各不相同; - 应用名和系统键盘切换器里的子类型名取自 `editions//res`(水杉五笔、水杉拼音、水杉日语、水杉越南语、水杉藏文),图标所有版本相同。子类型登记的语言是版本输入的语言:中文的版本是 `zh_CN`,日文、越南文、藏文版是 `ja_JP`、`vi_VN`、`bo`,系统的语言列表把它们列在日语、越南语、藏语下; - 每个 flavor 把版本 id、方案列表和默认方案写进 BuildConfig,Java 侧由 `AppEdition` 读取:键盘、设置页和首启引导只列本版本的方案入口,偏好里的方案本版本没有时回退到本版本的默认方案。五笔版只有五笔和手写(没有全拼 9 键),混拼默认打开;手写写进偏好的是本版本的默认方案,所以在五笔版里同样可用;高情商回复是工具栏入口,与方案无关,各版本都有。手写用 ML Kit 的中文模型,只认汉字,所以日文、越南文、藏文版没有手写,键盘只有本版本方案的入口(日文版是日语 9 键和 26 键,越南文、藏文版各一个 26 键);这三个版本不带 msime-pinyin.db、n-gram 和整句模型,资源只有英文词库及其 SCOWL 许可声明、符号表和清单,日文版另带日文词典和两份 Mozc 许可文本;它们也不带非英文离线释义(版本表 `features.offline_glosses` 为 false,`build-apk.sh` 和 `build-client-apk.sh` 不暂存并检查 APK 里没有 `assets/offline-glosses/`),ML Kit 的中文手写模型本来就只在打开手写面板时下载,这三个版本没有手写面板,也就从不下载。只有一个方案的版本跳过首启引导里选方案那一步; - `Bootstrap` 把非 full 的版本 id 交给 `msime_client_prepare_host`,host-api 按本版本的资源锁(`resources/editions/.lock.json`)校验 APK 里的词库,并在状态目录记下版本。 构建某个版本:`MSIME_EDITION=wubi bash platforms/android/build-apk.sh <已锁定词库目录>`,缺省是 full。脚本只暂存本版本资源锁列出的词库和本版本要的语言词库(full 是粤拼、注音和笔画,其他版本不带),资源锁在 APK 里仍叫 `desktop-dictionary.lock.json`。`release-android.yml` 按版本矩阵各打一个 APK,全部齐了才发到同一个 `android-v<版本>` 页面,所有版本用同一把发布密钥签名。Google 登录要在 Google Cloud 为每个新包名登记 OAuth client,这一步在仓库之外。 `MSIME_ANDROID_OMIT_ON_DEMAND=1`(只对 full 和 pinyin,其他版本设了直接失败)打发布用的瘦包,`release-android.yml` 给这两个版本打开它:日文词典那一组(`msime-japanese.dat` 与两份 Mozc 许可文本)、粤拼注音笔画语言词库、非英文离线释义和本地语音运行库都不进 APK,由应用在用户添加日语或这些语言(拼音版不提供粤拼、注音和笔画,只会为临时日语下载日文词典)、打开离线释义、打开「离线识别」时作为资源包下载到 `files/bootstrap/state/resource-packs/<资源包>/`,启动时不自动下载。随包的 `desktop-dictionary.lock.json` 是本版本的锁去掉日文那一组后的子集(Bootstrap 只按它解包),暂存的词库按 `verify_resources --omit-on-demand` 校验;host-api 仍按编译进去的完整锁校验,日文那一组整体缺席时放行,用户词库的代次不变。APK 签名后再检查包里没有 `msime-japanese.dat`、Mozc 许可文本、`assets/language-dictionaries/`、`assets/offline-glosses/`、`libonnxruntime.so` 和 `libsherpa-onnx-c-api.so`,并且包不超过 130,000,000 字节(`MSIME_ANDROID_MAX_APK_BYTES` 可改)。瘦包只能是 arm64-v8a 单 ABI,`MSIME_ANDROID_ABIS` 带上 x86_64 时直接失败:资源包 `voice-runtime` 只有 arm64-v8a 的库,在 x86_64 上下载了也加载不了。这个开关与 `MSIME_REQUIRE_LANGUAGE_DICTIONARIES=1` 互斥。不设时照旧全带,日文版的主词库就是日文词典,始终随包;Tauri 开发合包 `build-client-apk.sh` 也照旧全带。 ### 手机、大屏与二合一布局边界 原生输入面依据 Android 的 `Configuration.smallestScreenWidthDp` 区分设备形态,而不是依据旋转后的当前窗口宽度。小于 600 dp 的手机始终让键盘铺满可用窗口;因此一台 411 dp 的手机即使横屏后当前宽度超过 600 dp,也不会突然切换成平板键盘。600 dp 起的平板、展开态折叠屏和二合一进入大屏布局:整套键盘表面在窗口底部水平居中,宽度取当前可用宽度与 720 dp 的较小值。左右空出的区域使用当前键盘皮肤的背景色,避免十列按键被拉伸到桌面宽度。 “整套键盘表面”包括候选区、字母/符号/九键/手写主键区、展开候选、剪贴板、输入方案、皮肤、布局调整、语音结果、AI 润色、更多工具、表情和符号面板;这些层必须共用同一个外框(平时是 720 dp,分离式键盘画着时铺满,见下一节),不能只限制字母键而让覆盖面板重新铺满屏幕。系统发生旋转、折叠展开、自由窗口缩放或外接显示器配置变化时,`MSIMEInputService.onConfigurationChanged` 只重新计算外框,再重算按键高度和间距,分离式键盘该画与否因此改变时再重建一次键行;不会重建 Engine session,也不会清空当前组合、候选代次或面板状态。 这条原生键盘策略与共享设置页的响应式布局彼此独立:`platforms/android` 负责系统 IME 窗口,Tauri/React 设置页仍按自身 600 px CSS 断点在手机底部标签栏与大屏侧栏之间切换。`KeyboardFormFactorPolicySmoke` 固定验证 599/600 dp 边界、手机横屏、600 dp 折叠展开态、1280 dp 二合一的 720 dp 上限,以及配置暂时缺失当前宽度时的安全回退;`check-host.sh` 会编译并执行该回归。设备上的旋转、分屏、自由窗口和折叠铰链切换走的是同一条策略,JVM 回归钉住的是策略边界本身。 ### 平板横屏分离式键盘 键盘设置「布局」里的「横屏分离式键盘」(本地设置 `platform.android.split_keyboard`,布尔,默认关,随账号同步,在 `crates/client-core` 的 `ANDROID_LOCAL_SETTINGS` 里)打开后,大屏设备(同上,`smallestScreenWidthDp` ≥ 600)横屏时把键盘分成左右两半,中间空出约四分之一的宽度给双手握持。手机横屏、平板竖屏和开关关着时一律照旧。规则集中在无 Android 依赖的 `SplitKeyboardPolicy`,`SplitKeyboardPolicySmoke` 逐条验证: - 分离的布局:标准 26 键一族(全拼、各家双拼连同微软双拼的 `;` 键、五笔、英文、日文罗马字、粤拼、越南语、藏文)、韩文两套式,以及它们的 123 / #+= 层。九键、日文假名网格、手写、大千注音、笔画、注音 9 键和它们的符号页,以及符号、表情、剪贴板、功能面板都不分离;正在画的布局不分离时,键盘和平时一样是 720 dp 居中外框。 - 分离式键盘画着的时候整套键盘表面不受 720 dp 上限约束,铺满可用宽度(`KeyboardFormFactorPolicy.surfaceWidthDp` 的第三个参数)。候选区、工具栏和盖在键区上的各个面板与键区共用这个外框,同样铺满;它们本身不分成两半。 - 每一行都是「不分离时的那一行缩到 75%,在中间的键边界上断开,两段各自贴到两侧」:中间插入一个普通的 `View` 作为空隙,键之间原有的宽度比例和第二行的半键缩进原样保留,所以第二行的断口比第一行偏右半个键。断点取使左段份额最接近整行一半的那个键边界,一样接近时多出来的键归左边:qwert | yuiop、asdfg | hjkl、⇧zxcv | bnm⌫,123 / #+= 层第三行是 #+= 加三个标点 | 两个标点加 ⌫。⇧ 和 ⌫ 始终在两侧外缘。 - 字母层的第二、三行在右半边内侧重复左半边最内侧的字母(#6022):asdfg | ghjkl、⇧zxcv | vbnm⌫(韩文两套式同一位置是 ㅎ、ㅍ),总是生效,没有子开关。重复的键是同一种字母键,输入、提示角标、滑动符号和键位 id 都与左边那个相同;它从空隙里占一个键宽(`SplitKeyboardPolicy.gapWeight(float, float)`),所以三行键宽不变,两段等宽、空隙仍居中,只是这两行的空隙比第一行窄一个键。只在重复之后两段正好相等时才重复(`SplitKeyboardPolicy.duplicatesInnerKey` 按这一行的实际份额判断):微软双拼显示 `;` 时第二行本来就是 asdfg | hjkl; 五对五,这一行不重复 G。第一行、数字行和 123 / #+= 层不重复。滑行输入只认左边那个 G、V:交给 Engine 的键位每个字母只有一个中心点,右边的重复键在滑行时和空隙一样不算字母键,点按照常输入。 - 底行(字母层的功能行,以及 123 / #+= 层自带的底行)的空格键拆成两个,分别在两半的内侧,左边那个的宽度让空隙正好落在整行中间(每一半至少保留原宽度的四分之一)。两个都是空格:右边那个直接绑定同一个 `space()`,按键反馈、计数和键位 id(`Space`)与原空格相同,长按语音和拖动移光标用同一套手势,且两个键各有自己的手势状态,两根拇指先后按住时互不干扰。 - 中间空隙是死区:它不是键、不可点击,也不带键距外边距。`KeyboardKeyArea` 只把落在某个键外边距里的按下交还给那个键,空隙在所有键的外边距之外,所以点它什么也不发生,只有紧贴内侧键的那几 dp 键距仍归内侧键。 - 与单手模式互斥:分离式键盘画着时单手模式不生效,侧栏不显示;存着的 `platform.android.one_handed` 不改,回到竖屏或换到不分离的布局时原来的单手模式自动回来。功能面板里的「单手模式」磁贴这时显示为不可用。 - 键高、键盘高度设置、键距和行距都不变;打开「数字行」时数字行同样从中间分开(12345 | 67890)。旋转、换布局或开关变化后,下一次渲染发现键行与应画状态不一致时重建键行(`ImeLetterRows.splitStale`),不动 Engine session 和当前组合。 ### 数字行 键盘设置「布局」里的「数字行」(本地设置 `platform.android.number_row`,布尔,默认关,只在本机,同步字段表里没有这个键;#6022)打开后,标准 26 键一族和韩文两套式的字母层在字母上方多一行 1–0,所有设备都有;窗口可用高度低于 480 dp(手机横屏,`KeyboardLayout.NUMBER_ROW_MIN_WINDOW_HEIGHT_DP`)时不画,否则默认高度的键盘再加一行就把工具栏或底行挤出窗口,转回竖屏后自动回来。这一行和第一行字母一样高、按同样的方式分摊键盘高度调整,所以键盘整体高出一行;切到 123 / #+= 层(第一行本来就是数字,这一层不变)、九键、笔画、手写、日文假名网格和注音时没有数字行,键盘高度随之回到原来的样子。数字键经 `type()` 交给 Engine:没有组字时 Engine 不收,宿主上屏这个数字(全角模式下是全角);组字中当前页有对应候选的 1–9 由 runtime 选词,当前页之外的 1–9 被 runtime 吞掉、什么也不发生;Engine 不收的 `0` 和没有候选时的 1–9 先按首选结束组合再上屏(`DeclinedKeyPolicy`,否则数字会顶掉组字区里的拼音);英文直输时直接上屏。判定在 `KeyboardLayout.drawsNumberRow`,由 `KeyboardLayoutSmoke` 覆盖;开关变化后下一次渲染发现键行与应画状态不一致时重建键行(`ImeLetterRows.numberRowStale`)。 ### 浮动键盘 浮动键盘(#5621)让整套键盘表面缩成一块圆角面板悬在应用上面,应用不再被顶起或压缩,键盘把输入框挡住时把面板拖开即可。开关是本地设置 `platform.android.floating_keyboard`(布尔,默认关,只在本机),入口有三处:功能面板第 3 页的「浮动键盘」磁贴、设置「键盘」页「布局」里的开关,以及工具栏按钮——按钮默认不显示,在设置「键盘工具栏」里打开「浮动键盘」(`platform.android.toolbar_floating`,同样只在本机;同步的工具栏开关表在 Rust 的 `ANDROID_LOCAL_SETTINGS` 里,这一项还没加进去)。规则集中在无 Android 依赖的 `FloatingKeyboardPolicy`,`FloatingKeyboardPolicySmoke` 逐条验证: - 面板宽度是当前窗口的 80%,夹在 240–480 dp 之间且不超过窗口;高度与停靠时相同(键盘高度设置照样生效),顶部多一条 22 dp 的拖动条,按住横条拖动,右端的「停靠」键回到停靠在底部的完整键盘。 - 位置按可移动范围(系统栏之间的区域减去面板大小)里的千分比存在 `platform.android.floating_keyboard_x` / `_y`,默认水平居中、贴底;松手时写入。旋转、分屏或面板变高后换算回像素,面板总在窗口里。拖动只改面板的平移,不重新布局。 - 实现与 LatinIME 的做法相同:浮动时输入法窗口、系统的 `inputArea` 和键盘根视图都铺满屏幕,根视图透明;`onComputeInsets` 把内容区和可见区都设成从窗口底边算起(应用不让出高度),可触摸区域只有面板(`TOUCHABLE_INSETS_REGION`),面板外的触摸落到应用上。停靠时把窗口和 `inputArea` 恢复成第一次浮动前的高度,`setInputView` 与 `updateFullscreenMode` 之后重新套一遍。 - 浮动时单手模式和横屏分离式键盘不生效(存着的值不变,停靠后自动回来),功能面板的「单手模式」磁贴显示为不可用;外接键盘的候选条模式里不浮动,候选条停在底部(打开下文的「物理键盘时候选栏跟随光标」时跟着光标走),浮动磁贴与按钮显示为不可用。 ### 键盘底栏 全面屏手势导航下,键盘最下一行离屏幕底边只有手势条那一截(常见 16–24 dp,隐藏手势条时是 0),按底行容易碰到系统上滑回桌面的手势区。键盘底栏在键区下面垫一条 40 dp 的栏:左边地球图标弹出系统的输入法选择框,右边打开或关上剪贴板面板,中间左右滑动移动光标(先结束组字,每 12 dp 一格,与空格键拖动同一套步长,但不受「空格滑动移动光标」开关影响)。开关是设置「键盘」页「布局」里的「键盘底栏」(本地设置 `platform.android.bottom_bar`,布尔,默认开,只在本机)。规则集中在无 Android 依赖的 `KeyboardBottomBarPolicy`,`KeyboardBottomBarPolicySmoke` 逐条验证: - 只在导航栏低于 32 dp 时画。三键导航的导航栏有 48 dp,不画。 - 系统自己画了输入法的收起和切换按钮时不画。Android 13 起原生系统把它们画在输入法窗口底部 24 dp 的手势条两端(`NavigationBarFrame`,挂在输入法窗口的 decor 下),高度与国产系统的手势条相同,所以按 decor 里有没有可见的这一条判断。Android 12 的系统导航栏自己显示这两个按钮,高到 48 dp。 - 导航栏高度取键盘视图收到的 inset 与输入法窗口 `WindowMetrics` 的较大值:Android 14 及以前输入法窗口停在导航栏上方,视图收到的底部 inset 是 0,只看它会把三键导航误判成手势导航。Android 11 及以前不画底栏。 - 浮动键盘、外接键盘的候选条模式(键区收起时)和手机横屏不画。 - 底栏叠在键盘外框底部(`ImeBottomBar`),键盘列的底部内边距 = 底栏下面让出的距离 + 40 dp。让出的距离是导航栏落在窗口里的那截,加上系统手势区高出导航栏的部分,中间的横向拖动因此不会从系统手势区里起手。覆盖面板按这段内边距让出底边,剪贴板、表情等面板开着时底栏仍可点。 底栏不画的设备(系统自己画输入法按钮的、Android 11 及以前、三键导航)可以在同一处打开「底部留白」(本地设置 `platform.android.bottom_padding`,布尔,默认关,只在本机,#6392):键盘列的底部内边距再加一段 40 dp 的空白,把底行键抬离屏幕底边。底栏画着时不叠加;浮动键盘、外接键盘收起键区和手机横屏时不垫(`KeyboardBottomBarPolicy.paddingShown`)。 `check-host.sh` 在装有固定 NDK 28.2.13676358 的机器上额外用 `aarch64-linux-android28-clang++` 以 `-Wall -Werror` 对 `native/client_jni.cpp` 做目标平台编译:Java 里声明 `native` 的方法在没有 C++ 实现时照样能编过,而这是 Java 声明与共享 FFI 签名唯一必须一致的地方;完整原生构建需要 vcpkg、Rust Android 目标和固定的语音运行时,这一步都不需要。没有固定 NDK 的机器会跳过并明确说明。`verify-native.sh` 的导出清单同时覆盖 online query、云 URL、AI 请求描述符和两个在线候选写回入口。 宿主 Java 以 API 35 的 `android.jar` 编译,而 manifest 声明 minSdk 28,因此比真实 APK 构建宽松;`Files.readString`/`writeString` 属于 API 34,本宿主不使用,`check-host.sh` 对这两个方法有定向检查,其余 API 级别问题仍由 Gradle lint 覆盖。`scripts/verify-local.sh` 另有 `compile: android target` 阶段,在固定 NDK、Rust `aarch64-linux-android` 目标与 vcpkg 依赖前缀齐备时检查 `msime-desktop` 的 Android 分支;宿主的 `cargo check --workspace` 只覆盖宿主目标。 `NativeClient` 提供 Java/Kotlin 到共享运行时的 JNI 传输。UTF-8 字节数组保留非 BMP 字符,避免 JNI modified UTF-8 损坏候选或资源路径。JNI 负责释放 C API 响应;上层解析 ok/value,负责会话线程和生命周期。 `MSIMEInputService` 提供实际 InputMethodService 源码、系统 manifest 和输入法元数据;最小 Android 28,编译目标 35。软键盘、硬件 ASCII 键、候选点击和翻页调用同一 JNI;Engine 提交与剩余编辑串通过 `EditorBridge` 按顺序映射到 InputConnection。宿主不实现输入算法或分页规则。密码和非文本字段直接输入,不创建 Engine;带 `TYPE_TEXT_FLAG_NO_SUGGESTIONS` 的文本框(Chrome 地址栏、关了自动纠错的聊天框和网页输入框)照常组字;IME_FLAG_NO_PERSONALIZED_LEARNING 关闭当前会话学习。宿主不记录输入;联网只发生在 [PRIVACY.md](../../PRIVACY.md) 列出的场景(云联想与 AI、账号与同步、匿名使用统计、更新检查、按需下载资源包与模型等),各自的触发条件、目的地和开关以那里为准。 外接硬件键盘的退格、左右方向、Home、End 和 Forward Delete 通过 `HardwareKeyPolicy` 映射到共享 Engine 的 0/4/5/6/7/8 命令;组字或候选状态由 Engine 处理,空闲时返回给编辑器。Ctrl/Alt/Meta 组合键仍交给系统快捷键,不把宿主命令抢走。这样 Android 的物理键盘不会复制一套编辑状态机,也不会把前删错误地当成普通退格。 用实体键盘打字时键盘收起成候选条(#5584,`HardwareKeyboardModePolicy`):一次来自非虚拟、字母型键盘设备的按下就进入这个模式,键行、功能行和单手侧栏整行收起,只留顶部一行——空闲时是工具栏,组词时是候选条,候选前面标上数字行选词用的 1–9,每页十个候选时第十个标 0(`number_row_selection` 关着或英文直输时不标)。工具栏最右的收起键在这时朝上,点它回到完整的软键盘,下一次实体键盘打字时再收起(系统「显示虚拟键盘」关着时,挪光标、换输入框也不会把它收回去,直到键盘拔掉或重新接上);配置报告键盘拔掉或合上(`Configuration.keyboard` / `hardKeyboardHidden` 从有到无)时也退出。打开工具栏面板、展开候选、调整键盘高度或手写时键盘临时展开,关上后回到候选条。只凭配置报告接着键盘不会收起:有的平板常驻报告 QWERTY,打开系统「使用实体键盘时显示虚拟键盘」的用户也明确要软键盘。反过来,那个系统开关关着时系统不显示输入法窗口,组词的候选就无处可看,所以 `onEvaluateInputViewShown` 对走引擎的输入框照样允许显示窗口,但直接进入候选条模式;窗口被系统收着时,实体键盘打出组词后用 `requestShowSelf` 把候选条叫出来。密码、数字这类不走引擎的输入框照系统的意思。 设置「键盘」页「布局」里的「物理键盘时候选栏跟随光标」(本地设置 `platform.android.hardware_caret_candidates`,布尔,默认关,只在本机;#6792)打开后,候选条模式里的候选栏像桌面端那样出现在输入光标旁,不再停在屏幕底部。规则集中在无 Android 依赖的 `CaretCandidatePolicy`,`CaretCandidatePolicySmoke` 逐条验证: - 光标位置来自编辑器的 `CursorAnchorInfo`。开关打开时,走引擎的输入框只要配置报告接着实体键盘或已在候选条模式,就 `requestCursorUpdates(CURSOR_UPDATE_MONITOR | CURSOR_UPDATE_IMMEDIATE)`,不再需要时以 0 撤回;有组字时左边取组字第一个字,上下取整段组字和插入点所占的行(组字折行时候选栏放在最后一行下方或第一行上方),否则取插入点,经 `getMatrix()` 换成屏幕坐标。实体键盘第一次按下才要位置时,那一下的组字最多等第一次报告 300 ms(`ANCHOR_WAIT_MS`),期间候选栏不显示。 - 编辑器拒绝报告(`requestCursorUpdates` 返回假)时这个输入框照旧是停在底部的候选条;答应了却在要显示候选栏时等不来位置,这一次也停在底部,空闲时收到位置再回到光标旁,不在打字途中跳走。 - 窗口沿用浮动键盘的做法:输入法窗口铺满、根视图透明、`onComputeInsets` 不让应用让出高度,可触摸区域只有候选栏。候选栏与浮动面板同宽(窗口的 80%,240–480 dp),左边与光标对齐,放在光标所在行下方 4 dp;靠近底边放不下时翻到光标上方,始终夹在系统栏之间。旋转、分屏和窗口变化后按新窗口重新摆,旋转时还让编辑器立即重报一次位置。 - 空闲时不显示工具栏(与桌面端一样,不打字时什么也不画),所以这时没有工具栏上的展开键;要回到软键盘,关掉这个开关或拔掉键盘。空闲时整个输入法窗口用 `requestHideSelf` 收起(`CaretCandidatePolicy.hidesWindow`),返回键第一下就交给应用,导航栏也不再是收起键盘的样子;候选栏又有内容时(实体键盘开始组字、出现联想或英文建议)用 `requestShowSelf` 叫回来。这一收起不算用户收起键盘,`onFinishInputView` 不做收尾,收起落地前已经又开始组字的,落地后再叫回窗口,组字不会被上屏。打开展开候选等要占键区的面板时,键盘照旧停靠在底部展开。 组字中只按着 Ctrl 的回车(主键盘或小键盘)上屏高亮候选的译文(#7079,`HardwareTranslationCommitPolicy`,排在上面那条「Ctrl/Alt/Meta 组合键交给系统」之前):译文按长按菜单同一条规则(`CandidateTranslationPolicy.insertionGlosses`,两种目标语言都拆成义项)拆开,一个义项直接上屏,几个义项时候选行换成义项页(`ImeTranslationSenses`),1–9 选第几项、空格或回车上屏高亮项、方向键和 Home/End 移动、Esc 或返回只关页,其他键关页后照常处理;没有译文或释义显示关着时按键被吃掉、什么也不做,免得聊天应用在拼音还没上屏时就把消息发出去。上屏后和长按菜单一样用命令 3 结束组字。只在允许插入译文的方案里接(`candidateGlossInsertionEnabled`:本地模式、日语、韩语和不带释义的方案不接),没有组字时 Ctrl+Enter 照常交给应用。被接住的键按住时的自动重复和随后的抬起也不交给应用。 没有实体键盘时,屏幕键盘上组字中的「确认」(回车键)长按做同一件事(#6792):`HardwareTranslationCommitPolicy.returnHold` 与 Ctrl+Enter 同一个条件和去向,一个义项直接上屏,几个打开同一个义项页(点选即可);高亮候选没有译文时长按当作点按,照常确认组字(屏幕上的「确认」不会把回车交给应用,不必吃掉)。能用时回车键右上角标「译」(`KeyboardIconKey.setCornerHint`,与字母键角标同一字号和位置),读屏描述是「确认;长按上屏译文」。 共享 `number_row_selection` 开启时,硬件键盘数字行 1–9 选择当前候选页对应槽位,每页十个候选时 0 选第十个;选择仍携带 Engine 返回的 session、generation 和候选 index,候选过期或当前没有该槽位时按键交回编辑器。英文、密码和直接输入不抢数字键,关闭偏好也立即恢复系统行为。 硬件键盘快捷键消费共享 `keybindings`:Shift+Space、Ctrl+Alt+Space 和单击 Shift/Ctrl 可切换中英,Ctrl+Shift+F 切换简繁,Alt+Shift+H 切换全角输入;每个开关都按偏好即时生效。修饰键单击只有在 600 ms 内且期间没有按下其他键时才触发,组合键优先于普通编辑器快捷键;不匹配或关闭的快捷键继续交给 Android/编辑器。触屏键盘的 Shift 和“简/繁”按钮仍走各自原生路径。 键值与键面是两件事:`KeyboardLayout.rows(layer)` 只给键值,字母恒为小写,因为这是交给 Engine 的形式,Engine 只能用小写字母起拼音组合;键面由 `LetterKeyFacePolicy` 单独决定,中文 26 键按 Apple 一律画大写。两者曾被合并处理,导致中文态把 `N` 发给 Engine、被拒后当字面上屏,26 键中文输入整体失效。 软键盘的主按键区按 Apple 键盘的基础层次拆成字母层和符号层;字母层支持可见的 Shift 状态,符号层保留标点、括号和数字,两个层次均通过无障碍描述暴露当前按键。层次排列由无 Android 依赖的 `KeyboardLayout` 提供,便于在主机测试中验证布局不被宿主生命周期改变。 “符”入口按 Apple 的整屏符号面板适配为 Android 原生面板:常用、中文、英文、数字、网络五类使用左侧分类和右侧五列滚动网格,底部提供返回、删除和锁定连续输入。这五类之后是颜文字(每行两个)和形状、箭头、标点、数学、货币、爱心、字母、游戏、文化、自然、人物、更多十二类符号,内容不写在宿主里,而是打开分类时在表情目录的工作线程上经 `msime_client_emoji_catalog_request`(`category` 为 `kaomoji` 或 `symbols`,后者带 `parent`)从随包的 `msime-others.db` 每次读 64 条,滚到底再读下一页,与 iOS 键盘和桌面面板是同一份数据;颜文字不记进「常用」。打开前先由 Engine 完成组合;符号通过普通 `InputConnection` 以本地输入来源上屏,未锁定时插入一个后回到键盘,锁定时可连续输入。「常用」不是写死的表,而是用户在面板里实际点过的符号,最近点的在前、最多 30 个,只存在本机的 `android-symbol-recents`;隐私模式和不许个性化学习的输入框不记。还没有记录时面板打开在「中文」,原先写在「常用」里的中文标点也在「中文」最前面。「网络」在网址片段之后列出 20 个常用邮箱后缀(`EmailSuffixPolicy.SUFFIXES`,@qq.com、@163.com、@126.com、@foxmail.com、@139.com、@189.cn、@aliyun.com 等国内常用的在前,然后是 @gmail.com、@outlook.com 到 @yahoo.co.jp),超过 4 个字符的条目在五列格里缩小字号并单行省略,不折行。分类、网格数量、长条目字号、使用记录和锁定行为由无 Android 依赖的 `SymbolPanelModel` 验证,宿主只负责 View 与触摸反馈。 在系统标记为邮箱的输入框(`EMAIL_ADDRESS` / `WEB_EMAIL_ADDRESS`)里,光标前打到 `xxx@`(或在 `@` 后接着打了域名的开头,如 `abc@1`)时,候选栏列出能接上的邮箱后缀,点一下删掉已打的域名、上屏完整后缀(按 ASCII 原样,不随全角模式变)(#6147,`ImeEmailSuffixes`)。其他输入框里的 `@` 不触发,没有开关;组字时不读上文、不显示。每次选区变化向编辑器要光标前 64 个字符,只在内存里匹配一次,不记录、不保存。匹配规则(本地部分紧挨 `@`、以最后一个 `@` 为准、全角 `@` 不算、已是完整后缀时不再给)在无 Android 依赖的 `EmailSuffixPolicy`,由 `EmailSuffixPolicySmoke` 覆盖。 编辑器上下文按固定 Apple 来源的边界适配 Android `inputType`:URI、邮箱、密码和明确禁用建议的字段临时进入英文输入,允许 Engine 的字段使用 dedicated English 模式,敏感字段继续绕过 Engine;离开后恢复进入前的中英状态,同一字段内用户通过“中/英”手动切换后,输入重启回调不会再次覆盖。英文模式始终展示完整 26 键,即使底层方案为九键或手写;数字和标点会在完成英文组合后由宿主直接提交,空格会完成候选并保留实际空格。`TYPE_TEXT_FLAG_CAP_CHARACTERS`、`CAP_WORDS` 和 `CAP_SENTENCES` 分别映射为全大写、单词首字母和句首自动大写,URI/邮箱强制关闭;规则只读取最多 128 个光标前字符并在内存中即时判断,不记录或持久化编辑器内容。缺失上下文安全回退为关闭自动 Shift。 「键盘」设置里的「中英键轮换其他语言」(本机设置 `platform.android.language_key_cycle`,默认关,#6648)打开后,点按「中/英」键按「中 → 英 → 已添加的其他语言键盘 → 中」轮换:其他语言按启用顺序,每种语言只停一次(日语 9 键和 26 键都添加了时,取与当前中文键盘同为 9 键或 26 键的那个),之后切回进入其他语言之前用的中文键盘;停在其他语言上时键面写那种语言的字(あ、한、越、藏)。日语九键侧列的语言键也按同一轮换走,键面写下一站。没有添加其他语言时、以及实体键盘的中英快捷键和 Shift 进英文,仍只切中英。逻辑在 `LanguageKeyCyclePolicy`。 长按「中/英」键弹出系统的输入法选择框(`InputMethodManager.showInputMethodPicker`),用来临时换到密码管理器之类的键盘(#5615);日语九键侧列的「英」、以及只在 Android 9 以下才出现的地球键「切换」长按效果相同,读屏把这个长按动作念作「切换输入法」。密码框等没有引擎会话的输入框里点按「中/英」不起作用(键画淡、读屏念「中英切换暂不可用」,点按没有反馈),但这个键保持可用,否则禁用的按钮收不到长按,恰好是最需要换键盘的密码框里打不开选择框。弹出选择框时不结束组字,真的换了输入法时由 `onFinishInput` 照常收尾。 删除键(26 键、九键、注音、笔画、手写等所有经 `ImeLetterRows.bindBackspaceRepeat` 绑定的退格)按下立即删一次,按住 420 ms 后开始连删并逐级加速:前 8 次每 70 ms,接下来 12 次每 45 ms,之后每 30 ms(`BackspaceRepeatPolicy`,#5585)。按住往上滑离开键的上沿 8 dp 后停止连删,键上方弹出「快速删除」框(`QuickDeleteOverlay`);滑到框里(通常是键上沿往上 40 dp,九键第一排的删除键上方空间不够时框缩到至少 28 dp;连这样的框都放不下时,例如工具栏隐藏且没在组字,这次按压不支持上滑,照常连删)框换成强调色并振动一次,此时松手先清掉手写墨迹、丢掉组字和选中的文字,再删掉光标前的全部文字;待命后滑回框外再松手什么也不删。判定与分段删除在无 Android 依赖的 `BackspaceSwipePolicy` 里:每轮用 `getTextBeforeCursor` 读至多 4096 个 UTF-16 单元,只用来确定这一轮删多长,按读到的长度 `deleteSurroundingText`,最多 256 轮;读到的文字不保存、不记录。`BackspaceRepeatPolicySmoke` 与 `BackspaceSwipePolicySmoke` 验证加速时刻表、弹出/待命/取消的阈值、框的位置,以及重复文字、短读和不配合的编辑器下的删除。 功能面板第 3 页的「文本编辑」打开文本编辑面板;设置「键盘工具栏」里打开「文本编辑」(本地设置 `platform.android.toolbar_text_edit`,默认关,只在本机,#6351)后,工具栏上也有一个按钮直接开关它(#5625,`ImeTextEditPanel`,键与动作在无 Android 依赖的 `TextEditPanelModel`):和常用语、剪贴板面板一样盖在键区上、顶边对齐工具栏下沿,收起键此时是「返回键盘」。4 × 4 的布局照 Gboard 的编辑面板:左边 ← 和 → 各占三行,中间一列 ↑、选择、↓,下面一行 移到开头、移到结尾、删除,右边一列 全选、复制、剪切、粘贴。打开前先由 Engine 完成组字,之后所有动作直接作用于编辑器:方向键发 DPAD 按键事件,「选择」开着时先按下左 Shift 再发带 Shift 的方向键(EditText 只认文字缓冲里记着的 Shift,WebView 看事件的 meta),延伸选区;开头和结尾是 Ctrl+Home / Ctrl+End,到整篇文档的两端;全选、复制、剪切、粘贴走 `InputConnection.performContextMenuAction`,编辑器不支持粘贴时退回到直接上屏剪贴板里的文字,复制和剪切不退回(那要宿主自己读选中的文字,会绕过密码框禁止复制的规则);删除发退格键事件,有选区时删选区,按住连发、上滑同样有快速删除。方向键和删除按住连发,节奏与删除键相同。剪切、粘贴和删除之后「选择」自动关闭。`TextEditPanelModelSmoke` 验证 4 × 4 网格的覆盖、按键码、修饰键与菜单动作。 输入模式的默认值和记忆范围也消费共享偏好:`default_ime_mode` 决定没有历史记录时进入中文还是英文,`ime_mode_scope=app` 时按 `EditorInfo.packageName` 记住用户手动切换,`global` 时所有编辑器共享同一个手动选择。包名只作为受限键名保存,不保存编辑器文本;URI、邮箱和密码字段触发的临时英文覆盖不会写入记忆,离开字段后恢复切换前的模式;只带 `TYPE_TEXT_FLAG_NO_SUGGESTIONS` 的字段不触发这层覆盖,按上面的默认值和记忆进框(#5998)。包名缺失或格式异常时退回默认模式。 英文大小写状态继续对齐 Apple:中文态空组合点按 Shift 会先完成组合并进入英文的单次大写;单次 Shift 输入一个字母后自动回到小写,350 ms 内连续点按两次进入 Caps Lock,再次点按关闭。编辑器自动 Shift 与手动单次 Shift 使用同一三态状态机,但不会覆盖 Caps Lock;按钮以 `⇧` / `⇪`、选中态和“关闭 / 下一字母 / 自动开启 / 开启”的无障碍状态区分。切换符号层保留当前大小写,硬件 Shift 和本地模式触发使用每次事件自己的修饰状态,不污染软键盘状态;英文模式禁用中文本地输入工具。 中文全拼或双拼已有组合时,软键盘 Shift 保持中文会话并把后续字母以大写辅码交给 Engine,用于缩小候选;组合开始前仍按 Apple 行为切换到英文。五笔、日语、本地输入模式和空组合不启用辅码,Shift 的一次性状态在辅码输入后复位。 移动端智能标点消费共享 `smart_punctuation`、`chinese_punctuation` 和 `punctuation_lock`:中文跟随模式且 Engine 空闲时,逗号、句点或冒号紧跟 ASCII 字母/数字会保留 ASCII,锁定中文或英文优先;已有组合、日语、英文和本地模式仍交给 Engine。Android 每次只从 `InputConnection` 读取光标前最多两个 UTF-16 单元并向共享策略传一个 Unicode 标量,不保存或记录编辑器文字;缺失或异常上下文安全回退到 Engine 标点。 共享偏好 `paired_punctuation`(设置「表达 › 标点」里的「自动补全成对标点」,默认开)打开时,Engine 上屏以(、【、《、〈、“、‘ 结尾,宿主就补上后半个并把光标留在中间(后半个用 `commitText(closing, 0)` 写;个别应用不认这个 0、把光标放到后半个后面时,宿主写完立刻同步读光标两侧的文字,确认跑偏了才发左方向键退回去,见 #6458);每按一次引号键都开一对新的(Engine 交替出的后引号改回前引号),没有组字时再按 `)`、`]`、`>`、`"`、`'` 会跨过光标右边自己补上的那个后半个而不是再写一个,删除、用户移动光标或换输入框后不再跨过。补的是书名号时经 `msime_client_balance_paired_punctuation_after_auto_close` 告诉 Engine 这一层已闭合。符号面板不经过 Engine:轻点中文和英文括号、书名号、中文引号的前半个时成对上屏,长按只上屏这半个;之后在面板里轻点同一个后半个会跨过已补好的那个,而不是再写一个;ASCII 的 `"`、`'` 和 `<` 不成对(`<` 绝大多数时候是小于号)。标点键的补全和跨过规则与 iOS 宿主相同(HarmonyOS 只有补全、没有跨过),集中在无 Android 依赖的 `PairedPunctuationPolicy`,由 `PairedPunctuationPolicySmoke` 验证。 引号样式和方头括号样式(共享偏好 `quote_style`、`bracket_style`,设置「表达 › 标点」里「使用英文标点」之后的两行,点开是 `OptionSheet`)让中文标点下的 `'` 出「」、`"` 出『』,`[` `]` 出〖〗;「繁体输出时用直角引号」随繁体输出开关变化。出字由 Engine 决定,键盘只从 View 的 `punctuation_marks` 读生效样式:符号键、字母右上角的提示和 123 层的两个引号键跟着它画(123 层那两个键原样上屏,直角样式下是「」),样式变了就重建键行;成对补全、改回前引号和跨过都认这两组字。 重复标点和标点后空格也由共享 Host API 决定:Android 只在当前编辑器会话内保存带 `editor_generation` 的有界 snapshot,按下下一个标点或空格时重新读取光标前标量并消费 `replace_with` / `space_ascii`;焦点、会话或编辑器变化会清空 snapshot,过期或上下文不一致时不改写文本。重复时间窗口、候选数量、组字状态和开关均不在 Android 重实现。 微软双拼在字母第二行额外提供“微软双拼 ing”分词键,只有中文微软双拼普通输入时显示;它把 `;` 原样交给 Engine,由 Engine 根据当前组合决定 ing 韵母或标点语义。英文、日语、五笔和本地输入模式不显示该键。 双拼键位提示由 Engine 的 profile 表通过共享 Host API 提供,Android 不维护第二份键盘映射。提示中的 `/` 分隔声母侧与韵母侧,同一侧的多个单位以空格分隔;因此一个键可能同时显示多个韵母(例如小鹤 `K` 的 `ing uai`)。切换双拼方案后按 profile 刷新缓存;未知方案、损坏响应或原生失败直接隐藏提示,不用其他方案的标签误标当前键盘。提示只在中文双拼、非本地模式且非 dedicated English 时显示。用户还可以在设置的键盘页「双拼键位提示」关掉它,对应共享偏好 `touch_shuangpin_key_hints`(缺省为开,账号同步键 `platform.android.shuangpin_key_hints`);关掉后字母键不画提示、11dp 的下边距收回,TalkBack 也不再念「双拼提示」。 顶部“简 / 繁”快捷键消费共享 `traditional_chinese_output` 偏好,只在 Android 展示与插入边界用共享 OpenCC s2t 转换(`msime_client_simplified_to_traditional`,与 Windows、macOS、iOS、HarmonyOS 同一套词表):Engine 候选原文、候选身份、组合文本和输入算法保持不变。候选条、展开候选面板、Engine 最终提交和手写候选使用同一规则;日语方案、临时日语和 dedicated English 保留原文。快捷键通过共享 revision CAS 乐观刷新当前候选,冲突或写入失败恢复最近接受值;顶部语音入口开启时让出同一快捷位,高情商回复优先于语音。转换器拒收的文本保留原文,不伪装已转换。 “全角输入”沿用 Apple 键盘扩展的直接输出边界:Android 更多工具页提供持久化开关,宿主明确直写的 ASCII 字符、空格和九键字面在开启后转换为 Unicode 全角;Engine 的中文组合、候选身份、手写结果、日语和本地模式保持原文。专用英文模式虽由 Android Engine 管理组合,但其最终英文 commit 在同一宿主边界转换,保证键盘内英文候选与 Apple 的直接英文输入一致。 引擎不接受的标点按 Apple `handleSymbol` 的边界处理:组字中时先用共享宿主命令 9(`Action::Finish`)按首选候选结束组合,再由宿主把该标点上屏。Android 的预编辑是真正的 composing region,直接 `commitText` 会替换掉正在组的拼音,于是「nihao」后按 `@` 只剩 `@`;现在得到「你好@」,与 Apple 和 macOS 的 finish_composition 一致。没有组合时照旧直接上屏。被拒绝的数字(数字行的 `0`、没有候选时的 1–9)同样先结束组合再上屏;当前页之外的 1–9 在 runtime 里已经被吞掉,到不了宿主。边界由无 Android 依赖的 `DeclinedKeyPolicy` 提供。 收起键盘或输入视图结束时,若仍在组字,按首选候选结束组合后上屏,与 macOS 失焦边界的 `MSIME_FINISH_COMPOSITION` 一致;编辑器因此留下 `你好` 而不是字面 `nihao`。 回车键按当前 Android `EditorInfo` 显示并执行前往、搜索、发送、下一项、完成或上一项动作;无明确动作、未知动作或编辑器设置 `IME_FLAG_NO_ENTER_ACTION` 时显示“换行”并提交换行符。执行前先通过共享 Engine 完成当前组合;若组合已被处理,回车到此为止,不再误触发编辑器动作或追加换行。日语九键侧栏同步显示“改行/確定”,底部全局回车仍保留 Android 的 `EditorInfo` 标签和 dispatch 适配,条件由同一个纯 Java 契约提供。 工具栏“空格”支持轻点选词或插入空格,也可左右滑动向编辑器发送有界方向键事件以移动光标。滑动开始时先完成 Engine 组合,距离累积器绑定当前 `InputConnection` 身份;输入目标变化、手势取消、非有限坐标或异常跳变都会终止移动,不读取或持久化编辑器文本。 无障碍增减键盘高度每一步都会保存:拖动在松手时 commit,而无障碍调整没有松手这一刻,只预览会被下一次偏好应用覆盖回原值。 功能面板「键盘高度」与设置页的「键盘高度」滑块按 75%–160% 调整(百分比按四行设计键高 4 × 46 = 184 dp 换算,1% 是 1.84 dp),值以 dp 调整量(−46…110)存在本地设置 `platform.android.keyboard_height_adjustment`,不进共享偏好(共享的 `touch_keyboard_height_adjustment` 仍是 −12…48,只在本地没写过时沿用)。130%(55 dp)以内在任何窗口里照画;更高的部分最多占当前窗口高度(`Configuration.screenHeightDp`)的 14%,竖屏手机能用满 160%,键盘到屏幕一半以上,横屏手机止于 130%,旋转回来自动恢复,存着的值不变(`KeyboardGeometry.windowHeightAdjustment`)。内联高度条的说明在百分比后面显示键盘此刻实际画出来的像素高度,拖动时随布局刷新。键区四行一样高(#6354、#6472,与 iOS、HarmonyOS 相同):字母行、九键 / 笔画 / 手写这类三行高的键块的每一行和底行(功能行,以及 123 / #+= 层自带的底行)都是 52 dp 加一份行距(`KeyboardGeometry.KEY_ROW_HEIGHT_DP`),默认行距下键区 236 dp,键帽都是 52 dp。高度调整由四行均分:底行分到 `floorDiv(调整量, 4)`(`KeyboardGeometry.bottomRowAdjustment`),上面三行或三行高的键块分其余部分(`keyRowsAdjustment`),日语、注音九键连底行一起占四行的整块拿整份,各布局总高一致;数字行按第一行字母的份额另加一行。原来字母行是 56 dp、底栏是 46 dp 且不加行距、不分摊调整(键帽 39 dp 对 56 dp),#6488 加过的「加高底行」开关随之移除,本机或备份里留着的 `platform.android.tall_bottom_row` 读取时丢掉。 键盘工具栏的“设置”面板以远端默认分支固定来源 `MSIME-Apple@3d300cdc62fe0d09565b30bd3e4165571fb91562` 复刻透明实时调整层:键盘保持可见,键盘区域左右拖动按 Apple 的主轴锁定规则调整按键间距,上下拖动调整行间距,顶部工具条拖动把手调整高度;Android 额外保留顶部语音入口开关。高度在平台默认键区基础上支持 -12–+48 dp,并以整数写入共享 `touch_keyboard_height_adjustment`;增量由键区四行均分(三行字母或九键这类三行高的键块,加底行),手写把键块分到的那份用于书写与工具区。间距支持 3.0–6.0 dp 和 4.0–10.0 dp,并以 0.1 dp 精度写入共享 `touch_key_spacing_tenths` / `touch_row_spacing_tenths`。拖动时直接更新已有 View 的高度或 margin,不重建按键树、Engine 或丢失当前组词与手写笔迹;松手、无障碍增减及切换语音入口后通过共享 revision CAS 保存,冲突或写入失败会把间距和语音入口恢复到最近一次已接受快照。键盘高度只存在 Android 本地设置里,先于共享文档单独写入(`KeyboardHeightSave`),共享文档冲突或写入失败不会连带丢掉刚调好的高度;只调高度时不写共享文档。功能面板「键盘高度」打开的内联高度条只在点「取消」时放弃预览:点「完成」或不点就收起键盘都按当前高度保存,调整期间同步或设置页写来的新快照不覆盖预览。 剪贴板历史的拒绝理由按 Apple `ClipboardHistoryStore.Failure` 分开命名:空白文本、单条超过 10,000 字或 40,000 字节、以及 50 条全部固定各有自己的提示,最后一条明确要求先取消固定或删除一条;Android 没有 iOS 的粘贴授权提示,空白文案相应去掉该从句。全部固定是独立的 `ClipboardHistory.FullException`,与读不出或写不回历史文件的普通 `IllegalStateException` 分开,避免把用户指向错误的动作。面板状态行同时说明点按插入以及在「管理」中固定或删除。理由分类与文案由无 Android 依赖的 `ClipboardHistoryPolicy` 提供并在 JVM 回归中验证。 语音结果按 Android 平台能力适配:键盘可见且已有麦克风权限时,系统语音识别服务(SpeechRecognizer)、本地模型和豆包流式都直接在键区里聆听(`ImeVoiceEntry`),键区换成带麦克风圆盘、脉冲环和音量光圈的聆听面板,识别完直接上屏;输入位置在聆听期间变了才存进语音结果。系统识别服务的失败按错误码给出具体原因并带上错误码,识别服务自身故障(网络、服务端、服务起不来等)和空结果时再提示可在设置里改用本地模型或豆包;还没开始聆听就被拒(`ERROR_CLIENT`、`ERROR_INSUFFICIENT_PERMISSIONS`、`ERROR_SERVER_DISCONNECTED`)时转交识别窗口再试一次。还没有麦克风权限、配置了上传式服务商,或者设置应用的语音面板发起时,用独立 Activity 录音或调起系统语音识别服务,窗口里显示「正在录音」和「完成」「取消」,录音由该服务持有,MSIME 只接收有界文本。两份清单都声明了对 `android.speech.RecognitionService` 的包可见性。主应用进程与独立 `:ime` 进程通过应用私有目录中的非阻塞文件锁交接最新一条结果;结果最多 10,000 个 Unicode 码点、10 分钟有效,并在插入前一次性 claim,避免两个键盘实例重复插入。键盘“更多”工具页提供与 Apple 同级的语音结果入口,结果面板内提供 Android 平台的系统语音识别入口;共享 `touch_voice_shortcut` 开启后,候选栏显示直达语音结果按钮。存在 Engine 组合或本地模式时拒绝打开结果,确认插入前还会比对 InputConnection 身份、选择位置 generation 及光标前后/选中文本快照;真实上下文仅短暂保存在内存,不写日志或交接文件。 本地语音识别(共享设置里 provider 为 `local`、`asr_model_path` 指向共享安装器写好的模型目录)在同一个语音 Activity 里用 sherpa-onnx 在本机识别,音频不离开设备:共享层只在路径为绝对路径时下发 `modelPath`,宿主再确认目录里有 `msime-model.json` 才开始。识别复用 `shared/voice/LocalAsr` 与桌面同一套清单、热词和 VAD 逻辑,经 JNI 编进 `libmsime_android.so`;运行时 `libsherpa-onnx-c-api.so` 与 `libonnxruntime.so` 由 `build-native.sh` 通过 `scripts/fetch_voice_runtime.py` 按 `resources/voice-runtime.lock.json` 下载校验后只从 .aar 中取出,不进版本库,并与其它原生库同样检查 16KB 对齐和依赖白名单。瘦包(`MSIME_ANDROID_OMIT_ON_DEMAND=1`)构建时 `build-apk.sh` 给 `build-native.sh` 传 `--omit-voice-runtime`,这两个库不进 jniLibs,`verify-native.sh` 只要求三个宿主库,两个运行库在时照样检查、且必须成对;应用在用户打开「离线识别」而还没有这两个库时下载资源包 `voice-runtime`(语音页的运行库那一行也能点「下载」「重试」):取同一个固定的 .aar,只取出 arm64 的这两个库,按 `resources/voice-runtime-android.lock.json` 的长度和 SHA-256 校验后以只读权限发布到 `files/bootstrap/state/resource-packs/voice-runtime/`,再把路径交给 LocalAsr 加载。录音、模型加载与解码都在后台线程,边说边把部分结果显示在录音窗口;热词来自用户词库,经共享 `msime_client_voice_hotwords` 读取,清单声明 `pinyin` 模式的模型在识别后再经 `msime_client_voice_hotword_correct` 按拼音纠正。模型闲置 2 分钟后释放,系统回收内存时立即释放。模型缺失或损坏、运行时无法加载时直接提示错误,不会退回系统语音识别服务。 AI 润色对齐固定 Apple 来源的确认式流程:仅在 Engine 空闲且编辑器存在非空选区时显示入口,输入和输出各限制 10,000 个 Unicode 码点。全屏面板明确展示 HTTPS 目标 origin、模型和待发送文字,用户再次点按后才发起 Chat Completions 请求;单线程请求队列容量为 1,关闭面板或点击取消会中断任务并断开连接,响应限制为 1 MiB。请求前、响应后及最终替换前均校验 InputConnection、选区 generation、光标前后文本和完整 AI 配置;过期结果不会展示,结果也绝不自动插入。操作按钮固定在面板底部,长文本不遮挡取消或替换。共享设置按规范化的 endpoint origin(HTTPS 主机和端口)保存 Token,同主机不同路径可复用,主机或端口变化时不会沿用;日志、测试和诊断不包含选区、结果、Token 或原始响应。Android Tauri 设置页额外提供由原生 HTTPS transport 执行的模型目录读取、可用模型选择和确认式润色测试;模型分页、服务能力筛选、凭据和 1 MiB 响应均有边界保护。 底部“对话”标签页(`ChatFragment`,也就是“试用键盘”页:设置首页状态卡上的“试用键盘”切到这个标签并弹出键盘)提供 Apple 同级的 AI 对话:用户登录后直接发消息,用哪个模型由服务端决定——页面不加载 `/v1/models`,请求不带 `model`,后端用它配置的默认模型;消息按最多 14 条、每条 10,000 字符和总上下文 48,000 UTF-8 字节裁剪;发送前不会读取或上传输入框之外的内容。请求在后台执行,支持停止、失败提示和过期结果丢弃,响应只保留在这一页的内存中,在“我的”里换了账号后回到这一页会清空对话并改绑新账号;未登录时发送只会得到失败提示。 表情面板和手写板各自消费共享的 `emoji_theme` 与 `handwriting_theme`:表面显式 `dark`/`light` 覆盖全局 `theme`,`follow` 继承全局,全局为 `system` 时跟随 Android 夜间模式;缺失或无法识别的值按 `follow` 处理,不会让这两块面板在旧快照下单独翻到浅色。两者使用与键盘同一套皮肤标识和自定义设计,只是明暗解析不同;键盘整体着色照旧走 `screen_keyboard_theme`,候选栏继续由 `candidate_theme` 决定,偏好热更新只重画表面,不重建 Engine 会话或手写笔迹。`menu_theme` 没有 Android 消费者——系统 PopupMenu 由平台绘制——共享设置因此不再在移动端显示该项。 云联想与 AI 联想按 Windows/macOS/Linux/HarmonyOS 已有的共享 provider 边界接入 Android:组字停下 350 ms 后,宿主向共享 host 索取 `online_query`,再由单线程 worker 分别执行云候选 HTTPS GET 和 AI Chat Completions POST;URL 与请求描述符都由共享 host 构建,凭据留在 session 内,宿主只搬运字节。两者都是可选偏好:`cloud_candidates` 关闭即不发起云请求,AI 联想还要求 AI 辅助已启用且配置完整。**开启云联想意味着把当前正在组的拼音发送给云输入服务**,与其他桌面/移动宿主的既有行为一致,可在共享设置中关闭。请求身份由 session、cache key、identity、云开关和启用状态下的 AI 配置组成,同一组合只问一次;epoch 保证上一段组合的迟到结果不会写入新会话。云结果会推进 Engine 代次,所以 AI 请求在云结果落地后重新读取 query 再发出。云响应上限 256 KiB、AI 响应上限 1 MiB、AI JSON 内容上限 64 KiB,单条候选上限 4096 字节,空白、含控制字符、重复和超限候选被跳过而不影响同批其他候选;候选数量上限取共享配置。这些边界由无 Android 依赖的 `OnlineCandidatePolicy` 提供并在 JVM 回归中验证。 打字统计按固定 Apple 来源只记录成功上屏的 Unicode 扩展字符簇,空格、换行和未上屏按键不计,组合表情计为一个字符。提交内容只在 Rust 内存中分类,持久化文件只含日期、字符类别、提交来源和数量,不保存输入原文。分类覆盖汉字、拉丁字母、其他文字、数字、标点、表情、其他符号与旧版未分类;来源覆盖全拼 26/9 键、四种双拼、五笔、日语、韩语、手写、英文、本地输入、AI 润色、高情商回复和语音。每日计数与分类默认永久保留,按保留策略清理的日期同时从累计总数与分类中扣除;启停与清空使用同一跨进程文件锁,清空不会重新启用统计。写入通过容量 32 的单线程队列离开输入主线程,队列满或存储失败不保留待写文字,且每个输入会话只显示一次脱敏错误提示。按键热力图另按本地日期只保存每个键当天被按下的次数(W3C KeyboardEvent.code 名加软键盘 Nine0–Nine9、SoftPunctuation 等标识,白名单与 Rust `KEY_IDS` 逐字一致),不保存按键顺序、时间或输入内容;组字中的拼音按键和交给应用处理的按键都计入,长按删除和系统自动重复只算一次,密码框与无痕输入框不计入。统计关闭时键盘不缓存任何按键;开启时在内存中按「键 → 次数」攒批,满 256 次、跨日(先写旧日期)、离开输入框或收起键盘、以及约 30 秒定时时,经同一个统计队列以 `record_keys` 一次写入。 Android Tauri 设置仅在 Android WebView 注入统计能力,桌面设置不显示入口。页面提供 7 天、30 天和累计范围、最近 7/30 日趋势与单日下钻、字符类型/语言模式/输入方案占比、刷新、即时启停和确认清空;统计页独立于 Preferences 草稿和“保存设置”。主应用与独立 `:ime` 进程共享 `files/bootstrap/state/typing-statistics.json`,由锁文件串行读写;页面会区分从未写入和已清空状态,并明确说明本机只保存聚合计数。完整 arm64 Tauri 合包已在专用 API 35 arm64 AVD 验证实际上屏聚合、文件不含合成输入文本、页面跨进程读取、禁用后不增长、取消/确认清空、清空保留禁用状态和重新启用后累计。 “高情商回复”是键盘工具栏上的工具入口,不是输入方案,不向共享输入算法增加 AI 状态。在任何输入方案下点工具栏的「回复」按钮,都会在共享候选/快捷栏下方打开占据剩余键区的专用面板,再点一次收起并回到原来的键盘;“帮你回/帮润色”和社区模板位于面板内,输入方案、共享触屏键盘皮肤和收起入口继续复用上方共享快捷栏,避免重复入口;正文通过用户明确点按读取当前文本剪贴板,限制 10,000 个 Unicode 码点,并提供九种内置风格、删除、清空、取消、生成和同风格“换一句”。回复请求复用 AI 润色的 HTTPS transport、容量 1 队列、取消和 1 MiB 响应边界,但每次使用 Apple 对应风格 prompt;候选去重、最新在前且最多三条,服务结果绝不自动上屏,只有点按候选且 InputConnection、光标上下文、Engine 空闲状态、面板仍打开和完整 AI 配置仍匹配时才插入,插入的文字在打字统计里记为“高情商回复”来源。插入后面板让出编辑器,工具栏“回复”入口可重新打开。切换模式、修改源文字、收起面板、编辑器上下文或 AI 配置变化都会取消请求并废弃迟到结果。旧偏好里 `touch_keyboard_schemes` 存的 `thoughtful_reply` 按未知方案处理:从可见列表中去掉,若它是当前方案则回退到第一种可见方案。 社区回复模板只从应用私有 files 目录的 `CommunityLibrary.json` 读取,该文件用于主应用向独立 `:ime` 进程显式共享已收藏资源,不包含凭据或源消息。读取拒绝符号链接、非 UTF-8/非法 JSON、超过 4,000,000 字节、超过 50 项或无效字段,仅展示 `kind == reply` 且含 prompt 的条目;发起请求时重新读取,已移除模板不会复用。高情商回复不再出现在共享 `touch_keyboard_schemes` 里:旧文档存的 `thoughtful_reply` 在共享 `PreferencesStore` 读入时从 `enabled` 中去掉,若是 `selected` 则回退到按固定顺序第一种可见方案,与隐藏当前方案时的回退相同。 候选区独立显示当前组合文本、当前页和候选按钮;Engine 候选超过当前页容量时显示展开入口。用户打开面板后,宿主通过按需 host API 一次复制当前 generation 的完整 Engine 候选,在顶部显示 preedit、候选总数和收起入口,候选 chip 按实际测量宽度自动换行且不绘制序号;全局序号只保留在无障碍描述中。展开面板没有分页按钮,点按页外候选通过独立的全代次选择 API 交回 Engine。普通候选栏继续使用分页 `View` 和当前页选择边界,每次按键不会携带完整列表;两条选择路径都校验 session、generation 和全局索引,宿主不复制候选算法或组合状态。 候选英文释义按固定 Apple 来源 `MSIME-Apple@d117009573a1a619cfb1702645f38c3b4c378a78` 实现,并通过共享 `candidate_english_gloss` 偏好选择性开启,默认关闭。开启后,Android 只在 IME 主线程复制当前 generation 的完整候选;无 session 的有界 worker 请求由 Rust Engine(`crates/engine`)只读访问随包 `msime-english.db`,Java 不实现输入算法也不读取 SQLite。完成结果返回主线程后必须同时匹配 session、generation 和生命周期 epoch,才会调用共享 `apply_translations`;停止输入、替换会话、偏好变化与服务销毁都会使旧结果失效。Engine 的五笔等候选提示优先占用次要文本位置,离线释义仅在没有 Engine 提示时显示;候选条与展开面板以较小的皮肤兼容文字和不同无障碍说明展示,候选身份、点击选择和上屏原文不变。查询失败静默保留普通候选,不记录候选文字;关闭偏好会立即隐藏已返回释义,缺失资源不会创建数据库或用户数据。 非英文目标语言(fr、ja、es、ru、de、ko)的离线释义来自 `scripts/build_offline_glosses.py` 生成的 `zh-.db`。`build-apk.sh` 与 `build-client-apk.sh` 在 `target/offline-glosses`(或 `MSIME_OFFLINE_GLOSSES` 指定的目录)同时有数据库和 `offline-glosses-NOTICE.txt` 时把它们打进 `assets/offline-glosses/`,没有则照常构建;瘦包(`MSIME_ANDROID_OMIT_ON_DEMAND=1`)不打包它们,用户打开离线释义时下载资源包 `offline-glosses`(`resources/offline-glosses.lock.json`),host-api 先找资源目录旁随包的文件,再找资源包,请求里要带 `state_root` 才会去找资源包。每次打开 MSIME 应用时 `Bootstrap.prepare` 都会检查(已有运行配置也一样):安装包的 `lastUpdateTime` 变化时把它们解压到资源目录旁的 `files/bootstrap/offline-glosses/`,不含它们的新包会清掉旧文件;这一步不属于已校验的运行配置,失败只影响非英文释义。同一个 `candidate_english_gloss` 开关控制它们;已安装词典的目标语言按用户的目标顺序与英文释义、账号翻译逐候选合并,离线释义优先,账号翻译只补离线没有的行。日文方案与临时日文模式不请求其他语言释义,与账号路径一致;韩语方案的汉字候选与中文候选一样请求非英文离线释义和账号翻译(共享翻译查询现在为韩语作答)。 候选翻译的在线服务在「设置 → 输入 → 翻译 → 翻译服务」(`home/TranslationServicePage`)里选:不使用、水杉账号、小牛翻译、腾讯云机器翻译、自定义(DeepLX 兼容服务,或用自己密钥的 DeepL 官方 API)。写入共享文档的 `niutrans`、`custom_translation`、`tencent_tmt` 和 `translation_account`,与桌面、iOS、鸿蒙同一组字段;选服务时三个 `enabled` 只开一个,腾讯在共享默认值里是开着的,所以没选它时显式写 false,`translation_account` 只在选水杉账号时写 true,否则删掉。凭据与 AI 辅助的令牌一样只存在本机偏好里,不随设置同步上传;密钥(小牛和自定义的 API Key、腾讯的 SecretId 和 SecretKey)页面只显示「已填写」或「未填写」,小牛的 App ID 和自定义的接口地址照常显示;字段按共享偏好的校验规则在输入框里当场检查(腾讯 SecretId 只能是 ASCII 标识符,地域只含字母、数字和连字符且不超过 64 字符,小牛的值不能是 `<占位符>` 或 `FAKESECRET_` 样例,接口地址见下),「测试连接」调共享层的 `msime_client_test_api_credential`。「我的 → 隐私」的「用水杉账号翻译候选」打开时同时关掉三个自有服务,与共享设置页选「水杉账号」相同。键盘按 `CandidateTranslationPolicy.route` 选路,逐条照共享层 `providers.rs` 的 `selected_translation_services`:候选翻译关闭时什么都不发;水杉账号只在显式选了它、小牛和自定义都没启用、腾讯也不是「启用且两项凭据都可用」时才用;其余按 小牛 > 自定义 > 腾讯 选,所选服务凭据不全时不联网,也不改用别的服务(自定义服务只要求接口地址非空,DeepL 地址缺密钥由共享描述器拒绝)。这是 Java 对 Rust 的镜像,由 `tests/candidate/CandidateTranslationRouteSmoke.java` 用表驱动用例锁住。账号路径(`candidateTranslationStore` → `BackendTranslationClient`)保持原样;用户服务另有一个 `CandidateTranslationStore`,由 `UserTranslationClient` 请求,路由(含凭据)不可变,变了就整个重建,换服务或凭据会清掉已显示和已缓存的译文,正在逐词请求的旧客户端也随即停下。请求由共享层构造和签名(`msime_client_{custom,niutrans,tencent}_translation_http_request`),回复交回共享层解析,Java 只经 `TranslationHttpTransport` 收发字节:不跟随重定向,只接受 2xx,超时夹在 1 到 10 秒,回复在总时限内按描述器的上限(最多 1 MiB)读完,腾讯和小牛签过名的 `body_utf8` 逐字节原样发送;`check-host.sh` 禁止这两个类里出现任何鉴权或签名请求头。只翻译当前页前 10 个候选里不超过 40 字(按码点计)的中文词,跳过表情和颜文字候选;腾讯每批最多 9 个词,小牛和自定义一词一请求,设置了第二目标语言时每种语言各请求一次。问过的词连同回答(没有译文也算回答)记在 `UserTranslationMemory` 里,同一路由下不再问,键盘重绘作废了还在路上的回答时也直接用记下的,不重复请求;单个词的回复是失败(`msime_client_{custom,niutrans}_translation_reply_failed`)时这个词 30 秒内不再问;整个请求没有回复(连不上、超时、非 2xx,或腾讯回报错)时整个服务退避 30 秒(`UserTranslationMemory`)。记下的回答和退避跟着路由走,不跟着输入会话走:换输入框、收起再弹出键盘时 `stop()` 只把路由暂置为 NONE,偏好重读后还是同一路由就沿用原来的客户端,只有换服务或凭据才丢掉。地址规则与共享层 `is_secure_endpoint` 一致:https,或指向 `localhost`、`127.0.0.1`、`[::1]` 的 http,不能带用户名、密码或片段;设置页按共享层 `msime_client_translation_endpoint_issue` 的 `accepted` 判断能不能存,传输层用 `java.net.URL` 再查一遍这几条边界(`java.net.URI` 会误拒带下划线的主机名和国际化域名);局域网 http 不放行(AI 辅助放行,iOS 连回环 http 也不放行)。离线释义仍然优先,在线译文只补离线没有的行,鸿蒙相反。英文译文还没有像桌面和鸿蒙那样写回用户释义库。 辅助码表不在词库发布里,由仓库自带在 `resources/helpcodes/`:`build-apk.sh` 与 `build-client-apk.sh` 把七套表连同来源声明(`NOTICE.md`、`NOTICE-jiajia.md`、`NOTICE-wubi86.md`)打进 `assets/helpcodes/`。`Bootstrap.prepare` 每次安装包变化后把它们逐个原子替换到 `files/bootstrap/resources/helpcodes/`,Engine 就在资源目录下的这个子目录读辅助码表;共享校验放行真实的 `helpcodes/` 目录,所以首次安装之前就已准备好的配置也在升级后拿到它们,用户自己放进 `helpcodes/custom/` 的表不动。 用户自己的辅助码表从「输入 › 辅助码方案」的「导入辅助码表…」进来(#7176):`TypingPage` 经 SAF 选一个 `.txt`,原始字节超过 1 MiB 就不读,严格按 UTF-8 解码(GBK 直接提示,不交给核心),再调 `NativeClient.plugins` 的 `import_helpcode_table`,由 client-core 按辅助码表包的严格规则校验,装进 `HostStore.directory` 下的 `plugins/helpcode//`(id 以 `custom-` 开头)。它不进资源目录的 `helpcodes/custom/`:那里每次安装包变化后由 `Bootstrap.prepare` 管理,而插件目录正是键盘读的 runtime option `preferences_directory`,设置进程和 `:ime` 进程同时写由 `packs.lock` 互斥。导入成功后写 `plugins.helpcode_pack_` 选中它;选内置方案时把这个键清成空字符串,删除正在用的码表时同样清掉,host-api 在包载入不了时也会退回 `*_helpcode.schema`。选择规则在 `HelpcodeChoicePolicy`,由 `tests/home/HelpcodeChoicePolicySmoke.java` 在 JVM 里测。 触屏键盘和候选栏跟随共享的全局主题 `global_theme`:跟随系统、水杉、浅色、纸白、夜青、墨和自定义,顺序与标题都来自 `msime_client_theme_catalog`,本端不保存主题表。键盘颜色每次由 `msime_client_resolve_theme` 解析,请求带 `layout: "horizontal"`,不带 `skins_directory` 与 `package`(Android 不读取皮肤根目录,自定义主题的外部候选皮肤包因此不参与绘制)。跟随系统绘制设计稿的 Material 3 键盘:浅色底 `#E6EAE2`、白色字母键、`#CFE9D6` 功能键、`#2C7A4B` 强调色,深色一套对应取值;内置主题的明暗固定,解析结果里为空的槽位回落到 Material 3 取值。`screen_keyboard_theme`、`emoji_theme`、`handwriting_theme` 与 `candidate_theme` 各自决定该面板的明暗,`follow` 继承 `theme`,`theme` 为 `system` 时读取 Android 夜间模式。键盘内选择通过共享 revision CAS 写入 `global_theme`,失败恢复最近一次已接受的主题;设置热更新只重新应用视觉样式,不重建 Engine 会话。解析失败时绘制 Material 3 键盘,不把用户设置值当作颜色或资源名直接使用。 命名自定义皮肤图库沿用同一固定 Apple 来源,最多保存 12 套设计,名称去除首尾空白后限制为 32 个扩展字素。图库独立保存到共享状态目录的 `CustomSkins/library.json`,文件上限 9,000,000 字节;照片仍受单张 512,000 字节边界约束。Tauri command 每次在独立文件锁内读取最新文件,再原子执行新建、重命名、用当前设计更新或删除,避免多设置窗口以旧整表覆盖。图库操作不会增加 preferences revision,也不会进入输入法每次读取的热路径;新建设计会把当前页面草稿选择为 `custom`,应用、编辑和选择仍需通过页面底部“保存设置”写入普通共享偏好。损坏或超限文件不会被默认值覆盖。 “我的皮肤”继续使用同一固定 Apple 来源的 `CustomKeyboardSkin`、`SkinKeySurfaceView`、背景绘制和 `CustomSkinEditorView`。设计保存在共享 `custom_theme.keyboard`,保留 Apple 的 camelCase 字段和默认值,颜色限制为 24 位 RGB,圆角、边框、阴影、键帽透明度、纹理强度、照片压暗与位置均按 Apple 范围验证;照片只接受有界 base64 图像,解码后最多 512,000 字节。React 编辑器提供九种背景预设、14 套固定设计模板、渐变方向、照片缩放缩略图、三种纹理、文字对比度提示与优化、四种键帽造型、四种材质、撤销/重做和“使用皮肤”;当前设计随普通设置保存;在键盘里选用“我的皮肤”或已保存设计会同时选中 `custom`,此前显示的主题成为 `custom_theme.base` 并清除 `custom_theme.candidate_skin`。Android 原生键盘不是只显示预览,而是实际绘制照片铺满与压暗、渐变、纹理、卵石/票券/胶囊/圆角轮廓和哑光/立体/玻璃/纸张键帽,并在设计变化后原地重绘。Apple 的最多 12 套命名图库已使用上一段所述独立有界文件,避免把多张照片带入每次键盘读取的偏好;社区下载试用、评分、发布、我的作品范围和下架均已接入同一社区页,账号页可直达本机设计及这些社区集合。 “更多”入口现在使用与 Apple 同层级的全键盘工具页:顶部返回,工具(表情、剪贴板历史、AI 润色、本地输入、语音结果、应用设置)和设置(繁体输出、全角输入、中文标点、按键音、按键振动、振动强度)按设计稿画成每行四个、高 52 dp、圆角 16 的图标磁贴,开启的设置用主题强调色的浅底和强调色文字,关闭的用键帽底色;本地输入为两列 48 dp 卡片并可纵向滚动。选中、启用、禁用和不可用状态通过按钮状态与无障碍描述同步暴露,不再依赖锚定底栏的系统弹出菜单。按键、候选、翻页和面板操作共用反馈路径;按键反馈保存到主应用与 `:ime` 进程共同读取的 `files/bootstrap/state/keyboard-feedback.json`,以临时文件原子替换;文件缺失或损坏时使用默认值;输入法每次显示时重新读取,默认按键音开启、振动关闭。振动强度有轻、中、强和「跟随系统」四档(`keyboard-feedback.json` 的 `hapticStrength` 为 `light`、`medium`、`strong`、`system`,缺省和不认识的值按 `medium`),键盘按键与设置页试听共用 `KeyboardHaptics`:API 30 起设备支持所需原语时用 Composition 原语(轻是 TICK,中、强是不同缩放的 CLICK),API 29 起用预定义效果 TICK / CLICK / HEAVY_CLICK,有振幅控制时用 12–30 ms 的一次振动配三档振幅,都没有时只靠时长区分;三档不带触摸用途 `USAGE_TOUCH`,系统关掉触感反馈时它们照样振,跟系统走的是「跟随系统」那一档。原来一律是 10 ms 加三档振幅,没有振幅控制的马达三档都按满振幅播,有振幅控制的也短到摸不出轻重。「跟随系统」和没有振动器时交给 `View.performHapticFeedback(KEYBOARD_TAP)`,不忽略系统的触感反馈设置。 独立表情浏览器以远端默认分支固定来源 `MSIME-Apple@41c5db42184f505bb889f6efb88cf17d7e58901e` 为行为基线,在空闲候选栏和“更多”工具页提供入口。打开前先由 Engine 完成已有组合,面板提供返回、删除、固定 Unicode 顺序的笑脸/人物/动物/食物/旅行/活动/物品/符号/旗帜分类,以及每行八个的可滚动网格。Android 不复制 SQLite 读取器或内置 1,935 条表情,而是在后台通过共享 host API 以 64 行游标页读取已验证 `msime-others.db`;异常响应、超限字段、停滞或倒退游标会被拒绝。选择通过本地输入来源写入普通 InputConnection,成功后将去重、最近优先且最多 24 项的历史保存到输入法私有偏好;删除也走正常宿主编辑路径。API 35 arm64 专用 AVD 已验证组合完成顺序、分类切换、跨页加载、插入、删除、返回、最近使用和 IME 重绑后的持久化。 “更多”工具页的“本地输入”分组接入共享 `local_modes` 偏好和 Engine 的 Shift 触发契约,按 Apple 顺序提供 Unicode、日期时间、超级简拼、快捷短语、英文补全、表情、颜文字和临时日语入口,末尾是本仓库追加的拆字(Shift+C,输入字的各个部件的拼音找不会读的字,如 huohuohuo 得 焱;候选注释是 Engine 给的带调读音)。拆字的 `local_modes.chaizi` 缺键时在触屏宿主上读作开,和其余几项一样。禁用项或不支持本地工具的五笔/日语方案会置灰;宿主只发送触发字符,不实现本地模式算法。本地模式激活后,候选条显示独立的“退出本地模式”按钮,发送 Engine 的取消命令并保留共享输入状态边界。 键盘工具栏与 Android 设置按 Apple 固定顺序共享全拼 26 键、全拼 9 键、小鹤/自然码/微软/首道双拼、86 五笔、日语 9 键、日语 26 键、手写、韩语 26 键、粤拼 26 键、大千注音、越南语 26 键、藏文 26 键和笔画,最后是本仓库追加的全拼 14 键;默认只有中文方案可见(全拼 26 键、全拼 9 键、四种双拼、五笔和手写);日语 9 键、日语 26 键、韩语 26 键、后五种和全拼 14 键默认不可见,没有存储列表的偏好同样只显示中文方案,需要在设置的输入方案里打开,日语、粤拼、注音和笔画在 full 瘦包里打开时会先下载对应的资源包。`touch_keyboard_schemes.enabled` 控制快捷切换中可见的卡片并至少保留一种,`selected` 保存当前方案;隐藏当前方案时按固定顺序回退到第一种可见方案,设置与输入法进程重启后继续生效。键盘内切换先由 Engine 完成当前组合,再在后台通过共享 PreferencesStore 的 revision CAS 同步 `scheme`、`last_chinese_scheme`、`shuangpin_profile`、平台无关的 `touch_keyboard_layout` 及嵌套方案选择,保存成功后才更新当前会话;冲突或存储失败保留原方案。旧偏好默认全部可见和 26 键,并在第一次键盘内切换时迁移;`View.touch_keyboard_layout` 只报告已应用值,外部设置延迟时不会提前换布局。 全拼 9 键使用与 Apple 相同的分词/ABC–WXYZ 九宫格、常用中文标点,以及右列的删除、重输和 !,并显示 Engine 返回的拼音消歧条:它是一列铺满左侧标点栏的纵向滚动列表(与 iOS、HarmonyOS 一致),有可选项时盖住标点栏,依次是完整音节、下一个数字键上能起头的大写字母(限定下一个音节的首字母)和只在没有锁定音节时出现的数字本身。点候选条右端的展开键时,全拼 9 键组字中的展开面板换成三栏:左栏是同一列拼音(可切成 横 竖 撇 点 折 五个笔画键,顶部显示已选笔画),中间是可纵向滚动的完整候选,右栏是 返回、⌫、重输、拼音/笔画、全部/单字。在面板里选拼音或按 ⌫ 不收起面板,而是按新的一代重新取完整候选;笔画模式下 ⌫ 先撤一笔。全部/单字与笔画筛选经 `msime_client_set_nine_key_filter` 交给 Engine,状态以 `View.nine_key_single_character` 和 `View.nine_key_strokes` 为准;离开笔画模式清掉笔画筛选,面板收起时(组字结束、返回或选了候选)如组字还在就清掉全部筛选。这个面板在只有一页候选时也能打开;其他布局的展开面板不变。纯逻辑在 `NineKeyPanelPolicy`,由 JVM 回归覆盖。左侧标点栏(笔画键盘同样)是可上下滑动的 `NineKeySymbolRail`,一屏五个符号,默认 `, 。 ? 、 : ; …… ~ @`,可在设置「键盘 › 布局 › 九键左侧符号」里改成自己的符号表(本地设置 `platform.android.nine_key_symbols`,不随账号同步),解析与校验在 `NineKeySidebarPolicy`。拼音九键的字母键面没有单独的句点键;笔画键盘最下一行的句点走与其他标点相同的 Engine 策略:中文标点开启时输入 `。`,英文或关闭中文标点时输入 `.`。拼音九键数字键面的小数点见下一段。右列中间的「重输」在组字时丢掉整串数字和选过的拼音,没在组字时什么也不做;组字时 1 键从「@#」换成「分词」,在已打的数字末尾定一个音节分界。长按退格时,若 Engine 正在组字,就一次取消整段组合并立即停止连删,若没有组合才持续删除编辑器正文。短按退格仍只删除一次。2–9 键支持长按弹出该键的数字和小写字母;在设置「键盘 › 手势 › 九键滑动输入数字」选了上滑或下滑后(本地设置 `platform.android.nine_key_swipe`,默认关闭,不随账号同步,和 26 键的「滑动输入符号」分开),沿这个方向滑过「九键滑动距离」松手输入键上印的数字(1 键是 1),往反方向滑弹出和长按相同的选项(`NineKeySwipePolicy`);默认关闭是为了不改变老用户的九键点按。「九键滑动距离」在同一组里(本地设置 `platform.android.nine_key_swipe_distance`,14–48 dp、步长 2、默认 24 dp,不随账号同步),只作用于九键,26 键「滑动输入符号」的阈值仍是 14 dp:九键的键高,快速连打时手指往上带一下就会超过 14 dp,组字中误判时整串拼音会按原始数字上屏(#6208)。选择字面字符前先由 Engine 提交当前组合,再通过普通编辑器路径上屏。数字和拼音选择都进入共享 Engine,拼音选择携带当前 generation,过期选择不会作用于新输入;数字语义严格跟随 Engine 的 `View.nine_key`。九键英文候选按固定来源 `MSIME-Apple@1a0d7194bba6ae1ee4555a7d2866bfb06a9fca6b` 和 `msime-engine@15ff08fc50ff9b469dae0f4bdabeae76c2a66b91` 接入,继续由共享 `mixed_input.english` 与 `minimum_prefix` 控制(设置「表达 › 智能」里的「中英混输」和「触发字母数」,九键按按键数计;拼音一行候选都没有且没有锁定拼音时、或处在英文模式时不受这两项限制),Android 只发送数字、展示并选择 Engine 候选。合成加权词典回归验证完整编码 `65` 的 `ok` 排在更高频前缀词 `old` 前;候选顺序由 Engine 和当前锁定词库的权重决定,Android 只发送数字、展示并选择 Engine 返回的候选。 数字键面固定是 1 2 3 在上,与字母键面同一个行序。26 键按「123」始终是新设计的 123 层(1 到 0 排一行,下面是符号行),韩文两套式和全拼 14 键相同,不借用九键数字键面。共享偏好里的 `touch_number_keypad_order` 和 `touch_twenty_six_key_number_layout` 仍随配置和同步原样保留,但键盘不读它们,设置页也不再列出。全拼 9 键按“符号”切到数字键面时仍然是九键,与 Apple 一致,不再交给 26 键符号页——那会把十列键盘塞进用户特意选的三列布局。九个网格键改印自身数字(含只喂分词符的 1 键),无障碍描述改为“数字 N”,点击以本地输入来源直接上屏数字而不进入拼音会话;数字键面的左栏换成另一张符号表,默认 `+ - * / = % ( ) : @ , 。 ? !`(本地设置 `platform.android.nine_key_digit_symbols`,设置页「九键数字键盘左侧符号」可改),右列从删除、重输、! 换成删除、字面上屏的小数点 `.`(不随中文标点变成「。」)和 `0`,与 iOS、HarmonyOS 的九键右列一致;返回字母键面恢复分词/ABC–WXYZ 键面、长按弹出和原来的标点栏。在数字 / 符号键面上(九键和 26 键都算),光标前末尾是 `12*12` 这样带二元运算的算式时,工具栏品牌键右边出现「= 144」,点按以字面上屏 `=144`(算式后已有 `=` 时只上屏 `144`);`ImeCalculator` 在选区变化和切换键面时读光标前最多 64 个字符,密码框不读,求值在无 Android 依赖的 `ArithmeticResultPolicy`(BigDecimal,加减乘除、括号、`×` `÷` 与全角字符)。两套九键都自带数字键面,因此快捷条的 Shift 在它们的数字键面同样隐藏。键面与描述由无 Android 依赖的 `NineKeyLayout` 提供并在 JVM 回归中验证。 全拼 14 键(触屏方案和 `touch_keyboard_layout` 都是 `fourteen_key`,引擎方案是全拼,默认不启用;设置的「中文键盘」是 26 键 / 14 键 / 9 键三档,选中即启用并切换)把 QWERTY 上相邻两个字母合成一键:`QW ER TY UI OP` / `AS DF GH JK L`(不缩进)/ `[符/分词] ZX CV BN M [⌫]`,底行就是 26 键的设计底行(逗号键连同它的长按常用标点也一样),四行与 26 键等高,两端边键是 26 键的边键宽度。键表在无 Android 依赖的 `FourteenKeyLayout`,与 iOS、HarmonyOS 的 `FourteenKeyLayout` 和引擎 `KeyGrid::FourteenKey` 是同一张表,`scripts/test-fourteen-key-table.py` 核对四处的键、组码和 `Fourteen*` 键位 id。点一下经 `NativeClient.gridKey`(`msime_client_grid_key`)送这一组的首字母,不走 `type()`:硬件键盘的字母仍走 `character()`,是确定的全拼字母,14 键组字进行中则不受理。Engine 按组码网格解码,`View.key_grid` 是 `fourteen_key`(`nine_key` 为假),14 键的判断都看它。组字不写进编辑器,读音行显示 `nine_key_reading`;拼音选择与九键共用键区左侧的纵向侧栏(`SpellingPlacement.SIDEBAR`),空闲时显示符号、有拼音可选时覆盖符号栏,字母键位不会在开始组字时移动;不挤占读音或候选行,也不增加键盘高度,内容与九键左列相同但没有原样数字那一项,点音节锁定、退格先撤销锁定;展开候选是九键的三栏面板。第三排左端键组字时是「分词」(送 `'`),空闲时是「符」(打开符号面板)。两个字母的键长按弹出这两个字母,选中后先结束组字再上屏,L、M 不弹;没有按键气泡、Shift、滑行、数字行、分体、双拼键位提示和角标。英文模式、密码和网址框、本地模式画 26 键,切回中文恢复 14 键,`touch_keyboard_layout` 不变;123 打开 26 键的设计层。组字中回车、点 123 层的字符等走原样上屏(命令 2)的路径时,Engine 结束组字取首选,因为组码只是每组首字母,原样上屏会写出 `bugao` 这种字母。打字统计的来源是「全拼 14 键」(`fourteenKey`),键位 id 是 `FourteenQW` 到 `FourteenM` 共 14 个,分词键记作 `SoftSymbol`。设备验收在 `FourteenKeyDeviceSmoke`,`KeyboardHeightDeviceSmoke` 另有一轮核对 14 键与 26 键等高。 九键双拼(#6572;触屏方案和 `touch_keyboard_layout` 都是 `nine_key_shuangpin`,引擎方案是全拼,默认不启用)在九宫格上每个音节打两位数字:先按声母所在的键,再按韵母所在的键,零声母音节按表给的两位。用哪张表看共享偏好 `nine_key_shuangpin_profile`(米旮旯 3、米旮旯 2、辜氏 2 低重码或自定义),自定义表在 `nine_key_shuangpin_custom_profile`。Engine 按 `KeyGrid::NineKeyShuangpin` 解码,`View.key_grid` 是 `nine_key_shuangpin`(`nine_key` 为假),宿主按它画九键的键面(`touchLayout` 把它当作 `QUANPIN_NINE_KEY_LAYOUT`,本地模式回落 26 键),读音行、左列拼音、三栏面板和 123 数字层都与拼音九键相同,组字不写进编辑器。字母键面是四行:1–9 每个键印着这张表放在它上面的声母(零声母编码从这个键开始时前面加「零」)和韵母,不另印数字,点按经 `character()` 送这个数字;最后一行是「@#」/「分词」键(拼音九键的 1 键常被双拼的韵母占用,所以挪到这里)、0 和逗号,表用到 0 时 0 是组码键,否则原样上屏 0;右列 ⌫ 占两格,再是重输和!。键面不另存键位:`NativeClient.nineKeyShuangpinLayout`(`msime_client_nine_key_shuangpin_layout`)按偏好里的方案和表给出每个键的声母、韵母,请求没变时沿用;拼音九键和九键双拼是同一个触屏布局编号,换方案或换表时由 `ImeLayoutRows.shuangpinStale` 触发重建。九键双拼没有英文九键,键上没有长按字母和滑动数字。设置里「输入 › 普通话」的「九键双拼」子菜单选表(选中即切换到这个键盘),「九键双拼」页(`NineKeyShuangpinPage`)选表、看当前表每个键上的声母韵母,编辑自定义表(从内置方案开始、逐格改数字),经系统文件选择器导入导出同一份 JSON(`{initials, finals, zero_initials}`,键是数字);表的问题由同一个接口带着出错的单位给出,能用时才能保存。打字统计记作「9 键」(`nineKey`),分词键记作 `SoftSymbol`。设备验收在 `NineKeyShuangpinDeviceSmoke`。 日语 9 键复刻 Apple 的三列四行五向假名布局:轻点输入中间假名,向左、上、右、下滑过 12 dp 后抬起选择对应假名。手指一按下就在被按的键上显示不拦截触摸的五向提示(#6716):它只占这个键和四周 4 dp 的方框,按 3×3 排出中间和四个方向的假名,当前方向那格用强调色填满,方框夹在键盘里面,边上的键不会被裁掉,几何在无 Android 依赖的 `JapaneseFlickGuideGeometry`,由 JVM 冒烟覆盖;中间格写的是此刻轻点会打出的假名,同一个键连点切换(あ→い→う)时是下一个,按住超过连点时间窗再松手会从键面上的假名重新开始,提示在那一刻跟着换回来,这个判断在 `JapaneseNineKeyLayout.tapDirection`,与轻点的连点判断共用时间窗,同样由 JVM 冒烟覆盖。两只拇指交替快打时,先按的键松开不会收掉后按、还按着的键的提示。提示浮层收起时是 INVISIBLE,按下、松开不引起键区重新布局。`check-host.sh` 禁止 `ImeLayoutRows` 再用系统长按时长推迟提示;や键提供「」,わ键提供わ/を/ん/ー/〜,第四行提供、。?!…。符号层仍保持三列网格,提供数字、括号和常用日文符号。“小゛゜”只在存在日语组合时启用,直接发送共享 host 命令 10,由 Engine 按当前最后一个假名循环小假名、浊音和半浊音;Android 不保留另一份假名变体表,也不把变体模拟成新输入。数字符号层的同一位置改为括号入口。普通假名仍只把对应罗马字逐字符发送给日语 Engine,不在宿主实现假名组合或转换;长音使用 Engine 接受的 `-` 罗马字。`check-host.sh` 同时锁定命名常量、FFI 的 `CycleKanaVariant` 映射以及宿主不得重新引入变体表。 韩语 26 键是同一输入法里的又一个方案(共享偏好 `scheme = "korean"`,`View.scheme = 4`),不是另一个系统语言。布局只有两套式(Dubeolsik):26 个 QWERTY 键印韩文字母,Shift 把 Q W E R T O P 换成 ㅃ ㅉ ㄸ ㄲ ㅆ ㅒ ㅖ,并以大写字母发送这七个键,其余键在 Shift 下仍发送小写、输入同一个字母;Shift 在韩语键面上是双辅音而不是切到英文,也不进入本地模式。宿主只发送 ASCII 字母,音节自动机归 Engine:组字中的韩文(`View.reading`)用 `setComposingText` 内联标记,`editing_text` 只是按键字母,只用来判断是否在组字;下一个音节开始时,同一次 transition 先上屏上一个音节再标记新的组字。空格、回车、方向键、Home/End、向前删除和数字结束音节后仍按原本的作用交给编辑器(回车照常执行编辑器动作,所以回车键在韩语组字时不显示“确认”);标点固定为半角 ASCII,有组字时与音节一起上屏,空闲时由宿主直接插入,不受中文标点开关与全角输入影响;退格逐个删除字母,收起键盘或离开输入框时完成音节,在别处点选移动光标时音节原样留在文档里。韩语唯一的候选是下文的汉字列表,没有云候选、词条翻译、简繁转换、候选管理和本地工具。硬件键盘上 Shift 决定大小写,Caps Lock 不会把 ㄱ 变成 ㄲ。键面与键值由无 Android 依赖的 `KoreanKeyboardLayout`、内联组字、硬件大小写与汉字列表的按键规则由 `KoreanInputPolicy` 提供并在 JVM 回归中验证;`check-host.sh` 同时锁定方案序号与共享头文件一致,并禁止宿主引入自己的韩文音节组合表。 韩语组字时候选栏标题行出现「漢」按钮,点一下发送共享命令 16(`MSIME_CONVERT_HANJA`,宿主里是 `KoreanInputPolicy.CONVERT_HANJA_COMMAND`,`check-host.sh` 核对它与头文件和 FFI 映射一致),按 libhangul 汉字表的顺序在候选条上列出正在组字的那一个音节的汉字,有训音(훈음)的以小一号的字单独一行显示在汉字下面,不受候选释义与翻译开关影响,始终显示;候选带有释义或翻译(开关打开且有结果)时接在训音下一行。韩语方案下候选条始终为训音预留一行高度(另加释义行),列表打开时不会跳动。训音和释义只是显示文字,选择汉字只上屏汉字本身,按键字母不显示;再点一次关闭列表,列表打开时按钮显示为填充态。硬件键盘上不带修饰键的 F9(与 Linux 宿主相同),以及单独轻按一下右 Ctrl(与 Windows 相同,只在有音节组字时优先于「单击 Ctrl 切换语言」)效果相同;Android 没有韩国键盘汉字键的键码,那个键能否送到这里取决于设备的键盘布局,未在真机核实。只有单独的辅音或元音(如 ㄱ)时没有汉字,按钮和按键都不起作用,音节继续组字,F9 也不交给编辑器。列表打开时:空格、回车提交高亮的汉字(回车键显示「确认」),数字键与点选候选选择对应项,↑/↓、Page Up/Page Down、Tab 与 Home/End 按共享的候选导航设置移动高亮和翻页,←/→ 在箭头导航开启时移动高亮,Esc 和轻按退格只关闭列表、音节继续组字,按住退格或点翻页行的「取消」则与没有列表时一样丢掉音节(宿主连发两次取消,第一次关闭列表);字母关闭列表并继续组字;标点(包括 `-` `=` `[` `]` `,` `.`,此时不翻页也不以词定字)关闭列表并把韩文和标点一起上屏;收起键盘、离开输入框和带 Ctrl/Alt/Meta 的组合键上屏韩文;在编辑器里移动光标时音节原样留在文档里,宿主连发两次取消让 Engine 也丢掉它。汉字候选不学习,没有候选管理菜单。只能转换正在组字的那一个音节,已经上屏的音节不转换。 粤拼 26 键、大千注音和越南语 26 键同样是共享偏好里的方案(`scheme` 分别为 `cantonese`、`zhuyin`、`vietnamese`,`View.scheme` 为 5、6、7,与 `msime_client.h` 的序号由 `check-host.sh` 核对),宿主只发送按键并展示 Engine 的结果,按方案决定的行为集中在镜像 Engine `SchemeType` 谓词的 `InputSchemeTraits`,`scripts/test-scheme-traits-parity.py` 逐方案与 Engine 比对。粤拼用全拼的 26 键布局,候选直接是繁体字,与大千注音一样算作中文方案(会成为 `last_chinese_scheme`),不做简繁转换,也没有释义、本地模式和九键。大千注音只有大千排列:`ZhuyinKeyboardLayout` 的四排键面印注音符号与声调,但每个键发送 Engine 大千表(`crates/engine/src/zhuyin/layout.rs`)读取的 ASCII 键,空格是一声,宿主不保存任何注音组字表(`check-host.sh` 禁止宿主出现注音码位);组字中的 `View.reading` 内联标记,候选只在用户打开列表后出现:候选栏标题行的「選」按钮或硬件键盘的 ↓ 发送共享命令 16(`MSIME_OPEN_CANDIDATE_LIST`),列表中的导航、选择与关闭和韩语汉字列表相同;视图声明为拼写符号的键和 Shift 标点总是交给 Engine,不会被当作候选数字或翻页键,符号页上被大千占用的数字和标点按字面上屏。越南语不是中文方案,切换时保留原来的 `last_chinese_scheme`;Engine 按 Telex/VNI 组词,`editing_text` 内联标记,没有候选列表,标点为半角 ASCII,不受全角开关影响。越南语字母的大小写会带进词里,所以触屏 Shift 是大小写而不是切换语言,硬件键盘按设备给出的字符发送,Caps Lock 生效。注音和越南语在收起键盘、离开输入框或切换方案时上屏组字,光标固定在组字末尾,第一次取消只关闭列表(越南语为退回原始按键)。粤拼和注音依赖不在锁定资源里的语言词典:`build-apk.sh` 与 `build-client-apk.sh` 在 `target/language-dictionaries`(或 `MSIME_LANGUAGE_DICTIONARIES` 指定的目录)同时有 `msime-cantonese.db`/`msime-zhuyin.db` 和对应许可文件时把它们打进 `assets/language-dictionaries/`,`MSIME_REQUIRE_LANGUAGE_DICTIONARIES=1` 要求两者都在;`Bootstrap.prepare` 在安装包更新时把它们解压到 `files/bootstrap/language-dictionaries/`,并通过 `msime_client_refresh_host` 让运行配置记录该目录(`language_dictionaries`)。缺少某个词典时,该方案在键盘方案切换和设置的方案列表中都不显示,但存储的 `touch_keyboard_schemes.enabled` 不变,装上词典后自动恢复。发布的 full APK 是瘦包,不带这些词典:用户在「添加语言」里添加粤语、注音或笔画时,应用下载资源包 `language-dictionaries`(三套词库连同许可文本是一个包)到 `files/bootstrap/state/resource-packs/language-dictionaries/`,host-api 在那里同样找得到。 藏文 26 键是共享偏好里的 `tibetan` 方案(`View.scheme` 为 8,同样由 `check-host.sh` 与 `msime_client.h` 核对),在 26 个 QWERTY 键上按 EWTS(扩展威利转写)输入,不需要词库,所有宿主都提供,和越南语一样默认不启用、不是中文方案(切换时保留原来的 `last_chinese_scheme`),不做简繁转换,没有释义、本地模式、九键和候选列表,标点为半角 ASCII,不受中文标点和全角开关影响,打字统计里单独算作「藏文模式」。威利转写的大小写是拼写的一部分(`T` `D` `N` `Sh` `A` `I` `U` `M` `H` 等),所以与越南语相同:触屏 Shift 是大小写而不是切换语言,键面显示大小写,硬件键盘按设备给出的字符发送,Caps Lock 生效。转换全部由 Engine 完成:组字保存当前音节串的威利原文,内联标记的 `editing_text` 是转换后的藏文;`'` `+` `.` `-` `/` 是视图 `spelling_symbols` 里的拼写符号,宿主照常把它们作为字符或标点交给 Engine,不自己插入。组字时空格上屏藏文加音节点 `་`,`/` 上屏藏文加垂符 `།`,回车只上屏藏文、不加音节点,这三个键都由 Engine 处理,宿主不再插入空格、斜杠或换行,所以组字时回车键显示「确认」;没有组字时 `/` 单独上屏垂符,空格和回车照常。退格删掉一个原文按键,第一次取消把显示退回威利原文,收起键盘、离开输入框或切换方案时上屏组字,光标固定在组字末尾。宿主不保存任何藏文转换表,`check-host.sh` 禁止宿主源码出现藏文码位。藏文方案下触屏符号页第三排的 `=` 换成 `+`(`KeyboardLayout.symbolRowKey`),用于梵文叠字(如 `pad+ma`),照常作为字符交给 Engine;符号面板会先上屏组字,不能用来叠写;数字保持原样,不转成藏文数字。 笔画(共享偏好 `scheme = "stroke"`,`View.scheme = 9`,由 `check-host.sh` 与 `msime_client.h` 的 `9 stroke` 核对)是第五种默认不可见的方案,读取语言词典里的 `msime-stroke.db`,与粤拼、注音一样只在词库装好(随包或下载的资源包)时出现在键盘方案切换和设置的方案列表里,也同样算作中文方案(会成为 `last_chinese_scheme`),不做简繁转换,没有释义、本地模式和候选管理菜单,候选只读、不学习。笔画方案不论偏好是 26 键还是 9 键都画自己的笔画键盘(偏好为手写时仍是手写面板):九键的外框不变,左侧是常用中文标点栏,右侧是删除、句点和 0,中间换成 `StrokeKeyboardLayout` 的 2×3 网格「一 横、丨 竖、丿 撇 / 丶 点、乛 折、* 通配」,分别发送字母 h s p n z x,经 `character()` 直接交给 Engine,不经过标点和大小写路径;宿主不保存笔顺码表,也不自己打开 `msime-stroke.db`(`check-host.sh` 守着这两条)。没有组字时 Engine 不从通配开始组字,所以通配键在空组合时置灰。组字中输入框内联标记的是 `View.reading` 里的笔画字形(一丨丿丶乛*),不是 `editing_text` 里的字母;回车按 Engine 契约上屏键入的字母,空格选高亮候选,离开输入框不上屏组字。笔画字形不是用户写下的文字,所以在输入框里点到别处或会话结束时,`EditorBridge.discard` 删掉标记的组字区域(并把用户点到的选区按删掉的长度挪回原位),而不是像注音、韩文那样 `finishComposingText` 把它留在文档里。数字页与手写一样交给 26 键符号页。`build-apk.sh` 与 `build-client-apk.sh` 把 `msime-stroke.db` 与 `msime-rime_stroke_LICENSE.txt` 和粤拼、注音词典一起打进 `assets/language-dictionaries/`,`MSIME_REQUIRE_LANGUAGE_DICTIONARIES=1` 要求的是 `resources/language-dictionaries.lock.json` 固定的全部词库(`fetch_language_dictionaries.py --list-databases`),锁固定 `msime-stroke.db` 之前笔画词库存在就打包、缺少也不让发版失败;`build-native.sh` 把 rime-stroke 的 LGPL-3.0 许可随引擎声明一起分发。 手写提供 Android 原生画布和平台识别器注入边界:画布限制 64 笔、每笔 512 个采样点,支持单笔撤销、清空、坐标夹取和尺寸变化失效;识别请求复制不可变笔画快照,并以 session/revision/generation 拒绝过期结果,候选去重后最多 12 项。原生宿主与 Tauri 合包复用 `platforms/android` 中同一套 ML Kit Digital Ink Recognition 19.0.0 适配器和 `zh-Hani-CN` 模型,执行模型检查/下载、书写区域与时间戳笔画转换;专用 provider 在隔离的 `:ime` 进程中初始化 ML Kit 与其下载任务,设置主进程是否存活不影响手写。缺少适配器的源码级测试仍通过注入边界安全回退。与固定 Apple 来源 `MSIME-Apple@13cb320eb4ef662bbce0be3c73a5f34d68e86d80` 的平台取舍一致,Android 原生构建明确排除未调用的 Engine zinnia 识别器及其模型路径,桌面宿主继续保留共享 Engine 离线手写后备。手写方案、键盘内候选确认、无墨迹删除及退出清理已接入;有墨迹且候选已就绪时,软/硬件空格与回车确认第一候选,硬件退格和底部退格优先撤销最后一笔。专用 API 35 AVD 验证模型下载或既有模型就绪、真实触摸笔迹识别、第一候选确认、符号层往返和方案恢复。 Android 14 起可以拿手写笔直接在应用的输入框上写字(#7080,系统的输入法手写笔接口):full、拼音和五笔版的 `method.xml` 与 Tauri 合包的 `tauri_method.xml` 声明 `android:supportsStylusHandwriting="true"`,日文、越南文和藏文版没有中文手写,不声明(`scripts/test-editions.py` 按版本表的 `features.handwriting` 核对两边)。系统问要不要接时(`onStartStylusHandwriting`),API 34 以下、不走引擎的输入框(密码、数字)、专用英文、日韩越藏方案和本地模式一律不接,笔照常交给应用;规则在无 Android 依赖的 `StylusHandwritingPolicy`,由 `StylusHandwritingPolicySmoke` 覆盖。接下后键盘不弹出,笔迹画在系统的书写窗口里铺满屏幕的透明 `StylusInkView` 上:笔的事件由 `onStylusHandwritingMotionEvent` 直接交给它、按屏幕坐标记录(系统默认的「视图可见后补发」在同一窗口第二次书写时不再补发),书写窗口沿用输入法的浮动主题会被系统改成 wrap_content,接下时再交给 WindowManager 铺满;颜色和粗细跟手写设置,「跟随皮肤」时用皮肤的强调色(键帽文字色在深色皮肤下是白的,画在浅色应用上看不见);停笔后按手写面板的识别等待时间(服务防抖 550 ms,设置更长时取设置值,满 64 笔立即识别)整行识别,书写区域是笔迹外接框四周留 8 dp、宽高至少 64 dp,光标前最多 20 个字作上文,结果直接上屏第一候选:有组字时先按首选结束,经简繁转换,以手写来源计入统计。笔停 3 秒系统结束这次会话,笔回到应用;还没识别的笔迹照常识别上屏,下一次书写接着写的笔迹会和它们一起重新识别。识别器与手写面板各用一个(`ImeStylusHandwriting` 持有自己的,进程退出时关),这里不下载模型:没下载时接下这次书写,只在落笔处显示「首次下载后可离线手写:请在键盘的手写面板里下载中文手写模型」,2.5 秒后结束会话,同一个输入框只提示一次,之后不再接;这样做是因为键盘没弹出时 Toast 在 Android 13 起常被系统吞掉。到了该识别时模型还在检查的,最多再等 1 秒,之后丢掉笔迹并提示「正在检查中文手写模型,请稍后再写」,这条不算提示过。iPadOS 的 Apple Pencil 随手写由系统处理,不经过键盘扩展;鸿蒙尚未评估。模拟器上可以用 `adb shell input stylus swipe` 注入手写笔轨迹,真机验收需要 Android 14 以上、带主动式手写笔的设备。 中文候选在支持个人词典管理的方案中提供与固定 Apple 来源一致的长按菜单顺序:优先显示、固定到首位、取消固定和删除词条;删除收进「删除词条…」的子菜单,点开当场展开「确认删除此词条」(与 iOS 的嵌套子菜单同款),不弹系统对话框,删除后只刷新候选列表、键盘保持打开。固定位置通过共享 host API 限制为 1–5,本界面固定到首位时只传入位置 1。候选身份仍由 Engine 返回的 session/generation/index 传入 JNI,generation 或候选身份过期后不会修改当前会话;五笔、日语和本地输入模式不展示管理菜单。 候选 UI 现在消费共享的 `candidate_font_size`、`candidate_preedit_font_size`、`candidate_font_family`、`candidate_english_font`、`candidate_fallback_fonts` 和 `candidate_theme`;候选栏是键盘顶部的一条,按全局主题解析出的键盘调色板(以 `candidate_theme` 的明暗解析)渲染普通候选栏和展开面板,选中候选用强调色加粗,候选按钮、预编辑、页码和英文建议使用设置中的首选字体,缺字回落交给 Android 系统字体链。非法主题、颜色和字体安全回退。自定义主题的候选颜色选择器 `custom_theme.candidate_colors` 只作用于桌面候选窗,Android 候选栏跟随键盘调色板。共享的 `candidate_layout` 只描述桌面候选窗:触屏候选条只有一行高,和 iOS 一样始终横排,释义在候选下面另起一行,更多候选在展开面板里看。偏好热更新成功后立即调整候选字号、字体和调色板,不重建 Engine 会话。字号只接受核心偏好允许的 12–32 范围,字体名称遵循共享的 128 UTF-8 字节和控制字符边界。 横向候选条在 Engine session、generation 或候选页变化时回到当前页首项;这样翻页或新组字不会沿用上一页的横向偏移,把用户带到旧列表的中段。仅释义/翻译等同一代次的显示重绘保留用户当前滚动位置。候选身份仍由共享 session/generation/index 决定,Android 不自行排序或改写候选内容。 纵向候选布局也使用同一条滚动身份围栏:切换 session、generation 或 page 时同时回到纵向列表首项;同代次的候选附加信息重绘不打断用户位置。 候选词本身始终保持单行完整显示:候选按钮关闭 Android TextView 的自动省略与折行,横向候选条由外层滚动承载超长词,展开面板由候选布局负责换行候选格而不是在格内拆词。释义仍是候选的附加显示数据,不改变候选文字或选择索引。 候选 chip 的行数按当前候选实际渲染出的释义文本决定,而不是按全局请求的语言数量猜测:没有释义或只有一行释义时保持单行;只有 annotation 实际包含换行时才保留第二行。韩语汉字候选例外:训音和其后的每一行释义都单独成行,行数为 1 加次要文本的行数。这样某个候选缺少次要语言释义时,候选词不会被错误折行。 普通触屏候选 chip 不把 1、2、3 等序号画进候选文字;序号只通过无障碍描述提供,并继续作为硬件数字行选择的槽位。这样长按、点击和数字行选择共享同一候选身份,同时保持 Apple 触屏候选的纯文字外观。 Tauri/React 共享设置页现在按 Android 原生能力展示候选字体、字号、颜色和边框/悬停控件;Android 不枚举桌面系统字体,字体框允许用户输入完整字体名,保存后由输入法进程消费。这样设置页不会把 Android 未实现的桌面字体目录能力伪装成可用功能。 “更多”工具页提供键盘内剪贴板历史面板。和 Gboard 一样,开启 `clipboard_history` 后,水杉作为当前输入法时通过 `ClipboardManager.addPrimaryClipChangedListener` 在每次复制后自动记下文本,打开面板时再补读一次(覆盖键盘进程没在运行时的那次复制);隐私模式、密码类输入框和系统标记为敏感(`ClipDescription.EXTRA_IS_SENSITIVE`)的内容不记。Apple 键盘扩展读剪贴板会触发系统提示,所以只能手动保存,Android 没有这个限制。最多保存 50 条,支持去重、固定、删除、确认清空和点按插入。面板顶行在分段和「清空」之间居中显示已存条数和上限(如 10/50,云端按服务端的 50 条上限;历史未开启、读取失败或云端不可用时不显示)。长按一条后可以「添加到常用语」,经 `CommonPhrasesStore` 存成无编码常用语,回车先换成换行,已经有这条、常用语已满、超过 1000 字或含制表符等常用语不能保存的字符时给出对应提示。本机条目向左滑动露出删除按钮,再点一下才删除(置顶条目同样可以),手势判定在无 Android 依赖的 `ClipboardSwipePolicy`。长按「编辑」打开应用里的编辑页(`ClipboardEditPage`),保存后经共享存储的 `replace` 原地替换这一条,时间戳和固定状态不变,改成已有的文字时两条合并成一条。有记录时本机分段顶行还有「搜索」,打开应用里可搜索的剪贴板历史页(`ClipboardSearchPage`,设置首页搜「剪贴板历史」也能找到):顶部搜索框按 `Locale.ROOT` 不区分大小写做子串匹配,结果保留原顺序(置顶在前),空查询显示全部,筛选规则在无 Android 依赖的 `ClipboardSearchPolicy`;点按一条复制回系统剪贴板(键盘在别的应用里打开时随即回到那个应用),行尾可以编辑或删除;`clipboard_history` 关着时这一页不列记录,并当场清空本机历史。只搜本机历史,键盘里不放查询框。历史放在输入法私有偏好中,应用禁用备份且不记录内容;共享 `clipboard_history` 关闭时立即清空本机历史;此时入口只在可以显示「云端」的输入框中保留,并直接打开「云端」。非文本、空白或超过 10,000 UTF-16 单元/40,000 UTF-8 字节的内容不会保存。 Tauri 合包的 Android 账号页通过共享云剪贴板面板使用账号会话访问 HTTPS 云端接口;不自动读取系统剪贴板,只有用户明确在面板中添加文本时才上传。文本、64 位小写 hex ID、更新时间和分页均由共享 Rust/host 与 Android transport 做边界校验,单页最多 50 条,搜索在本机解密后进行。面板的端到端加密由 Rust 驱动完成,密钥串经 `AccountPlugin` 的 `loadCloudClipboardKeystore`(返回 `{"value": string|null}`)和 `saveCloudClipboardKeystore`(参数 `{"value": string}`)读写,与键盘和原生页同一个文件(见下文);`clearSession` 退出登录时一并删除它。面板提供启用开关、搜索、添加、删除和复制操作,复制通过 Android `ClipboardManager` 写入系统剪贴板,不尝试桌面输入目标注入。独立原生 APK 保留同等能力的原生 fallback 页面。关闭云剪贴板会删除云端历史;账号会话、云端内容和系统剪贴板均不写入日志。 原生 fallback 页面每次读取时绑定当前登录的会话 ID,并在重载开始时清掉旧列表。置顶、删除、清空、开关和保留时长的请求以及点按复制都会复核该会话;退出登录或重新登录后,旧页面不能操作新账号的剪贴板。 云剪贴板端到端加密(协议见 `docs/cloud-clipboard-e2e.md`),只有这一种模式:原先的明文上传、明文列表和服务端 `?q=` 搜索都已去掉。流程只在共享 Rust 驱动里有一份,经 JNI `NativeClient.cloudClipboardE2e`(`msime_client_cloud_clipboard_e2e`,请求上限 2 MiB)调用;宿主一侧的循环是 `account/CloudClipboardE2e`:带账号凭据发出驱动要的请求,任何 HTTP 状态都原样交回驱动(连不上服务器才由传输层报错),驱动交来新的密钥串就在发下一个请求之前保存,驱动的错误码(`clipboard_device_pending`、`clipboard_key_conflict`、`clipboard_sync_disabled` 等)原样交给调用方。原生页(`CloudClipboardApi`,经 `CloudApi.exchange`)、账号页的条数和键盘(`BackendAccount`)共用它;置顶、保留时长、删除、清空和开关不涉及内容,仍直接调用原来的接口。交回驱动的响应正文上限 1.5 MiB,放得下 50 条最长的密文。 密钥串(本机设备私钥和当前剪贴板密钥)存在应用私有目录的 `cloud-clipboard-keystore`,用 AndroidKeyStore 里不导出的 AES-GCM 密钥加密,做法与账号会话相同(`account/CloudClipboardKeystore`)。主进程(原生页、账号页、Tauri 插件)和 `:ime` 进程(键盘)直接读写这一个文件,不经过 provider:每次读、写、删都先拿进程内的监视器,再拿 `cloud-clipboard-keystore.lock` 上的文件锁,写入是临时文件落盘后原子改名;不用 SharedPreferences,因为它在每个进程各缓存一份。一个安装只有一个设备身份:文件里已有解得开的身份时,写入另一个身份一律被拒,第一次使用时两个进程各自生成了身份的话,后写的一方在发出任何请求之前就改用文件里的身份重来,所以服务端只登记一台设备;写入还在锁里核对开始这次操作的登录仍在,退出登录之后迟到的一步不会把文件写回来。退出登录和服务端吊销会话(`BackendAccount.signOut`、刷新被拒、Tauri 的 `clearSession`)都在清掉会话之后删除它,下一个账号拿不到上一个账号的剪贴板密钥。应用禁用备份,AndroidKeyStore 的密钥也不随备份走,文件解不开时按没有处理并生成新身份。 原生页「我的 → 云剪贴板」:本机等待允许时显示本机的 16 位核对码、去另一台设备「允许」的说明和「重置」;本机能读时先列「等待允许」的其他设备(名称、申请时间、核对码,「允许」前在确认框里再给出核对码,提示「核对两台设备上的核对码一致再允许」,「拒绝」删掉它的登记),再是「最近」列表(本机解不开的条数写在列表下方),最后是「设备」列表,其他设备可以「移除」,末尾一行「重置云剪贴板」。移除和重置的确认框都写明:会换一把只交给本机的新密钥,云端现有记录全部删除,其他设备都要在本机上重新允许。设备名取厂商加型号(`Build.MANUFACTURER` + `Build.MODEL`,如「Google Pixel 8」),不读用户自己起的设备名。键盘的「云端」在本机等待允许时只显示一句「这台设备等待允许,请在已同步的设备上打开云剪贴板设置允许」,不列条目,「发到云剪贴板」也不可用。宿主循环和密钥串存储由 JVM 冒烟 `tests/core/CloudClipboardE2eSmoke.java`、`tests/core/CloudClipboardKeystoreSmoke.java` 覆盖,AndroidKeyStore 加密落盘由设备冒烟 `CloudClipboardKeystoreDeviceSmoke`(`smoke.sh --settings`)覆盖。 键盘内剪贴板面板在「本机」之外提供「云端」:每次打开面板(以及点按「刷新」)时,在独立 `:ime` 进程的后台线程里用同一份账号会话读取云剪贴板列表,点按条目直接插入当前输入框,不经过系统剪贴板;没有轮询或推送,也不自动读取或上传系统剪贴板。未登录显示「登录水杉账号后可在设备间同步剪贴板」,服务端未开启显示「云剪贴板未开启」,本机等待允许时显示等待允许的那一句,其他失败提示刷新重试。本机条目的「管理」菜单提供「发到云剪贴板」,只有本次打开面板的读取确认已登录、服务端 `enabled` 为真、本机已被允许,且文本满足 4,000 UTF-16 单元边界时才可用。密码框(含数字 PIN)和要求不做个性化学习的输入框(无痕标签页等)不显示「云端」、不发起请求,也不提供上传;切换输入框、收起键盘或关闭面板都会递增代次并丢弃尚未返回的结果,云端条目只在面板打开期间留在内存中。字段判定、状态文案和上传条件集中在 `policy/CloudClipboardPanelPolicy`,由 JVM 冒烟 `tests/keyboard/CloudClipboardPanelPolicySmoke.java` 覆盖。服务端每次刷新都会轮换 refresh token,重复出示用过的 refresh token 会吊销整个会话,所以只有主进程读写、刷新 Keystore 加密的账号会话:`:ime` 进程(云剪贴板、候选翻译)通过主进程中不导出的 `AccountSessionProvider`(`${applicationId}.account-session`)以 `ContentResolver.call` 取当前 access token,刷新在主进程内由既有锁串行完成;provider 只回应本 uid 的 `access_token` 调用,回复带 `state`(已登录 / 未登录 / 暂不可用),网络或刷新失败时键盘提示重试而不是登录,令牌不写日志。两个包的登录落盘不同:原生 APK 由 `BackendAccount` 写 `msime_account_session_v2` 并在主进程刷新;Tauri 合包由 Rust 客户端经 `AccountPlugin` 写 `msime_account_session_v1` 并自行刷新。provider 优先用 v2 会话;没有 v2 时只读 v1 中距过期超过 30 秒的 access token,绝不在 Java 侧刷新 v1(否则会在 Rust 不知情时轮换 refresh token 并导致会话被吊销),v1 令牌将过期时回复暂不可用。进程归属判定在 `policy/AccountSessionRoutingPolicy`,由 JVM 冒烟 `tests/settings/AccountSessionRoutingSmoke.java` 覆盖;登录与退出仍只在主应用进程进行。 云剪贴板可以改走用户自建的中继(`docs/cloud-clipboard-relay.md`):「我的 → 云剪贴板」页顶部的「云剪贴板同步方式」选「自建中继」,在对话框里填地址和令牌,按共享规则(`clipboard/CloudClipboardRelayPolicy`,与 Rust 和共享设置页一致)当场校验,点「保存」才写进共享偏好的 `cloud_clipboard_relay`;令牌框不回显已存的令牌,「改回水杉账号」删掉这个键。设置页、键盘「云端」的列表、插入和「发到云剪贴板」都先经 JNI 问 `msime_client_cloud_clipboard_source`,选了中继就只走中继(`account/CloudClipboardRelay`):端到端加密的操作与账号路径同一个 Java 循环(`CloudClipboardE2e.run`),每个请求经 `msime_client_relay_cloud_clipboard_exchange` 由 Rust 发出,开关、置顶、保留、删除、清空经 `msime_client_relay_cloud_clipboard`,中继出错也不退回账号,`relay_unauthorized` 显示为令牌被拒而不是未登录。键盘在主线程上只看内存里的偏好快照,用地址和令牌的摘要(`relay:` 开头)代替账号编号认出条目属于哪个中继,后台线程发请求前再问一次共享层,两边对不上就按失败处理。中继的密钥串在 `noBackupFilesDir/cloud-clipboard-relay/`,主进程和 `:ime` 进程共用,与账号那份一样由 `CloudClipboardKeystore` 用 AndroidKeyStore 加密(另一把密钥,`forRelay`),不进云备份和设备间迁移。规则和状态映射由 JVM 冒烟 `tests/settings/CloudClipboardRelayPolicySmoke.java` 覆盖,`tests/device/settings/CloudClipboardRelayDeviceSmoke.java` 在设备上经 JNI 对本进程里的回环中继跑登记、批准、加密上传、列表、置顶、删除和令牌被拒、连不上,并确认设置页和键盘写的密钥串是加密的。Tauri 合包里共享设置页和面板在配置了中继时拒绝收发,不走账号。 Android 账号设置中的云词库现通过同一认证会话访问 HTTPS API,内联提供四类词库的查询、分页、添加、编辑、删除、UTF-8 TSV/汉字自动注音导入和标准/Windows 导出;“完整目录”页按编码、全拼/双拼方案查询基础词库与账号覆盖,支持分页、编辑和显式删除;“云端候选排序”页按全拼/双拼/简拼查询候选,支持 canonical pinyin 调频、固定位置、取消固定和删除;云词库面板还提供快照导出、校验、恢复到云端,以及下载并在输入法空闲边界应用到本机的流程。请求、响应、64 位小写 hex ID、权重、格式、文本、编码方案、候选调频参数、固定位置和页长由共享 Rust/host 边界验证。导出文件只在用户点击导出时取得,不自动把云词库合并到本机。账号会话和云词库内容不写入日志。 个人词库设置同时提供 Apple 对齐的 `msime-personal-dictionary` JSON 导入:文件最多 1 MiB、每次 1–128 条,支持拼音、五笔、快捷短语和英文,页面先在本地校验格式、编码、重复项和长度并展示预览,用户确认后才加入 Android 与 `:ime` 进程共享的逐条同步队列。导入文件不上传;同步失败的条目沿用现有重试/移除记录机制,Engine 仍负责最终规范化和应用。 配置缺失、原生库不可用或输入连接错误会显示状态并退回直接输入。服务从应用私有 files 目录读取 `runtime-options.json`,路径必须指向已在设备上准备的词库与私有用户目录,不能复制 macOS 的配置路径。开发 APK 的启动页提供首次资源准备。 本地检查:`ANDROID_SDK_ROOT= bash platforms/android/check-host.sh`。需要 JDK 17+、Android API 35 和 build-tools 35.0.0,以及 `rg`(缺它会让脚本里所有 `if rg` 守卫静默通过,因此脚本直接拒绝运行)。脚本先逐条比对 Android 侧与 `crates/host-api/src/ffi/input.rs` 的共享命令契约,再用 `javac --release 17 -Xlint:all -Werror` 编译不依赖 Android 框架的那部分宿主 Java(排除 `java/app/msime/android/home/` 与任何 import AndroidX/Material 的文件——那些是 AAR,只有 Gradle 能解析),执行 99 个不依赖 Android 运行时的策略冒烟,并用 `aapt2 compile` 校验 manifest/resource;中间资源包随临时目录清理,不作为 APK 交付。 JVM 冒烟只覆盖无 Android 依赖的策略层;焦点、选区、编辑器动作和进程生命周期由下面「专用模拟器验收」一节的 instrumentation 覆盖。React 设置页的 Android 合包与验证见下文。 图标与键盘页面入口另用 `ANDROID_SDK_ROOT= bash platforms/android/tests/device/host-launch.sh emulator-5566`(API 35 换 `emulator-5580`)验收已安装的原生宿主;拼音版在末尾加 `app.msime.android.pinyin`。它逐一切换五种图标,检查真实宿主保持启用且只有一个 launcher,从独立编辑器 UID 实际点击设置、词库、反馈、关于,并保持设置页可见十五秒以覆盖组件变更的延迟广播。准备阶段和外部页面验收分开运行,结束后恢复原图标、首次引导标志、默认输入法及临时禁用的系统键盘。该回归可在 API 28 上单独执行,不受 `smoke.sh --core` 跳过其他面板套件的影响。 `EmptyEditorBackspaceDeviceSmoke` 使用独立合成引用编辑器检查删除键:空框交给应用移除艾特/引用,有文字按码位删除,组字与选区先处理,读取失败不当作空框。API 35 的 `smoke.sh` 默认注册它;API 28 可在安装原生宿主与 `editor-test.apk`、启用被测输入法后单独执行 `adb -s emulator-5566 shell am instrument -w app.msime.android.test/app.msime.android.test.EmptyEditorBackspaceDeviceSmoke`。它验证系统 InputConnection 契约,QQ 等第三方应用仍需各自验收。 剪贴板编辑从外部应用进入宿主的回归用 `ANDROID_SDK_ROOT= bash platforms/android/tests/device/clipboard-edit.sh emulator-5566`(API 35 换 `emulator-5580`),默认验收已安装的拼音版,完整版在末尾加 `app.msime.android`。它在经典、林绿图标下长按真实历史卡片,检查长记录完整预填、页面保持十五秒、保存后返回外部编辑器、重新插入和取消;最后核对条目时间戳与固定状态,并恢复测试前的历史、偏好、图标、引导状态、系统剪贴板和默认输入法。准备阶段与页面操作分属宿主及独立编辑器 UID,避免宿主 instrumentation 绕过启动条件。 ## 两个 APK 入口,不要选错 `platforms/android` 有两个构建脚本,同一个版本的产物**同名同路径**(full 是 `target/android/msime-android.apk`,其他版本是 `target/android/msime-android-.apk`,见上节),装到设备上也是同一个包名,但内容完全不同: - `build-apk.sh <已锁定词库目录>` —— 本目录自己的原生 IME 包。Java 宿主来自 `platforms/android/java`,manifest 是 `platforms/android/AndroidManifest.xml`,图标是 `platforms/android/res/drawable/app_icon_*`,不含任何 Tauri/WebView/React。装真机验证本目录的改动用这个。 - `build-client-apk.sh <已锁定词库目录> [abi]` —— Tauri 合包。走 Gradle,从 `apps/desktop/src-tauri/gen/android/` 构建,把原生 IME 和 React 设置界面装进同一个包。 下面「Tauri + React 共享设置合包」一节里的「推荐本地构建入口」只针对需要管理 UI 的场景,不是默认入口。要改、要验、要装本目录的原生宿主,就用 `build-apk.sh`。 两者的资源来源也不同,改图标时尤其要认清:合包的 Gradle 用 `res.setSrcDirs` 覆盖了默认目录,取的是 `src/main/res-msime`、`platforms/android/res` 和 `apps/desktop/src-tauri/icons/android` 三处——`gen/android/app/src/main/res` 下那份 mipmap 不参与构建。启动器图标在合包里来自 `apps/desktop/src-tauri/icons/android`(adaptive icon,五个主题各一个 background 颜色),在原生包里来自 `platforms/android/res/drawable/app_icon_*`。改了一处不等于另一处也改了。 ## Tauri + React 共享设置合包 需要管理 UI 时的本地构建入口(不是本目录的默认入口,见上节):`ANDROID_SDK_ROOT= bash platforms/android/build-client-apk.sh <已锁定词库目录> [arm64-v8a|x86_64]`。默认 arm64-v8a,产物仍为 target/android/msime-android.apk;`MSIME_EDITION=` 选版本,规则同 `build-apk.sh`,Tauri 工程经 `ORG_GRADLE_PROJECT_msimeEdition` 换 applicationId、BuildConfig 和应用名(这个工程的 abi 维度归 Tauri 插件,所以版本不做成 flavor);需要先完成根目录 pnpm install --frozen-lockfile,准备 JDK 21、Android API 36、build-tools 35、固定 NDK/vcpkg 与 Rust Android target。Gradle 8.14.3 使用官方分发摘要固定,AGP/Kotlin 版本由项目固定。参数 --ci 仅用于 Tauri CLI 的非交互模式,不运行 GitHub CI。 apps/desktop/src-tauri/src/lib.rs 是桌面与移动共用的 Tauri commands/入口,Android 调用同一个 client-core PreferencesStore,指向应用私有 files/bootstrap/state,与 bootstrap 和 IME 监控目录一致。packages/ui 的 React 页没有 Android 副本。生成的 Android 工程已纳入源码,Gradle 直接引用 platforms/android/java、共享图标与暂存的锁定资源;不把原生宿主代码复制到 gen。不要重复执行 tauri android init 覆盖本仓定制。受版本控制的 Gradle 设置会从 `TAURI_ANDROID_DIR` 或 Cargo registry 定位锁定 Tauri Android 工程,合包脚本通过 `cargo metadata --locked` 注入精确路径;生成 Kotlin 绑定、native symlink、构建输出和本机配置仍忽略。 Android“我的”页通过 Rust account session 访问固定的 https://api.msime.app 账号服务。邮箱和手机号验证码、刷新、资料更新、退出及注销请求都在原生宿主内完成,WebView 只接收不含凭据的用户和 provider DTO。会话 JSON 由包私有 Android Keystore AES-GCM 密钥加密后写入 SharedPreferences,密钥和密文不跨应用包共享;请求不跟随重定向,普通 JSON 请求和响应均限制为 1 MiB。日常输入不需要登录,账号登录不会上传本地输入或统计。页面同时提供 Apple 对齐的五种 App 图标选择;Android 通过 launcher `activity-alias` 持久化系统选择,切换只启用目标入口并保留原版回退,不进入设置同步或账号云端数据。 Android“社区”页以 Apple 远端默认分支 `develop` 的固定来源 `MSIME-Apple@9ca823ab40018ced3cb71812503dbc3b94615ac0` 为浏览基线,提供公开皮肤双列列表、原样 UTF-8 搜索、分页去重和详情预览。匿名用户可直接浏览;已有账号会话时请求携带同一 Bearer token,使服务返回“我的作品”和个人评分状态,401 只刷新一次,账号在请求期间变化会废弃结果。登录后可下载:设计以社区 UUID 稳定保存到本地最多 12 项图库,写入前持久化原皮肤和试用记录,主应用重启时恢复未完成试用;详情页支持恢复原皮肤、保留使用及下载后的 1–5 星评分,自己的作品隐藏评分入口。发布侧提供本地命名皮肤上传、公开素材权利确认、失败安全重试、全部/我的作品范围切换和作者下架;下架沿用后端语义,已下载的本地副本保留。Rust transport 对 offset、查询、页长、UUID、文本、评分、发布和完整设计做边界校验,WebView 只接收稳定脱敏错误码。 Apple 的社区资源入口已迁移到同一页的“皮肤 / 词库 / 回复”分类:词库与回复资源支持公开列表、原样 UTF-8 搜索、全部/收藏/我的作品范围、详情预览、收藏、评分、发布新版本和作者下架;词库可显式导入本机词库或在账号版本检查后合并到云词库,回复模板只有用户明确添加后才写入应用私有 `files/CommunityLibrary.json`,由独立 `:ime` 进程重新读取。资源内容、分页、版本、权重、提示词和本地模板库均有大小、数量、控制字符、重复项及稳定错误码边界;发布前要求确认公开权利。账号页提供已发布皮肤、已发布资源和收藏资源的直达入口。 应用首次启动、缺少运行配置时由宿主自己准备词库:两个 launcher(原生宿主的 HomeActivity 与合包的 MainActivity)都调用幂等的 `FirstRunPreparation.startIfNeeded`,已有配置只报告不覆盖。准备中与失败在“设置”页顶部显示,失败可点击重试;就绪时不占屏幕。原先那个只有一排系统按钮的 SetupActivity 开发页已删除——把唯一的词库准备入口藏在一个用户找不到的脚手架页后面,等于允许键盘停在够不到 Engine 的状态。切换输入法的入口在“对话”标签页(试用键盘)和“设置”页状态卡的“设为默认”检查项,系统输入法设置在“设置”页的“系统输入法设置”行。不会自动启用或选择输入法。系统输入法设置入口也可打开共享设置页。Tauri 使用主进程,InputMethodService 使用同 UID 的独立 :ime 进程,通过文件锁和 revision 协作,不依赖设置窗口存活。这样 Tauri 退出最后一个窗口不会结束输入服务;不是通过让隐藏设置窗口常驻来维持输入。 原生宿主 `HomeActivity` 按 Android 改版设计稿绘制 Material 3 界面:主题为 `Theme.Material3.DayNight`,`values/colors.xml` 里写了具体色值的颜色在 `values-night/colors.xml` 都有深色对应值,只有 `ms_warn`、`ms_danger`、`ms_scrim` 和 `app_icon_field` 有意在两种模式下保持同一个值;引用其他颜色的别名(`@color/…`)不单独定义深色值,随所引用的颜色一起切换,明暗跟随共享的 `theme`(跟随系统 / 浅色 / 深色),强调色来自应用主题 `app_theme`(`AppThemeController` 与键盘侧 `ImeStyler` 共用 `AppThemeResolver` 调用 `NativeClient.resolveAppTheme` 按月份解析,水杉四季随季节换色;季节变化时回到前台的 Activity 会重建),跟随系统的键盘皮肤也用同一份色板。底部导航为「设置 / 对话 / 统计 / 我的」四个标签(第二格原先是「社区」,社区内容并进各功能页以后换成 AI 对话;`HostDeepLink` 的标签下标不变,下标 1 现在是对话);详情页不再是底部面板,而是 `SettingsNavigator` 管理的单一回退栈里的 `DetailPage`(`PageId` 列出全部页面,切换标签清栈,`settings/HostDeepLink` 解析并校验深链),页面用 `GroupCard`、`OptionSheet`、`SegmentedControl` 等共享组件构建。「设置」页是大标题、搜索框、状态卡、键盘预览与分组入口,下设皮肤(含 AI 设计皮肤)、键盘、输入、表达、AI 润色与回复、手写、语音、词库(命名词库与导入导出)、常用语和开发者选项等页;社区内容并进对应的功能页:皮肤页列出社区皮肤(点一下存进皮肤库并换上,页面打开时刷新键盘皮肤面板共用的社区目录缓存),词库页的「发现词库」、表达页的「发现短语」和 AI 润色与回复页的「社区回复模板」各列前几个,每组末尾的「浏览更多…」压入该类作品的完整列表(`CommunityFragment`,带搜索、皮肤分类筛选、详情、举报和获取/添加),返回回到原来的功能页;「统计」页分概览、习惯、按键、成就四段,含时段分布、按键热力图与成就徽章;「我的」页是账号卡、设备管理、隐私、数据导出与删除、云同步开关(`CloudSync` 在回到前台时同步设置、词库、常用语与皮肤)、应用主题、帮助与反馈、关于与其他平台下载。改版新增、其他平台没有的设置存在 `AndroidLocalSettings`(`files/bootstrap/state/android-settings.json`,设置应用与 `:ime` 进程共用,带进程间文件锁和原子写),不写进共享 Preferences。首次启动(尚未看过开屏且未完成引导)先播放描线开屏动画,结束后进入新手引导:启用与设为默认检查、选择输入方案、候选离线释义开关和账号介绍;最后一步的「登录」打开 `LoginSheet`(Apple、Google、邮箱验证码,Apple 走网页授权回到 `AuthRedirectActivity`),关闭面板不登录时按钮变为「开始使用」;已完成引导的老用户不会再看到开屏。设备冒烟脚本在引导页出现时点按「跳过」后继续,并且要等开屏视图 `home_intro` 消失、就绪条件连续两次轮询成立后才开始输入测试。 词库页「导入词库」的来源按 client-core 返回的格式清单列出,依次是文本文件、搜狗细胞词库(`.scel`)、Rime 词典和纯汉字词表;文件名以 `.scel` 结尾时不论从哪个来源选的都按细胞词库读(`DictionaryCollectionsStore.formatForFile`)。细胞词库是二进制文件,按 base64 放进命名词库 `import` 动作的 `bytes_base64`,上限 8 MiB(`MAX_SCEL_IMPORT_BYTES`,编码后连同宿主选项仍在 `msime_client_dictionary_collections` 的 17 MiB 请求上限之内),由 client-core 的 `dictionary::scel` 转成标准文本后照文本导入,权重统一为默认值 10000,不完整或损坏的文件整份拒绝(`import_malformed`)。 匿名使用统计走 client-core 的共享上报器(`msime_client_telemetry_*`,JNI 见 `NativeClient.telemetry*`),由 `core/Telemetry.java` 调起:两个进程共用 `files/telemetry/` 和其中的文件锁。会话属于 `:ime` 进程,`MSIMEInputService.onCreate` 开始、`onDestroy` 正常结束;键盘每次弹出时至多每 6 小时补发一次队列(同时记下新一天的 active)。`HomeActivity` 只补发并安装崩溃处理。未捕获异常在交给系统前同步写入崩溃记录(`:ime` 写本会话的记录,主进程写独立记录),下次键盘启动时由共享上报器转成 crash 事件;只留下会话标记(被系统杀掉)不算崩溃。开关是共享偏好 `usage_reporting`(默认开),在“词库与输入”面板“需要留意的”一组,关闭后立即清空队列;不再发送 download 事件,版本取 APK 的 versionName(`version.txt`,发布工作流可覆盖)。公告由“设置”页在回到前台时经 `msime_client_notices` 取(共享缓存一分钟),正文用 Markwon core 渲染(不解析 HTML、不加载图片,链接只以浏览器打开 http/https/mailto),“知道了”按公告 id 记在本机。社区详情面板提供“举报”,用固定的六个原因和可选说明发 `POST /v1/community/reports`,身份为 Google 账号或键盘的匿名账号。 Tauri 合包的“我的”页“云词库”入口打开同包共享云词库面板,继续复用 `packages/ui` 的目录、个人候选、文件和应用流程;入口通过 `msime_mobile_panel=cloud-dictionary` 传给 `MainActivity`,由 WebView 派发受控面板事件。独立原生 APK 没有 WebView,入口会明确提示使用管理界面合包,不伪造一个失效入口。 Tauri 合包的“键盘”页另有“个人词库”入口,打开共享设置页的 `dictionary` 分类,覆盖 Apple 个人词库的查询、编辑、导入和导出;词条校验及 Engine 写入仍由公共 Tauri/Rust 流程负责。入口通过 `msime_settings_page=dictionary` 传给 `MainActivity`,由 WebView 派发受控页面事件;独立原生 APK 会提示使用管理界面合包。 Tauri 合包的 Android“我的”页“关于水杉”入口打开共享 `about` 页面;页面内的使用帮助和反馈继续通过 `help` / `feedback` 分类切换,复用公共版本、隐私、开源、帮助和反馈 UI。独立原生 APK 保留 Android fallback 页面;这样两种构建产物都不会启动不存在的 WebView Activity。 “我的”页的“社区作品”入口打开共享账号页。Android Tauri 宿主现在注入账号命令客户端,复用公共登录、昵称、发布/收藏作品和账号管理流程;原生匿名身份在应用首次打开时向后端注册(`AccountIdentity.register`,后台线程,失败下次重试),用于无需登录的社区目录浏览与未登录时的候选翻译,账号会话由 Android 插件安全存储。 共享设置的“屏幕键盘”页面在 Android 上通过 `android_open_keyboard_tryout` 打开原生 `KeyboardTryoutActivity`,它单独承载与“对话”标签页同一个 `ChatFragment`;Tauri 只提供公共配置和入口,实际输入仍走 Android 原生试用键盘与 `InputConnection`,不在 WebView 内伪造输入法。 本地跑这条命令得到的是开发构建(versionCode 1),签名与正式版相同:机器上有发布密钥时用它,没有时才退回开发密钥并警告,规则见下文和 `scripts/signing.sh`;开发密钥签名的包不得发布。`build-apk.sh` 不是被取代的旧入口,而是本目录原生宿主自己的构建入口,验证本目录改动时用它。 在专用 AVD 上运行 `ANDROID_SDK_ROOT= bash platforms/android/tests/device/smoke.sh emulator-5580 --settings --statistics --handwriting`:保留原有输入与配置热更新测试,并在真实 Tauri WebView 中操作 React 表单,验证保存、共享 revision、自定义键盘皮肤、重新读取与另一个进程中的实际标点上屏;测试不是直接调用保存 command 代替表单行为。设置套件先确认“我的皮肤”入口存在,再打开真实编辑器应用“奶油桃桃”模板,通过 Tauri IPC 对独立命名图库执行新建、重命名、更新、应用和删除,并确认图库写入不会提前修改普通 preferences;随后检查 `custom` 选择及卵石、立体、圆角、纹理字段落盘,并在重绑的 `:ime` 进程中通过皮肤按钮无障碍状态确认实际消费“我的皮肤”。独立 fixture 还验证 Keystore 加密会话的往返、密文不含固定明文 marker、清除与 16 KiB 上限,不向生产账号服务发送验证码。测试前后恢复图库、偏好及 fixture 会话文件。独立控制端还连续两次打开/关闭设置,验证 :ime PID 不变且仍能上屏。统计套件通过真实 InputConnection 与 React 页面验证聚合文件、启停、清空和跨进程读写,固定失败阶段不输出编辑器内容,并恢复测试前文件。手写套件需要网络以首次下载 ML Kit 模型,随后使用合成触摸轨迹验证离线识别与真实 InputConnection 提交;模型已存在时直接验证就绪路径。测试恢复原输入方案和偏好文件,不输出候选或编辑器内容;instrumentation 的强制停止与普通设置窗口关闭分开处理。 移动入口布局依据 [Tauri 移动应用入口约定](https://v2.tauri.app/start/migrate/from-tauri-1/#preparing-for-mobile)。本地观察到最后一个 Tauri 窗口关闭时主进程正常退出,故使用 :ime 隔离;不依赖在同进程中禁止退出后的未验证窗口重建行为。 ## 共享设置热更新 bootstrap 配置包含绝对路径 `preferences_directory`。输入会话启动后,Android 后台读取该目录的共享 PreferencesStore,之后每秒重试;不在输入主线程等待文件锁,不重叠读取,切换编辑器或结束输入后丢弃旧读取结果并停止旧轮询。配置没有目录字段时不读取共享设置,不猜测其他应用的数据目录。 JNI `loadPreferences` 只读取共享层,快照回到会话主线程后调用同一个 `updatePreferences`;键盘侧写入通过 `savePreferences` 进入同一共享 CAS 边界。revision 校验、组词期间延迟应用和重建失败保护仍在 Rust 中。更新只刷新候选视图,不用空预编辑覆盖现有编辑器内容。相同视图和状态不反复重建键盘控件。密码等直接输入字段不启动配置轮询;禁止个性化学习的编辑器在创建和每次快照应用时均强制关闭学习,同时内存中保留未经隐私覆盖的已接受磁盘快照,避免键盘写入意外永久关闭全局学习设置。 读取或应用失败保留当前输入会话,显示非阻断提示,后续继续重试;不写入默认值覆盖坏文件。共享 React 设置页在上述 Tauri 合包中使用同一个存储。 ## 原生库交叉构建 固定 NDK r28c (`28.2.13676358`)、Android API 28 与 vcpkg `ef7dbf94b9198bc58f45951adcf1f041fcbc5ea0`。vcpkg manifest 只固定 `shared/voice/LocalAsr.cpp` 用到的 nlohmann-json;Engine 是纯 Rust crate,随 `msime-host-api` 一起编译,SQLite 由 `rusqlite` 的 `bundled` 特性编进宿主库。依赖和构建产物放在忽略的 target 下。需要先自行安装对应 SDK/NDK 和 Rust Android 目标;脚本不自动接受 SDK 许可。 ```sh git clone --depth 1 --branch 2025.06.13 https://github.com/microsoft/vcpkg.git target/tooling/vcpkg target/tooling/vcpkg/bootstrap-vcpkg.sh -disableMetrics rustup target add aarch64-linux-android x86_64-linux-android ANDROID_SDK_ROOT= bash platforms/android/build-native.sh arm64-v8a ANDROID_SDK_ROOT= bash platforms/android/build-native.sh x86_64 ``` 可用 `MSIME_VCPKG_ROOT`、`MSIME_ANDROID_NDK` 指定绝对路径。脚本校验固定版本后安装锁定依赖,构建 release Rust 宿主和 C++ JNI,SQLite 静态链接进宿主库;产物为 `target/android/jniLibs//{libmsime_host_api.so,libmsime_android.so,libc++_shared.so}`。验证脚本检查 ELF 架构、16 KB LOAD 对齐、动态依赖白名单与宿主/JNI 导出。NDK 和 vcpkg 声明,以及 Engine 内置韩语汉字表的 libhangul BSD-3-Clause 声明(`libhangul-hanja.txt`)、拆字表所据 kfcd/chaizi 的 CC BY 3.0 署名与改动说明(`kfcd-chaizi.txt`)和表中 Unihan 读音的 Unicode License V3(`unicode-unihan.txt`)复制到 `target/android/notices/`,两条打包路径都把它们放进 `assets/native-notices/`;正式分发还需汇总 Rust/Engine 与词库许可材料。 提供 arm64-v8a 与 x86_64 两条构建路径;不提供 32 位 ABI,也没有 Windows 构建脚本。原生库本身不是 APK,需用下述脚本打包。`build-apk.sh` 默认只构建并打包 arm64-v8a(发布包也是如此,arm64 真机和 Apple 芯片上的 arm64 AVD 都用它);要在 x86_64 模拟器上跑,设 `MSIME_ANDROID_ABIS="arm64-v8a x86_64"`(或只写 `x86_64`),脚本把同一份列表经 `-PmsimeAbis` 交给 Gradle 的 `abiFilters`,ML Kit 等依赖自带的 x86、armeabi-v7a 库也按它过滤,打完再核对 APK 的 `lib/` 恰好是这几个 ABI。Gradle 工程固定 `ndkVersion` 为同一个 NDK,AGP 用它的 llvm-strip 去掉打包的 `.so` 的调试符号。 ## 开发 APK 与首次准备 本地构建:`ANDROID_SDK_ROOT= bash platforms/android/build-apk.sh <已锁定词库目录>`。需要前述 NDK/vcpkg/JDK/Rust 工具及 zip;脚本先通过共享 ResourceStore 校验资源,再构建原生库(默认只有 arm64-v8a,`MSIME_ANDROID_ABIS` 加 x86_64,见上节),由 `platforms/android/gradle-app` 的 `assemble<版本>Release`(例如 `assembleFullRelease`)产出未签名 APK(AndroidX 与 Material 是 AAR,资源合并和 R 类生成必须交给 Gradle/AGP),最后用 SDK 的 zipalign/apksigner 对齐、签名并验证 `target/android/.apk`(full 是 `msime-android.apk`)。APK 随包带锁定词库、原生依赖声明和 `LICENSE`;签名前进行 16 KB zip 对齐,签名后再验证。 正式版、beta 和本地构建一律用同一把发布密钥(PKCS12,别名 `msime-release`,证书 SHA-256 `3a889e43…8e4132`)签名:Android 只允许同证书覆盖安装,哪个渠道换了证书,装过它的设备就只能卸载重装。`build-apk.sh`、`build-client-apk.sh` 和测试包 `tests/device/build-editor.sh`(它的 instrumentation 以 `app.msime.android` 为目标,必须与应用同证书)都经 `scripts/signing.sh` 取密钥:先看 `MSIME_ANDROID_RELEASE_KEYSTORE` 与 `MSIME_ANDROID_RELEASE_KEYSTORE_PASSWORD`(`release-android.yml` 从仓库 secrets `ANDROID_RELEASE_KEYSTORE_BASE64`、`ANDROID_RELEASE_KEYSTORE_PASSWORD` 解出),再看 `~/.android/msime-release.p12`,口令取环境变量或 macOS 钥匙串的 `msime-android-release-keystore` 条目;有密钥文件却拿不到口令(例如 SSH 会话读不了钥匙串)时直接失败,不换成别的证书。发布密钥不能更换,更换后已安装的用户无法覆盖升级。两处都没有发布密钥时(贡献者的机器、fork)才退回开发密钥,即 Android SDK 的调试密钥 `~/.android/debug.keystore`(由 `scripts/dev-keystore.sh` 给出),并打印警告:这样的包与正式包互相不能覆盖安装,不得用于发行;它每台机器各一把,Tauri、Android Studio 的调试构建也用它。构建临时文件留在 target/android 便于排查,不触碰任何设备。 用户打开启动页并点击“准备词库”后,后台任务在私有目录解包资源,调用共享 Rust/C++ 校验与工作数据准备,成功后通过 AtomicFile 发布配置。已有配置不会被重新准备覆盖,失败可重试。安装包换了词库版本时,`Bootstrap.prepare` 的共享刷新报 `dictionary_outdated`,如果配置记录的资源目录就是 `files/bootstrap/resources`,它把 APK 里的词库重新解包到这个目录(删掉锁里没有的旧文件,`helpcodes/` 不动),再刷新一次,由 Engine 准备新代次并回放用户词库;失败时配置保持原样,输入法继续用原来的代次,下次启动再试。解包和工作词库复制需要额外存储空间。启动页只提供手动进入系统设置/选择器的按钮,不自动启用或切换输入法。 APK 包结构、双 ABI、启动 Activity、IME 声明、签名与对齐均由脚本自身校验;API 35 arm64 专用模拟器覆盖安装、首次准备、已有配置不覆盖,以及软键盘通过系统 InputConnection 上屏、退格和密码直接输入。工具契约参考 [d8](https://developer.android.com/tools/d8)、[zipalign](https://developer.android.com/tools/zipalign) 与 [apksigner](https://developer.android.com/tools/apksigner)。 ## 专用模拟器验收 预先安装 `system-images;android-35;default;arm64-v8a`,在一个终端运行 `ANDROID_SDK_ROOT= bash platforms/android/tests/device/start-emulator.sh`。脚本在忽略的 target/android/avd-home 中创建 msime-client-test,固定 emulator-5580,不使用已有个人 AVD;目标端口属于其他 AVD 时拒绝运行。需要额外磁盘空间,测试结束后应停止该专用模拟器以释放内存;脚本不会下载系统镜像,也不自动接受 SDK 许可。 完成上述 APK 构建、等待系统启动后,在另一终端运行 `ANDROID_SDK_ROOT= bash platforms/android/tests/device/smoke.sh emulator-5580`。该命令会安装 `app.msime.android` 开发包和独立合成编辑器,准备资源,并在专用 AVD 上启用和选择 MSIME;拒绝非模拟器或名称不符的设备,不对现有真机执行操作。测试不会清空应用数据,重复执行覆盖已有配置路径而非重新模拟首次安装。 独立 instrumentation 读取编辑器与输入法的交互窗口,等待窗口稳定后重新定位并注入触摸,断言“你好”提交、退格、“直接输入”状态和密码框字符长度;不记录编辑器原文。普通 uiautomator dump 只用于准备 Activity,不能用它缺少输入法节点推断键盘未显示。APK fixture 不随产品打包。 同一 smoke 脚本还执行与应用同签名的 PreferencesDeviceSmoke;instrumentation 通过独立的 `app.msime.android.test` 测试包,在 `app.msime.android` 的私有测试目录原子发布合成设置,无需给产品增加导出的测试写接口。目标进程重启后重新绑定专用 AVD 的 IME;测试组词延迟、提交保留、页大小与标点生效、损坏文件保护以及恢复重试。结束时恢复原偏好文件(原本不存在则删除测试文件),不清空资源和用户数据。该测试必须经专用 AVD 检查的 smoke 脚本执行,不安装在个人设备。 KeyboardHeightDeviceSmoke 通过键盘内真实无障碍调节动作验证 -12、0 和 +48 dp 档位,以及调整中不点「完成」直接收起键盘时高度照样保存:正负调整必须改变实际字母键边界,调节和保存期间已有 Engine 组合不得丢失,保存值在 IME 进程重启后必须继续生效。`--settings` 还让 SettingsDeviceSmoke 在真实 React WebView 中修改并保存同一高度字段,再由独立输入法进程消费;两项测试结束时都恢复原偏好文件。 共享核心单元测试也可在该 AVD 实际执行(从仓库根运行,以下工具链为 macOS 主机): ```sh export ANDROID_SDK_ROOT= android_toolchain="$ANDROID_SDK_ROOT/ndk/28.2.13676358/toolchains/llvm/prebuilt/darwin-x86_64/bin" CC_aarch64_linux_android="$android_toolchain/aarch64-linux-android28-clang" \ AR_aarch64_linux_android="$android_toolchain/llvm-ar" \ CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$android_toolchain/aarch64-linux-android28-clang" \ CARGO_TARGET_AARCH64_LINUX_ANDROID_RUNNER="bash $PWD/platforms/android/tests/device/run-core-test.sh" \ cargo test -p msime-client-core --lib --target aarch64-linux-android --locked ``` 8 项测试在 Android 上通过,包含独立 PreferencesStore 的并发冲突和资源失败保护。Android 标准库文件锁不可用,共享锁封装在该目标使用 rustix 安全 flock,其他目标保留标准库锁;不放宽共享核心的 unsafe 禁令。启动页和软键盘均应用系统栏 inset,避免操作被 ActionBar 或导航栏遮挡。 系统契约参考:[InputMethodService](https://developer.android.com/reference/android/inputmethodservice/InputMethodService) 与 [InputConnection](https://developer.android.com/reference/android/view/inputmethod/InputConnection)。