diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 3146b63d1..4864c9084 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -328,6 +328,7 @@ jobs: if: needs.plan.outputs.mode == 'debug' working-directory: openless-all/app env: + OPENLESS_CACHE_ROOT: ${{ runner.temp }}/openless-cache ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} @@ -337,6 +338,7 @@ jobs: if: needs.plan.outputs.mode == 'release' working-directory: openless-all/app env: + OPENLESS_CACHE_ROOT: ${{ runner.temp }}/openless-cache # Resolve signing credentials at execution time; compiled Gradle scripts # are cached and must never contain secret literals. ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} diff --git a/.github/workflows/check-linux-egui.yml b/.github/workflows/check-linux-egui.yml index b592bf312..2c7f8833e 100644 --- a/.github/workflows/check-linux-egui.yml +++ b/.github/workflows/check-linux-egui.yml @@ -105,6 +105,7 @@ jobs: working-directory: openless-all/app run: | cargo test --locked -p openless-core + cargo test --locked -p openless-harmony-host cargo clippy --locked -p openless-core --all-targets -- -D warnings cargo test --locked -p openless-linux-egui --all-targets cargo clippy --locked -p openless-linux-egui --all-targets -- -D warnings diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e6296e55..d5b953220 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -189,6 +189,17 @@ jobs: - name: Copy Android production and test scaffolding run: node scripts/copy-android-scaffolding.mjs + - name: Cache Android Rime instrumentation dependencies + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/openless-cache/mobile-rime + key: mobile-rime-ndk26-x86_64-${{ hashFiles('openless-all/app/scripts/build-mobile-rime.py', 'openless-all/app/mobile-ime/**') }} + + - name: Build and stage Rime for Android instrumentation tests + env: + OPENLESS_CACHE_ROOT: ${{ runner.temp }}/openless-cache + run: python3 scripts/build-mobile-rime.py android --abi x86_64 --stage + - name: Merge Shizuku manifest run: node scripts/merge-android-shizuku-manifest.mjs @@ -219,7 +230,8 @@ jobs: api-level: 35 target: google_apis arch: x86_64 - # The instrumentation suite exercises AndroidKeyStore without launching Tauri. + # The suite exercises AndroidKeyStore and the real packaged Rime JNI + # without launching Tauri. Rime is staged for this emulator ABI above. script: >- cd openless-all/app && src-tauri/gen/android/gradlew diff --git a/docs/mobile-ime-engine.md b/docs/mobile-ime-engine.md new file mode 100644 index 000000000..3d8a481ce --- /dev/null +++ b/docs/mobile-ime-engine.md @@ -0,0 +1,53 @@ +# Android / 鸿蒙输入法后端整理与引擎选型 + +核对日期:2026-10-17。移动端已确定统一采用 librime,不引入 Fcitx5。Linux 保留现有 Fcitx5 系统宿主。Android 用 JNI、鸿蒙用 Node-API 接到同一套 `mobile-ime/rime_engine.cpp`;两端保留原生键盘和系统输入接口。 + +鸿蒙已完成 Rime 1.17.0 arm64 构建与签名安装,真机自检通过 `nihao` → “你好”候选、编码退格、候选确认、取消。用户已确认中文候选出现。Android 已改接共用引擎,NDK arm64 原生库及 JNI 编译通过,尚未完成 APK 真机验收和旧学习数据导入。 + +## 当前问题 + +鸿蒙早期首版只有英文直输;当前已接 Rime 拼音编码、候选、翻页、空格确认及中英文切换。已接通输入法录音、PCM 输送、ASR/润色管线与结果确认写入,并通过隔离的离线管线检查;真实联网识别尚需配置渠道后验收。 + +鸿蒙退格使用 IME Kit `InputClient.deleteForward` 删除光标前的内容;`deleteBackward` 删除光标后内容。方向和按键交互已修正,用户确认可删除。诊断仅记录动作类型、返回值和错误码,不记录输入内容;删除键支持长按重复。 + +移动端现已接入共享 librime,旧 LitePinyin 算法及 TSV 已移除。2026-10-17 核对本机配置发现,桌面默认实际为 Fcitx5 libime 拼音并启用云拼音,而非 Rime。移动端继续使用 Rime,升级固定修订的雾凇中文词库与拼写规则,并接入默认关闭的 Google 云候选;不迁移桌面个人词典,也不引入 Fcitx 到移动端。完整能力及隐私边界见 `openless-all/app/mobile-ime/README.md`。 + +## 开源候选 + +| 项目 | 可以复用的部分 | 适配成本及边界 | +| --- | --- | --- | +| [Fcitx5 Android](https://github.com/fcitx5-android/fcitx5-android) / [Fcitx5 Harmony](https://github.com/fcitx-contrib/fcitx5-harmony) | 两个平台的 IME 适配参考 | 已排除,不是当前移动端依赖。 | +| [librime](https://github.com/rime/librime) / [Trime](https://github.com/osfans/trime) | 跨平台 C++ 中文引擎、输入方案、候选和用户词典;Trime 提供 Android JNI 与前端参考 | librime 为 BSD-3-Clause,Trime 为 GPL-3.0。需要编译 Boost、LevelDB、marisa、OpenCC、yaml-cpp 等依赖并适配鸿蒙;目前核对的上游文档未提供可直接装入本项目的 OHOS 构建产物。 | +| [libpinyin](https://github.com/libpinyin/libpinyin) | 拼音算法库 | GPL-3.0;不是完整移动输入法。平台接线、UI 和词库打包仍需自行完成,当前不优先。 | + +以上是选型时的比较,最终按用户要求采用 Rime。Fcitx5 / libime 不作为移动端依赖;移动端只复用 librime 及其构建依赖、输入方案和词库。 + +来源:[鸿蒙子模块清单](https://github.com/fcitx-contrib/fcitx5-harmony/blob/main/.gitmodules)、[鸿蒙按键与提交适配](https://github.com/fcitx-contrib/fcitx5-harmony/blob/main/entry/src/main/ets/InputMethodExtensionAbility/model/KeyboardController.ets)、[librime 构建依赖](https://github.com/rime/librime#build-dependencies)。当前 OpenLess 仓库使用 AGPLv3;是否复用第三方源码或只链接引擎,需按实际文件许可记录,本文未引入第三方源码。 + +## 共用边界 + +两端保留原生键盘宿主与渲染,共用引擎接口和行为规范。输入框权限、原生客户端对象和面板生命周期不能抽进跨平台引擎。 + +1. **引擎适配层(C/C++ + 稳定 C ABI)**:创建/销毁会话、按键、选候选、翻页、清空、方案切换、用户词典。输出编码、预编辑、候选及待提交文字,不接触目标应用。 +2. **OpenLess 共用业务层(现有 Rust core)**:语音会话、ASR、润色、风格、渠道、历史。语音最终文字走与候选相同的提交路径;不重写现有提供商逻辑。 +3. **Android 平台层(Kotlin / JNI)**:InputMethodService、InputConnection、composing、提交、删除、编辑器 action、权限、原生 UI。 +4. **鸿蒙平台层(ArkTS / Node-API)**:InputMethodExtensionAbility、InputClient、Panel、预编辑、提交、删除、编辑器 action、权限、原生 UI。只使用输入法,不接无障碍。 + +统一请求携带 `sessionId`、`generation` 和递增 `requestId`;异步候选/语音结果必须匹配当前会话才可显示或提交。候选项用稳定 ID,不能只依赖旧列表下标。状态包括语言模式、编码、预编辑、候选及页码、录音状态、错误状态。平台返回提交成功/失败,成功后才清理待提交内容和更新学习记录。 + +## 行为规范 + +- 中文字母先进入编码,选择候选或空格确认首选候选;无编码时空格直接插入空格。 +- 退格优先删编码;无编码时删选中内容;无选中内容时删光标前一个用户感知字符。平台需处理 emoji、组合字符、目标应用的删除语义,不能统一按一个 UTF-16 单元处理。 +- 回车有编码时先确认编码;无编码时按目标编辑器的发送/搜索/换行语义执行。 +- 切换输入框、语言、密码框、关闭键盘时明确取消编码与未提交异步结果;不要把旧输入框文本带进新会话。 +- 密码框不采集词库学习,不把上下文送给语音/润色服务。普通按键、候选及个人词库默认本地处理。 +- 候选区支持翻页;布局区分语言、数字和符号;按键有按下反馈、足够触摸范围、明确删除标签和长按删除。 + +## 验证与迁移顺序 + +先验证引擎能在 OHOS arm64 交叉编译,加载真实中文词库,完成 `nihao` → 候选 → `你好` 提交,再验证 Android 使用同一引擎和同一套输入案例。记录离线首启耗时、候选延迟、库与词典体积及生命周期行为。未通过这一步前不整体替换安卓版。 + +随后接入编码/候选 UI、退格与选区、模式切换、编辑器 action,再接语音结果确认提交。最后逐段迁移安卓版大 Service,保留当前可用功能及用户词库数据,先有稳定新路径再删除旧实现。 + +共同验收案例包括全拼短句、简拼、重码翻页、连续空格、编码退格、选区删除、中间光标删除、emoji 删除、快速切换输入框、密码框、录音取消、识别结束时切换应用、输入失败后重新确认。两端均使用相同案例,不要求两端 UI 代码相同。 diff --git a/openless-all/app/Cargo.lock b/openless-all/app/Cargo.lock index f92c98e4f..804ab803b 100644 --- a/openless-all/app/Cargo.lock +++ b/openless-all/app/Cargo.lock @@ -3694,6 +3694,17 @@ dependencies = [ "zip", ] +[[package]] +name = "openless-harmony-host" +version = "0.1.0" +dependencies = [ + "base64", + "futures-util", + "openless-core", + "serde_json", + "tokio", +] + [[package]] name = "openless-linux-egui" version = "0.1.0" diff --git a/openless-all/app/Cargo.toml b/openless-all/app/Cargo.toml index 39fb972a4..3bbb9e7a8 100644 --- a/openless-all/app/Cargo.toml +++ b/openless-all/app/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "crates/openless-core", "linux-egui", + "harmony/rust", ] exclude = [ "src-tauri", diff --git a/openless-all/app/android/README.md b/openless-all/app/android/README.md index 33fb48f46..68f47f08b 100644 --- a/openless-all/app/android/README.md +++ b/openless-all/app/android/README.md @@ -40,9 +40,13 @@ Manifest 合并脚本: - [`scripts/merge-android-shizuku-manifest.mjs`](../scripts/merge-android-shizuku-manifest.mjs) — Shizuku Provider / 授权 Activity - [`scripts/patch-android-shizuku-deps.mjs`](../scripts/patch-android-shizuku-deps.mjs) — Shizuku Gradle 依赖 -### 轻量拼音输入(Pinyin,可选模式) +### Rime 拼音输入(Pinyin,可选模式) -英文键盘长按空格可在英文 / 拼音之间切换。拼音模式为离线精确匹配查找(单字全拼 + 高频简拼词语),不含 NDK / JNI / librime / 语言模型,不新增联网权限。核心类:`LitePinyinController`(编码缓冲 + 查询)、`LitePinyinRepository`(词库索引 + 排序)、`LitePinyinUserFrequency`(本地选择频率)。词库资源 `android/assets/pinyin_chars.tsv`(约 8105 字)与 `pinyin_phrases.tsv`(约 2000 词),由 [`scripts/generate-pinyin-characters.mjs`](../scripts/generate-pinyin-characters.mjs) / [`scripts/generate-pinyin-phrases.mjs`](../scripts/generate-pinyin-phrases.mjs) 从三方开源数据生成,来源、版本与许可证见同目录下的 `pinyin_chars.LICENSE.txt` / `pinyin_phrases.LICENSE.txt`。 +英文键盘长按空格可在英文 / 拼音之间切换。拼音路径已改为 `RimePinyinController` → `OpenLessRime` JNI → 与鸿蒙共用的 `mobile-ime/rime_engine.cpp` / librime 1.17.0,不引入 Fcitx5。编码和候选由 Rime 生成,空格确认候选,确认后由 InputConnection 提交。密码框不进入拼音,禁止学习时使用禁用用户词典的独立方案。 + +`npm run tauri:android:build` 会在 Tauri 构建之前按目标 ABI 编译 Rime、生成 JNI 库并复制离线词库到生成的 Android 工程。需要 `ANDROID_NDK_HOME`;缓存默认 `/data-win/openless-cache/mobile-rime`。单独验证 arm64 引擎:`python3 scripts/build-mobile-rime.py android --ndk /path/to/ndk`。直接 Gradle 构建前需带 `--stage` 执行该脚本。 + +旧 `LitePinyin*` 查询、排序、学习算法和旧内置拼音 TSV 已移除;构建脚本会清除生成工程中的同名遗留文件。`LegacyPinyinBackup` 仅保留旧导入/导出格式兼容,不参与候选或训练。当前学习与个人词典由 Rime 原生实现,沿用持久目录中的 `pinyin_simp.userdb`;普通输入框启用学习,私密字段使用不读取个人词典的独立方案。 ## 前端(`android/frontend/`,别名 `@android`) diff --git a/openless-all/app/android/assets/pinyin_chars.LICENSE.txt b/openless-all/app/android/assets/pinyin_chars.LICENSE.txt deleted file mode 100644 index 000024a8e..000000000 --- a/openless-all/app/android/assets/pinyin_chars.LICENSE.txt +++ /dev/null @@ -1,38 +0,0 @@ -pinyin_chars.tsv — attribution, source pinning, and license -============================================================= - -Sources: - -1. mozillazg/pinyin-data — kMandarin_8105.txt (character set + primary - reading) and pinyin.txt (supplementary readings for polyphones) - https://github.com/mozillazg/pinyin-data - Commit: 923b108dc5d45dee061324c011b478fb649f8b73 - Downloaded: 2026-09-25 - SHA-256 (kMandarin_8105.txt): 1b1546f6e190c0f1cbf23e3bb436b41c3ace16f852be4daebc2c7295636628a4 - SHA-256 (pinyin.txt): 621f8ca9eff8519f47e2b17b564fd318161e13bca07eea8c8e04993cd5d3b52e - License: MIT License, Copyright (c) 2016 mozillazg. Full text: - https://github.com/mozillazg/pinyin-data/blob/master/LICENSE - -2. rime/rime-pinyin-simp — pinyin_simp.dict.yaml (usage-frequency weight - per character+reading; single-character rows only — see - pinyin_phrases.LICENSE.txt for this same file's multi-character rows) - https://github.com/rime/rime-pinyin-simp - Commit: 0c6861ef7420ee780270ca6d993d18d4101049d0 - Downloaded: 2026-09-25 - SHA-256: e341598343a0f0f2035bb1aafc34a7f3bb7887deeecb3f60796262aaa2983e6b - License: Apache License 2.0. Full text: - https://github.com/rime/rime-pinyin-simp/blob/master/LICENSE - -Generation: this repo's scripts/generate-pinyin-characters.mjs — see that -script's own header comment for the exact re-fetch/regenerate commands and -selection/ranking rules. Covers the full 8105-character 《通用规范汉字表》 -set (raised from an initial ~5000-character cutoff on 2026-09-25, once the -smaller set proved noticeably incomplete in real use — the plan's own 7.1 -anticipated this: "如果5000字实测仍然很小,可在不改变架构的情况下扩展到 -8105个规范汉字"), primary reading always kept, up to 2 additional "常用" -polyphone readings kept when their own weight clears a threshold. Output -rows are tab-separated -"characterpinyinweight", pinyin lowercase ASCII with ü normalized -to v and tone marks stripped. No runtime code in OpenLess downloads, -modifies, or redistributes either original source — only this generated, -filtered derivative ships in the app. diff --git a/openless-all/app/android/assets/pinyin_chars.tsv b/openless-all/app/android/assets/pinyin_chars.tsv deleted file mode 100644 index 66dc8360c..000000000 --- a/openless-all/app/android/assets/pinyin_chars.tsv +++ /dev/null @@ -1,8225 +0,0 @@ -的 de 4828294 -了 le 1500186 -我 wo 1192789 -是 shi 1180957 -在 zai 974740 -你 ni 973526 -和 he 664874 -有 you 613906 -不 bu 590643 -就 jiu 558432 -也 ye 404388 -都 dou 401877 -要 yao 396900 -吧 ba 385205 -好 hao 378543 -他 ta 343908 -说 shuo 338803 -去 qu 330876 -到 dao 296879 -没 mei 295035 -上 shang 284918 -对 dui 279642 -还 hai 279642 -那 na 269381 -人 ren 250285 -个 ge 245957 -看 kan 245039 -中 zhong 243213 -呢 ne 239902 -来 lai 233997 -吗 ma 220136 -为 wei 210479 -会 hui 208391 -她 ta 204022 -与 yu 202250 -给 gei 191937 -等 deng 182604 -这 zhe 178109 -用 yong 168186 -把 ba 165484 -下 xia 163638 -恩 en 162015 -能 neng 161410 -很 hen 160208 -做 zuo 154137 -多 duo 154137 -想 xiang 141790 -被 bei 138128 -点 dian 137956 -而 er 135908 -后 hou 134560 -一 yi 134392 -又 you 132893 -从 cong 126905 -再 zai 120734 -将 jiang 119239 -时 shi 117616 -但 dan 115581 -过 guo 111897 -么 me 110511 -打 da 107925 -大 da 106058 -年 nian 105268 -啦 la 102549 -地 di 101785 -以 yi 100400 -买 mai 100275 -里 li 98050 -让 rang 97928 -小 xiao 92013 -着 zhe 87757 -先 xian 84326 -啊 a 83803 -或 huo 83282 -呀 ya 79232 -并 bing 79035 -找 zhao 78056 -新 xin 77282 -及 ji 77186 -得 de 75568 -跟 gen 74632 -于 yu 73708 -太 tai 71180 -嘛 ma 70738 -号 hao 70299 -日 ri 70124 -才 cai 69949 -发 fa 69515 -可 ke 68911 -比 bi 68654 -玩 wan 67131 -们 men 65806 -最 zui 65806 -月 yue 65397 -吃 chi 65315 -真 zhen 65153 -它 ta 58897 -其 qi 58168 -话 hua 57807 -请 qing 55964 -钱 qian 55755 -叫 jiao 55685 -出 chu 55547 -开 kai 54927 -哈 ha 53642 -当 dang 52846 -家 jia 52256 -向 xiang 52126 -问 wen 49777 -之 zhi 49530 -更 geng 48734 -该 gai 48251 -别 bie 48130 -走 zou 48130 -快 kuai 48011 -前 qian 46770 -由 you 46538 -高 gao 45167 -您 nin 44386 -加 jia 44110 -天 tian 44055 -所 suo 44055 -却 que 43347 -已 yi 43347 -谁 shui 41704 -内 nei 40881 -写 xie 40881 -啥 sha 40526 -搞 gao 39924 -老 lao 39924 -事 shi 38942 -只 zhi 38748 -拿 na 38603 -带 dai 38459 -如 ru 37936 -死 si 36726 -行 xing 35822 -卖 mai 35689 -晕 yun 35555 -地 de 35028 -像 xiang 34680 -使 shi 34422 -刚 gang 33324 -成 cheng 33117 -换 huan 32585 -无 wu 32585 -嗯 n 31982 -嗯 ng 31982 -哪 na 31902 -回 hui 31665 -爱 ai 31546 -听 ting 31156 -少 shao 31117 -元 yuan 30885 -卡 ka 30770 -靠 kao 30617 -帮 bang 30125 -算 suan 29938 -者 zhe 29752 -忙 mang 29641 -起 qi 29383 -各 ge 29019 -分 fen 28803 -学 xue 28803 -总 zong 28517 -则 ze 28270 -水 shui 27608 -改 gai 27437 -咯 lo 27403 -唉 ai 27403 -张 zhang 27300 -三 san 26761 -连 lian 26463 -见 jian 26266 -放 fang 26038 -车 che 26005 -万 wan 25941 -全 quan 25876 -按 an 25812 -区 qu 25334 -早 zao 25302 -拉 la 25270 -偶 ou 24864 -本 ben 24833 -页 ye 24679 -进 jin 24648 -性 xing 24434 -弄 nong 24374 -第 di 24313 -完 wan 24072 -同 tong 24012 -系 xi 23892 -花 hua 23744 -怕 pa 22787 -女 nv 22702 -讲 jiang 22589 -杀 sha 22449 -组 zu 22421 -干 gan 22116 -错 cuo 22088 -些 xie 21951 -住 zhu 21924 -跑 pao 21815 -名 ming 21788 -次 ci 21278 -睡 shui 21225 -网 wang 21198 -装 zhuang 20962 -挺 ting 20754 -掉 diao 20677 -王 wang 20625 -图 tu 20548 -正 zheng 20523 -外 wai 20471 -路 lu 20471 -二 er 20319 -生 sheng 20268 -头 tou 20243 -应 ying 20243 -心 xin 20168 -因 yin 19893 -子 zi 19769 -强 qiang 19428 -作 zuo 19307 -类 lei 19235 -送 song 19211 -每 mei 19115 -书 shu 19044 -乖 guai 18529 -法 fa 18506 -拍 pai 18299 -表 biao 18141 -市 shi 18050 -撒 sa 18028 -至 zhi 17782 -接 jie 17760 -美 mei 17650 -未 wei 17562 -办 ban 17518 -非 fei 17475 -哎 ai 17453 -级 ji 17388 -即 ji 17366 -此 ci 17237 -版 ban 17087 -副 fu 17045 -差 cha 16939 -俺 an 16897 -收 shou 16855 -男 nan 16792 -了 liao 16750 -字 zi 16750 -股 gu 16667 -几 ji 16625 -考 kao 16604 -两 liang 16584 -转 zhuan 16584 -块 kuai 16563 -线 xian 16338 -周 zhou 16297 -手 shou 16196 -道 dao 16155 -黄 huang 16135 -便 bian 16095 -米 mi 15995 -聊 liao 15975 -猪 zhu 15935 -取 qu 15876 -哇 wa 15817 -低 di 15738 -难 nan 15640 -哦 o 15582 -儿 er 15408 -包 bao 15389 -受 shou 15389 -阿 a 15389 -喝 he 15293 -白 bai 15236 -金 jin 15142 -管 guan 15029 -近 jin 15029 -四 si 15010 -定 ding 14898 -够 gou 14732 -交 jiao 14714 -门 men 14714 -样 yang 14677 -李 li 14513 -笑 xiao 14513 -传 chuan 14495 -自 zi 14405 -慢 man 14352 -边 bian 14352 -刷 shua 14316 -黑 hei 14316 -型 xing 14298 -穿 chuan 14227 -越 yue 14156 -位 wei 14033 -贵 gui 13963 -长 chang 13911 -部 bu 13825 -得 dei 13791 -据 ju 13756 -怪 guai 13739 -牛 niu 13739 -处 chu 13722 -选 xuan 13705 -站 zhan 13654 -倒 dao 13603 -省 sheng 13586 -谈 tan 13518 -称 cheng 13485 -台 tai 13451 -群 qun 13451 -汗 han 13318 -累 lei 13301 -经 jing 13284 -查 cha 13251 -象 xiang 13251 -变 bian 13218 -半 ban 13186 -数 shu 13153 -格 ge 13038 -面 mian 13022 -红 hong 12941 -陈 chen 12941 -五 wu 12861 -方 fang 12765 -顶 ding 12733 -楼 lou 12718 -读 du 12702 -某 mou 12623 -乱 luan 12576 -条 tiao 12498 -远 yuan 12498 -马 ma 12498 -海 hai 12451 -单 dan 12374 -相 xiang 12374 -光 guang 12327 -热 re 12281 -且 qie 12175 -咋 za 12160 -种 zhong 12160 -挂 gua 12144 -风 feng 12099 -林 lin 12069 -满 man 12039 -额 e 12039 -谁 shei 11979 -化 hua 11875 -班 ban 11860 -滴 di 11816 -涨 zhang 11728 -关 guan 11612 -店 dian 11612 -机 ji 11612 -安 an 11597 -坐 zuo 11583 -俄 e 11525 -度 du 11468 -约 yue 11468 -飞 fei 11468 -刘 liu 11439 -歌 ge 11397 -信 xin 11312 -报 bao 11312 -若 ruo 11227 -原 yuan 11213 -饭 fan 11199 -文 wen 11186 -晚 wan 11186 -量 liang 11116 -村 cun 11088 -式 shi 11075 -空 kong 10992 -毛 mao 10978 -山 shan 10951 -主 zhu 10842 -另 ling 10842 -伐 fa 10721 -间 jian 10668 -入 ru 10589 -占 zhan 10562 -贴 tie 10562 -爽 shuang 10536 -盘 pan 10536 -赚 zhuan 10432 -往 wang 10406 -队 dui 10367 -杨 yang 10328 -片 pian 10277 -狗 gou 10200 -国 guo 10112 -坏 huai 10112 -费 fei 10112 -岁 sui 10074 -血 xue 10049 -离 li 9937 -西 xi 9937 -共 gong 9924 -龙 long 9924 -明 ming 9887 -妈 ma 9875 -超 chao 9875 -均 jun 9826 -切 qie 9716 -期 qi 9716 -照 zhao 9704 -达 da 9692 -留 liu 9584 -特 te 9524 -动 dong 9465 -气 qi 9442 -哥 ge 9418 -神 shen 9418 -鸟 niao 9418 -教 jiao 9371 -甘 gan 9371 -梦 meng 9302 -蛮 man 9290 -唔 wu 9244 -值 zhi 9175 -长 zhang 9175 -鱼 yu 9175 -骂 ma 9163 -陪 pei 9129 -既 ji 9095 -哭 ku 9073 -镇 zhen 9073 -建 jian 9016 -洗 xi 9016 -力 li 8983 -懂 dong 8960 -双 shuang 8838 -口 kou 8827 -指 zhi 8783 -跌 die 8772 -仍 reng 8751 -活 huo 8740 -球 qiu 8740 -练 lian 8718 -东 dong 8696 -提 ti 8578 -清 qing 8578 -亿 yi 8567 -除 chu 8546 -云 yun 8398 -通 tong 8398 -酒 jiu 8304 -冲 chong 8294 -饿 e 8284 -猫 mao 8212 -咩 mie 8191 -票 piao 8191 -假 jia 8140 -久 jiu 8100 -六 liu 8090 -病 bing 8090 -画 hua 8040 -抓 zhua 8029 -呗 bei 8019 -曾 ceng 8019 -平 ping 8010 -急 ji 7980 -段 duan 7980 -套 tao 7960 -课 ke 7950 -药 yao 7930 -草 cao 7871 -任 ren 7851 -理 li 7822 -城 cheng 7812 -罗 luo 7793 -令 ling 7764 -货 huo 7754 -唱 chang 7744 -雨 yu 7744 -左 zuo 7716 -哟 yo 7696 -蓝 lan 7677 -抢 qiang 7639 -常 chang 7630 -菜 cai 7620 -色 se 7601 -县 xian 7573 -混 hun 7535 -集 ji 7498 -感 gan 7460 -操 cao 7451 -火 huo 7451 -敢 gan 7442 -较 jiao 7414 -退 tui 7395 -房 fang 7386 -南 nan 7350 -脸 lian 7322 -破 po 7313 -件 jian 7286 -噢 o 7268 -呃 e 7259 -吴 wu 7222 -德 de 7178 -借 jie 7169 -声 sheng 7169 -傻 sha 7160 -克 ke 7151 -整 zheng 7151 -雪 xue 7151 -喊 han 7142 -局 ju 7098 -跳 tiao 7089 -补 bu 7071 -养 yang 7053 -华 hua 7045 -恶 e 7036 -团 tuan 7027 -现 xian 7027 -反 fan 6966 -砸 za 6923 -待 dai 6914 -篇 pian 6897 -烦 fan 6871 -投 tou 6854 -根 gen 6854 -深 shen 6820 -求 qiu 6812 -亲 qin 6778 -电 dian 6752 -配 pei 6736 -修 xiu 6710 -沈 shen 6710 -款 kuan 6685 -八 ba 6677 -知 zhi 6669 -叶 ye 6660 -鬼 gui 6652 -何 he 6636 -器 qi 6578 -试 shi 6578 -乐 le 6513 -谢 xie 6505 -层 ceng 6480 -木 mu 6480 -仅 jin 6472 -北 bei 6464 -肉 rou 6416 -份 fen 6408 -重 zhong 6392 -情 qing 6384 -赵 zhao 6376 -需 xu 6368 -记 ji 6360 -七 qi 6313 -流 liu 6305 -合 he 6297 -列 lie 6282 -升 sheng 6266 -噶 ga 6258 -招 zhao 6258 -论 lun 6258 -痛 tong 6235 -率 lv 6211 -封 feng 6204 -石 shi 6204 -香 xiang 6204 -眼 yan 6142 -冷 leng 6135 -易 yi 6119 -付 fu 6081 -派 pai 6066 -帅 shuai 6051 -阳 yang 6013 -胡 hu 6006 -厂 chang 5991 -十 shi 5976 -排 pai 5969 -油 you 5961 -停 ting 5939 -树 shu 5939 -奥 ao 5814 -军 jun 5771 -牌 pai 5771 -科 ke 5764 -身 shen 5764 -翻 fan 5742 -星 xing 5735 -帖 tie 5721 -随 sui 5721 -权 quan 5707 -泡 pao 5700 -价 jia 5685 -还 huan 5685 -商 shang 5650 -库 ku 5650 -玉 yu 5643 -追 zhui 5643 -场 chang 5636 -须 xu 5622 -物 wu 5615 -背 bei 5608 -章 zhang 5608 -丢 diu 5594 -朱 zhu 5587 -宝 bao 5580 -脚 jiao 5573 -体 ti 5566 -伤 shang 5552 -解 jie 5545 -都 du 5545 -剑 jian 5532 -咱 zan 5532 -代 dai 5504 -亦 yi 5497 -底 di 5497 -孙 sun 5484 -业 ye 5470 -室 shi 5463 -旧 jiu 5456 -工 gong 5450 -极 ji 5429 -曾 zeng 5422 -刀 dao 5409 -波 bo 5395 -铁 tie 5369 -词 ci 5362 -笨 ben 5349 -哦 e 5335 -丫 ya 5309 -推 tui 5296 -证 zheng 5296 -炒 chao 5276 -春 chun 5269 -耍 shua 5269 -输 shu 5263 -骗 pian 5243 -轻 qing 5236 -含 han 5223 -九 jiu 5217 -俩 lia 5217 -千 qian 5217 -江 jiang 5217 -秒 miao 5204 -徐 xu 5191 -落 luo 5191 -短 duan 5185 -青 qing 5178 -节 jie 5152 -夜 ye 5146 -吹 chui 5133 -游 you 5120 -土 tu 5101 -姐 jie 5089 -板 ban 5076 -赢 ying 5076 -许 xu 5070 -直 zhi 5038 -苦 ku 5038 -公 gong 5026 -君 jun 5013 -烂 lan 5013 -皮 pi 5013 -秀 xiu 5013 -博 bo 5007 -亏 kui 4988 -存 cun 4982 -师 shi 4963 -立 li 4957 -素 su 4957 -利 li 4951 -吾 wu 4951 -烧 shao 4945 -诗 shi 4945 -睇 di 4939 -命 ming 4908 -制 zhi 4902 -静 jing 4902 -民 min 4896 -欲 yu 4883 -汤 tang 4871 -冰 bing 4841 -断 duan 4835 -造 zao 4835 -忘 wang 4829 -茶 cha 4823 -呆 dai 4817 -抽 chou 4805 -案 an 4805 -供 gong 4781 -尽 jin 4781 -布 bu 4781 -田 tian 4781 -抱 bao 4769 -烟 yan 4763 -初 chu 4757 -野 ye 4734 -胖 pang 4710 -品 pin 4693 -紧 jin 4681 -绿 lv 4675 -项 xiang 4675 -故 gu 4669 -签 qian 4658 -调 tiao 4646 -服 fu 4634 -苏 su 4634 -奖 jiang 4629 -兵 bing 4611 -填 tian 4606 -演 yan 4606 -百 bai 4588 -英 ying 4588 -嘴 zui 4583 -般 ban 4577 -挖 wa 4566 -准 zhun 4543 -重 chong 4543 -保 bao 4532 -果 guo 4532 -硬 ying 4526 -凡 fan 4520 -寒 han 4520 -挑 tiao 4515 -唐 tang 4509 -莫 mo 4509 -鸡 ji 4509 -夏 xia 4503 -寄 ji 4492 -言 yan 4470 -闹 nao 4470 -防 fang 4470 -竟 jing 4453 -纯 chun 4448 -距 ju 4448 -题 ti 4442 -官 guan 4437 -余 yu 4431 -源 yuan 4426 -街 jie 4420 -座 zuo 4409 -古 gu 4404 -思 si 4404 -删 shan 4393 -亮 liang 4387 -灯 deng 4376 -穷 qiong 4376 -郭 guo 4365 -戏 xi 4360 -桥 qiao 4360 -党 dang 4354 -爆 bao 4354 -首 shou 4344 -粉 fen 4338 -念 nian 4327 -钟 zhong 4317 -属 shu 4306 -戴 dai 4306 -疼 teng 4306 -然 ran 4290 -韩 han 4279 -扣 kou 4258 -租 zu 4253 -捏 nie 4237 -港 gang 4237 -狼 lang 4237 -盖 gai 4231 -雷 lei 4231 -兰 lan 4205 -狂 kuang 4205 -引 yin 4195 -佛 fo 4148 -压 ya 4148 -梁 liang 4148 -猛 meng 4138 -船 chuan 4132 -今 jin 4112 -齐 qi 4112 -亚 ya 4107 -托 tuo 4107 -恨 hen 4102 -赛 sai 4086 -宋 song 4081 -客 ke 4081 -斯 si 4076 -沙 sha 4071 -腿 tui 4066 -银 yin 4061 -领 ling 4061 -码 ma 4046 -友 you 4041 -虽 sui 4041 -祝 zhu 4031 -员 yuan 4026 -句 ju 4021 -丁 ding 4011 -望 wang 3996 -战 zhan 3991 -缺 que 3991 -食 shi 3981 -床 chuang 3976 -着 zhao 3976 -抗 kang 3971 -贼 zei 3971 -乡 xiang 3961 -界 jie 3961 -困 kun 3946 -河 he 3946 -松 song 3941 -郑 zheng 3941 -否 fou 3931 -步 bu 3931 -园 yuan 3927 -折 zhe 3927 -尚 shang 3912 -羊 yang 3912 -福 fu 3902 -宁 ning 3897 -必 bi 3878 -遭 zao 3873 -猜 cai 3859 -伊 yi 3849 -秋 qiu 3839 -端 duan 3825 -插 cha 3811 -毒 du 3811 -滚 gun 3811 -熊 xiong 3811 -获 huo 3801 -秦 qin 3797 -卷 juan 3792 -持 chi 3792 -设 she 3787 -架 jia 3782 -朝 chao 3764 -喂 wei 3754 -归 gui 3754 -广 guang 3745 -踢 ti 3745 -灵 ling 3722 -搬 ban 3708 -迷 mi 3703 -胜 sheng 3699 -基 ji 3694 -意 yi 3694 -赶 gan 3694 -乃 nai 3671 -甲 jia 3666 -拖 tuo 3653 -摸 mo 3639 -史 shi 3635 -右 you 3635 -惨 can 3630 -爸 ba 3625 -塔 ta 3616 -订 ding 3616 -纸 zhi 3607 -散 san 3585 -呈 cheng 3576 -姓 xing 3576 -圈 quan 3567 -洋 yang 3567 -咬 yao 3558 -喜 xi 3558 -嘎 ga 3558 -闪 shan 3554 -富 fu 3549 -治 zhi 3536 -喔 wo 3527 -料 liao 3523 -汉 han 3523 -批 pi 3519 -院 yuan 3519 -逛 guang 3514 -峰 feng 3510 -狠 hen 3506 -醒 xing 3506 -咧 lie 3501 -爬 pa 3501 -梅 mei 3492 -瘦 shou 3488 -降 jiang 3488 -结 jie 3484 -户 hu 3479 -疯 feng 3471 -偷 tou 3462 -语 yu 3458 -枪 qiang 3453 -踩 cai 3453 -足 zu 3449 -精 jing 3445 -志 zhi 3436 -支 zhi 3428 -呵 he 3423 -软 ruan 3419 -杜 du 3411 -赞 zan 3402 -依 yi 3390 -丽 li 3385 -展 zhan 3377 -康 kang 3377 -兴 xing 3373 -救 jiu 3373 -倍 bei 3368 -堂 tang 3364 -鞋 xie 3364 -愿 yuan 3339 -键 jian 3306 -似 si 3298 -冒 mao 3290 -守 shou 3290 -音 yin 3290 -观 guan 3281 -仲 zhong 3273 -弱 ruo 3273 -替 ti 3257 -世 shi 3253 -笔 bi 3249 -闲 xian 3241 -逼 bi 3237 -牙 ya 3233 -舞 wu 3233 -荣 rong 3233 -著 zhu 3225 -遍 bian 3213 -影 ying 3209 -行 hang 3209 -曲 qu 3201 -丑 chou 3197 -吓 xia 3197 -州 zhou 3193 -巴 ba 3193 -测 ce 3189 -状 zhuang 3181 -宽 kuan 3177 -武 wu 3161 -箱 xiang 3161 -程 cheng 3157 -偏 pian 3153 -庄 zhuang 3153 -细 xi 3153 -耶 ye 3149 -联 lian 3141 -湖 hu 3137 -晒 shai 3130 -柳 liu 3130 -锁 suo 3122 -印 yin 3118 -味 wei 3099 -紫 zi 3099 -众 zhong 3095 -环 huan 3091 -零 ling 3091 -实 shi 3087 -答 da 3087 -厚 hou 3079 -娘 niang 3079 -懒 lan 3072 -寸 cun 3068 -标 biao 3068 -兼 jian 3049 -孔 kong 3045 -欧 ou 3041 -勒 le 3034 -吖 a 3034 -闷 men 3034 -岛 dao 3026 -灭 mie 3026 -登 deng 3026 -顾 gu 3015 -瓦 wa 3011 -运 yun 3011 -抛 pao 3004 -顺 shun 3004 -陆 lu 3000 -雅 ya 2996 -司 si 2981 -温 wen 2981 -轮 lun 2977 -微 wei 2970 -社 she 2966 -杰 jie 2963 -奶 nai 2952 -杯 bei 2948 -搜 sou 2944 -凭 ping 2937 -奇 qi 2926 -居 ju 2926 -洞 dong 2922 -魔 mo 2922 -编 bian 2919 -虎 hu 2911 -佳 jia 2904 -充 chong 2904 -川 chuan 2883 -酸 suan 2875 -形 xing 2865 -墙 qiang 2861 -圆 yuan 2854 -害 hai 2854 -档 dang 2850 -负 fu 2850 -喽 lou 2847 -范 fan 2843 -糖 tang 2840 -煮 zhu 2836 -拟 ni 2833 -致 zhi 2826 -屁 pi 2822 -稳 wen 2822 -景 jing 2819 -觉 jue 2815 -吐 tu 2812 -碰 peng 2812 -注 zhu 2808 -肥 fei 2808 -免 mian 2805 -欠 qian 2805 -蛋 dan 2805 -挣 zheng 2794 -晓 xiao 2791 -曹 cao 2791 -侯 hou 2784 -盛 sheng 2773 -良 liang 2770 -擦 ca 2753 -脱 tuo 2753 -仙 xian 2749 -暗 an 2749 -砍 kan 2746 -夫 fu 2742 -熟 shu 2735 -兄 xiong 2729 -尔 er 2729 -皆 jie 2725 -琴 qin 2722 -创 chuang 2712 -永 yong 2712 -宫 gong 2702 -继 ji 2685 -谷 gu 2678 -宏 hong 2665 -专 zhuan 2662 -录 lu 2662 -施 shi 2662 -善 shan 2658 -增 zeng 2658 -例 li 2655 -税 shui 2652 -产 chan 2648 -嫩 nen 2645 -肯 ken 2642 -押 ya 2635 -麻 ma 2635 -视 shi 2625 -怒 nu 2622 -横 heng 2622 -扔 reng 2619 -母 mu 2619 -忍 ren 2612 -腰 yao 2606 -臭 chou 2596 -宗 zong 2593 -泪 lei 2586 -艾 ai 2586 -净 jing 2577 -肖 xiao 2577 -敏 min 2573 -吵 chao 2570 -罪 zui 2567 -争 zheng 2564 -伟 wei 2561 -响 xiang 2554 -棒 bang 2554 -邓 deng 2554 -这 zhei 2551 -减 jian 2548 -斗 dou 2548 -氏 shi 2548 -告 gao 2532 -吉 ji 2529 -圣 sheng 2529 -拆 chai 2529 -蔡 cai 2526 -闻 wen 2526 -末 mo 2523 -杂 za 2519 -衣 yi 2516 -麦 mai 2513 -冯 feng 2510 -妹 mei 2507 -义 yi 2504 -塞 sai 2504 -尼 ni 2504 -董 dong 2494 -角 jiao 2494 -旁 pang 2491 -潘 pan 2491 -耐 nai 2491 -醉 zui 2491 -呢 ni 2485 -宇 yu 2482 -丝 si 2479 -计 ji 2476 -校 xiao 2470 -姜 jiang 2467 -抄 chao 2467 -宜 yi 2457 -蒋 jiang 2451 -核 he 2445 -维 wei 2445 -灰 hui 2442 -钢 gang 2439 -催 cui 2436 -楚 chu 2427 -屋 wu 2421 -丹 dan 2418 -认 ren 2412 -乘 cheng 2409 -俊 jun 2406 -返 fan 2406 -榜 bang 2403 -芳 fang 2403 -仁 ren 2400 -矿 kuang 2400 -瑞 rui 2391 -严 yan 2385 -添 tian 2385 -评 ping 2385 -酷 ku 2385 -绝 jue 2382 -币 bi 2376 -飘 piao 2376 -威 wei 2373 -刻 ke 2370 -摆 bai 2364 -炸 zha 2358 -嘿 hei 2355 -犯 fan 2350 -京 jing 2347 -鲁 lu 2347 -具 ju 2344 -挤 ji 2344 -铜 tong 2344 -届 jie 2341 -密 mi 2338 -瓶 ping 2338 -夹 jia 2332 -骨 gu 2332 -黎 li 2323 -农 nong 2318 -射 she 2318 -馆 guan 2318 -士 shi 2309 -索 suo 2303 -季 ji 2300 -寻 xun 2300 -拼 pin 2300 -族 zu 2300 -显 xian 2300 -洪 hong 2297 -骑 qi 2295 -巨 ju 2292 -厄 e 2289 -厅 ting 2289 -容 rong 2278 -术 shu 2278 -复 fu 2272 -智 zhi 2272 -辉 hui 2272 -普 pu 2269 -剧 ju 2266 -采 cai 2263 -扯 che 2261 -燕 yan 2261 -竹 zhu 2261 -撞 zhuang 2258 -脑 nao 2258 -礼 li 2255 -聚 ju 2255 -膜 mo 2247 -讯 xun 2247 -诺 nuo 2244 -搭 da 2238 -魏 wei 2238 -泽 ze 2235 -胃 wei 2227 -曰 yue 2224 -侧 ce 2221 -彭 peng 2213 -钻 zuan 2213 -凤 feng 2210 -袁 yuan 2210 -吕 lv 2205 -窝 wo 2199 -凉 liang 2197 -乜 mie 2194 -阴 yin 2194 -蒙 meng 2191 -躲 duo 2188 -赖 lai 2186 -堆 dui 2183 -甜 tian 2180 -赴 fu 2178 -劲 jin 2175 -盐 yan 2175 -孟 meng 2172 -粗 cu 2172 -举 ju 2164 -甚 shen 2161 -载 zai 2159 -帐 zhang 2156 -申 shen 2151 -露 lu 2148 -劝 quan 2132 -瞎 xia 2132 -训 xun 2132 -驻 zhu 2132 -惹 re 2129 -逃 tao 2129 -休 xiu 2127 -彩 cai 2127 -独 du 2124 -庆 qing 2119 -藏 cang 2119 -仓 cang 2113 -斤 jin 2111 -姚 yao 2108 -浪 lang 2108 -喇 la 2106 -纳 na 2103 -针 zhen 2103 -吨 dun 2100 -嘉 jia 2100 -忠 zhong 2098 -吸 xi 2095 -森 sen 2095 -泰 tai 2095 -萧 xiao 2092 -功 gong 2090 -跨 kua 2090 -症 zheng 2079 -贝 bei 2074 -哼 heng 2072 -灌 guan 2064 -缘 yuan 2061 -卢 lu 2059 -卫 wei 2059 -喷 pen 2059 -蛇 she 2059 -盒 he 2054 -井 jing 2051 -导 dao 2043 -吊 diao 2033 -扫 sao 2033 -拜 bai 2033 -购 gou 2028 -贱 jian 2026 -饮 yin 2026 -调 diao 2023 -昏 hun 2021 -虚 xu 2018 -敲 qiao 2016 -岩 yan 2003 -埋 mai 2001 -寺 si 2001 -巧 qiao 2001 -娜 na 1998 -冬 dong 1996 -侬 nong 1991 -弹 tan 1988 -愁 chou 1988 -凯 kai 1983 -目 mu 1983 -淡 dan 1981 -优 you 1968 -虫 chong 1966 -池 chi 1964 -汇 hui 1959 -萨 sa 1956 -屏 ping 1954 -泥 ni 1944 -勇 yong 1939 -喔 o 1939 -怀 huai 1932 -扬 yang 1930 -迟 chi 1930 -铺 pu 1930 -磨 mo 1925 -呼 hu 1920 -怎 zen 1920 -赌 du 1920 -乔 qiao 1913 -涂 tu 1910 -财 cai 1906 -栏 lan 1903 -遇 yu 1894 -泉 quan 1889 -薛 xue 1889 -汪 wang 1887 -着 zhuo 1887 -稍 shao 1887 -嫁 jia 1884 -锅 guo 1884 -终 zhong 1882 -攻 gong 1880 -扎 zha 1873 -质 zhi 1873 -叼 diao 1868 -娃 wa 1868 -赔 pei 1866 -隔 ge 1866 -祥 xiang 1863 -呐 na 1861 -液 ye 1859 -豆 dou 1856 -碗 wan 1854 -绕 rao 1854 -佐 zuo 1852 -旺 wang 1852 -漏 lou 1850 -凑 cou 1847 -贾 jia 1845 -雄 xiong 1845 -委 wei 1840 -临 lin 1838 -吻 wen 1838 -湿 shi 1838 -崔 cui 1836 -框 kuang 1836 -探 tan 1829 -胸 xiong 1827 -碟 die 1824 -洛 luo 1822 -健 jian 1820 -辆 liang 1820 -速 su 1820 -顿 dun 1820 -唯 wei 1815 -拔 ba 1815 -暴 bao 1813 -失 shi 1804 -沟 gou 1802 -仪 yi 1797 -郎 lang 1793 -尿 niao 1791 -脏 zang 1786 -希 xi 1784 -逗 dou 1784 -识 shi 1779 -惠 hui 1777 -珠 zhu 1777 -菲 fei 1773 -谭 tan 1771 -透 tou 1771 -麽 me 1766 -凌 ling 1764 -府 fu 1757 -播 bo 1755 -父 fu 1755 -嫌 xian 1751 -窗 chuang 1744 -耳 er 1740 -残 can 1738 -砖 zhuan 1731 -患 huan 1729 -昌 chang 1729 -阵 zhen 1729 -哩 li 1723 -废 fei 1723 -扁 bian 1723 -捡 jian 1723 -苗 miao 1723 -帝 di 1718 -剪 jian 1712 -稿 gao 1710 -坑 keng 1708 -邦 bang 1708 -陶 tao 1708 -营 ying 1701 -剂 ji 1693 -熬 ao 1693 -舒 shu 1693 -丰 feng 1689 -模 mo 1689 -玲 ling 1689 -甩 shuai 1689 -贤 xian 1687 -拨 bo 1684 -趁 chen 1684 -栋 dong 1682 -羽 yu 1682 -沿 yan 1672 -乙 yi 1670 -盗 dao 1668 -伴 ban 1657 -妖 yao 1653 -尊 zun 1653 -幽 you 1647 -琳 lin 1647 -锦 jin 1647 -域 yu 1643 -瓜 gua 1641 -浅 qian 1637 -袋 dai 1637 -限 xian 1635 -浩 hao 1631 -霜 shuang 1629 -豪 hao 1625 -置 zhi 1619 -俾 bi 1617 -祖 zu 1615 -夸 kua 1613 -玄 xuan 1613 -颜 yan 1613 -惊 jing 1611 -肺 fei 1611 -煤 mei 1609 -趟 tang 1603 -辣 la 1603 -涛 tao 1601 -附 fu 1595 -遂 sui 1593 -划 hua 1591 -恒 heng 1589 -的 di 1589 -欣 xin 1587 -墨 mo 1585 -围 wei 1581 -剩 sheng 1579 -娶 qu 1577 -慧 hui 1573 -备 bei 1571 -鲜 xian 1571 -撤 che 1563 -助 zhu 1559 -撑 cheng 1557 -沉 chen 1555 -诸 zhu 1555 -险 xian 1551 -震 zhen 1549 -鹏 peng 1549 -兆 zhao 1548 -割 ge 1546 -斌 bin 1546 -烤 kao 1546 -控 kong 1542 -哒 da 1540 -暨 ji 1540 -贺 he 1538 -凶 xiong 1534 -锋 feng 1532 -童 tong 1528 -伯 bo 1525 -挡 dang 1525 -饰 shi 1525 -壳 ke 1521 -恋 lian 1521 -诚 cheng 1521 -馨 xin 1521 -浓 nong 1519 -振 zhen 1517 -轩 xuan 1517 -虾 xia 1515 -庭 ting 1513 -晴 qing 1513 -坊 fang 1509 -靓 liang 1509 -晨 chen 1506 -曼 man 1506 -镜 jing 1506 -魂 hun 1506 -益 yi 1504 -肝 gan 1504 -征 zheng 1502 -禁 jin 1502 -爹 die 1498 -粥 zhou 1498 -盾 dun 1496 -尘 chen 1493 -炎 yan 1493 -消 xiao 1489 -喃 nan 1485 -犬 quan 1481 -艳 yan 1481 -纪 ji 1480 -枫 feng 1478 -己 ji 1476 -妞 niu 1474 -昂 ang 1474 -暖 nuan 1474 -柱 zhu 1472 -隆 long 1472 -奔 ben 1469 -败 bai 1469 -政 zheng 1467 -滑 hua 1467 -妻 qi 1463 -廖 liao 1463 -误 wu 1463 -尹 yin 1459 -舍 she 1452 -刺 ci 1445 -沪 hu 1441 -弓 gong 1436 -邱 qiu 1432 -润 run 1431 -莲 lian 1431 -捞 lao 1427 -冠 guan 1425 -婚 hun 1425 -止 zhi 1425 -兔 tu 1422 -舔 tian 1418 -仔 zai 1416 -湾 wan 1416 -躺 tang 1416 -桑 sang 1415 -鑫 xin 1415 -链 lian 1411 -饱 bao 1411 -乌 wu 1409 -促 cu 1409 -敬 jing 1409 -迪 di 1408 -咪 mi 1406 -摄 she 1406 -蒸 zheng 1404 -串 chuan 1399 -愈 yu 1399 -阁 ge 1399 -承 cheng 1397 -摔 shuai 1397 -勤 qin 1394 -杆 gan 1394 -潮 chao 1394 -碎 sui 1392 -医 yi 1390 -臣 chen 1390 -鼠 shu 1388 -葛 ge 1383 -邪 xie 1380 -吼 hou 1376 -妙 miao 1375 -始 shi 1371 -珍 zhen 1371 -俗 su 1370 -迎 ying 1368 -续 xu 1364 -抬 tai 1363 -歪 wai 1361 -辛 xin 1361 -售 shou 1358 -伦 lun 1356 -启 qi 1356 -胶 jiao 1356 -捧 peng 1354 -研 yan 1354 -晃 huang 1353 -符 fu 1353 -苑 yuan 1349 -韦 wei 1349 -爷 ye 1348 -雾 wu 1346 -亭 ting 1344 -刮 gua 1343 -兽 shou 1341 -哄 hong 1341 -嗲 dia 1341 -尤 you 1339 -邹 zou 1334 -嗨 hai 1333 -鸣 ming 1333 -卓 zhuo 1329 -旗 qi 1329 -罚 fa 1329 -积 ji 1323 -斜 xie 1318 -湘 xiang 1318 -铝 lv 1318 -欢 huan 1313 -预 yu 1313 -乎 hu 1311 -枝 zhi 1309 -溪 xi 1301 -芬 fen 1301 -洲 zhou 1298 -鸭 ya 1296 -捐 juan 1293 -炮 pao 1292 -岗 gang 1288 -轴 zhou 1287 -简 jian 1282 -翔 xiang 1282 -勾 gou 1280 -堵 du 1280 -携 xie 1277 -染 ran 1276 -矣 yi 1276 -饼 bing 1276 -罢 ba 1274 -萍 ping 1274 -隐 yin 1272 -餐 can 1272 -戒 jie 1266 -逸 yi 1266 -岳 yue 1265 -荷 he 1265 -贷 dai 1265 -憋 bie 1261 -薄 bo 1255 -仿 fang 1252 -肾 shen 1252 -异 yi 1249 -朗 lang 1249 -扑 pu 1247 -桂 gui 1247 -瞧 qiao 1246 -颗 ke 1246 -鸿 hong 1246 -弯 wan 1244 -晶 jing 1240 -霞 xia 1240 -朝 zhao 1238 -延 yan 1233 -箭 jian 1233 -棉 mian 1232 -鼓 gu 1232 -籍 ji 1229 -桌 zhuo 1227 -互 hu 1223 -坤 kun 1221 -玛 ma 1221 -艺 yi 1221 -私 si 1220 -柔 rou 1215 -勿 wu 1214 -眉 mei 1214 -倾 qing 1211 -检 jian 1209 -腾 teng 1209 -焦 jiao 1205 -弟 di 1203 -浴 yu 1203 -碧 bi 1203 -资 zi 1203 -亩 mu 1202 -盆 pen 1202 -毁 hui 1200 -烫 tang 1200 -皇 huang 1199 -汁 zhi 1193 -粒 li 1193 -伞 san 1190 -莱 lai 1188 -洁 jie 1187 -哲 zhe 1185 -涵 han 1185 -企 qi 1179 -叔 shu 1178 -岭 ling 1178 -咳 hai 1176 -护 hu 1173 -浜 bang 1173 -屎 shi 1170 -衰 shuai 1170 -盟 meng 1166 -翼 yi 1166 -判 pan 1165 -钙 gai 1165 -傅 fu 1163 -妮 ni 1163 -驴 lv 1162 -迈 mai 1160 -铭 ming 1160 -柯 ke 1159 -宠 chong 1156 -桶 tong 1155 -殿 dian 1153 -菌 jun 1152 -敌 di 1150 -淫 yin 1150 -旭 xu 1145 -航 hang 1145 -棋 qi 1142 -仔 zi 1140 -访 fang 1140 -拽 zhuai 1139 -翠 cui 1136 -坡 po 1135 -纹 wen 1133 -鹰 ying 1133 -孝 xiao 1132 -赤 chi 1130 -技 ji 1129 -译 yi 1129 -挨 ai 1128 -予 yu 1126 -劳 lao 1126 -唔 n 1126 -唔 ng 1126 -肩 jian 1126 -露 lou 1126 -册 ce 1123 -介 jie 1122 -培 pei 1119 -融 rong 1118 -移 yi 1116 -韵 yun 1115 -势 shi 1112 -毕 bi 1112 -截 jie 1108 -渡 du 1108 -薇 wei 1108 -扶 fu 1107 -啃 ken 1105 -壮 zhuang 1103 -怡 yi 1103 -摘 zhai 1103 -宅 zhai 1101 -盼 pan 1100 -耗 hao 1100 -妍 yan 1097 -摇 yao 1097 -抹 mo 1094 -莉 li 1093 -乳 ru 1092 -孤 gu 1092 -锡 xi 1090 -夺 duo 1083 -确 que 1082 -醋 cu 1082 -损 sun 1081 -握 wo 1081 -旅 lv 1081 -帕 pa 1079 -拳 quan 1079 -芯 xin 1078 -奴 nu 1077 -颇 po 1077 -宣 xuan 1075 -击 ji 1074 -咸 xian 1073 -姬 ji 1070 -尖 jian 1070 -伍 wu 1069 -邵 shao 1069 -讨 tao 1067 -酶 mei 1067 -掌 zhang 1063 -桃 tao 1059 -寿 shou 1058 -规 gui 1058 -谋 mou 1058 -逢 feng 1058 -垫 dian 1057 -穆 mu 1057 -暂 zan 1055 -腻 ni 1055 -矮 ai 1054 -龟 gui 1054 -庙 miao 1053 -朵 duo 1051 -职 zhi 1051 -霍 huo 1049 -绑 bang 1046 -菊 ju 1046 -辞 ci 1044 -闯 chuang 1044 -壁 bi 1041 -枕 zhen 1038 -渐 jian 1037 -岸 an 1036 -婆 po 1035 -胆 dan 1035 -尝 chang 1032 -晋 jin 1032 -虹 hong 1032 -凝 ning 1031 -弦 xian 1029 -撮 cuo 1029 -澡 zao 1029 -避 bi 1029 -抵 di 1028 -贪 tan 1028 -俱 ju 1027 -噻 sai 1027 -息 xi 1027 -丸 wan 1024 -参 can 1023 -骚 sao 1022 -塘 tang 1020 -漆 qi 1018 -蝶 die 1018 -於 yu 1014 -兮 xi 1012 -郝 hao 1012 -穴 xue 1009 -芝 zhi 1009 -浮 fu 1008 -材 cai 1005 -娟 juan 1004 -缝 feng 1004 -耀 yao 1004 -萌 meng 1004 -蹭 ceng 1002 -烈 lie 1000 -痒 yang 1000 -伸 shen 997 -槽 cao 997 -翁 weng 995 -柜 gui 994 -盈 ying 994 -审 shen 993 -斑 ban 989 -渣 zha 989 -恐 kong 985 -殷 yin 984 -悲 bei 983 -溜 liu 982 -炉 lu 982 -幻 huan 981 -逆 ni 981 -伪 wei 978 -柴 chai 978 -券 quan 977 -固 gu 977 -抖 dou 976 -番 fan 976 -悦 yue 974 -斩 zhan 974 -屈 qu 973 -脉 mai 972 -衫 shan 970 -忽 hu 968 -怨 yuan 968 -粮 liang 968 -冻 dong 967 -彬 bin 967 -献 xian 966 -熙 xi 965 -摩 mo 964 -胎 tai 962 -薄 bao 962 -婷 ting 961 -缩 suo 956 -兹 zi 955 -赏 shang 955 -鹿 lu 955 -津 jin 954 -递 di 954 -枚 mei 952 -卿 qing 950 -函 han 949 -济 ji 949 -颖 ying 949 -扇 shan 947 -彦 yan 946 -拷 kao 946 -漫 man 945 -墓 mu 943 -淘 tao 943 -谱 pu 943 -候 hou 941 -鹤 he 939 -幅 fu 935 -茂 mao 933 -咯 luo 931 -戈 ge 931 -弄 long 927 -牵 qian 927 -娇 jiao 926 -雁 yan 926 -御 yu 924 -窄 zhai 922 -郡 jun 922 -掏 tao 921 -映 ying 921 -琼 qiong 921 -缓 huan 921 -唇 chun 920 -弃 qi 920 -佩 pei 919 -汝 ru 918 -昨 zuo 917 -策 ce 914 -琪 qi 911 -肌 ji 911 -雯 wen 911 -坛 tan 910 -邀 yao 910 -协 xie 907 -猴 hou 904 -销 xiao 903 -揉 rou 902 -漂 piao 901 -攒 zan 900 -肠 chang 900 -债 zhai 898 -倪 ni 894 -坚 jian 894 -舟 zhou 894 -藤 teng 893 -裤 ku 892 -爪 zhua 891 -什 shen 890 -磊 lei 889 -慌 huang 886 -务 wu 885 -蒂 di 885 -诉 su 884 -态 tai 883 -蟹 xie 883 -梨 li 882 -幸 xing 881 -炼 lian 881 -忧 you 880 -鼻 bi 879 -育 yu 876 -舌 she 875 -酱 jiang 874 -叹 tan 873 -堡 bao 873 -拐 guai 873 -陷 xian 873 -鹅 e 871 -腹 fu 870 -蜜 mi 870 -席 xi 869 -粤 yue 869 -葱 cong 869 -苍 cang 867 -捉 zhuo 866 -孩 hai 863 -赫 he 861 -尾 wei 859 -尾 yi 859 -肿 zhong 859 -效 xiao 858 -帧 zhen 857 -澳 ao 857 -钓 diao 857 -侠 xia 855 -儒 ru 855 -决 jue 854 -扭 niu 854 -闭 bi 854 -叉 cha 853 -癌 ai 853 -泵 beng 852 -捷 jie 851 -妆 zhuang 849 -彼 bi 847 -适 shi 846 -泛 fan 845 -瘤 liu 843 -龚 gong 843 -盯 ding 842 -忌 ji 840 -炖 dun 840 -纵 zong 840 -默 mo 840 -逾 yu 839 -绣 xiu 838 -悬 xuan 837 -宰 zai 836 -埃 ai 834 -辰 chen 833 -那 nei 832 -罐 guan 831 -奈 nai 828 -序 xu 828 -洒 sa 828 -叻 le 827 -伏 fu 826 -执 zhi 826 -幼 you 825 -鼎 ding 825 -卵 luan 824 -莹 ying 824 -旋 xuan 821 -午 wu 820 -刑 xing 819 -囊 nang 817 -担 dan 817 -粘 nian 816 -触 chu 815 -踏 ta 815 -宾 bin 813 -悟 wu 812 -腔 qiang 812 -牢 lao 811 -帽 mao 810 -聂 nie 810 -典 dian 809 -奉 feng 808 -妃 fei 806 -煎 jian 806 -频 pin 806 -历 li 804 -昭 zhao 804 -拥 yong 801 -巷 xiang 800 -犹 you 800 -弘 hong 797 -扛 kang 796 -揭 jie 796 -裸 luo 795 -柏 bai 794 -账 zhang 794 -霸 ba 793 -僧 seng 792 -啵 bo 792 -摊 tan 791 -掐 qia 789 -突 tu 788 -蓉 rong 788 -蕾 lei 787 -揍 zou 786 -彪 biao 786 -觉 jiao 786 -壹 yi 785 -毅 yi 784 -疑 yi 784 -罩 zhao 783 -拒 ju 781 -淋 lin 781 -赠 zeng 780 -警 jing 778 -逐 zhu 778 -痕 hen 777 -歇 xie 776 -统 tong 776 -迁 qian 775 -钩 gou 775 -垂 chui 773 -扮 ban 770 -廷 ting 769 -祸 huo 769 -淑 shu 768 -渝 yu 768 -裂 lie 768 -昆 kun 767 -涅 nie 767 -颈 jing 767 -喻 yu 766 -尸 shi 766 -嘞 lei 765 -忆 yi 765 -尺 chi 764 -朕 zhen 764 -惯 guan 762 -氧 yang 762 -仕 shi 761 -境 jing 761 -棍 gun 758 -律 lv 756 -恰 qia 755 -惜 xi 753 -斋 zhai 753 -坪 ping 752 -姆 mu 752 -束 shu 752 -浦 pu 752 -稀 xi 751 -拓 tuo 748 -吞 tun 745 -裙 qun 745 -叠 die 743 -疏 shu 743 -芸 yun 743 -劫 jie 740 -颠 dian 740 -砂 sha 739 -郁 yu 739 -锌 xin 739 -亡 wang 738 -尧 yao 737 -沾 zhan 737 -株 zhu 736 -涌 yong 736 -穗 sui 736 -翘 qiao 736 -邮 you 736 -潜 qian 735 -聪 cong 734 -糊 hu 732 -谓 wei 732 -陵 ling 732 -壶 hu 731 -媚 mei 730 -饶 rao 730 -庚 geng 729 -坦 tan 728 -慕 mu 728 -丘 qiu 725 -咖 ga 725 -兜 dou 724 -寨 zhai 724 -搓 cuo 724 -监 jian 724 -繁 fan 724 -蚊 wen 724 -霖 lin 722 -绍 shao 721 -遥 yao 721 -楠 nan 720 -牧 mu 720 -验 yan 720 -吟 yin 719 -裴 pei 717 -缠 chan 713 -哉 zai 712 -浇 jiao 711 -齿 chi 711 -挥 hui 710 -祭 ji 709 -庞 pang 706 -惟 wei 706 -拾 shi 706 -朋 peng 706 -膏 gao 703 -坝 ba 701 -锐 rui 699 -敷 fu 697 -筒 tong 697 -喵 miao 696 -涉 she 696 -粪 fen 693 -挪 nuo 688 -署 shu 687 -佬 lao 686 -拧 ning 686 -阮 ruan 686 -储 chu 685 -碳 tan 684 -丙 bing 683 -桩 zhuang 682 -荡 dang 682 -屯 tun 680 -趴 pa 680 -蹲 dun 680 -遮 zhe 680 -戳 chuo 679 -纱 sha 679 -崇 chong 675 -钦 qin 675 -瑶 yao 672 -赋 fu 671 -奎 kui 670 -渠 qu 667 -习 xi 665 -仇 chou 664 -宿 su 664 -夕 xi 663 -盲 mang 662 -坎 kan 660 -蹦 beng 660 -扩 kuo 659 -跃 yue 657 -杭 hang 656 -弗 fu 655 -泼 po 655 -臂 bi 655 -钉 ding 655 -幕 mu 654 -邢 xing 654 -筹 chou 653 -荒 huang 652 -棚 peng 651 -焊 han 651 -禅 chan 650 -召 zhao 649 -岚 lan 648 -拌 ban 647 -疾 ji 647 -裕 yu 647 -奏 zou 643 -栽 zai 643 -滨 bin 643 -筋 jin 643 -趋 qu 643 -示 shi 642 -卦 gua 641 -蠢 chun 641 -贞 zhen 640 -卧 wo 639 -苯 ben 639 -阻 zu 638 -咒 zhou 637 -琦 qi 636 -甫 fu 636 -绳 sheng 635 -耿 geng 635 -樊 fan 633 -贫 pin 633 -阅 yue 633 -撕 si 631 -铃 ling 631 -艘 sou 630 -浆 jiang 629 -竞 jing 629 -搅 jiao 628 -靖 jing 628 -珊 shan 627 -笼 long 625 -倚 yi 622 -妇 fu 620 -痰 tan 620 -缸 gang 620 -舰 jian 620 -帆 fan 619 -议 yi 618 -炫 xuan 617 -狐 hu 617 -徒 tu 614 -植 zhi 613 -责 ze 613 -释 shi 612 -拣 jian 611 -脂 zhi 610 -赐 ci 610 -享 xiang 608 -佑 you 608 -秘 mi 608 -沃 wo 607 -袖 xiu 607 -鲍 bao 607 -述 shu 606 -姨 yi 605 -啸 xiao 604 -硅 gui 604 -杖 zhang 602 -焉 yan 601 -捅 tong 600 -芽 ya 600 -倩 qian 599 -筑 zhu 598 -藏 zang 598 -傲 ao 597 -姿 zi 597 -径 jing 596 -邻 lin 595 -激 ji 594 -咦 yi 593 -铅 qian 593 -雕 diao 593 -姑 gu 592 -角 jue 592 -咯 ge 590 -捂 wu 590 -桐 tong 590 -幢 zhuang 587 -舱 cang 587 -踹 chuai 587 -辩 bian 587 -潭 tan 586 -覆 fu 586 -赚 zuan 586 -钠 na 586 -劲 jing 585 -恤 xu 585 -拦 lan 585 -煲 bao 584 -糟 zao 584 -趣 qu 584 -燃 ran 583 -脆 cui 583 -轰 hong 583 -阎 yan 583 -似 shi 582 -凸 tu 582 -龄 ling 582 -窜 cuan 581 -锤 chui 581 -鞍 an 580 -痴 chi 579 -孕 yun 578 -瑟 se 574 -茹 ru 574 -搁 ge 572 -缴 jiao 571 -弹 dan 570 -滩 tan 570 -熏 xun 570 -驱 qu 570 -咏 yong 569 -搂 lou 569 -浸 jin 569 -磷 lin 569 -织 zhi 569 -哀 ai 567 -昊 hao 567 -衡 heng 567 -阶 jie 567 -驾 jia 564 -辅 fu 562 -崩 beng 561 -谦 qian 560 -鄂 e 560 -窑 yao 559 -刃 ren 557 -晖 hui 557 -蒲 pu 557 -泻 xie 555 -硕 shuo 555 -屡 lv 554 -涩 se 553 -择 ze 552 -灾 zai 552 -丛 cong 550 -碑 bei 550 -柄 bing 549 -奸 jian 548 -揽 lan 548 -樱 ying 548 -禾 he 547 -俩 liang 546 -竖 shu 546 -婉 wan 545 -措 cuo 545 -眠 mian 545 -薰 xun 545 -磁 ci 544 -麟 lin 544 -侃 kan 543 -坠 zhui 543 -揪 jiu 543 -禄 lu 543 -咽 yan 542 -娅 ya 542 -仗 zhang 541 -革 ge 540 -瑜 yu 539 -畅 chang 537 -碱 jian 535 -肚 du 535 -逝 shi 535 -攀 pan 534 -皱 zhou 534 -阀 fa 534 -宴 yan 533 -瘾 yin 533 -翟 zhai 532 -跪 gui 532 -唤 huan 531 -途 tu 531 -勋 xun 530 -帘 lian 530 -狄 di 530 -瓷 ci 530 -辽 liao 530 -鞭 bian 530 -谜 mi 529 -厉 li 528 -磅 bang 528 -闸 zha 527 -凹 ao 526 -瞄 miao 526 -裁 cai 526 -娥 e 525 -宪 xian 525 -崖 ya 523 -扒 ba 523 -沁 qin 523 -刊 kan 522 -迹 ji 521 -血 xie 519 -乾 qian 516 -氮 dan 516 -霉 mei 516 -傍 bang 514 -葬 zang 514 -喘 chuan 513 -钧 jun 513 -詹 zhan 512 -俞 shu 510 -俞 yu 510 -闫 yan 508 -骆 luo 508 -察 cha 507 -授 shou 506 -楞 leng 506 -辨 bian 506 -喉 hou 505 -嚼 jiao 505 -拎 lin 505 -虞 yu 505 -辑 ji 505 -媛 yuan 504 -披 pi 504 -遗 yi 504 -阔 kuo 504 -剥 bo 503 -坟 fen 503 -塌 ta 503 -岂 qi 503 -悠 you 503 -卸 xie 502 -便 pian 501 -愚 yu 501 -狮 shi 501 -窦 dou 501 -藕 ou 499 -垮 kua 498 -怜 lian 498 -慈 ci 496 -拱 gong 496 -脾 pi 494 -氢 qing 493 -巢 chao 491 -幺 yao 491 -抚 fu 491 -溶 rong 491 -焕 huan 491 -廉 lian 490 -禹 yu 490 -亨 heng 489 -燥 zao 488 -卜 bu 487 -危 wei 487 -泄 xie 487 -聘 pin 487 -钾 jia 484 -橙 cheng 483 -滕 teng 482 -颂 song 482 -构 gou 479 -栓 shuan 479 -铲 chan 479 -堤 di 478 -枯 ku 478 -睿 rui 478 -允 yun 476 -堪 kan 473 -暮 mu 473 -冤 yuan 472 -篮 lan 472 -粘 zhan 472 -抠 kou 471 -巫 wu 470 -酥 su 470 -劈 pi 469 -畔 pan 469 -裹 guo 469 -雀 que 469 -骏 jun 469 -灿 can 468 -灶 zao 467 -芒 mang 467 -冥 ming 466 -杏 xing 466 -腐 fu 466 -绘 hui 465 -渔 yu 463 -筱 xiao 463 -蜀 shu 463 -醇 chun 463 -薪 xin 462 -袭 xi 462 -婕 jie 461 -梳 shu 461 -黛 dai 461 -孰 shu 459 -兑 dui 457 -卑 bei 457 -瞪 deng 457 -铸 zhu 457 -茨 ci 456 -轨 gui 456 -拂 fu 455 -搏 bo 455 -渊 yuan 454 -蜡 la 454 -豹 bao 454 -驰 chi 454 -荤 hun 453 -蒜 suan 453 -皓 hao 452 -嫂 sao 451 -弊 bi 451 -戚 qi 451 -际 ji 451 -馅 xian 450 -茅 mao 449 -叁 san 445 -枣 zao 445 -曦 xi 444 -棵 ke 444 -嗦 suo 443 -澄 cheng 443 -鉴 jian 443 -飙 biao 443 -冉 ran 442 -塑 su 439 -炳 bing 439 -耕 geng 439 -誓 shi 439 -朴 pu 438 -勺 shao 437 -敦 dun 437 -央 yang 435 -瞳 tong 435 -羞 xiu 435 -彤 tong 434 -稻 dao 434 -媒 mei 433 -笛 di 433 -乍 zha 432 -冀 ji 432 -匹 pi 432 -恭 gong 432 -贰 er 432 -祁 qi 431 -莪 e 431 -蜂 feng 431 -眺 tiao 430 -渴 ke 429 -妾 qie 427 -沐 mu 427 -估 gu 426 -仰 yang 425 -忒 tei 425 -滥 lan 425 -夷 yi 424 -嘘 xu 423 -丧 sang 421 -弥 mi 421 -辈 bei 421 -钛 tai 421 -捕 bu 420 -煞 sha 420 -磕 ke 420 -陀 tuo 420 -努 nu 418 -沧 cang 418 -茎 jing 417 -呸 pei 416 -汽 qi 416 -溅 jian 416 -炜 wei 416 -梗 geng 415 -蕊 rui 415 -屠 tu 414 -捣 dao 414 -旦 dan 414 -闵 min 414 -氨 an 413 -玮 wei 412 -翅 chi 412 -叙 xu 411 -氯 lv 411 -奕 yi 410 -惑 huo 410 -呦 you 409 -氟 fu 409 -豫 yu 409 -撇 pie 408 -荆 jing 408 -络 luo 407 -胀 zhang 407 -贯 guan 406 -详 xiang 405 -瓣 ban 404 -淮 huai 403 -迥 jiong 403 -况 kuang 402 -掘 jue 402 -浙 zhe 402 -蓬 peng 402 -挫 cuo 401 -溢 yi 401 -览 lan 401 -慎 shen 400 -梓 zi 400 -巡 xun 400 -绮 qi 399 -逮 dai 399 -徽 hui 398 -瞒 man 398 -谨 jin 398 -寇 kou 397 -葵 kui 397 -率 shuai 396 -绵 mian 396 -舜 shun 396 -佟 tong 395 -匪 fei 395 -昕 xin 393 -矛 mao 392 -苔 tai 392 -翰 han 391 -荐 jian 391 -蹬 deng 391 -墩 dun 390 -晾 liang 390 -耻 chi 389 -茜 qian 389 -觅 mi 389 -貌 mao 389 -冈 gang 387 -硫 liu 387 -纲 gang 387 -闽 min 387 -诱 you 384 -侵 qin 383 -哑 ya 382 -抑 yi 382 -削 xiao 381 -寝 qin 381 -掰 bai 381 -蔚 wei 381 -柏 bo 380 -熟 shou 380 -迦 jia 380 -掂 dian 379 -淤 yu 379 -痣 zhi 379 -颤 chan 379 -捆 kun 378 -腕 wan 378 -贡 gong 378 -昔 xi 377 -猎 lie 377 -刁 diao 376 -澜 lan 376 -污 wu 375 -援 yuan 373 -掀 xian 372 -泳 yong 372 -萱 xuan 370 -逊 xun 370 -俏 qiao 369 -愣 leng 369 -臀 tun 369 -娱 yu 367 -悔 hui 367 -矫 jiao 367 -挠 nao 366 -玫 mei 366 -蓄 xu 366 -卒 zu 365 -绊 ban 365 -镁 mei 365 -瑛 ying 364 -侍 shi 363 -循 xun 363 -旱 han 363 -概 gai 363 -馋 chan 362 -妥 tuo 361 -悉 xi 361 -绪 xu 361 -汀 ting 360 -甄 zhen 360 -唆 suo 359 -颐 yi 359 -扰 rao 358 -掩 yan 358 -迅 xun 358 -呕 ou 357 -垒 lei 357 -敖 ao 357 -酮 tong 357 -菁 jing 356 -钰 yu 356 -廿 nian 355 -璇 xuan 354 -畴 chou 354 -胤 yin 354 -蛙 wa 354 -茗 ming 353 -旬 xun 351 -朴 piao 351 -窍 qiao 351 -芙 fu 351 -蘸 zhan 351 -扽 den 350 -梯 ti 350 -炭 tan 349 -雇 gu 349 -瞅 chou 348 -茵 yin 348 -勃 bo 347 -庵 an 347 -糕 gao 347 -诀 jue 347 -锭 ding 347 -沥 li 346 -欺 qi 344 -乐 yue 343 -匀 yun 343 -碘 dian 343 -称 chen 343 -菱 ling 343 -侨 qiao 341 -籽 zi 341 -膝 xi 341 -莎 sha 341 -莎 suo 341 -雌 ci 341 -峡 xia 340 -眨 zha 340 -酿 niang 340 -泊 bo 339 -绒 rong 339 -臻 zhen 339 -隋 sui 339 -秉 bing 338 -胺 an 338 -衬 chen 338 -僵 jiang 337 -呵 a 337 -杉 shan 337 -祛 qu 336 -伽 ga 335 -纽 niu 335 -掺 chan 334 -斐 fei 334 -迫 po 334 -岑 cen 332 -玖 jiu 331 -娄 lou 331 -遣 qian 331 -溟 ming 330 -贩 fan 330 -陡 dou 330 -萝 luo 329 -乏 fa 328 -仨 sa 328 -婴 ying 328 -滞 zhi 328 -蕴 yun 328 -镶 xiang 328 -冶 ye 326 -嫖 piao 326 -厌 yan 325 -嗜 shi 325 -肆 si 324 -淳 chun 323 -嗒 da 322 -阑 lan 321 -叽 ji 320 -塞 se 320 -璐 lu 320 -黔 qian 320 -摁 en 319 -嵌 qian 318 -廊 lang 318 -彻 che 318 -椅 yi 318 -疗 liao 318 -禽 qin 318 -魁 kui 318 -挽 wan 317 -滋 zi 317 -焰 yan 317 -袍 pao 317 -栗 li 316 -睁 zheng 316 -稠 chou 316 -髓 sui 316 -呛 qiang 315 -札 zha 315 -呜 wu 314 -斥 chi 314 -渗 shen 314 -纬 wei 314 -螺 luo 314 -咳 ke 313 -峻 jun 313 -笋 sun 313 -魅 mei 313 -涯 ya 312 -晏 yan 311 -桓 huan 311 -没 mo 311 -靳 jin 311 -描 miao 310 -诊 zhen 310 -呗 bai 309 -瑾 jin 309 -藻 zao 309 -刨 pao 308 -捻 nian 308 -沽 gu 308 -蚕 can 308 -传 zhuan 307 -寂 ji 307 -沫 mo 307 -镍 nie 307 -厨 chu 306 -叮 ding 306 -慰 wei 306 -芦 lu 306 -芷 zhi 306 -丈 zhang 305 -寡 gua 305 -踪 zong 305 -荫 yin 304 -嗅 xiu 301 -嫣 yan 301 -矩 ju 301 -祠 ci 301 -雍 yong 301 -募 mu 300 -覃 qin 300 -榕 rong 299 -爵 jue 299 -袄 ao 299 -裔 yi 299 -杠 gang 298 -秤 cheng 298 -钮 niu 297 -役 yi 296 -烙 lao 296 -贸 mao 296 -匡 kuang 295 -契 qi 295 -疆 jiang 295 -窥 kui 295 -眸 mou 294 -裘 qiu 294 -簿 bu 293 -浊 zhuo 292 -淹 yan 292 -盏 zhan 292 -贬 bian 292 -劣 lie 291 -拴 shuan 291 -棠 tang 291 -宛 wan 290 -棕 zong 290 -沛 pei 290 -芹 qin 290 -苟 gou 290 -泣 qi 289 -殊 shu 288 -琛 chen 288 -箫 xiao 288 -椰 ye 287 -瞬 shun 287 -驶 shi 287 -噪 zao 286 -履 lv 286 -崽 zai 286 -皖 wan 286 -蚀 shi 286 -誉 yu 286 -倦 juan 285 -婧 jing 285 -庸 yong 285 -析 xi 285 -畏 wei 285 -哪 ne 284 -撩 liao 284 -湛 zhan 284 -熔 rong 284 -祈 qi 284 -芭 ba 284 -陕 shan 284 -恼 nao 283 -腺 xian 283 -邑 yi 283 -掷 zhi 282 -肤 fu 282 -厢 xiang 281 -差 chai 281 -毫 hao 281 -洽 qia 281 -烨 ye 280 -珂 ke 279 -靴 xue 279 -浑 hun 278 -滇 dian 278 -督 du 278 -衍 yan 278 -衔 xian 278 -铬 ge 278 -奋 fen 277 -痘 dou 277 -艇 ting 277 -巾 jin 276 -瞥 pie 276 -究 jiu 276 -簇 cu 276 -砌 qi 275 -酚 fen 275 -佰 bai 274 -腌 a 274 -蝉 chan 274 -寅 yin 273 -晟 sheng 273 -梵 fan 273 -苇 wei 273 -堕 duo 272 -桦 hua 272 -涮 shuan 272 -祺 qi 272 -膊 bo 272 -茬 cha 272 -伙 huo 271 -筛 shai 271 -胞 bao 271 -霄 xiao 271 -匠 jiang 270 -揣 chuai 270 -棺 guan 270 -淌 tang 270 -纤 qian 270 -障 zhang 270 -询 xun 269 -剁 duo 268 -剃 ti 268 -巩 gong 268 -酯 zhi 268 -妄 wang 267 -娴 xian 267 -恁 nen 267 -厘 li 266 -栈 zhan 266 -肽 tai 266 -曝 bao 263 -朔 shuo 263 -肘 zhou 263 -驳 bo 263 -搽 cha 262 -澈 che 262 -狱 yu 262 -粟 su 262 -吏 li 261 -吖 ya 261 -屑 xie 261 -皂 zao 261 -脓 nong 261 -襄 xiang 261 -扳 ban 260 -汞 gong 260 -涧 jian 260 -烛 zhu 260 -绞 jiao 260 -毓 yu 259 -绢 juan 259 -埠 bu 258 -袜 wa 258 -佘 she 257 -淇 qi 257 -刹 sha 256 -卤 lu 256 -遵 zun 256 -憨 han 255 -羹 geng 255 -鳖 bie 255 -唷 yo 254 -斧 fu 254 -赣 gan 254 -弩 nu 253 -柒 qi 253 -筐 kuang 253 -圭 gui 252 -撰 zhuan 252 -棱 leng 252 -甭 beng 252 -菇 gu 252 -违 wei 252 -剖 pou 251 -址 zhi 251 -坨 tuo 251 -瞿 qu 251 -纺 fang 251 -见 xian 251 -哨 shao 250 -梢 shao 250 -潇 xiao 250 -疤 ba 250 -舅 jiu 250 -卞 bian 249 -秃 tu 249 -窃 qie 249 -肃 su 249 -蚁 yi 249 -拢 long 248 -瞟 piao 248 -碍 ai 248 -寓 yu 247 -蛤 ha 247 -卜 bo 246 -旨 zhi 246 -辱 ru 246 -凋 diao 245 -滤 lv 245 -矢 shi 245 -励 li 244 -啪 pa 244 -愤 fen 244 -镑 bang 244 -槐 huai 243 -虑 lv 243 -谂 shen 243 -霆 ting 243 -妓 ji 242 -眯 mi 242 -诛 zhu 242 -侣 lv 241 -崎 qi 241 -煜 yu 241 -腥 xing 240 -蝇 ying 240 -鸽 ge 240 -噗 pu 239 -懿 yi 239 -泓 hong 239 -砚 yan 239 -纤 xian 239 -缀 zhui 239 -蛆 qu 239 -诵 song 239 -锯 ju 239 -阙 que 239 -焖 men 238 -苹 ping 238 -谛 di 238 -嗟 jie 237 -嘟 du 237 -弧 hu 237 -捶 chui 237 -煌 huang 237 -缕 lv 237 -缪 miu 237 -钵 bo 237 -阜 fu 237 -椒 jiao 236 -拙 zhuo 235 -粽 zong 235 -耸 song 235 -钝 dun 234 -陌 mo 234 -栖 qi 232 -蛾 e 232 -鸦 ya 232 -黏 nian 232 -尬 ga 231 -茄 jia 231 -蹄 ti 231 -彰 zhang 230 -脊 ji 230 -酬 chou 230 -铀 you 230 -韧 ren 230 -巳 si 229 -瀚 han 229 -翎 ling 229 -楷 kai 228 -锥 zhui 228 -凿 zao 227 -昼 zhou 227 -砼 tong 227 -臧 zang 227 -锰 meng 227 -鞠 ju 227 -敛 lian 226 -歼 jian 226 -茧 jian 226 -擒 qin 225 -疮 chuang 225 -俭 jian 224 -峪 yu 224 -淼 miao 224 -胚 pei 224 -羡 xian 223 -戎 rong 222 -涓 juan 222 -鳞 lin 222 -惧 ju 221 -撬 qiao 221 -昧 mei 220 -纶 lun 220 -釉 you 220 -睬 cai 218 -说 shui 218 -说 yue 218 -锈 xiu 218 -喏 nuo 217 -犁 li 217 -绯 fei 217 -驿 yi 217 -橘 ju 216 -漾 yang 216 -竿 gan 216 -纠 jiu 216 -莺 ying 216 -奚 xi 215 -桔 ju 215 -甸 dian 215 -糙 cao 215 -褐 he 215 -郊 jiao 215 -殇 shang 214 -牟 mou 214 -罕 han 214 -冢 zhong 213 -旷 kuang 213 -毋 wu 213 -猿 yuan 213 -辊 gun 213 -魄 po 213 -璧 bi 212 -畀 bi 212 -纷 fen 212 -啼 ti 211 -喀 ka 211 -烹 peng 211 -笙 sheng 211 -遁 dun 211 -嗣 si 210 -姝 shu 210 -娣 di 210 -韶 shao 210 -厦 xia 209 -盔 kui 209 -腮 sai 209 -藉 jie 209 -衷 zhong 209 -铮 zheng 209 -哗 hua 208 -挟 xie 208 -辖 xia 208 -镖 biao 208 -鸥 ou 208 -枉 wang 207 -迭 die 207 -钗 chai 207 -钞 chao 207 -喧 xuan 206 -鲸 jing 206 -麓 lu 206 -嗷 ao 205 -戊 wu 205 -曜 yao 205 -筝 zheng 205 -诏 zhao 205 -梭 suo 204 -漠 mo 204 -乞 qi 203 -垢 gou 203 -恕 shu 203 -扒 pa 203 -轿 jiao 203 -栾 luan 202 -芮 rui 202 -嚷 rang 201 -捎 shao 201 -褪 tui 201 -钳 qian 201 -仑 lun 200 -宵 xiao 200 -肇 zhao 200 -苞 bao 200 -镀 du 200 -陆 liu 200 -驼 tuo 200 -巍 wei 199 -焱 yan 199 -癖 pi 199 -邬 wu 199 -叩 kou 198 -庐 lu 198 -毙 bi 198 -爻 yao 198 -珏 jue 198 -嗔 chen 197 -孽 nie 197 -茄 qie 197 -诈 zha 197 -饥 ji 197 -鸾 luan 197 -絮 xu 196 -蕙 hui 196 -岱 dai 195 -褚 chu 195 -豁 huo 195 -俯 fu 194 -括 kuo 194 -摞 luo 194 -瀑 pu 194 -婶 shen 193 -灏 hao 193 -骄 jiao 193 -偿 chang 192 -炯 jiong 192 -硪 wo 192 -堰 yan 191 -岐 qi 191 -祯 zhen 191 -匣 xia 190 -悼 dao 190 -渍 zi 190 -骤 zhou 190 -檀 tan 189 -汶 wen 189 -焚 fen 189 -窟 ku 189 -荃 quan 189 -呐 ne 188 -浣 huan 188 -谏 jian 188 -韬 tao 188 -唑 zuo 187 -檐 yan 187 -藩 fan 187 -钊 zhao 187 -榆 yu 186 -靶 ba 186 -嚎 hao 185 -瓢 piao 185 -坯 pi 184 -懵 meng 184 -擅 shan 184 -灼 zhuo 184 -肢 zhi 184 -鹭 lu 184 -仟 qian 183 -倘 tang 183 -卉 hui 183 -桢 zhen 183 -玺 xi 183 -玻 bo 183 -胥 xu 183 -嵩 song 182 -摧 cui 182 -仆 pu 181 -尻 kao 181 -溴 xiu 181 -睛 jing 181 -褒 bao 181 -谊 yi 181 -辟 bi 180 -辟 pi 180 -鬓 bin 180 -黯 an 180 -擎 qing 179 -溃 kui 179 -怔 zheng 178 -砷 shen 178 -聋 long 178 -躁 zao 178 -卯 mao 177 -噜 lu 177 -涝 lao 177 -谎 huang 177 -颁 ban 177 -俐 li 176 -圹 kuang 176 -捌 ba 176 -璋 zhang 176 -谣 yao 176 -钒 fan 176 -嘀 di 175 -弋 yi 175 -忒 te 175 -昱 yu 175 -沸 fei 175 -疲 pi 175 -硒 xi 175 -脖 bo 175 -蛊 gu 175 -躬 gong 175 -铂 bo 175 -伶 ling 174 -睹 du 174 -葡 pu 174 -垣 yuan 173 -扼 e 173 -裳 shang 173 -遛 liu 172 -阉 yan 172 -哆 duo 171 -濑 lai 171 -笃 du 171 -趾 zhi 171 -霁 ji 171 -洼 wa 170 -痔 zhi 170 -瘫 tan 170 -箍 gu 170 -囡 nan 169 -烘 hong 169 -瘀 yu 169 -钨 wu 169 -隙 xi 169 -馍 mo 169 -捋 lv 168 -榨 zha 168 -辜 gu 168 -什 shi 167 -噤 jin 167 -碾 nian 167 -缅 mian 167 -葆 bao 167 -饵 er 167 -戟 ji 166 -癫 dian 166 -蝎 xie 166 -蹿 cuan 166 -鞘 qiao 166 -麒 qi 166 -亥 hai 165 -拭 shi 165 -椎 zhui 165 -瘪 bie 165 -盅 zhong 165 -胁 xie 165 -襟 jin 165 -兀 wu 164 -囚 qiu 164 -昵 ni 164 -泊 po 164 -绛 jiang 164 -贻 yi 164 -轶 yi 164 -铵 an 164 -宸 chen 163 -晔 ye 163 -殴 ou 163 -缚 fu 163 -蓓 bei 163 -谐 xie 163 -钼 mu 163 -铎 duo 163 -锚 mao 163 -恬 tian 162 -愧 kui 162 -樵 qiao 162 -涡 wo 162 -贮 zhu 162 -岔 cha 161 -稚 zhi 161 -脐 qi 161 -苷 gan 161 -丞 cheng 160 -和 huo 160 -嗄 a 160 -坞 wu 160 -尉 wei 160 -熵 shang 160 -翊 yi 160 -孚 fu 159 -恺 kai 159 -熄 xi 159 -玥 yue 159 -硝 xiao 159 -艮 gen 159 -怯 qie 158 -珉 min 158 -腊 la 158 -腊 xi 158 -倭 wo 157 -大 dai 157 -渭 wei 157 -跺 duo 157 -赎 shu 156 -坂 ban 155 -邝 kuang 155 -陇 long 155 -屿 yu 154 -瘸 que 154 -瞻 zhan 154 -践 jian 154 -饲 si 154 -勉 mian 153 -卅 sa 153 -嘻 xi 153 -忒 tui 153 -械 xie 153 -湝 jie 153 -绫 ling 153 -荔 li 153 -虱 shi 153 -仄 ze 152 -寰 huan 152 -畜 chu 152 -痞 pi 152 -盛 cheng 152 -绰 chao 152 -绰 chuo 152 -肛 gang 152 -啄 zhuo 151 -炕 kang 151 -禧 xi 151 -竭 jie 151 -踱 duo 151 -厦 sha 150 -泠 ling 150 -瘟 wen 150 -荻 di 150 -荼 tu 150 -匝 za 149 -匿 ni 149 -晤 wu 149 -漪 yi 149 -缆 lan 149 -蔓 man 149 -蔓 wan 149 -冼 xian 148 -厕 ce 148 -疫 yi 148 -窖 jiao 148 -蔫 nian 148 -驮 tuo 148 -凳 deng 147 -咚 dong 147 -暑 shu 147 -睦 mu 147 -祀 si 147 -笺 jian 147 -莞 guan 147 -骥 ji 147 -亢 kang 146 -倔 jue 146 -削 xue 146 -剔 ti 146 -枭 xiao 146 -樽 zun 146 -礁 jiao 146 -颊 jia 146 -鸠 jiu 146 -婵 chan 145 -渚 zhu 145 -霓 ni 145 -喆 zhe 144 -歆 xin 144 -蒿 hao 144 -讷 ne 144 -丕 pi 143 -噎 ye 143 -涤 di 143 -渺 miao 143 -乾 gan 142 -寮 liao 142 -抿 min 142 -撼 han 142 -炽 chi 142 -烯 xi 142 -琨 kun 142 -町 ding 142 -绸 chou 142 -茸 rong 142 -嘲 chao 141 -噬 shi 141 -妒 du 141 -峥 zheng 141 -搔 sao 141 -漱 shu 141 -糠 kang 141 -缇 ti 141 -薯 shu 141 -雏 chu 141 -萎 wei 140 -仂 le 139 -卟 bu 139 -壬 ren 139 -彝 yi 139 -悍 han 139 -憎 zeng 139 -歉 qian 139 -秧 yang 139 -粹 cui 139 -胰 yi 139 -蕉 jiao 139 -歧 qi 138 -糯 nuo 138 -食 si 138 -鲨 sha 138 -寐 mei 137 -憾 han 137 -撵 nian 137 -涕 ti 137 -荀 xun 137 -蒽 en 137 -谬 miu 137 -驹 ju 137 -奢 she 136 -弛 chi 136 -獒 ao 136 -绾 wan 136 -藉 ji 136 -叛 pan 135 -夯 hang 135 -扎 za 135 -拈 nian 135 -溯 su 135 -狭 xia 135 -璞 pu 135 -肋 le 135 -莽 mang 135 -咋 zha 134 -桨 jiang 134 -疣 you 134 -窘 jiong 134 -绩 ji 134 -绷 beng 134 -酉 you 134 -靡 mi 134 -伯 bai 133 -勘 kan 133 -抡 lun 133 -拘 ju 133 -汐 xi 133 -泗 si 133 -谅 liang 133 -辙 zhe 133 -锄 chu 133 -鳍 qi 133 -剌 la 132 -啤 pi 132 -墅 shu 132 -暇 xia 132 -汲 ji 132 -簪 zan 132 -舵 duo 132 -隅 yu 132 -叭 ba 131 -忻 xin 131 -瀛 ying 131 -琐 suo 131 -苓 ling 131 -杵 chu 130 -枢 shu 130 -栅 shan 130 -棘 ji 130 -眩 xuan 130 -硼 peng 130 -筠 jun 130 -逍 xiao 130 -凛 lin 129 -厥 jue 129 -姣 jiao 129 -绥 sui 129 -羌 qiang 129 -诞 dan 129 -辫 bian 129 -醛 quan 129 -丐 gai 128 -仇 qiu 128 -娼 chang 128 -悄 qiao 128 -毗 pi 128 -珀 po 128 -笠 li 128 -脲 niao 128 -逞 cheng 128 -咖 ka 127 -炙 zhi 127 -癣 xuan 127 -糜 mi 127 -舀 yao 127 -锂 li 127 -镉 ge 127 -咔 ka 126 -唠 lao 126 -圃 pu 126 -栖 xi 126 -琰 yan 126 -凄 qi 125 -匙 shi 125 -啧 ze 125 -姗 shan 125 -梧 wu 125 -毯 tan 125 -沼 zhao 125 -琅 lang 125 -颅 lu 125 -颓 tui 125 -唬 hu 124 -疝 shan 124 -疹 zhen 124 -皋 gao 124 -稼 jia 124 -茫 mang 124 -萃 cui 124 -虏 lu 124 -螨 man 124 -骊 li 124 -庶 shu 123 -怂 song 123 -撅 jue 123 -晗 han 123 -钴 gu 123 -孜 zi 122 -弈 yi 122 -柚 you 122 -毡 zhan 122 -煨 wei 122 -蔽 bi 122 -隶 li 122 -骇 hai 122 -偕 xie 121 -恃 shi 121 -旻 min 121 -综 zeng 121 -综 zong 121 -翡 fei 121 -骞 qian 121 -鹃 juan 121 -凰 huang 120 -勒 lei 120 -哌 pai 120 -垄 long 120 -孑 jie 120 -宿 xiu 120 -帛 bo 120 -樟 zhang 120 -炊 chui 120 -诃 he 120 -馥 fu 120 -咂 za 119 -挎 kua 119 -濮 pu 119 -犀 xi 119 -珑 long 119 -瓮 weng 119 -磐 pan 119 -罡 gang 119 -苛 ke 119 -莘 xin 119 -霏 fei 119 -骁 xiao 119 -夭 yao 118 -攥 zuan 118 -柑 gan 118 -滔 tao 118 -炬 ju 118 -貂 diao 118 -闺 gui 118 -吱 zhi 117 -囤 tun 117 -惩 cheng 117 -洙 zhu 117 -狸 li 117 -眷 juan 117 -竺 zhu 117 -缪 miao 117 -舆 yu 117 -谗 chan 117 -邸 di 117 -嬉 xi 116 -搀 chan 116 -椿 chun 116 -泾 jing 116 -瘘 lou 116 -膳 shan 116 -觞 shang 116 -轧 zha 116 -髻 ji 116 -啖 dan 115 -沅 yuan 115 -矶 ji 115 -秽 hui 115 -戆 gang 114 -旌 jing 114 -暧 ai 114 -滢 ying 114 -濠 hao 114 -燮 xie 114 -裆 dang 114 -躯 qu 114 -锣 luo 114 -锻 duan 114 -啜 chuo 113 -弼 bi 113 -恪 ke 113 -灞 ba 113 -畦 qi 113 -痹 bi 113 -胯 kua 113 -傈 li 112 -叟 sou 112 -摹 mo 112 -番 pan 112 -癸 gui 112 -禛 zhen 112 -茉 mo 112 -醚 mi 112 -锺 zhong 112 -匾 bian 111 -吠 fei 111 -氰 qing 111 -腚 ding 111 -蓟 ji 111 -酣 han 111 -墟 xu 110 -妤 yu 110 -嫔 pin 110 -孓 jue 110 -抒 shu 110 -璃 li 110 -痂 jia 110 -羚 ling 110 -膘 biao 110 -豚 tun 110 -铠 kai 110 -锛 ben 110 -隽 jun 110 -亓 qi 109 -嘱 zhu 109 -峦 luan 109 -斟 zhen 109 -湄 mei 109 -潞 lu 109 -芋 yu 109 -蛹 yong 109 -蹙 cu 109 -锕 a 109 -镭 lei 109 -骅 hua 109 -鲤 li 109 -嚼 jue 108 -柿 shi 108 -澎 peng 108 -煦 xu 108 -珺 jun 108 -顷 qing 108 -吮 shun 107 -垛 duo 107 -曳 ye 107 -濂 lian 107 -煽 shan 107 -肋 lei 107 -蛟 jiao 107 -蜷 quan 107 -酌 zhuo 107 -吁 yu 106 -宙 zhou 106 -揩 kai 106 -渤 bo 106 -璟 jing 106 -芜 wu 106 -顽 wan 106 -骐 qi 106 -嗳 ai 105 -噌 ceng 105 -戌 xu 105 -擂 lei 105 -柬 jian 105 -槌 chui 105 -汕 shan 105 -涔 cen 105 -琉 liu 105 -糗 qiu 105 -缎 duan 105 -苘 qing 105 -崴 wai 104 -徙 xi 104 -拗 ao 104 -攸 you 104 -柠 ning 104 -艰 jian 104 -蔷 qiang 104 -鸢 yuan 104 -佣 yong 103 -敝 bi 103 -晦 hui 103 -珈 jia 103 -砰 peng 103 -缨 ying 103 -铛 dang 103 -嗑 ke 102 -嗡 weng 102 -囿 you 102 -埔 pu 102 -恍 huang 102 -磬 qing 102 -筷 kuai 102 -粱 liang 102 -褶 zhe 102 -泯 min 101 -漳 zhang 101 -禀 bing 101 -绚 xuan 101 -羲 xi 101 -聆 ling 101 -谕 yu 101 -侄 zhi 100 -侦 zhen 100 -峨 e 100 -廓 kuo 100 -攒 cuan 100 -昀 yun 100 -檬 meng 100 -沂 yi 100 -涟 lian 100 -琢 zuo 100 -蔗 zhe 100 -讼 song 100 -呱 gu 99 -嗓 sang 99 -掳 lu 99 -曙 shu 99 -萦 ying 99 -蔺 lin 99 -铨 quan 99 -鹊 que 99 -熹 xi 98 -犟 jiang 98 -甬 yong 98 -诡 gui 98 -踮 dian 98 -辄 zhe 98 -佯 yang 97 -倡 chang 97 -吭 keng 97 -弑 shi 97 -戍 shu 97 -撂 liao 97 -沦 lun 97 -罔 wang 97 -蕃 fan 97 -郴 chen 97 -佛 fu 96 -厮 si 96 -壑 he 96 -壤 rang 96 -橱 chu 96 -灸 jiu 96 -烷 wan 96 -狙 ju 96 -赦 she 96 -嬴 ying 95 -橡 xiang 95 -蚌 bang 95 -剐 gua 94 -咎 jiu 94 -巽 xun 94 -懈 xie 94 -莓 mei 94 -菡 han 94 -鄢 yan 94 -锝 de 94 -垦 ken 93 -姊 zi 93 -孬 nao 93 -庇 bi 93 -愉 yu 93 -濡 ru 93 -烽 feng 93 -箐 qing 93 -篱 li 93 -讳 hui 93 -骶 di 93 -麽 mo 93 -咕 gu 92 -唰 shua 92 -坳 ao 92 -懋 mao 92 -沣 feng 92 -烁 shuo 92 -珅 shen 92 -珞 luo 92 -睑 jian 92 -聿 yu 92 -蟒 mang 92 -釜 fu 92 -钜 ju 92 -飒 sa 92 -喈 jie 91 -恢 hui 91 -敞 chang 91 -榭 xie 91 -焙 bei 91 -牡 mu 91 -犊 du 91 -篷 peng 91 -簧 huang 91 -膛 tang 91 -膨 peng 91 -迩 er 91 -呤 ling 90 -杞 qi 90 -棣 di 90 -铐 kao 90 -闰 run 90 -坷 ke 89 -慨 kai 89 -扈 hu 89 -歹 dai 89 -烩 hui 89 -砣 tuo 89 -祢 mi 89 -萤 ying 89 -僚 liao 88 -掬 ju 88 -芊 qian 88 -跋 ba 88 -铉 xuan 88 -隘 ai 88 -鳃 sai 88 -鳌 ao 88 -嘶 si 87 -惫 bei 87 -桀 jie 87 -畸 ji 87 -篆 zhuan 87 -郢 ying 87 -鄙 bi 87 -颦 pin 87 -驭 yu 87 -驯 xun 87 -叨 dao 86 -噩 e 86 -宕 dang 86 -榻 ta 86 -汾 fen 86 -烃 ting 86 -袂 mei 86 -钡 bei 86 -陋 lou 86 -隼 sun 86 -馊 sou 86 -乂 yi 85 -伢 ya 85 -劢 mai 85 -哺 bu 85 -宦 huan 85 -捺 na 85 -朽 xiu 85 -渥 wo 85 -瑰 gui 85 -钥 yao 85 -馈 kui 85 -鼬 you 85 -僻 pi 84 -冕 mian 84 -刎 wen 84 -嘈 cao 84 -嚣 xiao 84 -彳 chi 84 -祚 zuo 84 -耘 yun 84 -舫 fang 84 -遐 xia 84 -酰 xian 84 -伎 ji 83 -佚 yi 83 -俪 li 83 -喳 zha 83 -嗖 sou 83 -屙 e 83 -戋 jian 83 -敕 chi 83 -瑕 xia 83 -篓 lou 83 -诲 hui 83 -鳄 e 83 -偎 wei 82 -剟 duo 82 -挚 zhi 82 -捍 han 82 -汛 xun 82 -燎 liao 82 -筵 yan 82 -蹈 dao 82 -吆 yao 81 -幔 man 81 -幡 fan 81 -揖 yi 81 -暄 xuan 81 -秩 zhi 81 -缁 zi 81 -翦 jian 81 -蜕 tui 81 -讽 feng 81 -谟 mo 81 -赃 zang 81 -颌 he 81 -亍 chu 80 -侩 kuai 80 -俑 yong 80 -吡 bi 80 -掖 ye 80 -昶 chang 80 -槛 kan 80 -沏 qi 80 -纂 zuan 80 -腌 yan 80 -蹇 jian 80 -霾 mai 80 -尢 you 79 -岫 xiu 79 -淦 gan 79 -溥 pu 79 -秆 gan 79 -箕 ji 79 -箩 luo 79 -绽 zhan 79 -衙 ya 79 -靛 dian 79 -颍 ying 79 -鲲 kun 79 -龋 qu 79 -偃 yan 78 -叱 chi 78 -唧 ji 78 -噔 deng 78 -淬 cui 78 -舸 ge 78 -谌 chen 78 -霭 ai 78 -嗝 ge 77 -姥 lao 77 -怠 dai 77 -晰 xi 77 -淞 song 77 -蚝 hao 77 -衢 qu 77 -讥 ji 77 -喱 li 76 -夬 guai 76 -婿 xu 76 -擀 gan 76 -栀 zhi 76 -沱 tuo 76 -涎 xian 76 -绅 shen 76 -迸 beng 76 -扉 fei 75 -氦 hai 75 -淀 dian 75 -漕 cao 75 -筠 yun 75 -荪 sun 75 -葭 jia 75 -袅 niao 75 -裱 biao 75 -谥 shi 75 -钺 yue 75 -饺 jiao 75 -孵 fu 74 -屹 yi 74 -岙 ao 74 -幂 mi 74 -戮 lu 74 -朦 meng 74 -榄 lan 74 -殖 zhi 74 -洵 xun 74 -畿 ji 74 -矾 fan 74 -碴 cha 74 -稽 ji 74 -谍 die 74 -妨 fang 73 -恳 ken 73 -澍 shu 73 -皿 min 73 -羟 qiang 73 -羿 yi 73 -吁 xu 72 -咿 yi 72 -孢 bao 72 -尕 ga 72 -怖 bu 72 -抻 chen 72 -濯 zhuo 72 -盹 dun 72 -矜 jin 72 -腑 fu 72 -臼 jiu 72 -舷 xian 72 -荟 hui 72 -萘 nai 72 -萼 e 72 -虔 qian 72 -逵 kui 72 -锑 ti 72 -飨 xiang 72 -鬃 zong 72 -侑 you 71 -啐 cui 71 -喙 hui 71 -夔 kui 71 -撸 lu 71 -杳 yao 71 -潼 tong 71 -煊 xuan 71 -疱 pao 71 -筏 fa 71 -绡 xiao 71 -郦 li 71 -饷 xiang 71 -偈 ji 70 -啡 fei 70 -楣 mei 70 -汰 tai 70 -篡 cuan 70 -舶 bo 70 -荚 jia 70 -褂 gua 70 -诣 yi 70 -诫 jie 70 -镐 gao 70 -嫡 di 69 -宓 mi 69 -惭 can 69 -椀 wan 69 -殆 dai 69 -潩 yi 69 -焯 chao 69 -痢 li 69 -穹 qiong 69 -讹 e 69 -谒 ye 69 -赘 zhui 69 -颚 e 69 -吝 lin 68 -枷 jia 68 -楔 xie 68 -氓 mang 68 -痧 sha 68 -眶 kuang 68 -祗 zhi 68 -蟾 chan 68 -诬 wu 68 -铆 mao 68 -雉 zhi 68 -咨 zi 67 -婢 bi 67 -帷 wei 67 -榴 liu 67 -琏 lian 67 -璜 huang 67 -蜗 wo 67 -螯 ao 67 -赊 she 67 -铣 xi 67 -髯 ran 67 -剜 wan 66 -唾 tuo 66 -圳 zhen 66 -娑 suo 66 -忱 chen 66 -梃 ting 66 -膈 ge 66 -芥 jie 66 -辇 nian 66 -骡 luo 66 -厝 cuo 65 -啷 lang 65 -巅 dian 65 -晁 chao 65 -榛 zhen 65 -澶 chan 65 -瓤 rang 65 -痨 lao 65 -睫 jie 65 -蔬 shu 65 -轧 ya 65 -铿 keng 65 -阪 ban 65 -仝 tong 64 -哏 gen 64 -嘣 beng 64 -塾 shu 64 -壕 hao 64 -戾 li 64 -昙 tan 64 -曝 pu 64 -溺 ni 64 -牲 sheng 64 -皎 jiao 64 -翀 chong 64 -苒 ran 64 -蛰 zhe 64 -跷 qiao 64 -陨 yun 64 -颉 jie 64 -鳝 shan 64 -亘 gen 63 -勐 meng 63 -嗪 qin 63 -琀 han 63 -缔 di 63 -翌 yi 63 -耨 nou 63 -胫 jing 63 -蘑 mo 63 -鸳 yuan 63 -圩 wei 62 -堇 jin 62 -夙 su 62 -恣 zi 62 -槎 cha 62 -泌 mi 62 -砺 li 62 -竣 jun 62 -羁 ji 62 -耽 dan 62 -荧 ying 62 -葳 wei 62 -衾 qin 62 -谯 qiao 62 -辐 fu 62 -邈 miao 62 -颞 nie 62 -骸 hai 62 -俘 fu 61 -冽 lie 61 -忿 fen 61 -惶 huang 61 -拄 zhu 61 -掣 che 61 -棹 zhao 61 -椴 duan 61 -瀹 yue 61 -焗 ju 61 -熠 yi 61 -琬 wan 61 -睐 lai 61 -砾 li 61 -祆 xian 61 -臊 sao 61 -蘼 mi 61 -蛛 zhu 61 -赈 zhen 61 -踝 huai 61 -锉 cuo 61 -镔 bin 61 -咣 guang 60 -塬 yuan 60 -岷 min 60 -嵘 rong 60 -瓒 zan 60 -篁 huang 60 -耆 qi 60 -芍 shao 60 -蔑 mie 60 -蚜 ya 60 -蝗 huang 60 -贿 hui 60 -遏 e 60 -钚 bu 60 -铰 jiao 60 -鳗 man 60 -咻 xiu 59 -娉 ping 59 -彧 yu 59 -慵 yong 59 -昇 sheng 59 -滗 bi 59 -漩 xuan 59 -琮 cong 59 -祐 you 59 -绉 zhou 59 -缮 shan 59 -羯 jie 59 -舛 chuan 59 -蘅 heng 59 -跛 bo 59 -颢 hao 59 -鹫 jiu 59 -屉 ti 58 -殃 yang 58 -殉 xun 58 -浚 jun 58 -狩 shou 58 -猷 you 58 -玟 wen 58 -祷 dao 58 -翳 yi 58 -耦 ou 58 -肴 yao 58 -蛀 zhu 58 -谧 mi 58 -郅 zhi 58 -銮 luan 58 -锆 gao 58 -锗 zhe 58 -锟 kun 58 -锴 kai 58 -陛 bi 58 -剿 jiao 57 -啮 nie 57 -奠 dian 57 -姘 pin 57 -孺 ru 57 -恙 yang 57 -愕 e 57 -椽 chuan 57 -浏 liu 57 -漓 li 57 -璨 can 57 -痿 wei 57 -蝠 fu 57 -谙 an 57 -谤 bang 57 -趑 zi 57 -镛 yong 57 -俸 feng 56 -倌 guan 56 -匈 xiong 56 -哽 geng 56 -噙 qin 56 -囔 nang 56 -壅 yong 56 -惰 duo 56 -慑 she 56 -摒 bing 56 -斛 hu 56 -浠 xi 56 -煸 bian 56 -翩 pian 56 -谰 lan 56 -郜 gao 56 -闾 lv 56 -隽 juan 56 -韭 jiu 56 -骋 cheng 56 -黍 shu 56 -嗽 sou 55 -峭 qiao 55 -怵 chu 55 -桅 wei 55 -殁 mo 55 -牒 die 55 -狡 jiao 55 -瑙 nao 55 -盂 yu 55 -硌 ge 55 -箔 bo 55 -粲 can 55 -胧 long 55 -芡 qian 55 -蒯 kuai 55 -贲 ben 55 -跤 jiao 55 -迄 qi 55 -媳 xi 54 -寥 liao 54 -弁 bian 54 -怅 chang 54 -悖 bei 54 -槿 jin 54 -溏 tang 54 -瑄 xuan 54 -瞽 gu 54 -箴 zhen 54 -藜 li 54 -辕 yuan 54 -酪 lao 54 -钕 nv 54 -镬 huo 54 -佗 tuo 53 -吒 zha 53 -哮 xiao 53 -啉 lin 53 -嗫 nie 53 -宥 you 53 -恹 yan 53 -愀 qiao 53 -拇 mu 53 -桉 an 53 -桡 rao 53 -瓯 ou 53 -疵 ci 53 -睢 sui 53 -蔼 ai 53 -蚤 zao 53 -讵 ju 53 -阖 he 53 -龛 kan 53 -仞 ren 52 -墒 shang 52 -嫉 ji 52 -寞 mo 52 -峙 zhi 52 -攘 rang 52 -楹 ying 52 -渎 du 52 -熨 yun 52 -绶 shou 52 -耙 ba 52 -腱 jian 52 -膀 bang 52 -螟 ming 52 -谚 yan 52 -蹋 ta 52 -阕 que 52 -髂 qia 52 -鹆 yu 52 -嗤 chi 51 -噘 jue 51 -埂 geng 51 -妲 da 51 -幌 huang 51 -棰 chui 51 -澧 li 51 -矗 chu 51 -缃 xiang 51 -腋 ye 51 -莆 pu 51 -蓑 suo 51 -藐 miao 51 -蜇 zhe 51 -谪 zhe 51 -跖 zhi 51 -踞 ju 51 -锹 qiao 51 -阐 chan 51 -哓 xiao 50 -忞 min 50 -悯 min 50 -檫 cha 50 -氡 dong 50 -泅 qiu 50 -涣 huan 50 -渲 xuan 50 -滟 yan 50 -痈 yong 50 -粕 po 50 -绎 yi 50 -缙 jin 50 -蜒 yan 50 -谴 qian 50 -迂 yu 50 -遽 ju 50 -镂 lou 50 -镯 zhuo 50 -傣 dai 49 -兢 jing 49 -刈 yi 49 -拯 zheng 49 -欤 yu 49 -沤 ou 49 -燊 shen 49 -窒 zhi 49 -箸 zhu 49 -粑 ba 49 -绦 tao 49 -苄 bian 49 -蓼 liao 49 -蕨 jue 49 -觑 qu 49 -辔 pei 49 -钎 qian 49 -镰 lian 49 -雒 luo 49 -伫 zhu 48 -佃 dian 48 -啶 ding 48 -垚 yao 48 -奂 huan 48 -娲 wa 48 -崧 song 48 -庾 yu 48 -怛 da 48 -氖 nai 48 -汊 cha 48 -沮 ju 48 -泷 long 48 -璀 cui 48 -璎 ying 48 -癞 lai 48 -绀 gan 48 -蔻 kou 48 -跚 shan 48 -锶 si 48 -镗 tang 48 -骰 tou 48 -髁 ke 48 -傩 nuo 47 -圻 qi 47 -晌 shang 47 -泮 pan 47 -泸 lu 47 -湜 shi 47 -濒 bin 47 -珪 gui 47 -甯 ning 47 -祉 zhi 47 -稷 ji 47 -茴 hui 47 -荇 xing 47 -荜 bi 47 -钯 ba 47 -钽 tan 47 -隧 sui 47 -馁 nei 47 -馕 nang 47 -垃 la 46 -嫦 chang 46 -忏 chan 46 -揆 kui 46 -杈 cha 46 -栩 xu 46 -椤 luo 46 -橹 lu 46 -滘 jiao 46 -璁 cong 46 -疙 ge 46 -祜 hu 46 -纣 zhou 46 -腓 fei 46 -腴 yu 46 -蟀 shuai 46 -诠 quan 46 -邰 tai 46 -霈 pei 46 -侮 wu 45 -僦 jiu 45 -刍 chu 45 -嚯 huo 45 -嵇 ji 45 -徊 huai 45 -扪 men 45 -橇 qiao 45 -汴 bian 45 -琇 xiu 45 -琶 pa 45 -盎 ang 45 -睨 ni 45 -缉 ji 45 -誊 teng 45 -谮 zen 45 -赭 zhe 45 -邺 ye 45 -髋 kuan 45 -佸 huo 44 -侗 dong 44 -倏 shu 44 -劭 shao 44 -匮 kui 44 -嶂 zhang 44 -彗 hui 44 -忖 cun 44 -暝 ming 44 -氐 di 44 -琥 hu 44 -皑 ai 44 -砧 zhen 44 -翱 ao 44 -腩 nan 44 -腭 e 44 -轲 ke 44 -铊 ta 44 -铌 ni 44 -铡 zha 44 -韫 yun 44 -驽 nu 44 -嘁 qi 43 -埭 dai 43 -妊 ren 43 -娆 rao 43 -惺 xing 43 -愠 yun 43 -枳 zhi 43 -浔 xun 43 -湍 tuan 43 -祟 sui 43 -缈 miao 43 -羔 gao 43 -膻 shan 43 -芠 wen 43 -莅 li 43 -螭 chi 43 -讫 qi 43 -踵 zhong 43 -酋 qiu 43 -钏 chuan 43 -铋 bi 43 -驷 si 43 -冗 rong 42 -呶 nao 42 -哞 mou 42 -帏 wei 42 -昴 mao 42 -畈 fan 42 -碇 ding 42 -肱 gong 42 -膺 ying 42 -苕 shao 42 -虬 qiu 42 -诒 yi 42 -诧 cha 42 -醌 kun 42 -铖 cheng 42 -锷 e 42 -鲫 ji 42 -乒 ping 41 -亟 ji 41 -俎 zu 41 -匕 bi 41 -奄 yan 41 -妫 gui 41 -恽 yun 41 -撷 xie 41 -桁 heng 41 -濞 bi 41 -炀 yang 41 -皙 xi 41 -莜 you 41 -菀 wan 41 -菩 pu 41 -蔸 dou 41 -谶 chen 41 -辍 chuo 41 -阈 yu 41 -魇 yan 41 -俨 yan 40 -僖 xi 40 -堑 qian 40 -婊 biao 40 -惕 ti 40 -揸 zha 40 -猩 xing 40 -础 chu 40 -磋 cuo 40 -胄 zhou 40 -芩 qin 40 -苡 yi 40 -葶 ting 40 -衿 jin 40 -蹩 bie 40 -蹴 cu 40 -迳 jing 40 -凫 fu 39 -匆 cong 39 -喋 die 39 -垠 yin 39 -垭 ya 39 -掸 dan 39 -斫 zhuo 39 -晞 xi 39 -殒 yun 39 -殓 lian 39 -毂 gu 39 -炝 qiang 39 -碌 lu 39 -禺 yu 39 -稗 bai 39 -窿 long 39 -轸 zhen 39 -遒 qiu 39 -邕 yong 39 -郗 xi 39 -铟 yin 39 -镱 yi 39 -陂 bei 39 -颀 qi 39 -骈 pian 39 -偌 ruo 38 -嘹 liao 38 -噫 yi 38 -徇 xun 38 -忝 tian 38 -忤 wu 38 -洱 er 38 -潍 wei 38 -稔 ren 38 -箬 ruo 38 -羸 lei 38 -裾 ju 38 -诽 fei 38 -譬 pi 38 -酵 jiao 38 -鞅 yang 38 -馔 zhuan 38 -骠 biao 38 -佺 quan 37 -僭 jian 37 -坍 tan 37 -埙 xun 37 -妩 wu 37 -恸 tong 37 -挞 ta 37 -涸 he 37 -淖 nao 37 -澹 dan 37 -爰 yuan 37 -猝 cu 37 -瑗 yuan 37 -矬 cuo 37 -纾 shu 37 -肟 wo 37 -荞 qiao 37 -萜 tie 37 -蠹 du 37 -诤 zheng 37 -诳 kuang 37 -轼 shi 37 -锵 qiang 37 -鬟 huan 37 -厩 jiu 36 -啾 jiu 36 -喹 kui 36 -墉 yong 36 -悸 ji 36 -慷 kang 36 -斡 wo 36 -旄 mao 36 -柘 zhe 36 -梆 bang 36 -氩 ya 36 -珥 er 36 -甑 zeng 36 -祼 guan 36 -脘 wan 36 -葩 pa 36 -藓 xian 36 -虢 guo 36 -褥 ru 36 -訾 zi 36 -铑 lao 36 -阡 qian 36 -俚 li 35 -哂 shen 35 -哐 kuang 35 -哔 bi 35 -噼 pi 35 -姹 cha 35 -徕 lai 35 -憩 qi 35 -挹 yi 35 -攫 jue 35 -椋 liang 35 -榫 sun 35 -樾 yue 35 -殡 bin 35 -氛 fen 35 -泱 yang 35 -洮 tao 35 -漉 lu 35 -烬 jin 35 -煅 duan 35 -狎 xia 35 -獾 huan 35 -璘 lin 35 -畲 she 35 -痫 xian 35 -皤 po 35 -羧 suo 35 -覃 tan 35 -醯 xi 35 -铳 chong 35 -雹 bao 35 -靥 ye 35 -鲛 jiao 35 -倬 zhuo 34 -儋 dan 34 -嘭 peng 34 -圪 ge 34 -圮 pi 34 -婺 wu 34 -屾 shen 34 -崛 jue 34 -悭 qian 34 -懦 nuo 34 -擢 zhuo 34 -枋 fang 34 -椁 guo 34 -榔 lang 34 -洹 huan 34 -涪 fu 34 -爨 cuan 34 -爪 zhao 34 -痤 cuo 34 -眦 zi 34 -窸 xi 34 -舐 shi 34 -苻 fu 34 -蜥 xi 34 -豺 chai 34 -邯 han 34 -颏 ke 34 -骧 xiang 34 -佥 qian 33 -侥 jiao 33 -勖 xu 33 -唵 an 33 -帜 zhi 33 -樯 qiang 33 -汆 cuan 33 -滁 chu 33 -瘴 zhang 33 -癜 dian 33 -砬 la 33 -祓 fu 33 -篙 gao 33 -纨 wan 33 -缄 jian 33 -缰 jiang 33 -耒 lei 33 -舂 chong 33 -芃 peng 33 -苴 ju 33 -茑 niao 33 -菂 di 33 -菅 jian 33 -菟 tu 33 -蜿 wan 33 -褰 qian 33 -诌 zhou 33 -诰 gao 33 -谑 xue 33 -谔 e 33 -谝 pian 33 -谲 jue 33 -赉 lai 33 -逻 luo 33 -邋 la 33 -酝 yun 33 -鲷 diao 33 -麝 she 33 -凼 dang 32 -垸 yuan 32 -屐 ji 32 -庹 tuo 32 -彘 zhi 32 -揶 ye 32 -椐 ju 32 -浒 hu 32 -溧 li 32 -滂 pang 32 -玑 ji 32 -玳 dai 32 -籁 lai 32 -粳 jing 32 -缶 fou 32 -臆 yi 32 -蚬 xian 32 -蟠 pan 32 -觋 xi 32 -诩 xu 32 -遨 ao 32 -邃 sui 32 -邛 qiong 32 -铢 zhu 32 -镌 juan 32 -霰 xian 32 -骢 cong 32 -佤 wa 31 -侈 chi 31 -叵 po 31 -恻 ce 31 -惮 dan 31 -搪 tang 31 -杩 ma 31 -栊 long 31 -桧 gui 31 -楸 qiu 31 -涞 lai 31 -湮 yan 31 -潴 zhu 31 -牝 pin 31 -猥 wei 31 -珐 fa 31 -皴 cun 31 -綦 qi 31 -缭 liao 31 -罹 li 31 -胍 gua 31 -芪 qi 31 -菘 song 31 -蚴 you 31 -觐 jin 31 -谳 yan 31 -赂 lu 31 -赍 ji 31 -赡 shan 31 -酊 ding 31 -阱 jing 31 -鹜 wu 31 -亵 xie 30 -佞 ning 30 -偓 wo 30 -嗬 he 30 -妣 bi 30 -姻 yin 30 -娓 wei 30 -帚 zhou 30 -惘 wang 30 -桠 ya 30 -殚 dan 30 -沓 da 30 -浼 mei 30 -渌 lu 30 -滦 luan 30 -炆 wen 30 -煋 xing 30 -瑀 yu 30 -疚 jiu 30 -痉 jing 30 -碓 dui 30 -笏 hu 30 -籼 xian 30 -绺 liu 30 -绻 quan 30 -缂 ke 30 -肼 jing 30 -腈 jing 30 -莒 ju 30 -菠 bo 30 -蓦 mo 30 -蕈 xun 30 -薨 hong 30 -蘖 nie 30 -蚧 jie 30 -蜈 wu 30 -蟆 ma 30 -蠡 li 30 -豕 shi 30 -酽 yan 30 -铒 er 30 -颧 quan 30 -黢 qu 30 -咛 ning 29 -哧 chi 29 -姒 si 29 -婠 wan 29 -嫒 ai 29 -怆 chuang 29 -憬 jing 29 -掇 duo 29 -搡 sang 29 -旎 ni 29 -旮 ga 29 -昉 fang 29 -殛 ji 29 -洇 yin 29 -淄 zi 29 -淅 xi 29 -湃 pai 29 -溱 qin 29 -爿 pan 29 -琚 ju 29 -瑨 jin 29 -砜 feng 29 -笈 ji 29 -簟 dian 29 -罄 qing 29 -苌 chang 29 -茱 zhu 29 -莳 shi 29 -薏 yi 29 -觊 ji 29 -讶 ya 29 -蹑 nie 29 -逯 lu 29 -郾 yan 29 -铄 shuo 29 -铕 you 29 -铧 hua 29 -阗 tian 29 -鲟 xun 29 -仵 wu 28 -剽 piao 28 -哝 nong 28 -唏 xi 28 -啬 se 28 -埸 yi 28 -孛 bei 28 -崮 gu 28 -嶷 yi 28 -戛 jia 28 -潋 lian 28 -燧 sui 28 -狷 juan 28 -瑚 hu 28 -甾 zai 28 -疃 tuan 28 -瞑 ming 28 -窠 ke 28 -脯 pu 28 -苫 shan 28 -茆 mao 28 -茔 ying 28 -莘 shen 28 -菏 he 28 -萸 yu 28 -蝌 ke 28 -诹 zou 28 -谩 man 28 -蹶 jue 28 -逑 qiu 28 -钤 qian 28 -镲 cha 28 -镳 biao 28 -阂 he 28 -鲈 lu 28 -鹞 yao 28 -鼢 fen 28 -亶 dan 27 -侏 zhu 27 -呖 li 27 -唳 li 27 -塍 cheng 27 -屼 wu 27 -岘 xian 27 -徨 huang 27 -搴 qian 27 -杲 gao 27 -椪 peng 27 -沭 shu 27 -洌 lie 27 -睾 gao 27 -瞰 kan 27 -砭 bian 27 -硎 xing 27 -碉 diao 27 -缜 zhen 27 -胭 yan 27 -腠 cou 27 -菽 shu 27 -萄 tao 27 -蕤 rui 27 -薜 bi 27 -衮 gun 27 -讦 jie 27 -豸 zhi 27 -趸 dun 27 -迤 yi 27 -郧 yun 27 -鄄 juan 27 -钿 dian 27 -铍 pi 27 -骛 wu 27 -骜 ao 27 -鲠 geng 27 -鸩 zhen 27 -鹂 li 27 -麸 fu 27 -黜 chu 27 -呋 fu 26 -嗥 hao 26 -囤 dun 26 -圯 yi 26 -嫫 mo 26 -嬛 huan 26 -峒 dong 26 -嵴 ji 26 -幄 wo 26 -弭 mi 26 -徘 pai 26 -恚 hui 26 -悌 ti 26 -惦 dian 26 -戢 ji 26 -扦 qian 26 -掼 guan 26 -楝 lian 26 -楫 ji 26 -楮 chu 26 -橄 gan 26 -湟 huang 26 -焓 han 26 -猗 yi 26 -玷 dian 26 -疽 ju 26 -瘿 ying 26 -磡 kan 26 -莼 chun 26 -蕞 zui 26 -蝈 guo 26 -蟑 zhang 26 -蠕 ru 26 -衲 na 26 -觇 chan 26 -诓 kuang 26 -谀 yu 26 -赳 jiu 26 -醮 jiao 26 -醴 li 26 -铱 yi 26 -镓 jia 26 -馏 liu 26 -馒 man 26 -鹋 miao 26 -麂 ji 26 -乓 pang 25 -亳 bo 25 -墀 chi 25 -幛 zhang 25 -庑 wu 25 -庖 pao 25 -惇 dun 25 -愍 min 25 -擤 xing 25 -斓 lan 25 -枰 ping 25 -槟 bin 25 -檄 xi 25 -沌 dun 25 -泐 le 25 -獭 ta 25 -玎 ding 25 -珣 xun 25 -珲 hui 25 -瑷 ai 25 -疡 yang 25 -眄 mian 25 -瞋 chen 25 -砥 di 25 -硐 dong 25 -秸 jie 25 -紊 wen 25 -缤 bin 25 -缥 piao 25 -翕 xi 25 -翮 he 25 -肓 huang 25 -芗 xiang 25 -茕 qiong 25 -葫 hu 25 -葺 qi 25 -蚓 yin 25 -蜓 ting 25 -蝴 hu 25 -螂 lang 25 -讣 fu 25 -豢 huan 25 -赓 geng 25 -蹰 chu 25 -轭 e 25 -逄 pang 25 -邳 pi 25 -铈 shi 25 -镧 lan 25 -雎 ju 25 -骓 zhui 25 -鬻 yu 25 -鲧 gun 25 -鸸 er 25 -倜 ti 24 -倮 luo 24 -劼 jie 24 -劾 he 24 -呻 shen 24 -哙 kuai 24 -哚 duo 24 -嫚 man 24 -嫱 qiang 24 -寤 wu 24 -岬 jia 24 -岿 kui 24 -庠 xiang 24 -徉 yang 24 -恂 xun 24 -恫 dong 24 -悒 yi 24 -悚 song 24 -惋 wan 24 -惬 qie 24 -揿 qin 24 -沔 mian 24 -泫 xuan 24 -洄 hui 24 -瀵 fen 24 -狞 ning 24 -瑁 mao 24 -痱 fei 24 -绠 geng 24 -缢 yi 24 -耱 mo 24 -脍 kuai 24 -茁 zhuo 24 -蒌 lou 24 -蓁 zhen 24 -薮 sou 24 -蠲 juan 24 -谄 chan 24 -跻 ji 24 -郯 tan 24 -闳 hong 24 -霹 pi 24 -饬 chi 24 -鲑 gui 24 -冏 jiong 23 -刳 ku 23 -剀 kai 23 -嗌 ai 23 -怿 yi 23 -抟 tuan 23 -曩 nang 23 -槊 shuo 23 -橐 tuo 23 -汜 si 23 -洧 wei 23 -涘 si 23 -淙 cong 23 -滃 weng 23 -潢 huang 23 -焐 wu 23 -狈 bei 23 -獠 liao 23 -畹 wan 23 -瞩 zhu 23 -窣 su 23 -竦 song 23 -笆 ba 23 -糍 ci 23 -胳 ge 23 -膦 lin 23 -衅 xin 23 -袒 tan 23 -酞 tai 23 -锲 qie 23 -鎏 liu 23 -饯 jian 23 -饴 yi 23 -驺 zou 23 -鸬 lu 23 -鼾 han 23 -佶 ji 22 -俦 chou 22 -凇 song 22 -刿 gui 22 -啭 zhuan 22 -嚅 ru 22 -埵 duo 22 -媪 ao 22 -嵛 yu 22 -廪 lin 22 -挛 luan 22 -旖 yi 22 -桄 guang 22 -氪 ke 22 -涐 e 22 -潺 chan 22 -焜 kun 22 -熘 liu 22 -牯 gu 22 -牺 xi 22 -犇 ben 22 -獐 zhang 22 -玠 jie 22 -琵 pi 22 -瓠 hu 22 -甥 sheng 22 -疖 jie 22 -痪 huan 22 -眇 miao 22 -砀 dang 22 -筌 quan 22 -篦 bi 22 -篾 mie 22 -簋 gui 22 -缦 man 22 -纛 dao 22 -臌 gu 22 -苋 xian 22 -苧 ning 22 -菰 gu 22 -萁 qi 22 -葑 feng 22 -蒇 chan 22 -蚂 ma 22 -蜃 shen 22 -襦 ru 22 -迨 dai 22 -酐 gan 22 -醪 lao 22 -镒 yi 22 -闼 ta 22 -陟 zhi 22 -骟 shan 22 -鲻 zi 22 -麾 hui 22 -黻 fu 22 -鼐 nai 22 -侔 mou 21 -吽 hong 21 -咀 ju 21 -咫 zhi 21 -喑 yin 21 -垟 yang 21 -婀 e 21 -峁 mao 21 -忉 dao 21 -憭 liao 21 -挝 wo 21 -曷 he 21 -枥 li 21 -栉 zhi 21 -栎 li 21 -桎 zhi 21 -桤 qi 21 -樘 tang 21 -滏 fu 21 -滹 hu 21 -烜 xuan 21 -牍 du 21 -瘙 sao 21 -矸 gan 21 -祇 qi 21 -箧 qie 21 -缬 xie 21 -胗 zhen 21 -苎 zhu 21 -荏 ren 21 -薅 hao 21 -蛴 qi 21 -蝽 chun 21 -袢 pan 21 -谠 dang 21 -踊 yong 21 -钹 bo 21 -镝 di 21 -镫 deng 21 -顼 xu 21 -鲢 lian 21 -鳐 yao 21 -鹄 gu 21 -鼋 yuan 21 -龌 wo 21 -儆 jing 20 -卮 zhi 20 -厣 yan 20 -呓 yi 20 -咆 pao 20 -咐 fu 20 -咤 zha 20 -圾 ji 20 -坭 ni 20 -垡 fa 20 -埕 cheng 20 -夐 xiong 20 -娈 luan 20 -媾 gou 20 -嫪 lao 20 -孪 luan 20 -徂 cu 20 -徼 jiao 20 -怙 hu 20 -惚 hu 20 -愫 su 20 -拊 fu 20 -柞 zha 20 -柰 nai 20 -棂 ling 20 -毐 ai 20 -氘 dao 20 -氲 yun 20 -汔 qi 20 -洺 ming 20 -淝 fei 20 -溷 hun 20 -滓 zi 20 -炷 zhu 20 -琯 guan 20 -瑭 tang 20 -璠 fan 20 -盱 xu 20 -眭 sui 20 -穰 rang 20 -窈 yao 20 -笳 jia 20 -糅 rou 20 -罘 fu 20 -脩 xiu 20 -芈 mi 20 -芣 fu 20 -芫 yan 20 -蕲 qi 20 -蛎 li 20 -蛩 qiong 20 -觌 di 20 -觥 gong 20 -赙 fu 20 -踌 chou 20 -蹼 pu 20 -辗 nian 20 -邴 bing 20 -邾 zhu 20 -鄱 po 20 -铪 ha 20 -铯 se 20 -锏 jian 20 -闩 shuan 20 -阒 qu 20 -霪 yin 20 -鞑 da 20 -鬣 lie 20 -鹳 guan 20 -佽 ci 19 -兖 yan 19 -卣 you 19 -嗵 tong 19 -垩 e 19 -奁 lian 19 -妗 jin 19 -崒 zu 19 -帼 guo 19 -彖 tuan 19 -怃 wu 19 -怄 ou 19 -戣 kui 19 -抉 jue 19 -挈 qie 19 -旃 zhan 19 -昝 zan 19 -栝 gua 19 -殄 tian 19 -殳 shu 19 -溉 gai 19 -熜 cong 19 -猾 hua 19 -疥 jie 19 -碛 qi 19 -笞 chi 19 -箪 dan 19 -篪 chi 19 -簸 bo 19 -纫 ren 19 -缗 min 19 -耋 die 19 -耧 lou 19 -耷 da 19 -肪 fang 19 -胬 nu 19 -胱 guang 19 -腆 tian 19 -腙 zong 19 -苜 mu 19 -蓥 ying 19 -蘩 fan 19 -蜱 pi 19 -蝾 rong 19 -觎 yu 19 -诅 zu 19 -赁 lin 19 -赅 gai 19 -躔 chan 19 -郏 jia 19 -酹 lei 19 -铗 jia 19 -霎 sha 19 -饕 tao 19 -骝 liu 19 -髦 mao 19 -鲇 nian 19 -鲵 ni 19 -鳅 qiu 19 -鳓 le 19 -鳙 yong 19 -鹑 chun 19 -佼 jiao 18 -儇 xuan 18 -吩 fen 18 -咙 long 18 -嚄 huo 18 -坼 che 18 -嬬 ru 18 -孖 ma 18 -嵬 wei 18 -帙 zhi 18 -彷 pang 18 -憧 chong 18 -懊 ao 18 -捩 lie 18 -掾 yuan 18 -摈 bin 18 -杓 biao 18 -楦 xuan 18 -槲 hu 18 -歙 she 18 -氚 chuan 18 -汨 mi 18 -涿 zhuo 18 -濋 chu 18 -燚 yi 18 -牖 you 18 -猖 chang 18 -玦 jue 18 -瘠 ji 18 -碣 jie 18 -禳 rang 18 -稣 su 18 -笫 zi 18 -笄 ji 18 -筮 shi 18 -粼 lin 18 -绌 chu 18 -绱 shang 18 -缟 gao 18 -翥 zhu 18 -耄 mao 18 -耜 si 18 -聃 dan 18 -蛭 zhi 18 -蜚 fei 18 -讪 shan 18 -讴 ou 18 -诔 lei 18 -诟 gou 18 -貉 hao 18 -蹊 qi 18 -锔 ju 18 -镣 liao 18 -颔 han 18 -魉 liang 18 -鲆 ping 18 -鸯 yang 18 -鹚 ci 18 -黼 fu 18 -黾 min 18 -龊 chuo 18 -仃 ding 17 -傕 jue 17 -僮 tong 17 -剞 ji 17 -嚭 pi 17 -墘 qian 17 -娌 li 17 -嬿 yan 17 -岜 ba 17 -峄 yi 17 -崭 zhan 17 -弢 tao 17 -忸 niu 17 -悃 kun 17 -悴 cui 17 -戗 qiang 17 -暹 xian 17 -栲 kao 17 -桕 jiu 17 -椭 tuo 17 -槁 gao 17 -洑 fu 17 -洳 ru 17 -瓴 ling 17 -痼 gu 17 -瘁 cui 17 -眍 kou 17 -竽 yu 17 -笥 si 17 -笪 da 17 -箨 tuo 17 -篌 hou 17 -绔 ku 17 -罴 pi 17 -舄 xi 17 -茏 long 17 -藁 gao 17 -蚩 chi 17 -衽 ren 17 -褟 ta 17 -谖 xuan 17 -贽 zhi 17 -赜 ze 17 -赝 yan 17 -趔 lie 17 -轫 ren 17 -迢 tiao 17 -逋 bu 17 -邂 xie 17 -邗 han 17 -郄 qie 17 -鄞 yin 17 -醺 xun 17 -钇 yi 17 -鋆 yun 17 -錾 zan 17 -锨 xian 17 -镞 zu 17 -陲 chui 17 -隹 zhui 17 -骺 hou 17 -鱿 you 17 -鲎 hou 17 -鸵 tuo 17 -鹗 e 17 -鹬 yu 17 -黝 you 17 -伈 xin 16 -俳 pai 16 -倨 ju 16 -勰 xie 16 -匐 fu 16 -呙 guo 16 -咄 duo 16 -圉 yu 16 -垆 lu 16 -埝 nian 16 -姮 heng 16 -岢 ke 16 -徜 chang 16 -忮 zhi 16 -怩 ni 16 -怼 dui 16 -戕 qiang 16 -戡 kan 16 -扃 jiong 16 -捃 jun 16 -杼 zhu 16 -欸 ai 16 -洎 ji 16 -洚 jiang 16 -漈 ji 16 -炅 jiong 16 -煳 hu 16 -犍 jian 16 -珙 gong 16 -瓘 guan 16 -町 ting 16 -纡 yu 16 -绂 fu 16 -绋 fu 16 -罂 ying 16 -翟 di 16 -肫 zhun 16 -芨 ji 16 -苣 ju 16 -茭 jiao 16 -荩 jin 16 -荭 hong 16 -蒉 kui 16 -蘧 qu 16 -蛔 hui 16 -蜩 tiao 16 -蟮 shan 16 -蠃 luo 16 -衄 nv 16 -衩 cha 16 -詈 li 16 -诂 gu 16 -谡 su 16 -豨 xi 16 -赧 nan 16 -邨 cun 16 -郇 huan 16 -鄣 zhang 16 -醅 pei 16 -钍 tu 16 -镪 qiang 16 -阆 lang 16 -隈 wei 16 -隳 hui 16 -鹾 cuo 16 -黥 qing 16 -佻 tiao 15 -侪 chai 15 -僳 su 15 -兕 si 15 -嘧 mi 15 -圬 wu 15 -屣 xi 15 -廋 sou 15 -徭 yao 15 -戬 jian 15 -拶 za 15 -摭 zhi 15 -柁 duo 15 -柝 tuo 15 -槭 qi 15 -樗 chu 15 -溻 ta 15 -猡 luo 15 -玢 bin 15 -璈 ao 15 -璩 qu 15 -疬 li 15 -疳 gan 15 -瘗 yi 15 -皈 gui 15 -磴 deng 15 -粿 guo 15 -绁 xie 15 -绐 dai 15 -绗 hang 15 -繇 yao 15 -缯 zeng 15 -肮 ang 15 -膑 bin 15 -芾 fei 15 -茀 fu 15 -葙 xiang 15 -蓍 shi 15 -蔟 cu 15 -薹 tai 15 -虻 meng 15 -蚶 han 15 -蛏 cheng 15 -蝮 fu 15 -裟 sha 15 -褛 lv 15 -诋 di 15 -貅 xiu 15 -蹂 rou 15 -蹉 cuo 15 -逶 wei 15 -遢 ta 15 -钋 po 15 -钌 liao 15 -铙 nao 15 -铥 diu 15 -铼 lai 15 -锇 e 15 -鏊 ao 15 -阊 chang 15 -陬 zou 15 -鞣 rou 15 -顸 han 15 -飧 sun 15 -馄 hun 15 -骖 can 15 -魃 ba 15 -鹦 ying 15 -黠 xia 15 -龇 zi 15 -龠 yue 15 -伥 chang 14 -伲 ni 14 -侂 tuo 14 -俣 yu 14 -俶 chu 14 -僰 bo 14 -刭 jing 14 -匦 gui 14 -厍 she 14 -囟 xin 14 -埇 yong 14 -妪 yu 14 -嵋 mei 14 -幢 chuang 14 -忑 te 14 -忡 chong 14 -悢 liang 14 -愔 yin 14 -掭 tian 14 -擞 sou 14 -晷 gui 14 -杪 miao 14 -柢 di 14 -柩 jiu 14 -榷 que 14 -殂 cu 14 -殍 piao 14 -殪 yi 14 -氆 pu 14 -氇 lu 14 -涑 su 14 -溦 wei 14 -潆 ying 14 -牦 mao 14 -狒 fei 14 -甪 lu 14 -痊 quan 14 -瘩 da 14 -瞠 cheng 14 -碜 chen 14 -碲 di 14 -磔 zhe 14 -秫 shu 14 -篝 gou 14 -罅 xia 14 -脒 mi 14 -艏 shou 14 -茌 chi 14 -莠 you 14 -蒹 jian 14 -蓖 bi 14 -蓠 li 14 -蝼 lou 14 -谫 jian 14 -豳 bin 14 -貘 mo 14 -趿 ta 14 -逅 hou 14 -逦 li 14 -郓 yun 14 -酎 zhou 14 -酩 ming 14 -钣 ban 14 -铘 ye 14 -闱 wei 14 -驸 fu 14 -髡 kun 14 -鬘 man 14 -鲳 chang 14 -鳎 ta 14 -鸱 chi 14 -鹈 ti 14 -麋 mi 14 -伉 kang 13 -伾 pi 13 -佾 yi 13 -倥 kong 13 -偁 cheng 13 -呔 dai 13 -咴 hui 13 -嘌 piao 13 -囱 cong 13 -垏 lv 13 -妯 zhou 13 -嬖 bi 13 -尴 gan 13 -庥 xiu 13 -彀 gou 13 -慝 te 13 -憔 qiao 13 -抔 pou 13 -拮 jie 13 -攽 ban 13 -旒 liu 13 -曌 zhao 13 -曛 xun 13 -枸 gou 13 -柊 zhong 13 -栅 zha 13 -楂 zha 13 -檩 lin 13 -氤 yin 13 -沩 wei 13 -湔 jian 13 -湫 jiao 13 -溲 sou 13 -犷 guang 13 -瘃 zhu 13 -瘕 jia 13 -瘢 ban 13 -盍 he 13 -眈 dan 13 -碚 bei 13 -簌 su 13 -粞 xi 13 -纭 yun 13 -缡 li 13 -胝 zhi 13 -胴 dong 13 -腼 mian 13 -舻 lu 13 -芰 ji 13 -茚 yin 13 -萩 qiu 13 -葸 xi 13 -虺 hui 13 -袪 qu 13 -觏 gou 13 -觚 gu 13 -诜 shen 13 -谵 zhan 13 -貊 mo 13 -跄 qiang 13 -跗 fu 13 -躞 xie 13 -遄 chuan 13 -酆 feng 13 -锢 gu 13 -镄 fei 13 -镊 nie 13 -隰 xi 13 -雩 yu 13 -靓 jing 13 -韪 wei 13 -颟 man 13 -飑 biao 13 -饔 yong 13 -骘 zhi 13 -髀 bi 13 -髌 bin 13 -髭 zi 13 -鬲 ge 13 -魈 xiao 13 -鲋 fu 13 -鳕 xue 13 -伺 ci 12 -倓 tan 12 -傥 tang 12 -刖 yue 12 -匏 pao 12 -嚆 hao 12 -坜 li 12 -坩 gan 12 -垓 gai 12 -垴 nao 12 -堞 die 12 -娩 mian 12 -媲 pi 12 -孀 shuang 12 -屦 ju 12 -岣 gou 12 -嵊 sheng 12 -廛 chan 12 -廨 xie 12 -懔 lin 12 -捭 bai 12 -旆 pei 12 -暾 tun 12 -杷 pa 12 -枧 jian 12 -桴 fu 12 -毖 bi 12 -氅 chang 12 -汩 gu 12 -汹 xiong 12 -浈 zhen 12 -浯 wu 12 -淆 xiao 12 -溽 ru 12 -炘 xin 12 -烊 yang 12 -燔 fan 12 -狍 pao 12 -猊 ni 12 -猬 wei 12 -猺 yao 12 -獬 xie 12 -璺 wen 12 -甦 su 12 -畋 tian 12 -痦 wu 12 -癍 ban 12 -瞭 liao 12 -礴 bo 12 -秣 mo 12 -箜 kong 12 -篑 kui 12 -篚 fei 12 -缣 jian 12 -耩 jiang 12 -聩 kui 12 -苤 pie 12 -莨 lang 12 -薤 xie 12 -蛄 gu 12 -蛉 ling 12 -蜞 qi 12 -蝰 kui 12 -蠊 lian 12 -谆 zhun 12 -赀 zi 12 -趱 zan 12 -跆 tai 12 -踅 xue 12 -躇 chu 12 -辂 lu 12 -遴 lin 12 -醍 ti 12 -钪 kang 12 -钭 tou 12 -钲 zheng 12 -鞨 he 12 -颛 zhuan 12 -馗 kui 12 -鲂 fang 12 -鳏 guan 12 -鹪 jiao 12 -鼯 wu 12 -乩 ji 11 -俅 qiu 11 -劓 yi 11 -嗍 suo 11 -嘤 ying 11 -噍 jiao 11 -噱 jue 11 -嚏 ti 11 -囵 lun 11 -垌 dong 11 -埘 shi 11 -堋 peng 11 -奭 shi 11 -嬗 shan 11 -屺 qi 11 -峤 jiao 11 -崂 lao 11 -崆 kong 11 -崦 yan 11 -崾 yao 11 -嶙 lin 11 -忪 song 11 -怍 zuo 11 -悫 que 11 -悱 fei 11 -戽 hu 11 -掊 pou 11 -掎 ji 11 -枞 cong 11 -橛 jue 11 -沆 hang 11 -泺 luo 11 -泚 ci 11 -洸 guang 11 -湣 min 11 -灈 qu 11 -猱 nao 11 -玹 xuan 11 -琊 ya 11 -眚 sheng 11 -矧 shen 11 -砻 long 11 -礅 dun 11 -秕 bi 11 -穑 se 11 -窕 tiao 11 -竑 hong 11 -籴 di 11 -缑 gou 11 -缛 ru 11 -耔 zi 11 -胪 lu 11 -臃 yong 11 -芴 wu 11 -茯 fu 11 -蒈 kai 11 -蓐 ru 11 -藿 huo 11 -蜘 zhi 11 -袱 fu 11 -裥 jian 11 -褊 bian 11 -訇 hong 11 -诎 qu 11 -趺 fu 11 -轱 gu 11 -郃 he 11 -醢 hai 11 -钬 huo 11 -铷 ru 11 -镨 pu 11 -陔 gai 11 -隍 huang 11 -雠 chou 11 -鞯 jian 11 -饩 xi 11 -餮 tie 11 -馐 xiu 11 -骷 ku 11 -鼍 tuo 11 -鼙 pi 11 -仳 pi 10 -侁 shen 10 -偬 zong 10 -偲 cai 10 -儳 chan 10 -剡 shan 10 -唁 yan 10 -啜 chuai 10 -啻 chi 10 -喟 kui 10 -嗐 hai 10 -囹 ling 10 -埌 lang 10 -埒 lie 10 -夼 kuang 10 -媱 yao 10 -媵 ying 10 -巯 qiu 10 -幞 fu 10 -庤 zhi 10 -弶 jiang 10 -怦 peng 10 -悛 quan 10 -惆 chou 10 -愆 qian 10 -愦 kui 10 -搠 shuo 10 -撄 ying 10 -擐 huan 10 -敫 jiao 10 -晫 zhuo 10 -椟 du 10 -榈 lv 10 -榧 fei 10 -槃 pan 10 -橼 yuan 10 -殣 jin 10 -浥 yi 10 -淠 pi 10 -湴 ban 10 -犰 qiu 10 -甍 meng 10 -痍 yi 10 -瘰 luo 10 -矍 jue 10 -硖 xia 10 -禤 xuan 10 -稹 zhen 10 -粜 tiao 10 -缱 qian 10 -缳 huan 10 -缵 zuan 10 -耥 tang 10 -臬 nie 10 -舣 yi 10 -苁 cong 10 -苈 li 10 -苾 bi 10 -荦 luo 10 -莸 you 10 -蕗 lu 10 -蚺 ran 10 -螅 xi 10 -螵 piao 10 -蠓 meng 10 -诮 qiao 10 -豇 jiang 10 -赕 dan 10 -赟 yun 10 -跎 tuo 10 -跣 xian 10 -跹 xian 10 -踺 jian 10 -躅 zhu 10 -辘 lu 10 -辚 lin 10 -逖 ti 10 -遑 huang 10 -遘 gou 10 -郤 xi 10 -酃 ling 10 -镦 dui 10 -闶 kang 10 -阃 kun 10 -阄 jiu 10 -阍 hun 10 -雳 li 10 -骼 ge 10 -魍 wang 10 -鲽 die 10 -鸨 bao 10 -鸷 zhi 10 -齑 ji 10 -侉 kua 9 -偰 xie 9 -偾 fen 9 -傒 xi 9 -僬 jiao 9 -匍 pu 9 -唿 hu 9 -圜 huan 9 -夥 huo 9 -婪 lan 9 -嫄 yuan 9 -孱 can 9 -峋 xun 9 -崀 lang 9 -恿 yong 9 -悆 yu 9 -悻 xing 9 -惴 zhui 9 -抨 peng 9 -搐 chu 9 -柙 xia 9 -榉 ju 9 -榖 gu 9 -檠 qing 9 -氍 qu 9 -氙 xian 9 -泔 gan 9 -泞 ning 9 -湓 pen 9 -濉 sui 9 -瀣 xie 9 -煟 wei 9 -犒 kao 9 -狰 zheng 9 -狳 yu 9 -琤 cheng 9 -甓 pi 9 -畚 ben 9 -疔 ding 9 -疠 li 9 -瞌 ke 9 -瞢 meng 9 -砟 zha 9 -礞 meng 9 -窨 xun 9 -笕 jian 9 -筘 kou 9 -纥 ge 9 -絷 zhi 9 -绨 ti 9 -罾 zeng 9 -羼 chan 9 -耠 huo 9 -胂 shen 9 -舾 xi 9 -艉 wei 9 -艋 meng 9 -艿 nai 9 -芏 du 9 -茼 tong 9 -莛 ting 9 -菖 chang 9 -萋 qi 9 -蕖 qu 9 -蚋 rui 9 -蚰 you 9 -螃 pang 9 -螬 cao 9 -螳 tang 9 -蟋 xi 9 -裎 cheng 9 -褡 da 9 -襞 bi 9 -觯 zhi 9 -觳 hu 9 -诙 hui 9 -謇 jian 9 -踉 liang 9 -轵 zhi 9 -迺 nai 9 -酗 xu 9 -酡 tuo 9 -酤 gu 9 -铞 diao 9 -铴 tang 9 -鋈 wu 9 -镆 mo 9 -镏 liu 9 -阌 wen 9 -陉 xing 9 -靼 da 9 -飓 ju 9 -饨 tun 9 -餍 yan 9 -魑 chi 9 -鲞 xiang 9 -鲼 fen 9 -鳢 li 9 -鸫 dong 9 -麈 zhu 9 -黟 yi 9 -龅 bao 9 -仉 zhang 8 -伋 ji 8 -俵 biao 8 -傧 bin 8 -勍 qing 8 -吲 yin 8 -呣 m 8 -咝 si 8 -嗉 su 8 -垵 an 8 -埚 guo 8 -埼 qi 8 -埽 sao 8 -堍 tu 8 -堙 yin 8 -堠 hou 8 -墁 man 8 -姤 gou 8 -姽 gui 8 -媸 chi 8 -孥 nu 8 -孳 zi 8 -宬 cheng 8 -尥 liao 8 -岌 ji 8 -嵎 yu 8 -嵯 cuo 8 -庋 gui 8 -忾 kai 8 -悝 kui 8 -惔 tan 8 -慊 qian 8 -懑 men 8 -搛 jian 8 -摽 biao 8 -撺 cuan 8 -旯 la 8 -旸 yang 8 -晢 zhe 8 -曈 tong 8 -枇 pi 8 -柃 ling 8 -柽 cheng 8 -栌 lu 8 -栻 shi 8 -棐 fei 8 -棻 fen 8 -毳 cui 8 -浃 jia 8 -浞 zhuo 8 -涠 wei 8 -涫 guan 8 -淏 hao 8 -湉 tian 8 -滆 ge 8 -滠 she 8 -漭 mang 8 -燠 yu 8 -牮 jian 8 -猁 li 8 -玡 ya 8 -珧 yao 8 -瓿 bu 8 -畤 zhi 8 -疴 ke 8 -痄 zha 8 -瘌 la 8 -瘳 chou 8 -癔 yi 8 -睚 ya 8 -睽 kui 8 -砒 pi 8 -禊 xi 8 -稞 ke 8 -穸 xi 8 -笾 bian 8 -箅 bi 8 -籀 zhou 8 -糁 san 8 -绲 gun 8 -縻 mi 8 -缫 sao 8 -罟 gu 8 -罽 ji 8 -翾 xuan 8 -肄 yi 8 -舲 ling 8 -芑 qi 8 -芟 shan 8 -芤 kou 8 -茳 jiang 8 -荛 rao 8 -蓊 weng 8 -蔌 su 8 -虮 ji 8 -虿 chai 8 -蚱 zha 8 -蛘 yang 8 -蛸 shao 8 -蝻 nan 8 -螋 sou 8 -袤 mao 8 -裉 ken 8 -裨 bi 8 -褫 chi 8 -褴 lan 8 -豌 wan 8 -趄 ju 8 -跂 qi 8 -蹚 tang 8 -郐 kuai 8 -鄜 fu 8 -钆 ga 8 -锃 zeng 8 -镢 jue 8 -阋 xi 8 -靺 mo 8 -飕 sou 8 -驵 zang 8 -鬈 quan 8 -鬯 chang 8 -鳔 biao 8 -黹 zhi 8 -龆 tiao 8 -仫 mu 7 -佖 bi 7 -佝 gou 7 -偻 lou 7 -傺 chi 7 -儡 lei 7 -劬 qu 7 -啕 tao 7 -嚓 ca 7 -囷 qun 7 -圄 yu 7 -圊 qing 7 -坫 dian 7 -垕 hou 7 -垤 die 7 -垧 shang 7 -埴 zhi 7 -堉 yu 7 -塄 leng 7 -墼 ji 7 -姞 ji 7 -媄 mei 7 -崟 yin 7 -帑 tang 7 -帻 ze 7 -廒 ao 7 -弸 peng 7 -忭 bian 7 -怏 yang 7 -憷 chu 7 -掮 qian 7 -揄 yu 7 -攮 nang 7 -昃 ze 7 -昺 bing 7 -朐 qu 7 -枨 cheng 7 -栳 lao 7 -桫 suo 7 -桷 jue 7 -棬 quan 7 -楗 jian 7 -樨 xi 7 -櫆 kui 7 -毽 jian 7 -沚 zhi 7 -洘 kao 7 -洿 wu 7 -浡 bo 7 -渫 xie 7 -渼 mei 7 -溘 ke 7 -澂 cheng 7 -澌 si 7 -炻 shi 7 -烝 zheng 7 -煺 tui 7 -狃 niu 7 -狺 yin 7 -獗 jue 7 -獯 xun 7 -疸 dan 7 -瘛 chi 7 -盥 guan 7 -矞 yu 7 -砑 ya 7 -秭 zi 7 -稃 fu 7 -窅 yao 7 -筢 pa 7 -篼 dou 7 -纰 pi 7 -缌 si 7 -缧 lei 7 -胛 jia 7 -芄 wan 7 -苊 e 7 -荠 ji 7 -荬 mai 7 -莴 wo 7 -菥 xi 7 -葚 ren 7 -葜 qia 7 -蒎 pai 7 -蒨 qian 7 -虓 xiao 7 -蚪 dou 7 -蜴 yi 7 -蜻 qing 7 -螈 yuan 7 -螫 shi 7 -蟪 hui 7 -襻 pan 7 -诨 hun 7 -诿 wei 7 -贶 kuang 7 -趼 jian 7 -跸 bi 7 -躜 zuan 7 -辎 zi 7 -遹 yu 7 -邙 mang 7 -郫 pi 7 -郸 dan 7 -鄯 shan 7 -钐 shan 7 -钫 fang 7 -铩 sha 7 -鍪 mou 7 -鏖 ao 7 -靬 qian 7 -鞫 ju 7 -颡 sang 7 -馘 guo 7 -骒 ke 7 -髹 xiu 7 -鲩 huan 7 -鲴 gu 7 -鲺 shi 7 -鹇 xian 7 -鹘 gu 7 -鹣 jian 7 -鹩 liao 7 -黉 hong 7 -黧 li 7 -龉 yu 7 -侘 cha 6 -倻 ye 6 -劁 qiao 6 -劂 jue 6 -卺 jin 6 -吣 qin 6 -喾 ku 6 -囫 hu 6 -坌 ben 6 -坻 chi 6 -垙 guang 6 -垿 xu 6 -奘 zang 6 -妁 shuo 6 -娠 shen 6 -嫘 lei 6 -嫭 hu 6 -宄 gui 6 -尜 ga 6 -岽 dong 6 -崃 lai 6 -崤 xiao 6 -崶 feng 6 -嶝 deng 6 -帔 pei 6 -弇 yan 6 -悰 cong 6 -惛 hun 6 -愎 bi 6 -戥 deng 6 -扆 yi 6 -摅 shu 6 -晡 bu 6 -栴 zhan 6 -桊 juan 6 -棤 cuo 6 -棼 fen 6 -槠 zhu 6 -橥 zhu 6 -檗 bo 6 -沄 yun 6 -泖 mao 6 -溆 xu 6 -漤 lan 6 -漯 luo 6 -潲 shao 6 -潸 shan 6 -瀍 chan 6 -烔 tong 6 -狻 suan 6 -瑢 rong 6 -瑧 zhen 6 -畛 zhen 6 -疰 zhu 6 -瘐 yu 6 -盦 an 6 -睃 suo 6 -砝 fa 6 -硗 qiao 6 -磻 pan 6 -禋 yin 6 -秬 ju 6 -窭 ju 6 -窳 yu 6 -笱 gou 6 -筇 qiong 6 -筚 bi 6 -箢 yuan 6 -粝 li 6 -缒 zhui 6 -罍 lei 6 -罨 yan 6 -羑 you 6 -耖 chao 6 -耢 lao 6 -耪 pang 6 -耵 ding 6 -胼 pian 6 -脬 pao 6 -腧 shu 6 -臾 yu 6 -舁 yu 6 -舴 ze 6 -艽 jiao 6 -茛 gen 6 -荥 xing 6 -莶 xian 6 -菪 dang 6 -萣 ding 6 -蒴 shuo 6 -蓿 xu 6 -蕺 ji 6 -蕻 hong 6 -蛐 qu 6 -蜢 meng 6 -蜣 qiang 6 -蟛 peng 6 -蠖 huo 6 -袈 jia 6 -裒 pou 6 -褓 bao 6 -褕 yu 6 -诖 gua 6 -诼 zhuo 6 -谇 sui 6 -赇 qiu 6 -踔 chuo 6 -踣 bo 6 -踯 zhi 6 -蹒 pan 6 -逡 qun 6 -逭 huan 6 -邠 bin 6 -郛 fu 6 -鄹 zou 6 -酲 cheng 6 -銎 qiong 6 -锍 liu 6 -锒 lang 6 -锘 nuo 6 -镥 lu 6 -阼 zuo 6 -陞 sheng 6 -隗 kui 6 -雱 pang 6 -鞮 di 6 -饪 ren 6 -饫 yu 6 -馑 jin 6 -鲭 qing 6 -鳟 zun 6 -鸪 gu 6 -鹕 hu 6 -鹛 mei 6 -黩 du 6 -黪 can 6 -鼹 yan 6 -龃 ju 6 -丏 mian 5 -伧 cang 5 -伭 xian 5 -侹 ting 5 -俟 qi 5 -倧 zong 5 -傃 su 5 -剋 ke 5 -哳 zha 5 -哿 ge 5 -唢 suo 5 -嗾 sou 5 -垲 kai 5 -埆 que 5 -埤 pi 5 -堨 ye 5 -墦 fan 5 -夤 yin 5 -娀 song 5 -婞 xing 5 -嫠 li 5 -岈 ya 5 -岖 qu 5 -崌 ju 5 -崞 guo 5 -嵝 lou 5 -嵫 zi 5 -忐 tan 5 -恝 jia 5 -捽 zuo 5 -搦 nuo 5 -摛 chi 5 -敉 mi 5 -旰 gan 5 -暲 zhang 5 -朓 tiao 5 -梏 gu 5 -棨 qi 5 -椹 shen 5 -榇 chen 5 -榱 cui 5 -橦 tong 5 -毵 san 5 -毹 shu 5 -浬 li 5 -湎 mian 5 -溍 jin 5 -滉 huang 5 -漶 huan 5 -漷 huo 5 -澥 xie 5 -烀 hu 5 -燏 yu 5 -爇 ruo 5 -牁 ke 5 -牂 zang 5 -狨 rong 5 -狲 sun 5 -猇 xiao 5 -猰 ya 5 -獍 jing 5 -玕 gan 5 -玘 qi 5 -珋 liu 5 -珛 xiu 5 -珽 ting 5 -珷 wu 5 -瘊 hou 5 -癃 long 5 -皞 hao 5 -眢 yuan 5 -瞍 sou 5 -砗 che 5 -砘 dun 5 -碹 xuan 5 -礓 jiang 5 -祧 tiao 5 -禔 zhi 5 -禚 zhuo 5 -稑 lu 5 -窬 yu 5 -筅 xian 5 -箖 lin 5 -箦 ze 5 -簏 lu 5 -簠 fu 5 -籥 yue 5 -粢 zi 5 -羰 tang 5 -胙 zuo 5 -脔 luan 5 -脞 cuo 5 -腽 wa 5 -舳 zhu 5 -艄 shao 5 -艟 chong 5 -艴 fu 5 -苠 min 5 -荽 sui 5 -菔 fu 5 -蚍 pi 5 -蚣 gong 5 -蚯 qiu 5 -蛞 kuo 5 -蛱 jia 5 -蜊 li 5 -蜮 yu 5 -蝙 bian 5 -螽 zhong 5 -衃 pei 5 -裢 lian 5 -襁 qiang 5 -跏 jia 5 -踬 zhi 5 -踽 ju 5 -蹀 die 5 -辏 cou 5 -迓 ya 5 -邶 bei 5 -醵 ju 5 -釐 li 5 -铽 te 5 -锞 ke 5 -锱 zi 5 -锸 cha 5 -镘 man 5 -镩 cuan 5 -陴 pi 5 -鞒 qiao 5 -鞲 gou 5 -饽 bo 5 -馓 san 5 -髅 lou 5 -鲅 ba 5 -鲐 tai 5 -鲥 shi 5 -鲮 ling 5 -鳜 gui 5 -鸲 qu 5 -鸶 si 5 -鸹 gua 5 -鹨 liu 5 -麹 qu 5 -鼗 tao 5 -鼩 qu 5 -龀 chen 5 -伛 yu 4 -佁 yi 4 -俍 liang 4 -俜 ping 4 -倞 jing 4 -偡 zhan 4 -偭 mian 4 -僔 zun 4 -刽 gui 4 -劄 zha 4 -劐 huo 4 -虒 si 4 -哃 tong 4 -唣 zao 4 -唼 sha 4 -喤 huang 4 -嘏 gu 4 -堧 ruan 4 -塱 lang 4 -奡 ao 4 -尪 wang 4 -岵 hu 4 -崁 kan 4 -嶒 ceng 4 -巉 chan 4 -帡 ping 4 -帨 shui 4 -廑 jin 4 -怫 fu 4 -恧 nv 4 -慬 qin 4 -憙 xi 4 -憺 dan 4 -扺 zhi 4 -捯 dao 4 -掞 shan 4 -揠 ya 4 -攉 huo 4 -敔 yu 4 -斝 jia 4 -晱 shan 4 -暌 kui 4 -杌 wu 4 -枘 rui 4 -枵 xiao 4 -梣 cen 4 -棫 yu 4 -椎 chui 4 -槔 gao 4 -檎 qin 4 -檞 jie 4 -氾 fan 4 -汧 qian 4 -汭 rui 4 -洨 xiao 4 -涴 wo 4 -淯 yu 4 -渟 ting 4 -溞 sao 4 -潏 yu 4 -潦 lao 4 -澉 gan 4 -澪 ling 4 -瀼 rang 4 -烺 lang 4 -燹 xian 4 -犏 pian 4 -犴 an 4 -犸 ma 4 -狁 yun 4 -猢 hu 4 -猹 cha 4 -珦 xiang 4 -瑱 zhen 4 -瓞 die 4 -甏 beng 4 -甡 shen 4 -畯 jun 4 -瘥 chai 4 -瘵 zhai 4 -癀 huang 4 -皛 xiao 4 -眙 yi 4 -矻 ku 4 -硭 mang 4 -磙 gun 4 -禒 xian 4 -禘 di 4 -笤 tiao 4 -筥 ju 4 -篥 li 4 -簖 duan 4 -簰 pai 4 -糇 hou 4 -纩 kuang 4 -縠 hu 4 -縢 teng 4 -繄 yi 4 -羝 di 4 -翃 hong 4 -聍 ning 4 -肷 qian 4 -胨 dong 4 -胩 ka 4 -胲 hai 4 -脶 luo 4 -膂 lv 4 -舯 zhong 4 -艚 cao 4 -茈 ci 4 -茺 chong 4 -荖 lao 4 -莰 kan 4 -萆 bi 4 -萑 huan 4 -蒗 lang 4 -蓰 xi 4 -薷 ru 4 -虼 ge 4 -蚨 fu 4 -蛲 nao 4 -蛳 si 4 -蜉 fu 4 -蜾 guo 4 -蝓 yu 4 -蝤 qiu 4 -螗 tang 4 -蟊 mao 4 -蠋 zhu 4 -袗 zhen 4 -袼 ge 4 -裣 lian 4 -襜 chan 4 -觫 su 4 -诘 ji 4 -谿 xi 4 -貔 pi 4 -贳 shi 4 -赆 jin 4 -跽 ji 4 -蹁 pian 4 -蹯 fan 4 -躏 lin 4 -躐 lie 4 -轳 lu 4 -轹 li 4 -辁 quan 4 -辋 wang 4 -迮 ze 4 -邡 fang 4 -邽 gui 4 -郿 mei 4 -酢 cu 4 -醐 hu 4 -醑 xu 4 -钔 men 4 -锖 qiang 4 -锪 huo 4 -锫 pei 4 -锾 huan 4 -镕 rong 4 -霅 zha 4 -骱 jie 4 -髫 tiao 4 -鬏 jiu 4 -鲔 wei 4 -鲕 er 4 -鲚 ji 4 -鲣 jian 4 -鲦 tiao 4 -鳊 bian 4 -鹉 wu 4 -鹌 an 4 -鹎 bei 4 -齁 hou 4 -龈 ken 4 -仡 ge 3 -佴 er 3 -俙 xi 3 -僇 lu 3 -冁 chan 3 -剕 fei 3 -匜 yi 3 -匼 ke 3 -啁 zhao 3 -噀 xun 3 -垞 cha 3 -埯 an 3 -塝 bang 3 -墈 kan 3 -墚 liang 3 -墡 shan 3 -墣 pu 3 -姱 kua 3 -嫽 liao 3 -岠 ju 3 -岍 qian 3 -崚 leng 3 -嶲 xi 3 -廙 yi 3 -怊 chao 3 -慆 tao 3 -慥 zao 3 -扂 dian 3 -抃 bian 3 -挓 zha 3 -搋 chuai 3 -搌 zhan 3 -擘 bai 3 -斠 jiao 3 -昳 die 3 -枍 yi 3 -柈 ban 3 -椠 qian 3 -楙 mao 3 -楯 dun 3 -榍 xie 3 -檑 lei 3 -歃 sha 3 -泃 ju 3 -洈 wei 3 -洫 xu 3 -洭 kuang 3 -洴 ping 3 -浭 geng 3 -湑 xu 3 -湲 yuan 3 -滍 zhi 3 -潽 pu 3 -澼 pi 3 -濩 huo 3 -炟 da 3 -炱 tai 3 -焘 dao 3 -煃 kui 3 -煓 tuan 3 -牚 cheng 3 -牿 gu 3 -犄 ji 3 -狯 kuai 3 -猕 mi 3 -珰 dang 3 -珹 cheng 3 -琲 bei 3 -璆 qiu 3 -瓖 xiang 3 -甗 yan 3 -畎 quan 3 -痃 xuan 3 -瘅 dan 3 -癯 qu 3 -皦 jiao 3 -皲 jun 3 -盉 he 3 -睥 pi 3 -瞀 mao 3 -砹 ai 3 -硇 nao 3 -祊 beng 3 -祏 shi 3 -祲 jin 3 -稆 lv 3 -稂 lang 3 -稙 zhi 3 -窀 zhun 3 -笊 zhao 3 -笸 po 3 -簃 yi 3 -糈 xu 3 -糨 jiang 3 -缪 mou 3 -罱 lan 3 -羖 gu 3 -羕 yang 3 -翯 he 3 -聱 ao 3 -肭 na 3 -舢 shan 3 -艨 meng 3 -荑 ti 3 -荨 xun 3 -荸 bi 3 -菉 lu 3 -菼 tan 3 -萏 dan 3 -蒐 sou 3 -蒡 bang 3 -蒺 ji 3 -蔹 lian 3 -薸 piao 3 -藟 lei 3 -蛑 mou 3 -蝣 you 3 -蝥 mao 3 -蠼 qu 3 -裛 yi 3 -裰 duo 3 -褙 bei 3 -觖 jue 3 -讧 hong 3 -趵 bao 3 -踟 chi 3 -辿 chan 3 -迕 wu 3 -邘 yu 3 -鄠 hu 3 -鄫 zeng 3 -酴 tu 3 -酺 pu 3 -醭 bu 3 -铹 lao 3 -锎 kai 3 -锓 qin 3 -锩 juan 3 -锬 tan 3 -锼 sou 3 -锿 ai 3 -镅 mei 3 -鐾 bei 3 -靰 wu 3 -鞡 la 3 -鞴 bei 3 -馇 cha 3 -髑 du 3 -鲒 jie 3 -鲱 fei 3 -鳘 min 3 -鸺 xiu 3 -鹁 bo 3 -鹧 zhe 3 -鹱 hu 3 -麇 jun 3 -鼷 xi 3 -鼽 qiu 3 -儦 biao 2 -冱 hu 2 -哢 long 2 -唪 feng 2 -喁 yong 2 -嚚 yin 2 -埏 shan 2 -堌 gu 2 -堎 leng 2 -堽 gang 2 -奓 zha 2 -妘 yun 2 -姈 ling 2 -婌 shu 2 -婍 qi 2 -婘 quan 2 -媓 huang 2 -嫕 yi 2 -嫜 zhang 2 -宧 yi 2 -岊 jie 2 -崿 e 2 -嶓 bo 2 -幪 meng 2 -庳 bi 2 -弨 chao 2 -徛 ji 2 -忺 xian 2 -惎 ji 2 -惝 chang 2 -戤 gai 2 -掴 guai 2 -揳 xie 2 -摴 chu 2 -撖 han 2 -撙 zun 2 -斶 chu 2 -旐 zhao 2 -旿 wu 2 -昄 ban 2 -晪 tian 2 -朏 fei 2 -杙 yi 2 -枲 xi 2 -桲 po 2 -梠 lv 2 -梼 tao 2 -棪 yan 2 -椑 bei 2 -椓 zhuo 2 -楩 pian 2 -槱 you 2 -欻 chua 2 -毌 guan 2 -毪 mu 2 -氕 pie 2 -汫 jing 2 -沇 yan 2 -沘 bi 2 -泂 jiong 2 -泇 jia 2 -泙 ping 2 -洢 yi 2 -淴 hu 2 -渑 mian 2 -漼 cui 2 -潟 xi 2 -潾 lin 2 -澭 yong 2 -熇 he 2 -爚 yue 2 -爝 jue 2 -牻 mang 2 -牾 wu 2 -犨 chou 2 -狴 bi 2 -玓 di 2 -玭 pin 2 -玶 ping 2 -珖 guang 2 -珵 cheng 2 -琭 lu 2 -瑆 xing 2 -瑑 zhuan 2 -瑳 cuo 2 -疢 chen 2 -瘼 mo 2 -皕 bi 2 -眊 mao 2 -眵 chi 2 -瞵 lin 2 -矰 zeng 2 -砮 nu 2 -碥 bian 2 -磉 sang 2 -磲 qu 2 -礌 lei 2 -祕 mi 2 -穄 ji 2 -穜 zhong 2 -窊 wa 2 -筦 guan 2 -筲 shao 2 -筻 gang 2 -糌 zan 2 -罶 liu 2 -翛 xiao 2 -耇 gou 2 -耰 you 2 -肸 xi 2 -脎 sa 2 -臑 nao 2 -芼 mao 2 -荄 gai 2 -荮 zhou 2 -莙 jun 2 -莩 fu 2 -菝 ba 2 -葎 lv 2 -蒄 guan 2 -蒻 ruo 2 -蓣 yu 2 -蔀 bu 2 -蕹 weng 2 -薁 yu 2 -薿 ni 2 -藦 mo 2 -藨 biao 2 -蘘 rang 2 -蜍 chu 2 -蜎 yuan 2 -蟥 huang 2 -衎 kan 2 -衒 xuan 2 -裼 ti 2 -褯 jie 2 -觱 bi 2 -觿 xi 2 -貆 huan 2 -跐 ci 2 -跬 kui 2 -踦 yi 2 -轷 hu 2 -轺 yao 2 -轾 zhi 2 -邲 bi 2 -郈 hou 2 -郪 qi 2 -鄗 hao 2 -鄚 mao 2 -酏 yi 2 -酾 shai 2 -醨 li 2 -钷 po 2 -铫 diao 2 -镎 na 2 -镤 pu 2 -阚 han 2 -陑 er 2 -陧 nie 2 -霨 wei 2 -靸 sa 2 -鞬 jian 2 -颃 hang 2 -骣 chan 2 -魋 tui 2 -鲡 li 2 -鲰 zou 2 -鳇 huang 2 -麀 you 2 -麑 ni 2 -鼱 jing 2 -龢 he 2 -冔 xu 1 -凘 si 1 -呇 qi 1 -咇 bie 1 -咡 er 1 -咥 xi 1 -咺 xuan 1 -嘬 chuai 1 -噂 zun 1 -坉 tun 1 -坒 bi 1 -坰 jiong 1 -堐 ya 1 -塥 ge 1 -娵 ju 1 -婻 nan 1 -嬥 tiao 1 -寁 zan 1 -峗 wei 1 -峘 huan 1 -峛 li 1 -峿 yu 1 -嵲 nie 1 -嶍 xi 1 -嶟 zun 1 -巇 xi 1 -帱 chou 1 -庱 cheng 1 -恓 xi 1 -惙 chuo 1 -愐 mian 1 -扊 yan 1 -挲 sa 1 -揕 zhen 1 -搒 bang 1 -旴 xu 1 -旵 chan 1 -昈 hu 1 -昒 hu 1 -昡 xuan 1 -昤 ling 1 -昫 xu 1 -晅 xuan 1 -晊 zhi 1 -晙 jun 1 -暵 han 1 -暿 xi 1 -朳 ba 1 -朸 li 1 -杻 chou 1 -枅 ji 1 -栒 xun 1 -桯 ting 1 -桹 lang 1 -梌 tu 1 -梴 chan 1 -椸 yi 1 -楒 si 1 -楪 ye 1 -榑 fu 1 -橑 lao 1 -橞 hui 1 -欂 bo 1 -歅 yin 1 -氿 gui 1 -汋 zhuo 1 -㳇 fu 1 -沺 tian 1 -泜 zhi 1 -浟 you 1 -浰 lian 1 -涍 xiao 1 -淜 ping 1 -淟 tian 1 -渰 yan 1 -溠 zha 1 -溹 suo 1 -滫 xiu 1 -漹 yan 1 -澴 huan 1 -澽 ju 1 -瀌 biao 1 -瀔 gu 1 -瀱 ji 1 -烠 hui 1 -烻 yan 1 -焌 jun 1 -焞 tun 1 -煁 chen 1 -熛 biao 1 -熥 teng 1 -爔 xi 1 -爟 guan 1 -犋 ju 1 -猃 xian 1 -猞 she 1 -猯 tuan 1 -玃 jue 1 -玒 hong 1 -玿 shao 1 -珇 zu 1 -珌 bi 1 -珒 jin 1 -珝 xu 1 -珫 chong 1 -琄 xuan 1 -琈 fu 1 -琫 beng 1 -瑂 mei 1 -瑃 chun 1 -瑔 quan 1 -璪 zao 1 -璱 se 1 -璲 sui 1 -瓀 ruan 1 -瘭 biao 1 -癗 lei 1 -癿 qie 1 -皭 jiao 1 -睎 xi 1 -瞫 shen 1 -砆 fu 1 -砫 zhu 1 -硿 kong 1 -碃 qing 1 -碨 wei 1 -磏 lian 1 -礵 shuang 1 -稌 tu 1 -穟 sui 1 -竘 qu 1 -笯 nu 1 -簉 zao 1 -糒 bei 1 -缲 qiao 1 -羱 yuan 1 -翂 fen 1 -耏 nai 1 -胈 ba 1 -胠 qu 1 -腯 tu 1 -膙 jiang 1 -舠 dao 1 -舥 pa 1 -舭 bi 1 -艅 yu 1 -艎 huang 1 -荁 huan 1 -莝 cuo 1 -荓 ping 1 -莿 ci 1 -萳 nan 1 -萹 bian 1 -葖 tu 1 -葴 zhen 1 -蒟 ju 1 -蒱 pu 1 -蓂 ming 1 -蓇 gu 1 -蓏 luo 1 -蔊 han 1 -薢 xie 1 -虷 han 1 -蚆 ba 1 -蝘 yan 1 -蟫 yin 1 -襚 sui 1 -襫 shi 1 -觜 zi 1 -訄 qiu 1 -谼 hong 1 -跞 li 1 -跱 zhi 1 -踒 wo 1 -踶 di 1 -蹅 cha 1 -蹐 ji 1 -蹜 su 1 -逴 chuo 1 -邿 shi 1 -郚 wu 1 -鄀 ruo 1 -鄃 shu 1 -鄅 yu 1 -鄑 zi 1 -鄘 yong 1 -酅 xi 1 -阽 dian 1 -陎 shu 1 -雊 gou 1 -靿 yao 1 -鞁 bei 1 -鞳 ta 1 -颥 ru 1 -馧 yun 1 -鬒 zhen 1 -鬷 zong 1 -魆 xu 1 -鸰 ling 1 -鼒 zi 1 -鼫 shi 1 -齉 nang 1 -乸 na 0 -𠅤 xi 0 -亸 duo 0 -亹 wei 0 -伣 qian 0 -㑇 zhou 0 -㑊 yi 0 -侴 chou 0 -俫 lai 0 -倕 chui 0 -倴 ben 0 -𫢸 dan 0 -傀 gui 0 -傉 nu 0 -僎 zhuan 0 -儴 rang 0 -冮 gang 0 -凓 li 0 -𠙶 ou 0 -刬 chan 0 -剅 lou 0 -𪟝 ji 0 -勔 mian 0 -勚 yi 0 -勠 lu 0 -卬 ang 0 -厖 pang 0 -厾 du 0 -叆 ai 0 -叇 dai 0 -叕 zhuo 0 -叚 jia 0 -呒 wu 0 -㕮 fu 0 -呲 ci 0 -呷 ga 0 -咉 yang 0 -咍 hai 0 -哕 hui 0 -哱 bo 0 -唛 ma 0 -唝 gong 0 -𠳐 bang 0 -啰 luo 0 -啴 chan 0 -啫 zhe 0 -嗞 zi 0 -嗲 die 0 -𫫇 e 0 -嘚 de 0 -嘡 tang 0 -噇 chuang 0 -㘎 han 0 -圌 chuan 0 -圐 ku 0 -圙 lve 0 -圢 ting 0 -圫 yu 0 -圲 qian 0 -坋 ben 0 -𫭟 ou 0 -𫭢 lun 0 -坥 qu 0 -坬 gua 0 -坽 ling 0 -垈 dai 0 -垍 ji 0 -垎 he 0 -垯 da 0 -垱 dang 0 -鿍 gang 0 -垺 fu 0 -垾 han 0 -埗 bu 0 -埫 chong 0 -堃 kun 0 -㙍 duo 0 -𫭼 lao 0 -𫮃 shan 0 -埪 kong 0 -堲 ci 0 -堼 heng 0 -堾 chun 0 -塅 duan 0 -塆 wan 0 -㙘 yao 0 -𡎚 pian 0 -𪣻 lou 0 -墐 jin 0 -墕 yan 0 -㙦 xie 0 -𡐓 kang 0 -𪤗 liao 0 -壸 kun 0 -妧 wan 0 -妭 ba 0 -𫰛 xing 0 -姶 e 0 -㛃 jie 0 -㛚 tong 0 -婤 chou 0 -婫 kun 0 -婳 hua 0 -婼 chuo 0 -媂 di 0 -媆 ruan 0 -媖 ying 0 -媞 shi 0 -媭 xu 0 -㛹 pian 0 -嬷 ma 0 -孅 qian 0 -尨 mang 0 -屃 xi 0 -𫵷 li 0 -岞 zuo 0 -岨 qu 0 -峂 tong 0 -峃 xue 0 -㟃 si 0 -峣 yao 0 -峧 jiao 0 -𪨰 qu 0 -峱 nao 0 -崄 xian 0 -𪨶 she 0 -崡 han 0 -嵁 kan 0 -嵅 han 0 -嵖 cha 0 -嵚 qin 0 -𫶇 die 0 -嶅 ao 0 -㠇 jiu 0 -嶦 zhan 0 -㠓 meng 0 -𪩘 yan 0 -幖 biao 0 -庼 qing 0 -𫷷 xin 0 -廆 gui 0 -弆 ju 0 -𫸩 kou 0 -彟 yue 0 -徵 zheng 0 -忳 tun 0 -㤘 zhou 0 -恔 jiao 0 -悈 jie 0 -㥄 ling 0 -愃 xuan 0 -愭 qi 0 -慭 yin 0 -憕 cheng 0 -戭 yan 0 -扅 yi 0 -扞 gan 0 -㧐 song 0 -㧑 hui 0 -拃 zha 0 -拤 qia 0 -㧟 kuai 0 -挦 xian 0 -掠 lve 0 -掺 can 0 -摏 chong 0 -擿 ti 0 -敩 xiao 0 -旞 sui 0 -𬀩 wei 0 -𬀪 xian 0 -昣 zhen 0 -昪 bian 0 -昽 long 0 -晐 gai 0 -晟 cheng 0 -㫰 lang 0 -暅 geng 0 -暕 jian 0 -㬊 huan 0 -㬎 xian 0 -暶 xuan 0 -㬚 che 0 -杄 qian 0 -杕 di 0 -杧 mang 0 -㭎 gang 0 -枹 bao 0 -柖 shao 0 -柷 chu 0 -栐 yong 0 -㭕 qu 0 -栟 ben 0 -𬂩 jia 0 -梽 zhi 0 -梾 lai 0 -梿 lian 0 -棁 zhuo 0 -棓 bang 0 -棽 shen 0 -椆 chou 0 -𬃊 zhi 0 -榃 tan 0 -榅 wen 0 -榰 zhi 0 -槚 jia 0 -槜 zui 0 -𣗋 dang 0 -㮾 lang 0 -㰀 li 0 -欹 yi 0 -汈 diao 0 -𬇕 wan 0 -沨 feng 0 -𣲗 wei 0 -𣲘 wu 0 -𬇙 pei 0 -洓 se 0 -洣 mi 0 -浉 shi 0 -浍 hui 0 -浐 chan 0 -浕 jin 0 -㳘 chong 0 -㳚 xu 0 -浛 han 0 -浲 feng 0 -涄 ping 0 -涢 yun 0 -𬇹 guo 0 -溇 lou 0 -㴔 xi 0 -𣸣 fen 0 -溁 ying 0 -溚 ta 0 -溵 yin 0 -滧 yao 0 -滪 yu 0 -漖 jiao 0 -漦 chi 0 -漴 zhuang 0 -漻 liao 0 -漋 long 0 -潖 pa 0 -潵 sa 0 -澛 lu 0 -㵐 jue 0 -炌 kai 0 -炔 gui 0 -𬉼 ou 0 -炣 ke 0 -㶲 yong 0 -𬊈 xun 0 -烶 ting 0 -焆 juan 0 -𬊤 chan 0 -煴 yun 0 -熻 xi 0 -燋 jiao 0 -㸆 kao 0 -㸌 huo 0 -𬌗 he 0 -牤 mang 0 -牥 fang 0 -狉 pi 0 -狝 xian 0 -猄 jing 0 -㺄 yu 0 -獴 meng 0 -玙 yu 0 -玚 chang 0 -玞 fu 0 -玤 bang 0 -玱 qiang 0 -玼 ci 0 -𬍛 li 0 -珕 li 0 -珢 yin 0 -珩 hang 0 -𬍡 dang 0 -𬍤 xun 0 -珸 wu 0 -琎 jin 0 -琔 dian 0 -琟 wei 0 -琡 chu 0 -瑅 ti 0 -瑓 lian 0 -瑖 duan 0 -瑝 huang 0 -𤧛 di 0 -瑬 liu 0 -㻬 tu 0 -𫞩 men 0 -璒 deng 0 -璥 jing 0 -璬 jiao 0 -璮 tan 0 -𤩽 huan 0 -𤫉 xie 0 -瓻 chi 0 -㽏 gan 0 -畖 wa 0 -略 lve 0 -畬 she 0 -疁 liu 0 -疍 dan 0 -疐 zhi 0 -疟 nve 0 -疭 zong 0 -痓 chi 0 -瘆 shen 0 -㿠 huang 0 -盷 tian 0 -𪾢 xian 0 -眬 long 0 -睄 shao 0 -䁖 lou 0 -矼 gang 0 -砄 jue 0 -砉 huo 0 -砠 ju 0 -砵 bo 0 -硁 keng 0 -𬒈 que 0 -硊 wei 0 -硍 xian 0 -硔 hong 0 -硙 wei 0 -硚 qiao 0 -鿎 ta 0 -䂮 lve 0 -碈 min 0 -碏 que 0 -䃅 di 0 -𬒔 geng 0 -碡 du 0 -碶 qi 0 -䃎 zha 0 -𬒗 lan 0 -磜 qi 0 -𥔲 e 0 -𥕢 cao 0 -磹 tan 0 -𥖨 zao 0 -祃 ma 0 -祋 dui 0 -祎 yi 0 -祾 ling 0 -秾 nong 0 -䅟 can 0 -穙 pu 0 -窎 diao 0 -竫 jing 0 -笮 ze 0 -筀 gui 0 -筜 dang 0 -筤 lang 0 -筶 gao 0 -筼 yun 0 -𬕂 gong 0 -箓 lu 0 -篯 jian 0 -簕 le 0 -簝 liao 0 -𥻗 cha 0 -糵 nie 0 -𬘓 xun 0 -纮 hong 0 -纴 ren 0 -纻 zhu 0 -纼 zhen 0 -𬘘 dan 0 -䌹 jiong 0 -絜 jie 0 -绖 die 0 -𬘡 yin 0 -绤 xi 0 -𫄧 yan 0 -𫄨 chi 0 -𬘩 ting 0 -𬘫 huan 0 -綮 qi 0 -绹 tao 0 -𫟅 liang 0 -𬘬 qian 0 -𬘭 lin 0 -𬘯 zhun 0 -缊 yun 0 -缐 xian 0 -缞 cui 0 -𬙂 yan 0 -𫄷 yi 0 -𦈡 xu 0 -𫄸 xun 0 -𬙊 mo 0 -𬙋 xiang 0 -羓 ba 0 -翈 xia 0 -䎃 ran 0 -翙 hui 0 -翚 hui 0 -翷 lin 0 -䎖 zeng 0 -𦒍 tong 0 -耑 duan 0 -耤 ji 0 -聒 gua 0 -䏝 zhuan 0 -胣 chi 0 -䏡 shi 0 -𦙶 gu 0 -脟 lie 0 -䏲 die 0 -脿 biao 0 -腒 ju 0 -腘 guo 0 -䐃 jun 0 -腨 shuan 0 -𦝼 lv 0 -臜 za 0 -芎 qiong 0 -芘 pi 0 -苉 pi 0 -𫇭 wei 0 -茋 zhi 0 -茓 xue 0 -𦭜 zhi 0 -茽 zhong 0 -荙 da 0 -𬜬 man 0 -茝 chai 0 -䓖 qiong 0 -𦰡 na 0 -𬜯 liang 0 -菍 nie 0 -菹 ju 0 -萚 tuo 0 -䓛 qu 0 -䓨 ying 0 -䓫 qi 0 -䓬 zhuo 0 -葰 sui 0 -蓢 lang 0 -蔃 qiang 0 -蔈 biao 0 -蕰 wen 0 -𬞟 pin 0 -薳 wei 0 -藠 jiao 0 -𬟁 yi 0 -虐 nve 0 -虤 yan 0 -虸 zi 0 -蚄 fang 0 -蚲 ping 0 -蛃 bing 0 -𬟽 dong 0 -蜐 jie 0 -蝲 la 0 -䗖 di 0 -螠 yi 0 -螣 te 0 -䗛 xiu 0 -螱 wei 0 -蟏 xiao 0 -䗪 zhe 0 -䗴 ting 0 -衠 zhun 0 -袆 hui 0 -袯 bo 0 -袷 jia 0 -裈 kun 0 -𫌀 ji 0 -襕 lan 0 -觃 yan 0 -觟 hua 0 -觭 ji 0 -訚 yin 0 -讱 ren 0 -𬣙 xu 0 -讻 xiong 0 -䜣 xin 0 -𬣞 zhu 0 -詟 zhe 0 -诇 xiong 0 -诐 bi 0 -𬣡 jian 0 -𫍣 tong 0 -𬣳 hen 0 -谞 xu 0 -𫍯 xian 0 -𫍲 xiao 0 -𬤇 yin 0 -𬤊 shi 0 -𬤝 hui 0 -𫍽 xuan 0 -豉 shi 0 -豮 fen 0 -䝙 chu 0 -赑 bi 0 -赒 zhou 0 -赗 feng 0 -赪 cheng 0 -趯 ti 0 -𧿹 mu 0 -跶 da 0 -蹢 di 0 -蹽 liao 0 -蹾 dun 0 -轪 dai 0 -𫐄 yue 0 -𬨂 qi 0 -辀 zhou 0 -𨐈 guang 0 -辌 liang 0 -𫐐 ni 0 -辒 wen 0 -𫐓 bu 0 -𬨎 you 0 -𫟦 sui 0 -遆 ti 0 -𨙸 qi 0 -䢺 chu 0 -𨚕 bian 0 -䢼 gong 0 -𬩽 xun 0 -𫑡 meng 0 -鄌 tang 0 -䣘 tang 0 -酂 cuo 0 -𨟠 quan 0 -酦 po 0 -𬪩 nong 0 -醾 mi 0 -钖 yang 0 -𬬩 yi 0 -钘 xing 0 -𫓧 fu 0 -𬬭 lun 0 -𬬮 chang 0 -𬬱 jin 0 -䥽 po 0 -𬬸 shu 0 -𬬹 shen 0 -𬬻 lu 0 -𬬿 zhao 0 -𬭁 mu 0 -铏 xing 0 -铚 zhi 0 -铤 ding 0 -𫓯 ji 0 -𫟹 hong 0 -铻 wu 0 -锊 lve 0 -鿏 mai 0 -𨱇 qiu 0 -𫓶 xuan 0 -𫟼 da 0 -𬭊 du 0 -𬭎 hong 0 -锜 qi 0 -锧 zhi 0 -𫓹 ji 0 -𬭚 chun 0 -𬭛 bo 0 -锳 ying 0 -锽 huang 0 -镃 zi 0 -𬭤 hou 0 -镈 bo 0 -镋 tang 0 -𨱏 da 0 -𬭩 weng 0 -镚 beng 0 -镠 liu 0 -𬭬 wei 0 -𬭯 pie 0 -𨭉 ban 0 -镡 chan 0 -䦃 zhuo 0 -𨱑 huang 0 -𨱔 zun 0 -𫔍 fan 0 -𫔎 jue 0 -𬭳 xi 0 -𬭶 hei 0 -𬭸 lin 0 -镮 huan 0 -𬭼 sui 0 -镴 la 0 -镵 chan 0 -闿 kai 0 -阇 du 0 -阏 e 0 -𬮱 yin 0 -阘 da 0 -𫔶 nie 0 -𬮿 qi 0 -𬯀 ji 0 -𨺙 ni 0 -隃 shu 0 -𬯎 tui 0 -隩 ao 0 -隺 hu 0 -靽 ban 0 -鞔 man 0 -鞧 qiu 0 -韂 chan 0 -韨 fu 0 -𫠆 kui 0 -𬱖 di 0 -颋 ting 0 -颎 jiong 0 -𫖮 yi 0 -𫖯 fu 0 -𬱟 wei 0 -𫖳 yun 0 -颙 yong 0 -飏 yang 0 -飐 zhan 0 -飔 si 0 -飗 liu 0 -饧 tang 0 -饳 duo 0 -饸 he 0 -饹 le 0 -饻 xi 0 -𫗧 su 0 -馃 guo 0 -馉 gu 0 -馌 ye 0 -𫗴 zhan 0 -馝 bi 0 -馞 bo 0 -驲 ri 0 -𫘜 wen 0 -𫘝 jue 0 -骀 dai 0 -𫠊 xuan 0 -𬳵 pi 0 -𬳶 jiong 0 -骃 yin 0 -骉 biao 0 -𬳽 shen 0 -骍 xing 0 -骎 qin 0 -𬳿 tu 0 -骕 su 0 -𫘦 tao 0 -𫘧 lu 0 -𬴂 fei 0 -骙 kui 0 -𫘨 ti 0 -𬴃 huo 0 -𫘪 yuan 0 -𫘬 xi 0 -𬴊 lin 0 -骦 shuang 0 -髃 yu 0 -髎 liao 0 -髢 di 0 -髽 zhua 0 -鬶 gui 0 -鱽 dao 0 -鱾 ji 0 -鲀 tun 0 -鲃 ba 0 -鲉 you 0 -鲊 zha 0 -鲌 ba 0 -鲏 pi 0 -䲟 yin 0 -𬶋 ju 0 -𬶍 tuo 0 -鲖 tong 0 -鲗 zei 0 -鲘 hou 0 -鲙 kuai 0 -鲝 zha 0 -𩽾 an 0 -𫚕 shi 0 -𫚖 ci 0 -𬶏 wei 0 -𬶐 zhao 0 -鲪 jun 0 -鲬 yong 0 -𩾃 mian 0 -鲯 qi 0 -鲹 shen 0 -鲿 chang 0 -鳀 ti 0 -鳁 wen 0 -鳂 wei 0 -鳈 quan 0 -鳉 jiang 0 -䲠 chun 0 -𬶟 la 0 -𬶠 lian 0 -𬶨 ji 0 -鲾 bi 0 -鳑 pang 0 -鳒 jian 0 -䲢 teng 0 -鳚 wei 0 -鳛 xi 0 -𩾌 kang 0 -𬶭 ji 0 -𬶮 xi 0 -鳠 hu 0 -鳡 gan 0 -鳣 zhan 0 -鳤 guan 0 -𫚭 lie 0 -鸤 shi 0 -鸧 cang 0 -䴓 shi 0 -鸮 xiao 0 -鸻 heng 0 -鸼 zhou 0 -䴔 jiao 0 -䴕 lie 0 -鹀 wu 0 -𫛭 kuang 0 -𬷕 bu 0 -鹍 kun 0 -鹐 qian 0 -鹒 geng 0 -鹔 su 0 -䴖 jing 0 -鹖 he 0 -鹙 qiu 0 -䴗 ju 0 -𬸘 yan 0 -𬸚 yue 0 -鹝 yi 0 -鹟 weng 0 -鹠 liu 0 -鹡 ji 0 -鹢 yi 0 -䴘 ti 0 -𬸣 xian 0 -𬸦 zhuo 0 -𬸪 fan 0 -鹮 huan 0 -鹯 zhan 0 -䴙 pi 0 -鹲 meng 0 -鹴 shuang 0 -麖 jing 0 -黇 tian 0 -黡 yan 0 -齇 zha 0 -龁 he 0 -龂 yin 0 -𬹼 xie 0 -𫠜 ni 0 -𬺈 yi 0 -𬺓 chu 0 -䶮 yan 0 diff --git a/openless-all/app/android/assets/pinyin_dictionary_meta.json b/openless-all/app/android/assets/pinyin_dictionary_meta.json deleted file mode 100644 index 1791e644a..000000000 --- a/openless-all/app/android/assets/pinyin_dictionary_meta.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "format_version": 1, - "generated": "2026-09-25", - "chars": { - "file": "pinyin_chars.tsv", - "character_count": 8105, - "row_count": 8225, - "generator": "scripts/generate-pinyin-characters.mjs" - }, - "phrases": { - "file": "pinyin_phrases.tsv", - "row_count": 2006, - "corpus_limit": 2000, - "whitelist_added": 6, - "blacklist_removed": 1, - "generator": "scripts/generate-pinyin-phrases.mjs" - } -} diff --git a/openless-all/app/android/assets/pinyin_phrases.LICENSE.txt b/openless-all/app/android/assets/pinyin_phrases.LICENSE.txt deleted file mode 100644 index fd4cb9f7d..000000000 --- a/openless-all/app/android/assets/pinyin_phrases.LICENSE.txt +++ /dev/null @@ -1,51 +0,0 @@ -pinyin_phrases.tsv — attribution, source pinning, and license -================================================================= - -Sources: - -1. rime/rime-pinyin-simp — pinyin_simp.dict.yaml (base candidate pool and - usage-frequency weight; multi-character rows only — see - pinyin_chars.LICENSE.txt for this same file's single-character rows) - https://github.com/rime/rime-pinyin-simp - Commit: 0c6861ef7420ee780270ca6d993d18d4101049d0 - Downloaded: 2026-09-25 - SHA-256: e341598343a0f0f2035bb1aafc34a7f3bb7887deeecb3f60796262aaa2983e6b - License: Apache License 2.0. Full text: - https://github.com/rime/rime-pinyin-simp/blob/master/LICENSE - -2. mozillazg/pinyin-data — kMandarin_8105.txt, used only as a character-set - membership filter (rejects any candidate phrase containing a character - outside 《通用规范汉字表》, i.e. traditional-only/rare/PUA characters) - https://github.com/mozillazg/pinyin-data - Commit: 923b108dc5d45dee061324c011b478fb649f8b73 - Downloaded: 2026-09-25 - SHA-256: 1b1546f6e190c0f1cbf23e3bb436b41c3ace16f852be4daebc2c7295636628a4 - License: MIT License, Copyright (c) 2016 mozillazg. Full text: - https://github.com/mozillazg/pinyin-data/blob/master/LICENSE - -3. mozillazg/phrase-pinyin-data — pinyin.txt, authoritative word-level - readings used to correct a phrase's pinyin when it disagrees with rime's - own per-character reading (resolves polyphones-in-context) - https://github.com/mozillazg/phrase-pinyin-data - Commit: cee0ed6e6e4898580cafd2bd5e3723e20b214aa0 - Downloaded: 2026-09-25 - SHA-256: dcc769607c220b312fea3e71cb63421298b4b891b1f7356a95ab58f2c96fff81 - License: MIT License, Copyright (c) 2017 mozillazg. Full text: - https://github.com/mozillazg/phrase-pinyin-data/blob/master/LICENSE - -Generation: this repo's scripts/generate-pinyin-phrases.mjs — see that -script's own header comment for the exact re-fetch/regenerate commands and -selection/ranking rules (2-4 character simplified-only words, top ~2000 by -usage weight, plus a small manually curated domain-term whitelist confirmed -with the user on 2026-09-25 — see the WHITELIST constant in that script — and -a small blacklist of corpus noise found by spot-checking the output, e.g. -"回复日期", a forum/IME-corpus UI-template phrase rather than real vocabulary -— see the BLACKLIST constant). LitePinyinRepository.kt additionally ranks -the whitelisted words above an ordinary same-abbreviation phrase at query -time (its own PRIORITY_PHRASES constant), not something this generator's -own output encodes. -Output rows are tab-separated "phrasefull_pinyinabbreviation -weight", pinyin lowercase ASCII with ü normalized to v and tone marks -stripped, abbreviation = first letter of each syllable. No runtime code in -OpenLess downloads, modifies, or redistributes any of the three original -sources — only this generated, filtered derivative ships in the app. diff --git a/openless-all/app/android/assets/pinyin_phrases.tsv b/openless-all/app/android/assets/pinyin_phrases.tsv deleted file mode 100644 index 326f0b8b1..000000000 --- a/openless-all/app/android/assets/pinyin_phrases.tsv +++ /dev/null @@ -1,2006 +0,0 @@ -一个 yi ge yg 373855 -没有 mei you my 325559 -我们 wo men wm 322329 -可以 ke yi ky 304372 -什么 shen me sm 295403 -这个 zhe ge zg 253424 -呵呵 he he hh 249662 -现在 xian zai xz 246263 -自己 zi ji zj 239007 -就是 jiu shi js 211267 -不是 bu shi bs 173509 -怎么 zen me zm 173077 -还是 hai shi hs 166934 -他们 ta men tm 156264 -如果 ru guo rg 155100 -知道 zhi dao zd 144465 -时候 shi hou sh 143389 -问题 wen ti wt 137270 -这样 zhe yang zy 131084 -已经 yi jing yj 129784 -哈哈 ha ha hh 121640 -因为 yin wei yw 121338 -公司 gong si gs 118498 -那个 na ge ng 117323 -今天 jin tian jt 109961 -工作 gong zuo gz 107925 -中国 zhong guo zg 106588 -但是 dan shi ds 105663 -不过 bu guo bg 103577 -可能 ke neng kn 100901 -应该 ying gai yg 97684 -所以 suo yi sy 95995 -你们 ni men nm 95399 -一下 yi xia yx 94335 -时间 shi jian sj 92935 -都是 dou shi ds 91784 -还有 hai you hy 86132 -进行 jin xing jx 84537 -觉得 jue de jd 83907 -需要 xu yao xy 82765 -不能 bu neng bn 80727 -开始 kai shi ks 78154 -东西 dong xi dx 77090 -那么 na me nm 76230 -这么 zhe me zm 74446 -喜欢 xi huan xh 72704 -比较 bi jiao bj 72343 -然后 ran hou rh 69601 -一些 yi xie yx 69428 -不会 bu hui bh 68739 -大家 da jia dj 67803 -很多 hen duo hd 66548 -看到 kan dao kd 65642 -不要 bu yao by 65478 -一样 yi yang yy 64748 -这些 zhe xie zx 62997 -也是 ye shi ys 62372 -感觉 gan jue gj 61600 -看看 kan kan kk 59859 -其实 qi shi qs 59636 -出来 chu lai cl 59118 -朋友 peng you py 58897 -明天 ming tian mt 58168 -以后 yi hou yh 58095 -只是 zhi shi zs 58095 -这里 zhe li zl 57305 -而且 er qie eq 56949 -系统 xi tong xt 56454 -通过 tong guo tg 56313 -一定 yi ding yd 56103 -使用 shi yong sy 55824 -一起 yi qi yq 55755 -企业 qi ye qy 54722 -或者 huo zhe hz 54518 -发展 fa zhan fz 54045 -市场 shi chang sc 54045 -一直 yi zhi yz 53242 -北京 bei jing bj 53176 -其他 qi ta qt 52912 -好像 hao xiang hx 52780 -产品 chan pin cp 51481 -这种 zhe zhong zz 51161 -事情 shi qing sq 51097 -的人 de ren dr 50970 -为什么 wei shen me wsm 50907 -发现 fa xian fx 50590 -只有 zhi you zy 50276 -地方 di fang df 50276 -希望 xi wang xw 50276 -情况 qing kuang qk 50151 -要求 yao qiu yq 49284 -电话 dian hua dh 49100 -信息 xin xi xx 48673 -多少 duo shao ds 47891 -提供 ti gong tg 47772 -一般 yi ban yb 47653 -谢谢 xie xie xx 47594 -回来 hui lai hl 47004 -主要 zhu yao zy 46887 -不错 bu cuo bc 46770 -生活 sheng huo sh 46770 -上海 shang hai sh 46654 -目前 mu qian mq 45167 -直接 zhi jie zj 44386 -所有 suo you sy 44275 -晚上 wan shang ws 43945 -技术 ji shu js 43401 -方面 fang mian fm 43347 -出现 chu xian cx 43293 -虽然 sui ran sr 43131 -为了 wei le wl 43078 -支持 zhi chi zc 43078 -价格 jia ge jg 42438 -美国 mei guo mg 42227 -是不是 shi bu shi sbs 42122 -我的 wo de wd 41860 -选择 xuan ze xz 41445 -以前 yi qian yq 41394 -项目 xiang mu xm 41136 -非常 fei chang fc 41085 -里面 li mian lm 40932 -别人 bie ren br 40576 -国家 guo jia gj 40475 -以及 yi ji yj 39676 -你的 ni de nd 39676 -同时 tong shi ts 39627 -对于 dui yu dy 39578 -两个 liang ge lg 39430 -准备 zhun bei zb 39430 -管理 guan li gl 39332 -一点 yi dian yd 39136 -肯定 ken ding kd 38942 -这是 zhe shi zs 38748 -昨天 zuo tian zt 38603 -认为 ren wei rw 38603 -服务 fu wu fw 38555 -最后 zui hou zh 38459 -成为 cheng wei cw 38459 -网站 wang zhan wz 38411 -作为 zuo wei zw 37936 -哪里 na li nl 37936 -起来 qi lai ql 37936 -之后 zhi hou zh 37747 -继续 ji xu jx 37700 -手机 shou ji sj 37653 -真的 zhen de zd 37606 -不同 bu tong bt 37559 -用户 yong hu yh 37419 -由于 you yu yy 37419 -结果 jie guo jg 37326 -估计 gu ji gj 37233 -最近 zui jin zj 36818 -影响 ying xiang yx 36726 -学校 xue xiao xx 36498 -孩子 hai zi hz 36407 -好的 hao de hd 36136 -设计 she ji sj 36001 -任务 ren wu rw 35912 -关系 guan xi gx 35644 -部分 bu fen bf 35467 -活动 huo dong hd 35379 -必须 bi xu bx 34984 -网络 wang luo wl 34723 -研究 yan jiu yj 34680 -如何 ru he rh 34508 -当然 dang ran dr 34508 -那些 nei xie nx 34379 -功能 gong neng gn 34251 -可是 ke shi ks 34208 -学生 xue sheng xs 33996 -数据 shu ju sj 33827 -告诉 gao su gs 33658 -给我 gei wo gw 33658 -方式 fang shi fs 33616 -内容 nei rong nr 33241 -一次 yi ci yc 33200 -那里 na li nl 33200 -女人 nv ren nr 33076 -是否 shi fou sf 33035 -电脑 dian nao dn 33035 -意思 yi si ys 32504 -联系 lian xi lx 32464 -游戏 you xi yx 32383 -老师 lao shi ls 32262 -只要 zhi yao zy 32102 -特别 te bie tb 32102 -个人 ge ren gr 32022 -文件 wen jian wj 31862 -有点 you dian yd 31823 -能够 neng gou ng 31823 -软件 ruan jian rj 31744 -几个 ji ge jg 31704 -过去 guo qu gq 31665 -学习 xue xi xx 31468 -社会 she hui sh 31390 -过来 guo lai gl 31233 -给你 gei ni gn 31117 -男人 nan ren nr 31039 -了解 liao jie lj 31001 -只能 zhi neng zn 30962 -吃饭 chi fan cf 30885 -世界 shi jie sj 30808 -不好 bu hao bh 30541 -不用 bu yong by 30503 -的话 de hua dh 30389 -表示 biao shi bs 30351 -嘿嘿 hei hei hh 30087 -发生 fa sheng fs 29900 -得到 de dao dd 29752 -同学 tong xue tx 29715 -基本 ji ben jb 29715 -处理 chu li cl 29715 -老婆 lao po lp 29641 -照片 zhao pian zp 29567 -客户 ke hu kh 29530 -方法 fang fa ff 29383 -完成 wan cheng wc 29347 -关于 guan yu gy 28983 -有些 you xie yx 28983 -当时 dang shi ds 28947 -刚才 gang cai gc 28875 -实现 shi xian sx 28767 -原来 yuan lai yl 28696 -包括 bao kuo bk 28660 -解决 jie jue jj 28553 -中的 zhong de zd 28517 -一种 yi zhong yz 28059 -反正 fan zheng fz 27816 -政府 zheng fu zf 27677 -根据 gen ju gj 27677 -重要 zhong yao zy 27643 -成功 cheng gong cg 27437 -考虑 kao lv kl 27403 -下载 xia zai xz 27334 -能力 neng li nl 27266 -过程 guo cheng gc 27165 -他的 ta de td 27030 -分析 fen xi fx 27030 -机会 ji hui jh 26962 -下午 xia wu xw 26828 -提高 ti gao tg 26728 -认识 ren shi rs 26695 -注意 zhu yi zy 26662 -介绍 jie shao js 26562 -之前 zhi qian zq 26463 -存在 cun zai cz 26430 -日本 ri ben rb 26430 -进入 jin ru jr 26430 -其中 qi zhong qz 26299 -今年 jin nian jn 26200 -增加 zeng jia zj 26070 -记者 ji zhe jz 26005 -出去 chu qu cq 25973 -无法 wu fa wf 25973 -国内 guo nei gn 25779 -决定 jue ding jd 25683 -人家 ren jia rj 25556 -相关 xiang guan xg 25428 -完全 wan quan wq 25334 -单位 dan wei dw 25176 -组织 zu zhi zz 25176 -有人 you ren yr 25051 -测试 ce shi cs 24989 -还要 hai yao hy 24989 -业务 ye wu yw 24958 -任何 ren he rh 24926 -后来 hou lai hl 24926 -正在 zheng zai zz 24926 -专业 zhuan ye zy 24772 -之间 zhi jian zj 24556 -每天 mei tian mt 24404 -除了 chu le cl 24283 -建议 jian yi jy 24252 -论坛 lun tan lt 24162 -投资 tou zi tz 24132 -具有 ju you jy 24042 -空间 kong jian kj 23982 -计划 ji hua jh 23982 -不行 bu xing bx 23714 -原因 yuan yin yy 23567 -开发 kai fa kf 23508 -建立 jian li jl 23362 -名字 ming zi mz 23304 -参加 can jia cj 23246 -回家 hui jia hj 23246 -人员 ren yuan ry 23159 -全部 quan bu qb 23131 -回去 hui qu hq 23044 -睡觉 shui jiao sj 23044 -建设 jian she js 23016 -显示 xian shi xs 22987 -甚至 shen zhi sz 22901 -最好 zui hao zh 22844 -我国 wo guo wg 22844 -找到 zhao dao zd 22844 -获得 huo de hd 22787 -到了 dao le dl 22702 -这次 zhe ci zc 22646 -部门 bu men bm 22646 -人们 ren men rm 22618 -标准 biao zhun bz 22561 -经济 jing ji jj 22505 -控制 kong zhi kz 22393 -网上 wang shang ws 22365 -程序 cheng xu cx 22310 -简单 jian dan jd 22254 -让我 rang wo rw 22143 -一切 yi qie yq 22116 -效果 xiao guo xg 22006 -本来 ben lai bl 22006 -上班 shang ban sb 21869 -哪个 na ge ng 21733 -城市 cheng shi cs 21733 -真是 zhen shi zs 21679 -也许 ye xu yx 21545 -好了 hao le hl 21545 -电影 dian ying dy 21545 -达到 da dao dd 21518 -具体 ju ti jt 21384 -速度 su du sd 21384 -负责 fu ze fz 21357 -环境 huan jing hj 21251 -小时 xiao shi xs 21172 -经常 jing chang jc 21172 -银行 yin hang yh 21172 -办法 ban fa bf 21119 -消息 xiao xi xx 21040 -提出 ti chu tc 21014 -生产 sheng chan sc 21014 -申请 shen qing sq 20988 -大学 da xue dx 20962 -看来 kan lai kl 20962 -说话 shuo hua sh 20962 -因此 yin ci yc 20936 -我是 wo shi ws 20858 -来说 lai shuo ls 20754 -你好 ni hao nh 20728 -相信 xiang xin xx 20728 -许多 xu duo xd 20728 -说明 shuo ming sm 20702 -作用 zuo yong zy 20651 -安全 an quan aq 20599 -各种 ge zhong gz 20574 -终于 zhong yu zy 20574 -真正 zhen zheng zz 20548 -记得 ji de jd 20497 -全国 quan guo qg 20471 -正常 zheng chang zc 20446 -调整 tiao zheng tz 20420 -操作 cao zuo cz 20395 -领导 ling dao ld 20395 -没事 mei shi ms 20370 -资料 zi liao zl 20370 -采用 cai yong cy 20370 -下来 xia lai xl 20319 -努力 nu li nl 20294 -另外 ling wai lw 20294 -修改 xiu gai xg 20268 -国际 guo ji gj 20243 -有关 you guan yg 20243 -行业 hang ye hy 20243 -以上 yi shang ys 20218 -图片 tu pian tp 20218 -接受 jie shou js 20218 -容易 rong yi ry 20142 -才能 cai neng cn 20142 -有没有 you mei you ymy 20142 -设置 she zhi sz 20067 -妈妈 ma ma mm 20017 -产生 chan sheng cs 19968 -以为 yi wei yw 19968 -明白 ming bai mb 19968 -上面 shang mian sm 19794 -历史 li shi ls 19769 -合作 he zuo hz 19647 -目标 mu biao mb 19598 -代表 dai biao db 19525 -我也 wo ye wy 19500 -需求 xu qiu xq 19500 -总是 zong shi zs 19476 -这一 zhe yi zy 19452 -第一 di yi dy 19403 -安装 an zhuang az 19379 -广告 guang gao gg 19307 -按照 an zhao az 19307 -那样 na yang ny 19307 -股票 gu piao gp 19235 -也有 ye you yy 19187 -也不 ye bu yb 19139 -文化 wen hua wh 19115 -不断 bu duan bd 19068 -确定 que ding qd 19020 -服务器 fu wu qi fwq 18997 -好好 hao hao hh 18973 -销售 xiao shou xs 18926 -左右 zuo you zy 18902 -麻烦 ma fan mf 18902 -中心 zhong xin zx 18879 -应用 ying yong yy 18808 -表现 biao xian bx 18785 -如此 ru ci rc 18761 -经验 jing yan jy 18761 -不少 bu shao bs 18691 -根本 gen ben gb 18691 -关注 guan zhu gz 18622 -文章 wen zhang wz 18598 -经过 jing guo jg 18598 -差不多 cha bu duo cbd 18575 -地址 di zhi dz 18483 -条件 tiao jian tj 18437 -比如 bi ru br 18414 -都有 dou you dy 18391 -确实 que shi qs 18345 -形成 xing cheng xc 18299 -整个 zheng ge zg 18299 -帮助 bang zhu bz 18231 -家里 jia li jl 18208 -精神 jing shen js 18163 -身体 shen ti st 18163 -水平 shui ping sp 18118 -资源 zi yuan zy 18118 -马上 ma shang ms 18050 -似乎 si hu sh 17983 -休息 xiu xi xx 17938 -香港 xiang gang xg 17916 -突然 tu ran tr 17871 -一天 yi tian yt 17849 -刚刚 gang gang gg 17849 -是个 shi ge sg 17849 -明显 ming xian mx 17805 -收到 shou dao sd 17782 -并且 bing qie bq 17738 -教育 jiao yu jy 17716 -行为 xing wei xw 17716 -带来 dai lai dl 17650 -要是 yao shi ys 17628 -造成 zao cheng zc 17606 -上的 shang de sd 17540 -清楚 qing chu qc 17540 -房子 fang zi fz 17475 -深圳 shen zhen sz 17475 -利用 li yong ly 17431 -就会 jiu hui jh 17431 -大概 da gai dg 17410 -重新 chong xin cx 17410 -安排 an pai ap 17388 -理解 li jie lj 17345 -资金 zi jin zj 17280 -不到 bu dao bd 17258 -先生 xian sheng xs 17215 -变化 bian hua bh 17215 -拥有 yong you yy 17173 -推荐 tui jian tj 17173 -博客 bo ke bk 17151 -状态 zhuang tai zt 17109 -保证 bao zheng bz 17087 -美女 mei nv mn 17045 -难道 nan dao nd 17045 -干嘛 gan ma gm 17002 -品牌 pin pai pp 16876 -打开 da kai dk 16876 -改变 gai bian gb 16876 -不想 bu xiang bx 16834 -有个 you ge yg 16750 -比赛 bi sai bs 16729 -保持 bao chi bc 16708 -超过 chao guo cg 16708 -地区 di qu dq 16667 -下面 xia mian xm 16646 -广州 guang zhou gz 16646 -楼主 lou zhu lz 16625 -工程 gong cheng gc 16604 -位置 wei zhi wz 16584 -视频 shi pin sp 16584 -回答 hui da hd 16563 -看见 kan jian kj 16522 -自然 zi ran zr 16522 -方便 fang bian fb 16501 -快乐 kuai le kl 16481 -还在 hai zai hz 16481 -规定 gui ding gd 16419 -分钟 fen zhong fz 16399 -声音 sheng yin sy 16399 -心里 xin li xl 16399 -幸福 xing fu xf 16358 -音乐 yin yue yy 16358 -设备 she bei sb 16338 -媒体 mei ti mt 16256 -那是 na shi ns 16256 -漂亮 piao liang pl 16216 -更新 geng xin gx 16196 -老板 lao ban lb 16196 -基金 ji jin jj 16176 -实在 shi zai sz 16176 -密码 mi ma mm 16075 -慢慢 man man mm 16075 -第一次 di yi ci dyc 16075 -导致 dao zhi dz 16035 -很好 hen hao hh 15995 -讨论 tao lun tl 15975 -上网 shang wang sw 15935 -版本 ban ben bb 15935 -至少 zhi shao zs 15916 -基础 ji chu jc 15896 -通知 tong zhi tz 15896 -以下 yi xia yx 15856 -结婚 jie hun jh 15836 -结束 jie shu js 15836 -未来 wei lai wl 15797 -每个 mei ge mg 15679 -我想 wo xiang wx 15660 -检查 jian cha jc 15621 -还没 hai mei hm 15621 -曾经 ceng jing cj 15582 -治疗 zhi liao zl 15582 -随便 sui bian sb 15582 -医院 yi yuan yy 15543 -离开 li kai lk 15543 -上次 shang ci sc 15524 -集团 ji tuan jt 15524 -严重 yan zhong yz 15504 -发表 fa biao fb 15504 -相当 xiang dang xd 15504 -怎么样 zen me yang zmy 15408 -不仅 bu jin bj 15370 -衣服 yi fu yf 15351 -属于 shu yu sy 15332 -实际 shi ji sj 15312 -购买 gou mai gm 15293 -政策 zheng ce zc 15274 -注册 zhu ce zc 15236 -更加 geng jia gj 15198 -居然 ju ran jr 15142 -这边 zhe bian zb 15142 -加强 jia qiang jq 15104 -报告 bao gao bg 15104 -有效 you xiao yx 15104 -保护 bao hu bh 15066 -并不 bing bu bb 15048 -目的 mu di md 15048 -我就 wo jiu wj 15010 -记录 ji lu jl 15010 -质量 zhi liang zl 14991 -坚持 jian chi jc 14954 -新的 xin de xd 14954 -而已 er yi ey 14806 -机器 ji qi jq 14732 -郁闷 yu men ym 14732 -宝宝 bao bao bb 14714 -实施 shi shi ss 14714 -模式 mo shi ms 14714 -好多 hao duo hd 14695 -是啊 shi a sa 14695 -便宜 pian yi py 14659 -怎样 zen yang zy 14659 -让人 rang ren rr 14659 -关键 guan jian gj 14622 -后面 hou mian hm 14622 -培训 pei xun px 14586 -故事 gu shi gs 14550 -意见 yi jian yj 14532 -爱情 ai qing aq 14477 -都会 du hui dh 14477 -看了 kan le kl 14441 -台湾 tai wan tw 14405 -发布 fa bu fb 14387 -方案 fang an fa 14370 -每次 mei ci mc 14370 -大量 da liang dl 14352 -同样 tong yang ty 14316 -到底 dao di dd 14298 -永远 yong yuan yy 14280 -老公 lao gong lg 14280 -受到 shou dao sd 14262 -看着 kan zhe kz 14227 -绝对 jue dui jd 14227 -来了 lai le ll 14174 -于是 yu shi ys 14121 -感情 gan qing gq 14121 -太多 tai duo td 14103 -愿意 yuan yi yy 14068 -对方 dui fang df 14051 -压力 ya li yl 14033 -随着 sui zhe sz 14033 -网友 wang you wy 14016 -机构 ji gou jg 13998 -不管 bu guan bg 13963 -考试 kao shi ks 13946 -交流 jiao liu jl 13929 -之一 zhi yi zy 13911 -毕业 bi ye by 13911 -价值 jia zhi jz 13894 -免费 mian fei mf 13894 -一年 yi nian yn 13825 -平台 ping tai pt 13808 -成本 cheng ben cb 13808 -配置 pei zhi pz 13808 -几乎 ji hu jh 13791 -生命 sheng ming sm 13791 -制度 zhi du zd 13756 -首先 shou xian sx 13756 -变成 bian cheng bc 13739 -她们 ta men tm 13722 -升级 sheng ji sj 13705 -打算 da suan ds 13705 -一家 yi jia yj 13688 -稳定 wen ding wd 13688 -工资 gong zi gz 13671 -帖子 tie zi tz 13654 -专家 zhuan jia zj 13637 -那就 na jiu nj 13637 -即使 ji shi js 13603 -现场 xian chang xc 13586 -重点 zhong dian zd 13586 -交易 jiao yi jy 13552 -错误 cuo wu cw 13552 -参与 can yu cy 13485 -材料 cai liao cl 13485 -传统 chuan tong ct 13434 -同事 tong shi ts 13434 -调查 diao cha dc 13434 -她的 ta de td 13401 -结构 jie gou jg 13384 -费用 fei yong fy 13384 -厉害 li hai lh 13368 -而是 er shi es 13368 -尤其 you qi yq 13318 -作品 zuo pin zp 13301 -天天 tian tian tt 13284 -样子 yang zi yz 13284 -第二 di er de 13284 -父母 fu mu fm 13268 -确认 que ren qr 13268 -加入 jia ru jr 13251 -眼睛 yan jing yj 13218 -引起 yin qi yq 13202 -更多 geng duo gd 13186 -心情 xin qing xq 13169 -身上 shen shang ss 13169 -家庭 jia ting jt 13153 -最终 zui zhong zz 13136 -面对 mian dui md 13120 -一位 yi wei yw 13104 -分别 fen bie fb 13104 -它们 ta men tm 13087 -习惯 xi guan xg 13071 -来自 lai zi lz 13071 -不够 bu gou bg 13038 -事件 shi jian sj 13022 -推出 tui chu tc 13022 -知识 zhi shi zs 13022 -范围 fan wei fw 13022 -你是 ni shi ns 13006 -去年 qu nian qn 13006 -新闻 xin wen xw 13006 -自动 zi dong zd 13006 -垃圾 la ji lj 12941 -早上 zao shang zs 12909 -意义 yi yi yy 12893 -还好 hai hao hh 12877 -积极 ji ji jj 12845 -适合 shi he sh 12829 -全面 quan mian qm 12781 -执行 zhi xing zx 12781 -人生 ren sheng rs 12765 -正式 zheng shi zs 12749 -进一步 jin yi bu jyb 12749 -满足 man zu mz 12702 -运行 yun xing yx 12670 -收入 shou ru sr 12639 -不知 bu zhi bz 12607 -十分 shi fen sf 12607 -聊天 liao tian lt 12591 -代码 dai ma dm 12576 -遇到 yu dao yd 12576 -其它 qi ta qt 12560 -优势 you shi ys 12544 -等等 deng deng dd 12544 -哈哈哈 ha ha ha hhh 12529 -去了 qu le ql 12513 -取得 qu de qd 12513 -及时 ji shi js 12420 -方向 fang xiang fx 12420 -不乖 bu guai bg 12358 -不再 bu zai bz 12343 -邮件 you jian yj 12312 -姐姐 jie jie jj 12297 -系列 xi lie xl 12297 -再次 zai ci zc 12266 -普通 pu tong pt 12266 -经历 jing li jl 12266 -解释 jie shi js 12266 -您的 nin de nd 12251 -会议 hui yi hy 12236 -领域 ling yu ly 12236 -风险 feng xian fx 12236 -统一 tong yi ty 12220 -全球 quan qiu qq 12205 -形式 xing shi xs 12205 -思想 si xiang sx 12205 -无论 wu lun wl 12205 -农民 nong min nm 12190 -几天 ji tian jt 12190 -员工 yuan gong yg 12190 -短信 duan xin dx 12144 -进去 jin qu jq 12099 -增长 zeng zhang zz 12069 -本人 ben ren br 12054 -现实 xian shi xs 12054 -所谓 suo wei sw 12024 -不然 bu ran br 11994 -法律 fa lv fl 11964 -开心 kai xin kx 11934 -就要 jiu yao jy 11920 -移动 yi dong yd 11905 -域名 yu ming ym 11890 -农村 nong cun nc 11875 -合同 he tong ht 11875 -开展 kai zhan kz 11875 -时代 shi dai sd 11875 -同意 tong yi ty 11860 -工具 gong ju gj 11860 -想到 xiang dao xd 11845 -汽车 qi che qc 11845 -中午 zhong wu zw 11831 -女孩 nv hai nh 11831 -想法 xiang fa xf 11816 -自由 zi you zy 11801 -儿子 er zi ez 11787 -电视 dian shi ds 11787 -文字 wen zi wz 11728 -欢迎 huan ying hy 11728 -无聊 wu liao wl 11684 -一会 yi hui yh 11670 -担心 dan xin dx 11641 -听说 ting shuo ts 11626 -共同 gong tong gt 11597 -现象 xian xiang xx 11597 -下去 xia qu xq 11583 -事实 shi shi ss 11583 -理论 li lun ll 11568 -三个 san ge sg 11554 -人民 ren min rm 11554 -一张 yi zhang yz 11539 -前面 qian mian qm 11511 -减少 jian shao js 11482 -的事 de shi ds 11482 -宣传 xuan chuan xc 11468 -链接 lian jie lj 11439 -竟然 jing ran jr 11439 -感到 gan dao gd 11425 -力量 li liang ll 11411 -消费者 xiao fei zhe xfz 11411 -马甲 ma jia mj 11397 -办公室 ban gong shi bgs 11368 -阶段 jie duan jd 11368 -健康 jian kang jk 11354 -回复 hui fu hf 11354 -语言 yu yan yy 11354 -会有 hui you hy 11326 -放在 fang zai fz 11326 -是在 shi zai sz 11326 -也就 ye jiu yj 11312 -页面 ye mian ym 11312 -证明 zheng ming zm 11284 -医生 yi sheng ys 11269 -对象 dui xiang dx 11269 -周末 zhou mo zm 11241 -针对 zhen dui zd 11241 -要不 yao bu yb 11227 -仍然 reng ran rr 11213 -数量 shu liang sl 11199 -运动 yun dong yd 11186 -我在 wo zai wz 11172 -报道 bao dao bd 11172 -让你 rang ni rn 11172 -都在 dou zai dz 11172 -失败 shi bai sb 11144 -貌似 mao si ms 11130 -旅游 lv you ly 11088 -相对 xiang dui xd 11088 -下次 xia ci xc 11047 -号码 hao ma hm 11047 -毕竟 bi jing bj 11047 -责任 ze ren zr 11033 -期间 qi jian qj 11019 -没什么 mei shen me msm 11019 -一条 yi tiao yt 11006 -不敢 bu gan bg 10978 -促进 cu jin cj 10978 -恢复 hui fu hf 10978 -算了 suan le sl 10978 -长期 chang qi cq 10978 -不如 bu ru br 10965 -删除 shan chu sc 10965 -心理 xin li xl 10937 -忘记 wang ji wj 10937 -输入 shu ru sr 10924 -再说 zai shuo zs 10897 -试试 shi shi ss 10897 -制作 zhi zuo zz 10869 -可惜 ke xi kx 10856 -享受 xiang shou xs 10815 -下班 xia ban xb 10802 -成了 cheng le cl 10802 -经营 jing ying jy 10802 -都要 dou yao dy 10775 -很快 hen kuai hk 10721 -结合 jie he jh 10721 -维护 wei hu wh 10721 -最大 zui da zd 10708 -身边 shen bian sb 10708 -作者 zuo zhe zz 10681 -南京 nan jing nj 10668 -否则 fou ze fz 10668 -股市 gu shi gs 10668 -母亲 mu qin mq 10642 -登录 deng lu dl 10628 -也会 ye hui yh 10615 -尽管 jin guan jg 10615 -最新 zui xin zx 10602 -加上 jia shang js 10602 -外面 wai mian wm 10602 -搜索 sou suo ss 10602 -有限公司 you xian gong si yxgs 10602 -本身 ben shen bs 10602 -问问 wen wen ww 10602 -听到 ting dao td 10589 -可爱 ke ai ka 10575 -利益 li yi ly 10562 -平时 ping shi ps 10562 -做好 zuo hao zh 10549 -很大 hen da hd 10549 -查询 cha xun cx 10536 -科技 ke ji kj 10536 -内部 nei bu nb 10523 -英语 ying yu yy 10523 -也要 ye yao yy 10510 -认真 ren zhen rz 10510 -哥哥 ge ge gg 10497 -完善 wan shan ws 10497 -宝贝 bao bei bb 10497 -很难 hen nan hn 10484 -杭州 hang zhou hz 10484 -计算 ji suan js 10484 -父亲 fu qin fq 10445 -访问 fang wen fw 10445 -我要 wo yao wy 10393 -因素 yin su ys 10367 -就像 jiu xiang jx 10367 -上午 shang wu sw 10354 -明确 ming que mq 10341 -真实 zhen shi zs 10328 -反映 fan ying fy 10315 -感受 gan shou gs 10315 -符合 fu he fh 10315 -美丽 mei li ml 10302 -越来越 yue lai yue yly 10289 -一边 yi bian yb 10277 -日子 ri zi rz 10277 -启动 qi dong qd 10251 -兴趣 xing qu xq 10238 -数据库 shu ju ku sjk 10238 -感谢 gan xie gx 10226 -打电话 da dian hua ddh 10226 -都不 dou bu db 10213 -国外 guo wai gw 10200 -往往 wang wang ww 10200 -我不 wo bu wb 10187 -特点 te dian td 10187 -必要 bi yao by 10162 -放弃 fang qi fq 10162 -熟悉 shu xi sx 10149 -社区 she qu sq 10149 -专门 zhuan men zm 10137 -计算机 ji suan ji jsj 10137 -政治 zheng zhi zz 10124 -一份 yi fen yf 10112 -电子 dian zi dz 10099 -会员 hui yuan hy 10086 -复杂 fu za fz 10086 -妹妹 mei mei mm 10074 -依然 yi ran yr 10061 -区域 qu yu qy 10061 -星期 xing qi xq 10061 -程度 cheng du cd 10061 -整体 zheng ti zt 10036 -唯一 wei yi wy 9999 -集中 ji zhong jz 9986 -投入 tou ru tr 9974 -对了 dui le dl 9961 -类型 lei xing lx 9961 -限制 xian zhi xz 9961 -帮我 bang wo bw 9937 -正确 zheng que zq 9937 -统计 tong ji tj 9937 -韩国 han guo hg 9937 -做了 zuo le zl 9924 -咱们 zan men zm 9900 -上市 shang shi ss 9887 -关心 guan xin gx 9887 -采取 cai qu cq 9863 -规划 gui hua gh 9850 -装备 zhuang bei zb 9838 -在线 zai xian zx 9814 -年轻 nian qing nq 9814 -爸爸 ba ba bb 9814 -很少 hen shao hs 9789 -不得 bu de bd 9777 -良好 liang hao lh 9753 -暂时 zan shi zs 9740 -沟通 gou tong gt 9740 -别的 bie de bd 9728 -成绩 cheng ji cj 9716 -帮忙 bang mang bm 9704 -最高 zui gao zg 9692 -双方 shuang fang sf 9692 -小心 xiao xin xx 9680 -数字 shu zi sz 9680 -类似 lei si ls 9680 -附近 fu jin fj 9680 -买了 mai le ml 9668 -各位 ge wei gw 9668 -来看 lai kan lk 9668 -点击 dian ji dj 9668 -人类 ren lei rl 9656 -失去 shi qu sq 9656 -女儿 nv er ne 9656 -举行 ju xing jx 9620 -人物 ren wu rw 9620 -是一种 shi yi zhong syz 9620 -成都 cheng du cd 9608 -提升 ti sheng ts 9608 -科学 ke xue kx 9608 -巨大 ju da jd 9584 -及其 ji qi jq 9572 -态度 tai du td 9572 -成立 cheng li cl 9572 -颜色 yan se ys 9560 -评价 ping jia pj 9536 -风格 feng ge fg 9513 -兄弟 xiong di xd 9477 -困难 kun nan kn 9453 -产业 chan ye cy 9442 -更多的 geng duo de gdd 9442 -没人 mei ren mr 9430 -教师 jiao shi js 9418 -连接 lian jie lj 9418 -中文 zhong wen zw 9371 -丰富 feng fu ff 9371 -值得 zhi de zd 9371 -反应 fan ying fy 9371 -概念 gai nian gn 9348 -请问 qing wen qw 9336 -当地 dang di dd 9325 -人的 ren de rd 9313 -有了 you le yl 9313 -观点 guan dian gd 9313 -面前 mian qian mq 9313 -配合 pei he ph 9302 -连续 lian xu lx 9290 -主题 zhu ti zt 9278 -独立 du li dl 9255 -那种 na zhong nz 9255 -中国人 zhong guo ren zgr 9244 -同志 tong zhi tz 9232 -代理 dai li dl 9221 -降低 jiang di jd 9221 -职业 zhi ye zy 9198 -从来 cong lai cl 9186 -评论 ping lun pl 9175 -即可 ji ke jk 9163 -相应 xiang ying xy 9163 -女生 nv sheng ns 9152 -月份 yue fen yf 9152 -经理 jing li jl 9152 -原则 yuan ze yz 9129 -性能 xing neng xn 9129 -综合 zong he zh 9129 -从而 cong er ce 9095 -当年 dang nian dn 9095 -改革 gai ge gg 9095 -英文 ying wen yw 9073 -每年 mei nian mn 9050 -痛苦 tong ku tk 9050 -艺术 yi shu ys 9050 -协议 xie yi xy 9039 -意识 yi shi ys 9039 -消费 xiao fei xf 8983 -英国 ying guo yg 8983 -节目 jie mu jm 8960 -上来 shang lai sl 8949 -商品 shang pin sp 8938 -还能 hai neng hn 8938 -那边 na bian nb 8927 -提示 ti shi ts 8905 -合理 he li hl 8893 -用于 yong yu yy 8882 -应当 ying dang yd 8871 -形象 xing xiang xx 8849 -等待 deng dai dd 8849 -行动 xing dong xd 8849 -最大的 zui da de zdd 8816 -干什么 gan shen me gsm 8816 -中间 zhong jian zj 8805 -它的 ta de td 8805 -来到 lai dao ld 8805 -理由 li you ly 8805 -背景 bei jing bj 8805 -就在 jiu zai jz 8794 -翻译 fan yi fy 8794 -事业 shi ye sy 8783 -发挥 fa hui fh 8783 -回到 hui dao hd 8783 -持续 chi xu cx 8783 -制定 zhi ding zd 8772 -老大 lao da ld 8772 -追求 zhui qiu zq 8772 -既然 ji ran jr 8761 -留下 liu xia lx 8761 -皮肤 pi fu pf 8761 -不好意思 bu hao yi si bhys 8751 -公布 gong bu gb 8751 -不太 bu tai bt 8729 -大部分 da bu fen dbf 8729 -办理 ban li bl 8718 -合适 he shi hs 8718 -就算 jiu suan js 8718 -成熟 cheng shu cs 8718 -多了 duo le dl 8707 -当前 dang qian dq 8707 -教授 jiao shou js 8707 -添加 tian jia tj 8707 -竞争 jing zheng jz 8707 -女性 nv xing nx 8664 -看过 kan guo kg 8653 -加油 jia you jy 8642 -不懂 bu dong bd 8621 -创新 chuang xin cx 8621 -旁边 pang bian pb 8621 -高兴 gao xing gx 8621 -内存 nei cun nc 8610 -辛苦 xin ku xk 8610 -见到 jian dao jd 8588 -使得 shi de sd 8578 -互联网 hu lian wang hlw 8567 -规范 gui fan gf 8567 -你要 ni yao ny 8546 -正是 zheng shi zs 8546 -重庆 chong qing cq 8546 -硬盘 ying pan yp 8535 -做到 zuo dao zd 8514 -攻击 gong ji gj 8514 -详细 xiang xi xx 8503 -天涯 tian ya ty 8482 -人才 ren cai rc 8461 -笔记本 bi ji ben bjb 8461 -可怜 ke lian kl 8450 -观众 guan zhong gz 8450 -走了 zou le zl 8450 -有时 you shi ys 8440 -又是 you shi ys 8429 -才是 cai shi cs 8429 -寻找 xun zhao xz 8419 -大盘 da pan dp 8398 -主动 zhu dong zd 8387 -或许 huo xu hx 8387 -指导 zhi dao zd 8377 -特殊 te shu ts 8377 -病毒 bing du bd 8377 -下降 xia jiang xj 8367 -改善 gai shan gs 8367 -迅速 xun su xs 8367 -比例 bi li bl 8356 -商业 shang ye sy 8346 -好久 hao jiu hj 8346 -措施 cuo shi cs 8335 -更是 geng shi gs 8325 -上去 shang qu sq 8315 -就能 jiu neng jn 8315 -房间 fang jian fj 8315 -明年 ming nian mn 8315 -经典 jing dian jd 8315 -优秀 you xiu yx 8304 -不去 bu qu bq 8294 -紧张 jin zhang jz 8294 -奇怪 qi guai qg 8273 -群众 qun zhong qz 8273 -有限 you xian yx 8263 -处于 chu yu cy 8253 -提前 ti qian tq 8253 -倒是 dao shi ds 8242 -投资者 tou zi zhe tzz 8242 -不足 bu zu bz 8232 -好吧 hao ba hb 8222 -的确 di que dq 8222 -伤害 shang hai sh 8212 -以来 yi lai yl 8201 -嘻嘻 xi xi xx 8201 -期待 qi dai qd 8201 -不可 bu ke bk 8191 -带着 dai zhe dz 8191 -规模 gui mo gm 8181 -表达 biao da bd 8161 -据说 ju shuo js 8140 -尽量 jin liang jl 8120 -并不是 bing bu shi bbs 8120 -酒店 jiu dian jd 8120 -接口 jie kou jk 8110 -美元 mei yuan my 8090 -角度 jiao du jd 8090 -动作 dong zuo dz 8080 -技能 ji neng jn 8080 -不了 bu liao bl 8070 -判断 pan duan pd 8070 -小说 xiao shuo xs 8070 -广东 guang dong gd 8060 -自身 zi shen zs 8060 -规则 gui ze gz 8050 -区别 qu bie qb 8040 -核心 he xin hx 8040 -德国 de guo dg 8029 -整理 zheng li zl 8029 -进来 jin lai jl 8029 -体现 ti xian tx 8019 -体系 ti xi tx 8019 -你说 ni shuo ns 8019 -手段 shou duan sd 8019 -轻松 qing song qs 8019 -参考 can kao ck 8010 -有时候 you shi hou ysh 8010 -至于 zhi yu zy 8010 -邮箱 you xiang yx 8010 -出口 chu kou ck 8000 -战略 zhan lue zl 8000 -掌握 zhang wo zw 8000 -放心 fang xin fx 7990 -帐号 zhang hao zh 7980 -一段 yi duan yd 7970 -好看 hao kan hk 7970 -让他 rang ta rt 7960 -创造 chuang zao cz 7950 -时期 shi qi sq 7950 -接触 jie chu jc 7930 -避免 bi mian bm 7930 -实行 shi xing sx 7920 -思考 si kao sk 7920 -变得 bian de bd 7910 -推广 tui guang tg 7910 -也没 ye mei ym 7900 -充分 chong fen cf 7891 -心中 xin zhong xz 7891 -自我 zi wo zw 7891 -召开 zhao kai zk 7881 -中央 zhong yang zy 7861 -土地 tu di td 7832 -格式 ge shi gs 7832 -留言 liu yan ly 7822 -站在 zhan zai zz 7822 -培养 pei yang py 7812 -距离 ju li jl 7812 -两人 liang ren lr 7803 -名称 ming cheng mc 7803 -欧洲 ou zhou oz 7803 -加班 jia ban jb 7793 -阳光 yang guang yg 7793 -吸引 xi yin xy 7783 -小姐 xiao jie xj 7773 -一句 yi ju yj 7764 -快速 kuai su ks 7764 -想起 xiang qi xq 7764 -我说 wo shuo ws 7754 -支付 zhi fu zf 7754 -强调 qiang diao qd 7744 -基本上 ji ben shang jbs 7735 -之类 zhi lei zl 7725 -武汉 wu han wh 7725 -一旦 yi dan yd 7716 -不在 bu zai bz 7716 -现代 xian dai xd 7716 -附件 fu jian fj 7716 -资产 zi chan zc 7706 -推进 tui jin tj 7696 -推动 tui dong td 7687 -理想 li xiang lx 7677 -人士 ren shi rs 7658 -飞机 fei ji fj 7658 -今日 jin ri jr 7649 -在于 zai yu zy 7649 -论文 lun wen lw 7649 -主任 zhu ren zr 7620 -保障 bao zhang bz 7620 -涉及 she ji sj 7620 -地位 di wei dw 7592 -此次 ci ci cc 7592 -网页 wang ye wy 7582 -给他 gei ta gt 7563 -忘了 wang le wl 7554 -编辑 bian ji bj 7554 -一场 yi chang yc 7544 -建筑 jian zhu jz 7544 -拒绝 ju jue jj 7544 -想想 xiang xiang xx 7535 -就行 jiu xing jx 7526 -小孩 xiao hai xh 7516 -指出 zhi chu zc 7516 -逐渐 zhu jian zj 7516 -团队 tuan dui td 7507 -尽快 jin kuai jk 7507 -一块 yi kuai yk 7498 -出了 chu le cl 7498 -利润 li run lr 7488 -文档 wen dang wd 7488 -民族 min zu mz 7488 -上涨 shang zhang sz 7479 -不大 bu da bd 7479 -你就 ni jiu nj 7479 -广大 guang da gd 7479 -强烈 qiang lie ql 7479 -承担 cheng dan cd 7479 -通常 tong chang tc 7479 -大小 da xiao dx 7470 -就不 jiu bu jb 7470 -就有 jiu you jy 7470 -干吗 gan ma gm 7470 -平均 ping jun pj 7470 -作出 zuo chu zc 7460 -各个 ge ge gg 7460 -公开 gong kai gk 7451 -突破 tu po tp 7451 -一片 yi pian yp 7442 -战士 zhan shi zs 7442 -赚钱 zhuan qian zq 7432 -有着 you zhe yz 7423 -舒服 shu fu sf 7423 -在家 zai jia zj 7405 -咨询 zi xun zx 7395 -完美 wan mei wm 7395 -法国 fa guo fg 7395 -大陆 da lu dl 7386 -不多 bu duo bd 7368 -充满 chong man cm 7368 -就好 jiu hao jh 7340 -正好 zheng hao zh 7340 -坐在 zuo zai zz 7331 -天气 tian qi tq 7331 -机制 ji zhi jz 7322 -患者 huan zhe hz 7313 -上述 shang shu ss 7304 -保存 bao cun bc 7304 -允许 yun xu yx 7304 -超级 chao ji cj 7304 -从事 cong shi cs 7295 -刺激 ci ji cj 7295 -状况 zhuang kuang zk 7277 -天津 tian jin tj 7268 -缺乏 que fa qf 7268 -负责人 fu ze ren fzr 7268 -做出 zuo chu zc 7259 -地图 di tu dt 7249 -基于 ji yu jy 7249 -如下 ru xia rx 7249 -宿舍 su she ss 7249 -老子 lao zi lz 7249 -怀疑 huai yi hy 7222 -为主 wei zhu wz 7204 -大型 da xing dx 7204 -强大 qiang da qd 7204 -很有 hen you hy 7204 -实力 shi li sl 7195 -拿到 na dao nd 7187 -流程 liu cheng lc 7187 -电信 dian xin dx 7187 -定义 ding yi dy 7178 -拍摄 pai she ps 7178 -难以 nan yi ny 7178 -两天 liang tian lt 7169 -今晚 jin wan jw 7169 -过了 guo le gl 7169 -这位 zhe wei zw 7151 -总结 zong jie zj 7142 -高速 gao su gs 7142 -开放 kai fang kf 7133 -重大 zhong da zd 7133 -观察 guan cha gc 7124 -赶紧 gan jin gj 7124 -表明 biao ming bm 7098 -提交 ti jiao tj 7080 -一只 yi zhi yz 7071 -微软 wei ruan wr 7062 -成员 cheng yuan cy 7062 -目录 mu lu ml 7062 -成长 cheng zhang cz 7053 -有空 you kong yk 7053 -矛盾 mao dun md 7045 -某些 mou xie mx 7036 -确保 que bao qb 7036 -实践 shi jian sj 7027 -相比 xiang bi xb 7027 -会不会 hui bu hui hbh 7010 -联合 lian he lh 7010 -晓得 xiao de xd 7001 -取消 qu xiao qx 6992 -权利 quan li ql 6983 -一半 yi ban yb 6975 -门口 men kou mk 6975 -发送 fa song fs 6966 -提醒 ti xing tx 6957 -一套 yi tao yt 6949 -参数 can shu cs 6949 -味道 wei dao wd 6949 -玩家 wan jia wj 6949 -角色 jue se js 6940 -上升 shang sheng ss 6931 -显得 xian de xd 6914 -深入 shen ru sr 6906 -适应 shi ying sy 6906 -高级 gao ji gj 6906 -想要 xiang yao xy 6888 -什么时候 shen me shi hou smsh 6871 -描述 miao shu ms 6871 -立即 li ji lj 6863 -路上 lu shang ls 6863 -损失 sun shi ss 6854 -训练 xun lian xl 6854 -重视 zhong shi zs 6854 -一名 yi ming ym 6846 -彻底 che di cd 6846 -令人 ling ren lr 6837 -印象 yin xiang yx 6829 -浪费 lang fei lf 6829 -学院 xue yuan xy 6820 -著名 zhu ming zm 6812 -固定 gu ding gd 6803 -扩大 kuo da kd 6803 -身份 shen fen sf 6803 -道理 dao li dl 6803 -商务 shang wu sw 6795 -实际上 shi ji shang sjs 6795 -一致 yi zhi yz 6786 -发出 fa chu fc 6786 -承认 cheng ren cr 6786 -展示 zhan shi zs 6778 -查看 cha kan ck 6769 -英雄 ying xiong yx 6752 -说了 shuo le sl 6752 -制造 zhi zao zz 6744 -消失 xiao shi xs 6744 -百度 bai du bd 6744 -相同 xiang tong xt 6744 -万元 wan yuan wy 6736 -之外 zhi wai zw 6736 -面试 mian shi ms 6736 -之中 zhi zhong zz 6710 -学会 xue hui xh 6710 -交通 jiao tong jt 6694 -仅仅 jin jin jj 6694 -警察 jing cha jc 6694 -特色 te se ts 6669 -跟我 gen wo gw 6669 -答案 da an da 6660 -表情 biao qing bq 6660 -最多 zui duo zd 6644 -清除 qing chu qc 6644 -读书 du shu ds 6636 -两年 liang nian ln 6627 -众多 zhong duo zd 6627 -感动 gan dong gd 6627 -女子 nv zi nz 6603 -收费 shou fei sf 6594 -不说 bu shuo bs 6586 -实验 shi yan sy 6586 -都能 dou neng dn 6586 -争取 zheng qu zq 6578 -找个 zhao ge zg 6578 -世纪 shi ji sj 6570 -洗澡 xi zao xz 6570 -高度 gao du gd 6570 -哪些 na xie nx 6562 -有所 you suo ys 6562 -组成 zu cheng zc 6553 -周围 zhou wei zw 6545 -太阳 tai yang ty 6545 -转载 zhuan zai zz 6545 -近期 jin qi jq 6537 -黑色 hei se hs 6537 -年龄 nian ling nl 6529 -面临 mian lin ml 6529 -性格 xing ge xg 6521 -满意 man yi my 6513 -趋势 qu shi qs 6496 -顺利 shun li sl 6496 -个月 ge yue gy 6488 -导演 dao yan dy 6488 -检测 jian ce jc 6488 -位于 wei yu wy 6480 -多个 duo ge dg 6480 -鼓励 gu li gl 6480 -上课 shang ke sk 6464 -始终 shi zhong sz 6464 -时尚 shi shang ss 6456 -为啥 wei sha ws 6448 -增强 zeng qiang zq 6448 -是我 shi wo sw 6448 -将会 jiang hui jh 6440 -女朋友 nv peng you npy 6432 -看法 kan fa kf 6432 -贷款 dai kuan dk 6432 -级别 ji bie jb 6416 -还会 hai hui hh 6416 -人民币 ren min bi rmb 6408 -情绪 qing xu qx 6408 -一声 yi sheng ys 6392 -打击 da ji dj 6392 -指标 zhi biao zb 6392 -楼上 lou shang ls 6392 -突出 tu chu tc 6392 -记忆 ji yi jy 6384 -不对 bu dui bd 6376 -具备 ju bei jb 6376 -宣布 xuan bu xb 6376 -很久 hen jiu hj 6376 -体验 ti yan ty 6368 -而言 er yan ey 6368 -农业 nong ye ny 6360 -禁止 jin zhi jz 6360 -一生 yi sheng ys 6344 -工业 gong ye gy 6337 -指定 zhi ding zd 6337 -对我 dui wo dw 6329 -模块 mo kuai mk 6321 -我还 wo hai wh 6313 -能否 neng fou nf 6305 -生气 sheng qi sq 6289 -头发 tou fa tf 6282 -对不起 dui bu qi dbq 6282 -收购 shou gou sg 6282 -生日 sheng ri sr 6282 -多次 duo ci dc 6266 -效率 xiao lv xl 6266 -老人 lao ren lr 6266 -严格 yan ge yg 6258 -定位 ding wei dw 6250 -补充 bu chong bc 6250 -队伍 dui wu dw 6250 -做的 zuo de zd 6242 -房价 fang jia fj 6242 -早点 zao dian zd 6242 -首次 shou ci sc 6242 -多年 duo nian dn 6235 -天下 tian xia tx 6235 -生意 sheng yi sy 6235 -市民 shi min sm 6227 -很多人 hen duo ren hdr 6227 -搞定 gao ding gd 6227 -欣赏 xin shang xs 6219 -死了 si le sl 6219 -回忆 hui yi hy 6211 -帮你 bang ni bn 6211 -采访 cai fang cf 6211 -完了 wan le wl 6196 -武器 wu qi wq 6196 -教学 jiao xue jx 6188 -危险 wei xian wx 6173 -常常 chang chang cc 6173 -把握 ba wo bw 6173 -课程 ke cheng kc 6173 -预计 yu ji yj 6173 -大学生 da xue sheng dxs 6165 -举办 ju ban jb 6157 -年代 nian dai nd 6157 -跟你 gen ni gn 6157 -果然 guo ran gr 6150 -话题 hua ti ht 6150 -将来 jiang lai jl 6142 -返回 fan hui fh 6142 -直到 zhi dao zd 6127 -晚安 wan an wa 6119 -信号 xin hao xh 6112 -而不是 er bu shi ebs 6104 -保险 bao xian bx 6096 -信心 xin xin xx 6096 -加大 jia da jd 6096 -多久 duo jiu dj 6096 -报价 bao jia bj 6096 -第三 di san ds 6096 -一是 yi shi ys 6089 -妻子 qi zi qz 6066 -都没 dou mei dm 6066 -仔细 zi xi zx 6059 -几点 ji dian jd 6059 -只好 zhi hao zh 6059 -明星 ming xing mx 6059 -界面 jie mian jm 6059 -运用 yun yong yy 6059 -梦想 meng xiang mx 6051 -最佳 zui jia zj 6043 -反而 fan er fe 6036 -半年 ban nian bn 6028 -完整 wan zheng wz 6028 -属性 shu xing sx 6028 -模型 mo xing mx 6028 -说法 shuo fa sf 6021 -透露 tou lu tl 6021 -女孩子 nv hai zi nhz 6013 -渠道 qu dao qd 6013 -画面 hua mian hm 6013 -分享 fen xiang fx 6006 -当初 dang chu dc 6006 -怎么办 zen me ban zmb 6006 -创建 chuang jian cj 5998 -回头 hui tou ht 5998 -家人 jia ren jr 5998 -报名 bao ming bm 5998 -讨厌 tao yan ty 5998 -动物 dong wu dw 5991 -昨晚 zuo wan zw 5991 -你在 ni zai nz 5983 -分配 fen pei fp 5976 -影片 ying pian yp 5976 -足够 zu gou zg 5976 -加快 jia kuai jk 5969 -部队 bu dui bd 5969 -有钱 you qian yq 5961 -高手 gao shou gs 5961 -专用 zhuan yong zy 5954 -普遍 pu bian pb 5954 -逐步 zhu bu zb 5954 -今后 jin hou jh 5946 -和谐 he xie hx 5946 -四川 si chuan sc 5946 -脸上 lian shang ls 5946 -无所谓 wu suo wei wsw 5939 -驱动 qu dong qd 5939 -随时 sui shi ss 5932 -一把 yi ba yb 5924 -广泛 guang fan gf 5924 -挑战 tiao zhan tz 5924 -快递 kuai di kd 5917 -死亡 si wang sw 5917 -伟大 wei da wd 5909 -上传 shang chuan sc 5902 -两次 liang ci lc 5902 -他说 ta shuo ts 5902 -发行 fa xing fx 5902 -组合 zu he zh 5895 -协调 xie tiao xt 5887 -展开 zhan kai zk 5887 -道路 dao lu dl 5887 -一件 yi jian yj 5880 -网吧 wang ba wb 5880 -阅读 yue du yd 5873 -开会 kai hui kh 5865 -浙江 zhe jiang zj 5865 -见面 jian mian jm 5865 -一部 yi bu yb 5858 -先进 xian jin xj 5851 -娱乐 yu le yl 5851 -杂志 za zhi zz 5843 -说过 shuo guo sg 5843 -防止 fang zhi fz 5843 -命令 ming ling ml 5836 -落实 luo shi ls 5836 -表演 biao yan by 5836 -表面 biao mian bm 5829 -较大 jiao da jd 5829 -邀请 yao qing yq 5829 -镜头 jing tou jt 5829 -共享 gong xiang gx 5822 -下周 xia zhou xz 5814 -大哥 da ge dg 5814 -小区 xiao qu xq 5814 -接近 jie jin jj 5814 -流行 liu xing lx 5814 -算是 suan shi ss 5814 -保留 bao liu bl 5807 -即将 ji jiang jj 5807 -热情 re qing rq 5807 -然而 ran er re 5807 -优化 you hua yh 5793 -尝试 chang shi cs 5793 -食品 shi pin sp 5793 -疯狂 feng kuang fk 5771 -半天 ban tian bt 5764 -采购 cai gou cg 5764 -作业 zuo ye zy 5757 -聪明 cong ming cm 5757 -那时 na shi ns 5757 -付款 fu kuan fk 5750 -奖励 jiang li jl 5750 -用来 yong lai yl 5750 -白色 bai se bs 5750 -精彩 jing cai jc 5750 -大大 da da dd 5742 -男生 nan sheng ns 5742 -简直 jian zhi jz 5742 -食物 shi wu sw 5742 -个性 ge xing gx 5735 -最好的 zui hao de zhd 5728 -心态 xin tai xt 5728 -所在 suo zai sz 5728 -看不到 kan bu dao kbd 5728 -至今 zhi jin zj 5728 -准确 zhun que zq 5721 -发言 fa yan fy 5721 -关闭 guan bi gb 5714 -必然 bi ran br 5714 -照顾 zhao gu zg 5714 -本地 ben di bd 5707 -美好 mei hao mh 5707 -转移 zhuan yi zy 5707 -出发 chu fa cf 5678 -现有 xian you xy 5678 -面积 mian ji mj 5671 -各类 ge lei gl 5664 -奥运 ao yun ay 5664 -批准 pi zhun pz 5664 -此时 ci shi cs 5664 -破坏 po huai ph 5664 -法师 fa shi fs 5657 -营销 ying xiao yx 5657 -贡献 gong xian gx 5657 -二是 er shi es 5650 -股份 gu fen gf 5650 -出差 chu chai cc 5643 -吃了 chi le cl 5643 -如今 ru jin rj 5643 -全市 quan shi qs 5636 -还不 hai bu hb 5636 -日期 ri qi rq 5629 -工作人员 gong zuo ren yuan gzry 5622 -要么 yao me ym 5622 -几年 ji nian jn 5615 -引用 yin yong yy 5615 -战争 zhan zheng zz 5615 -特征 te zheng tz 5615 -道德 dao de dd 5608 -什么样 shen me yang smy 5601 -金融 jin rong jr 5601 -冠军 guan jun gj 5594 -家长 jia zhang jz 5594 -动力 dong li dl 5580 -包含 bao han bh 5580 -球员 qiu yuan qy 5580 -不得不 bu de bu bdb 5573 -红色 hong se hs 5573 -细节 xi jie xj 5573 -年度 nian du nd 5566 -见过 jian guo jg 5566 -大多数 da duo shu dds 5559 -品种 pin zhong pz 5552 -没关系 mei guan xi mgx 5552 -更好 geng hao gh 5545 -看起来 kan qi lai kql 5545 -本次 ben ci bc 5539 -空调 kong tiao kt 5539 -反对 fan dui fd 5532 -委员会 wei yuan hui wyh 5532 -和你 he ni hn 5525 -我都 wo dou wd 5525 -公告 gong gao gg 5518 -写的 xie de xd 5518 -跟着 gen zhe gz 5518 -你也 ni ye ny 5511 -社会主义 she hui zhu yi shzy 5511 -策略 ce lue cl 5504 -做法 zuo fa zf 5497 -输出 shu chu sc 5497 -弟弟 di di dd 5490 -相互 xiang hu xh 5490 -分类 fen lei fl 5484 -多种 duo zhong dz 5484 -供应 gong ying gy 5477 -收益 shou yi sy 5477 -人口 ren kou rk 5470 -绿色 lv se ls 5470 -创业 chuang ye cy 5463 -多么 duo me dm 5463 -山东 shan dong sd 5456 -厂商 chang shang cs 5450 -屏幕 ping mu pm 5450 -无奈 wu nai wn 5450 -生成 sheng cheng sc 5443 -监督 jian du jd 5443 -称为 cheng wei cw 5443 -好象 hao xiang hx 5436 -显然 xian ran xr 5436 -到处 dao chu dc 5429 -对手 dui shou ds 5429 -平衡 ping heng ph 5429 -电池 dian chi dc 5429 -一会儿 yi hui er yhe 5422 -为何 wei he wh 5422 -打印 da yin dy 5422 -当中 dang zhong dz 5416 -高中 gao zhong gz 5416 -主席 zhu xi zx 5409 -互相 hu xiang hx 5409 -害怕 hai pa hp 5409 -俄罗斯 e luo si els 5402 -却是 que shi qs 5402 -想象 xiang xiang xx 5402 -但我 dan wo dw 5395 -各地 ge di gd 5395 -律师 lv shi ls 5395 -早就 zao jiu zj 5395 -窗口 chuang kou ck 5395 -读者 du zhe dz 5395 -两种 liang zhong lz 5389 -转换 zhuan huan zh 5389 -一项 yi xiang yx 5382 -装修 zhuang xiu zx 5382 -付出 fu chu fc 5369 -我去 wo qu wq 5369 -本站 ben zhan bz 5369 -激情 ji qing jq 5362 -进步 jin bu jb 5362 -例如 li ru lr 5355 -财务 cai wu cw 5355 -除非 chu fei cf 5355 -收藏 shou cang sc 5349 -整合 zheng he zh 5342 -白天 bai tian bt 5342 -石头 shi tou st 5342 -两个人 liang ge ren lgr 5335 -复制 fu zhi fz 5335 -内心 nei xin nx 5329 -哪儿 na er ne 5329 -构成 gou cheng gc 5322 -包装 bao zhuang bz 5315 -利于 li yu ly 5309 -放假 fang jia fj 5302 -淘宝 tao bao tb 5302 -究竟 jiu jing jj 5302 -体会 ti hui th 5296 -变态 bian tai bt 5296 -重复 chong fu cf 5296 -承诺 cheng nuo cn 5289 -男朋友 nan peng you npy 5289 -中华 zhong hua zh 5282 -印度 yin du yd 5282 -有事 you shi ys 5282 -被人 bei ren br 5282 -青年 qing nian qn 5276 -停止 ting zhi tz 5269 -太大 tai da td 5269 -干部 gan bu gb 5269 -物质 wu zhi wz 5269 -四个 si ge sg 5250 -好处 hao chu hc 5250 -官方 guan fang gf 5250 -歌曲 ge qu gq 5250 -车辆 che liang cl 5243 -异常 yi chang yc 5236 -顺便 shun bian sb 5236 -授权 shou quan sq 5230 -居民 ju min jm 5223 -签名 qian ming qm 5223 -力度 li du ld 5217 -预测 yu ce yc 5217 -业绩 ye ji yj 5210 -地说 de shuo ds 5210 -那天 na tian nt 5210 -函数 han shu hs 5204 -祝福 zhu fu zf 5204 -试验 shi yan sy 5204 -几次 ji ci jc 5197 -尊重 zun zhong zz 5197 -接着 jie zhe jz 5197 -日志 ri zhi rz 5197 -相机 xiang ji xj 5197 -标志 biao zhi bz 5191 -不算 bu suan bs 5185 -改进 gai jin gj 5185 -这款 zhe kuan zk 5185 -也能 ye neng yn 5178 -了吗 le ma lm 5178 -合法 he fa hf 5172 -挺好 ting hao th 5172 -上线 shang xian sx 5165 -不见 bu jian bj 5165 -他是 ta shi ts 5165 -后果 hou guo hg 5165 -房地产 fang di chan fdc 5165 -提到 ti dao td 5165 -硬件 ying jian yj 5159 -好友 hao you hy 5152 -引导 yin dao yd 5146 -等级 deng ji dj 5146 -行情 hang qing hq 5146 -这就 zhe jiu zj 5140 -一路 yi lu yl 5133 -病人 bing ren br 5133 -协会 xie hui xh 5127 -演员 yan yuan yy 5127 -碰到 peng dao pd 5127 -好玩 hao wan hw 5120 -发票 fa piao fp 5114 -联盟 lian meng lm 5114 -背后 bei hou bh 5114 -打造 da zao dz 5108 -温度 wen du wd 5108 -不怕 bu pa bp 5101 -案件 an jian aj 5101 -动态 dong tai dt 5095 -西安 xi an xa 5095 -儿童 er tong et 5089 -博士 bo shi bs 5089 -恶心 e xin ex 5089 -色彩 se cai sc 5089 -多少钱 duo shao qian dsq 5076 -说的 shuo de sd 5076 -深刻 shen ke sk 5070 -男子 nan zi nz 5070 -订单 ding dan dd 5070 -不但 bu dan bd 5063 -并非 bing fei bf 5063 -考生 kao sheng ks 5063 -获取 huo qu hq 5063 -就业 jiu ye jy 5057 -素质 su zhi sz 5057 -基地 ji di jd 5051 -小学 xiao xue xx 5051 -丈夫 zhang fu zf 5044 -三年 san nian sn 5044 -改造 gai zao gz 5044 -片子 pian zi pz 5044 -看你 kan ni kn 5044 -进口 jin kou jk 5044 -优惠 you hui yh 5038 -多大 duo da dd 5032 -成果 cheng guo cg 5032 -人体 ren ti rt 5019 -秘密 mi mi mm 5019 -一批 yi pi yp 5013 -意味着 yi wei zhe ywz 5013 -维持 wei chi wc 5013 -联想 lian xiang lx 5013 -进程 jin cheng jc 5013 -主板 zhu ban zb 5007 -权限 quan xian qx 5007 -预期 yu qi yq 5007 -本网 ben wang bw 5001 -要去 yao qu yq 5001 -亚洲 ya zhou yz 4988 -又有 you you yy 4988 -地点 di dian dd 4988 -才会 cai hui ch 4988 -工厂 gong chang gc 4982 -适当 shi dang sd 4976 -机场 ji chang jc 4969 -这几天 zhe ji tian zjt 4969 -金额 jin e je 4969 -副本 fu ben fb 4963 -显卡 xian ka xk 4963 -简历 jian li jl 4963 -轻轻 qing qing qq 4963 -探讨 tan tao tt 4957 -这么多 zhe me duo zmd 4957 -官员 guan yuan gy 4951 -依据 yi ju yj 4939 -偶尔 ou er oe 4939 -纷纷 fen fen ff 4939 -全新 quan xin qx 4932 -型号 xing hao xh 4932 -指数 zhi shu zs 4932 -立刻 li ke lk 4932 -一句话 yi ju hua yjh 4926 -我家 wo jia wj 4926 -排名 pai ming pm 4926 -法院 fa yuan fy 4926 -仿佛 fang fu ff 4920 -招聘 zhao pin zp 4920 -集体 ji ti jt 4920 -审核 shen he sh 4914 -来源 lai yuan ly 4914 -比我 bi wo bw 4914 -寂寞 ji mo jm 4908 -资本 zi ben zb 4902 -频道 pin dao pd 4902 -临时 lin shi ls 4890 -一看 yi kan yk 4883 -客人 ke ren kr 4883 -大约 da yue dy 4877 -空气 kong qi kq 4877 -无线 wu xian wx 4871 -要不要 yao bu yao yby 4871 -也在 ye zai yz 4865 -生存 sheng cun sc 4859 -没有什么 mei you shen me mysm 4853 -清晰 qing xi qx 4853 -独特 du te dt 4847 -评估 ping gu pg 4847 -岁的 sui de sd 4841 -管理员 guan li yuan gly 4841 -行政 xing zheng xz 4841 -剩下 sheng xia sx 4835 -资格 zi ge zg 4835 -伤心 shang xin sx 4829 -列表 lie biao lb 4829 -彼此 bi ci bc 4829 -情感 qing gan qg 4829 -报纸 bao zhi bz 4829 -劳动 lao dong ld 4823 -各项 ge xiang gx 4823 -在此 zai ci zc 4823 -播放 bo fang bf 4823 -苏州 su zhou sz 4823 -加工 jia gong jg 4817 -演出 yan chu yc 4817 -传播 chuan bo cb 4811 -实习 shi xi sx 4811 -开通 kai tong kt 4811 -手术 shou shu ss 4811 -北京市 bei jing shi bjs 4805 -或是 huo shi hs 4805 -难得 nan de nd 4805 -不久 bu jiu bj 4799 -人数 ren shu rs 4799 -最低 zui di zd 4799 -投诉 tou su ts 4799 -高校 gao xiao gx 4799 -魅力 mei li ml 4799 -机关 ji guan jg 4793 -某个 mou ge mg 4793 -球队 qiu dui qd 4793 -登记 deng ji dj 4793 -说是 shuo shi ss 4793 -除去 chu qu cq 4787 -收集 shou ji sj 4781 -厦门 xia men xm 4775 -黄金 huang jin hj 4775 -是指 shi zhi sz 4769 -理念 li nian ln 4769 -设立 she li sl 4769 -那就是 na jiu shi njs 4769 -下跌 xia die xd 4757 -和我 he wo hw 4757 -青岛 qing dao qd 4757 -文明 wen ming wm 4751 -专辑 zhuan ji zj 4745 -地震 di zhen dz 4745 -思路 si lu sl 4745 -答应 da ying dy 4745 -恐怖 kong bu kb 4740 -喝酒 he jiu hj 4734 -很高 hen gao hg 4734 -担任 dan ren dr 4734 -研发 yan fa yf 4734 -接到 jie dao jd 4722 -没法 mei fa mf 4722 -火车 huo che hc 4722 -这时 zhe shi zs 4722 -地上 di shang ds 4716 -对应 dui ying dy 4716 -起码 qi ma qm 4716 -锻炼 duan lian dl 4716 -验证 yan zheng yz 4716 -分手 fen shou fs 4710 -天空 tian kong tk 4710 -中学 zhong xue zx 4704 -出台 chu tai ct 4704 -把我 ba wo bw 4704 -西方 xi fang xf 4704 -将在 jiang zai jz 4698 -总体 zong ti zt 4698 -江苏 jiang su js 4693 -出生 chu sheng cs 4687 -备份 bei fen bf 4687 -账户 zhang hu zh 4687 -后悔 hou hui hh 4681 -婚姻 hun yin hy 4681 -每月 mei yue my 4681 -稍微 shao wei sw 4681 -老是 lao shi ls 4681 -各自 ge zi gz 4675 -财富 cai fu cf 4675 -全省 quan sheng qs 4669 -典型 dian xing dx 4669 -干净 gan jing gj 4669 -智慧 zhi hui zh 4669 -观念 guan nian gn 4663 -物品 wu pin wp 4652 -积分 ji fen jf 4652 -职工 zhi gong zg 4652 -请您 qing nin qn 4652 -广场 guang chang gc 4646 -插件 cha jian cj 4646 -搞笑 gao xiao gx 4646 -退出 tui chu tc 4646 -胜利 sheng li sl 4640 -逻辑 luo ji lj 4640 -每个人 mei ge ren mgr 4629 -键盘 jian pan jp 4629 -兴奋 xing fen xf 4623 -您好 nin hao nh 4623 -现金 xian jin xj 4623 -东方 dong fang df 4617 -则是 ze shi zs 4617 -大众 da zhong dz 4617 -输入法 shu ru fa srf 2665 -候选词 hou xuan ci hxc 500 -剪贴板 jian tie ban jtb 138 -供应商 gong ying shang gys 3398 -物料 wu liao wl 1226 -主管 zhu guan zg 3764 diff --git a/openless-all/app/android/kotlin/LegacyPinyinBackup.kt b/openless-all/app/android/kotlin/LegacyPinyinBackup.kt new file mode 100644 index 000000000..94289276f --- /dev/null +++ b/openless-all/app/android/kotlin/LegacyPinyinBackup.kt @@ -0,0 +1,22 @@ +package com.openless.app + +import android.content.Context + +/** Old backup-format compatibility only; never queried by the Rime engine. */ +internal class LegacyPinyinBackup(context: Context) { + private val preferences = context.applicationContext.getSharedPreferences( + "openless_pinyin_learned_phrases", Context.MODE_PRIVATE, + ) + + fun entryCount(): Int = exportAll().size + + fun exportAll(): Map = preferences.all.mapNotNull { (key, value) -> + (value as? String)?.let { key to it } + }.toMap() + + fun importAll(entries: Map) { + check(preferences.edit().clear().apply { + entries.forEach { (key, value) -> putString(key, value) } + }.commit()) { "Legacy pinyin backup save failed" } + } +} diff --git a/openless-all/app/android/kotlin/LitePinyinController.kt b/openless-all/app/android/kotlin/LitePinyinController.kt deleted file mode 100644 index f884f4a58..000000000 --- a/openless-all/app/android/kotlin/LitePinyinController.kt +++ /dev/null @@ -1,186 +0,0 @@ -package com.openless.app - -import android.content.Context - -/** - * Owns the Pinyin-mode encoding buffer AND its candidate query pipeline on - * the English keyboard panel (see OpenLessImeService.LatinInputMode) — - * phases 3+4 of the lite-pinyin plan (docs/pinyin-lite/phase-0-audit.md): - * single-character full-pinyin lookup merged with high-frequency - * abbreviation-phrase lookup (LitePinyinRepository.query() does the actual - * merging/ranking; this class just owns the buffer and epoch around it). - * - * Deliberately does not touch currentInputConnection or any View — callers - * get results through the [onCandidates] callback and decide what to do - * with them (render a row, commit a character, etc.) — see the lite-pinyin - * plan's 5.2 ("不让 View 直接查询词库或持有 Repository"). - */ -internal class LitePinyinController(context: Context) { - private val repository = LitePinyinRepository(context) - private val encoding = StringBuilder() - private var learningAllowed = false - fun setLearningAllowed(allowed: Boolean) { - learningAllowed = allowed - clear() - resetAssociationContext() - } - fun shutdown() = repository.shutdown() - - // --- Two-step combo learning (见 LitePinyinLearnedPhrases 的文档注释) --- - // "上次直接打拼音上屏的是什么" — separate from confirmedText above - // (which accumulates without bound until a panel/session boundary): - // this only ever remembers the ONE immediately-preceding commit, and is - // cleared at the same boundaries so a chain never spans a mode switch. - private var lastCommitEncoding: String? = null - private var lastCommitText: String? = null - - // Independent from englishCandidateQueryEpoch/strokeQueryEpoch/ - // phraseQueryEpoch by design (plan 8.3: "不得复用笔画 epoch 变量,避免 - // 不同输入模式互相影响") — bumped on every state change so a slow - // background query for an encoding the user has since edited or - // cleared can never overwrite what's on screen now. - private var queryEpoch = 0L - - fun currentEncoding(): String = encoding.toString() - - fun isEmpty(): Boolean = encoding.isEmpty() - - fun preloadAsync() = repository.preloadAsync() - - /** - * Record frequency under the candidate's real sourceKey, then drop only - * that source encoding from the buffer (full clear when the pick matched - * the whole buffer; first-syllable consume when it matched the leading - * syllable — including multi-character phrases keyed on that syllable). - */ - fun commitSelection(text: String, onCandidates: (List) -> Unit) { - val currentEncoding = encoding.toString() - val sourceKey = repository.resolveSourceKey(currentEncoding, text) - if (learningAllowed) repository.recordSelection(sourceKey, text) - if (sourceKey.length >= currentEncoding.length) { - clear() - onCandidates(emptyList()) - return - } - encoding.delete(0, sourceKey.length) - query(onCandidates) - } - - /** - * Call with the encoding still intact — i.e. before clear() — for every - * DIRECT pinyin/abbreviation commit (not an association pick — see - * OpenLessImeService.selectPinyinAssociation(), which doesn't call this - * in this first version). If this immediately follows another such - * commit (nothing else broke the chain — see resetAssociationContext() - * for what counts as "broke"), records the two-step combo with - * LitePinyinLearnedPhrases so a real habit (not a one-off) eventually - * surfaces on its own — see that class's own doc comment. - */ - fun observeCommitForLearning(text: String) { - if (!learningAllowed) return - val currentEncoding = encoding.toString() - // A single full-pinyin syllable ("guo") contributes only its first - // letter, matching how a real abbreviation is built one letter per - // character — an already-abbreviation-shaped encoding ("zg", one - // letter per character in `text`) is used as-is. - val abbreviation = if (currentEncoding.length == text.length) currentEncoding else currentEncoding.take(1) - val previousAbbreviation = lastCommitEncoding - val previousText = lastCommitText - if (previousAbbreviation != null && previousText != null) { - repository.observeSequence(previousAbbreviation + abbreviation, previousText + text) - } - lastCommitEncoding = abbreviation - lastCommitText = text - } - - /** Letters only — OpenLessImeService is responsible for routing non-letter keys elsewhere. */ - fun appendLetter(char: Char, onCandidates: (List) -> Unit) { - if (!char.isLetter()) return - encoding.append(char.lowercaseChar()) - query(onCandidates) - } - - /** @return true if a character was actually removed (false when the encoding was already empty, meaning the caller should fall back to normal backspace). */ - fun backspace(onCandidates: (List) -> Unit): Boolean { - if (encoding.isEmpty()) return false - encoding.deleteCharAt(encoding.length - 1) - query(onCandidates) - return true - } - - /** Consume only the first valid syllable, then query the remaining buffer. */ - fun consumeFirstSyllable(onCandidates: (List) -> Unit): Boolean { - if (encoding.isEmpty()) return false - val firstSyllable = repository.segmentEncoding(encoding.toString()).firstOrNull() - val consumeLength = firstSyllable?.length ?: encoding.length - encoding.delete(0, consumeLength) - query(onCandidates) - return true - } - - /** Mode switch, panel switch, new input session, or a space press — never carries a half-typed encoding across any of these (plan 3.2 point 8/9). Deliberately does NOT touch the association context below — that's meant to survive individual commits/mode toggles, only reset at true panel-switch/session boundaries (see resetAssociationContext()). */ - fun clear() { - encoding.clear() - queryEpoch++ - } - - // --- Post-commit association (取最后几个字联想下一个词,与笔画面板共用同一份联想词库+调频) --- - // Deliberately NOT the same buffer/epoch as the encoding above: this one - // survives every individual candidate commit (that's the entire point — - // it's what lets one word's commit suggest the next), only reset at - // panel-switch/new-session boundaries, mirroring - // StrokeInputController's own confirmedText exactly (same - // MAX_ASSOCIATION_CONTEXT cap, cleared at the same two call sites: - // selectInputMode()'s mode-switch reset and onStartInput()'s session - // reset — see resetAssociationContext()). - private val confirmedText = StringBuilder() - private var associationEpoch = 0L - - /** Feeds just-committed on-screen text into the association context — call after every pinyin commit (a fresh candidate or an association suffix alike). */ - fun recordCommittedText(text: String) { - if (!learningAllowed) return - confirmedText.append(text) - val overflow = confirmedText.length - StrokeInputController.MAX_ASSOCIATION_CONTEXT - if (overflow > 0) confirmedText.delete(0, overflow) - } - - /** - * Association candidates for whatever's currently in the shared - * context. [phraseRepository] is passed in rather than owned here — the - * caller hands over StrokeInputController's own instance (see that - * class's phraseRepository doc comment for why this is the SAME - * ~220k-entry index Stroke mode uses, not a second copy), so usage - * recorded from either input mode benefits both. - */ - fun queryAssociations(phraseRepository: StrokePhraseRepository, onResults: (List) -> Unit) { - val context = confirmedText.toString() - if (context.isEmpty()) { - onResults(emptyList()) - return - } - val epoch = ++associationEpoch - phraseRepository.searchAsync(context) { result -> - if (epoch == associationEpoch) onResults(result) - } - } - - /** Panel switch or new input session only — an individual commit must NOT call this (see this section's own doc comment above). Also breaks the two-step combo-learning chain (lastCommitEncoding/Text) for the same reason. */ - fun resetAssociationContext() { - confirmedText.clear() - associationEpoch++ - lastCommitEncoding = null - lastCommitText = null - } - - private fun query(onCandidates: (List) -> Unit) { - if (encoding.isEmpty()) { - queryEpoch++ - onCandidates(emptyList()) - return - } - val epoch = ++queryEpoch - repository.query(encoding.toString(), personalize = learningAllowed) { results -> - if (epoch == queryEpoch) onCandidates(results) - } - } -} diff --git a/openless-all/app/android/kotlin/LitePinyinLearnedPhrases.kt b/openless-all/app/android/kotlin/LitePinyinLearnedPhrases.kt deleted file mode 100644 index 46ac7e89e..000000000 --- a/openless-all/app/android/kotlin/LitePinyinLearnedPhrases.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.openless.app - -import android.content.Context - -/** - * Learns a multi-character "combo" phrase purely from the user's own - * consecutive typing pattern — no manual whitelist entry required. If the - * user types encoding "zg" and picks 中国, then immediately types "rm" and - * picks 人民, this records the pair (combined encoding "zgrm" -> combined - * text 中国人民) with a hit count; once that exact combo has been observed - * [PROMOTION_THRESHOLD] times, it "graduates" — LitePinyinRepository.query() - * starts surfacing it (pinned first) whenever the user types "zgrm" from - * scratch, the same effect a manually-curated whitelist entry would have, - * except it only ever contains combos this specific user actually built up - * themselves. - * - * Deliberately its own small store, not folded into LitePinyinUserFrequency - * — that one ranks/reorders candidates already present in the static - * dictionary; this one's job is to conjure a candidate that was NEVER in - * the dictionary at all, gated by a hit-count threshold rather than a - * continuous score. - */ -internal class LitePinyinLearnedPhrases(context: Context) { - private val preferences = context.applicationContext.getSharedPreferences(STORE, Context.MODE_PRIVATE) - - /** - * Call once per observed two-step sequence (see - * LitePinyinController.observeCommitForLearning()). Only overwrites the - * stored hit count when [text] matches what's already stored for - * [combinedEncoding] — a different text combo landing on the same - * encoding starts its own count from zero rather than inheriting an - * unrelated combo's progress. - */ - fun observeSequence(combinedEncoding: String, text: String) { - if (combinedEncoding.length < 2 || text.length < 2) return - val existing = preferences.getString(combinedEncoding, null)?.split(',') - val count = if (existing?.getOrNull(0) == text) (existing.getOrNull(1)?.toIntOrNull() ?: 0) + 1 else 1 - preferences.edit().putString(combinedEncoding, "$text,$count").apply() - } - - /** The learned text for this exact encoding, only once it has crossed the promotion threshold — null otherwise (including "seen once or twice but not yet promoted", which is deliberately invisible to callers). */ - fun promoted(encoding: String): String? { - val stored = preferences.getString(encoding, null)?.split(',') ?: return null - val text = stored.getOrNull(0) ?: return null - val count = stored.getOrNull(1)?.toIntOrNull() ?: 0 - return text.takeIf { count >= PROMOTION_THRESHOLD } - } - - /** How many combos have crossed the promotion threshold and are actively surfacing — for the settings page's "简拼优选" count (see OpenLessKeyboardSettingsActivity). */ - fun promotedCount(): Int = preferences.all.values.count { value -> - val count = (value as? String)?.split(',')?.getOrNull(1)?.toIntOrNull() ?: 0 - count >= PROMOTION_THRESHOLD - } - - /** Every stored combo (promoted or not) as a plain encoding->"text,count" map, for the settings page's export/import feature — see OpenLessSettingsExport. */ - fun exportAll(): Map { - @Suppress("UNCHECKED_CAST") - return preferences.all as Map - } - - /** Replaces the entire store with [entries] — an import wholesale-restores a backup rather than merging with whatever's already on this device. */ - fun importAll(entries: Map) { - check(preferences.edit().clear().apply { entries.forEach { (key, value) -> putString(key, value) } }.commit()) { "Pinyin learning save failed" } - } - - private companion object { - const val STORE = "openless_pinyin_learned_phrases" - // Chosen so one coincidental repeat (2 total) doesn't graduate a - // combo, but a real habit (3 total) does — easy to retune, not - // derived from any measurement. - const val PROMOTION_THRESHOLD = 3 - } -} diff --git a/openless-all/app/android/kotlin/LitePinyinRepository.kt b/openless-all/app/android/kotlin/LitePinyinRepository.kt deleted file mode 100644 index 24bcd7d85..000000000 --- a/openless-all/app/android/kotlin/LitePinyinRepository.kt +++ /dev/null @@ -1,265 +0,0 @@ -package com.openless.app - -import android.content.Context -import android.os.Handler -import android.os.Looper -import java.util.LinkedHashMap -import java.util.concurrent.Executors - -/** - * Full-pinyin single-character AND high-frequency-abbreviation lookup for - * Pinyin mode (phases 3-5 of the lite-pinyin plan — - * docs/pinyin-lite/phase-0-audit.md). Same background-load-off-a-dedicated- - * thread shape as EnglishCandidateProvider/StrokePhraseRepository, but plain - * exact-match Maps instead of a trie — ~5000 characters and ~2000 phrases is - * small enough that a prefix index buys nothing (plan 8.1: "当前规模下无需 - * SQLite,也无需大型 Trie"). - * - * Reads two android/assets files, each one row per entry: - * pinyin_chars.tsv: characterpinyinweight - * pinyin_phrases.tsv: phrasefull_pinyinabbreviationweight - * (see scripts/generate-pinyin-characters.mjs / generate-pinyin-phrases.mjs - * and their .LICENSE.txt files for where these come from). - */ -internal class LitePinyinRepository(context: Context) { - private val appContext = context.applicationContext - private val userFrequency = LitePinyinUserFrequency(context) - private val learnedPhrases = LitePinyinLearnedPhrases(context) - private val executor = Executors.newSingleThreadExecutor { task -> - Thread(task, "openless-pinyin-candidate").apply { isDaemon = true } - } - - private data class Entry(val text: String, val weight: Int) - - // pinyin -> its characters; abbreviation -> its phrases. Each list is - // sorted by weight descending once at load time (mirrors - // EnglishCandidateProvider's Node.topWords: sort once on load, not per - // query). - private val charIndex = HashMap>() - private val syllableSet = HashSet() - private val abbreviationIndex = HashMap>() - - // Caches only the STATIC merge (char entries then phrase entries, each - // already weight-sorted) — same split as StrokePhraseRepository's own - // cache: the user-frequency re-rank below is cheap (at most ~40 - // entries) and depends on data that changes independently of this, so - // it's deliberately redone on every query rather than cached, which - // sidesteps plan 8.2's "用户词频变化后,使相关缓存失效" requirement - // entirely instead of having to implement invalidation. - private val mergeCache = object : LinkedHashMap>(CACHE_SIZE, .75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry>?) = size > CACHE_SIZE - } - - @Volatile - private var loaded = false - - /** - * Off the caller's thread; posts [callback] back to the main looper. - * Exact full-key matches are followed by the first syllable's matches - * when the encoding contains multiple syllables. Ranks by - * plan 3.5's five tiers, in order: - * 0. a personally-learned two-commit combo for this exact encoding - * (LitePinyinLearnedPhrases — see observeSequence()) — not from - * the static dictionary at all, so it's pinned first outright - * rather than woven into the weight-based tiers below. - * 1. a candidate the user picked before for this exact encoding - * (LitePinyinUserFrequency — see recordSelection()) - * 2. exact single-character full-pinyin matches - * 3. exact abbreviation-phrase matches (only once the encoding is - * >= 2 letters — plan 3.4) - * 4/5. each tier's own static weight / original order - * Tiers 2 and 3 are never weight-interleaved with each other even - * though pinyin_chars.tsv and pinyin_phrases.tsv share a comparable - * weight scale (both ultimately from rime-pinyin-simp) — a phrase - * never outranks a character on raw weight alone, only via tier 0/1. - */ - fun query(encoding: String, limit: Int = 20, personalize: Boolean = true, callback: (List) -> Unit) { - val normalized = encoding.trim().lowercase() - if (normalized.isEmpty()) { - callback(emptyList()) - return - } - executor.execute { - ensureLoaded() - val raw = candidateEntries(normalized) - fun rankEntries(entries: List): List { - val previouslySelected = entries - .filter { personalize && userFrequency.score(it.sourceKey, it.entry.text) > 0.0 } - .sortedByDescending { userFrequency.score(it.sourceKey, it.entry.text) } - val rest = entries.filterNot { entry -> - previouslySelected.any { it.entry.text == entry.entry.text } - } - return (previouslySelected + rest).map { it.entry.text } - } - var ranked = ( - rankEntries(raw.filter { it.sourceKey == normalized }) + - rankEntries(raw.filter { it.sourceKey != normalized }) - ).distinct() - if (personalize) learnedPhrases.promoted(normalized)?.let { learned -> ranked = listOf(learned) + ranked.filterNot { it == learned } } - Handler(Looper.getMainLooper()).post { callback(ranked.take(limit)) } - } - } - - /** Greedy left-to-right segmentation using the loaded single-character syllables. */ - fun segmentEncoding(encoding: String): List { - ensureLoaded() - val normalized = encoding.trim().lowercase() - if (normalized.isEmpty() || syllableSet.isEmpty()) return emptyList() - val segments = mutableListOf() - var offset = 0 - while (offset < normalized.length) { - val maxLength = minOf(MAX_SYLLABLE_LENGTH, normalized.length - offset) - var match: String? = null - for (length in maxLength downTo 1) { - val candidate = normalized.substring(offset, offset + length) - if (syllableSet.contains(candidate)) { - match = candidate - break - } - } - if (match == null) break - segments += match - offset += match.length - } - return segments - } - - /** Call once a candidate is actually committed (a candidate tap — see OpenLessImeService.selectPinyinCandidate()) — off the caller's thread. */ - fun recordSelection(encoding: String, text: String) { - val normalized = encoding.trim().lowercase() - if (normalized.isEmpty() || text.isEmpty()) return - executor.execute { userFrequency.record(normalized, text) } - } - - /** - * Which encoding key produced [text] for the current buffer — mirrors - * [candidateEntries] priority (exact full encoding first, then first - * syllable). Learned / unknown picks fall back to the full encoding. - */ - fun resolveSourceKey(encoding: String, text: String): String { - ensureLoaded() - val normalized = encoding.trim().lowercase() - if (normalized.isEmpty() || text.isEmpty()) return normalized - if (mergedEntries(normalized).any { it.text == text }) return normalized - if (learnedPhrases.promoted(normalized) == text) return normalized - val firstSyllable = segmentEncoding(normalized).firstOrNull() - if ( - firstSyllable != null && - firstSyllable.length < normalized.length && - mergedEntries(firstSyllable).any { it.text == text } - ) { - return firstSyllable - } - return normalized - } - - /** Call after two direct pinyin commits land back-to-back — see LitePinyinController.observeCommitForLearning() — off the caller's thread. */ - fun observeSequence(combinedEncoding: String, text: String) { - val normalized = combinedEncoding.trim().lowercase() - if (normalized.isEmpty() || text.isEmpty()) return - executor.execute { learnedPhrases.observeSequence(normalized, text) } - } - - /** Warms both indexes off the caller's thread without waiting for a query — mirrors StrokePhraseRepository.preloadAsync(); call once, e.g. when the Pinyin panel first becomes reachable. */ - fun preloadAsync() { - executor.execute { ensureLoaded() } - } - - fun shutdown() = executor.shutdownNow() - - private fun mergedEntries(normalized: String): List { - synchronized(mergeCache) { mergeCache[normalized] }?.let { return it } - val charMatches = charIndex[normalized].orEmpty() - val phraseMatches = if (normalized.length >= 2) abbreviationIndex[normalized].orEmpty() else emptyList() - val merged = charMatches + phraseMatches - synchronized(mergeCache) { mergeCache[normalized] = merged } - return merged - } - - private data class CandidateEntry(val sourceKey: String, val entry: Entry) - - private fun candidateEntries(normalized: String): List { - val exact = mergedEntries(normalized).map { CandidateEntry(normalized, it) } - val firstSyllable = segmentEncoding(normalized).firstOrNull() - if (firstSyllable == null || firstSyllable.length >= normalized.length) return exact - val first = mergedEntries(firstSyllable).map { CandidateEntry(firstSyllable, it) } - return (exact + first).distinctBy { it.entry.text } - } - - private fun ensureLoaded() { - if (loaded) return - synchronized(this) { - if (loaded) return - loadChars() - loadPhrases() - loaded = true - } - } - - private fun loadChars() { - val grouped = HashMap>() - runCatching { - appContext.assets.open("pinyin_chars.tsv").bufferedReader().useLines { lines -> - lines.forEach { line -> - val parts = line.split('\t') - if (parts.size < 3) return@forEach - val char = parts[0] - val pinyin = parts[1].trim().lowercase() - val weight = parts[2].toIntOrNull() ?: 0 - if (char.isEmpty() || pinyin.isEmpty()) return@forEach - grouped.getOrPut(pinyin) { mutableListOf() }.add(Entry(char, weight)) - } - } - } - grouped.forEach { (pinyin, entries) -> charIndex[pinyin] = entries.sortedByDescending { it.weight } } - syllableSet.clear() - syllableSet.addAll(charIndex.keys) - } - - private fun loadPhrases() { - val grouped = HashMap>() - runCatching { - appContext.assets.open("pinyin_phrases.tsv").bufferedReader().useLines { lines -> - lines.forEach { line -> - val parts = line.split('\t') - if (parts.size < 4) return@forEach - val phrase = parts[0] - val abbreviation = parts[2] - val weight = parts[3].toIntOrNull() ?: 0 - if (phrase.isEmpty() || abbreviation.isEmpty()) return@forEach - grouped.getOrPut(abbreviation) { mutableListOf() }.add(Entry(phrase, weight)) - } - } - } - // Within one abbreviation's own phrase list only (never crosses into - // the char tier above it) — a manually curated domain term (see - // PRIORITY_PHRASES) sorts before any ordinary corpus-ranked phrase - // sharing that same code, regardless of either one's raw weight; - // ties within each of those two groups still fall back to weight. - grouped.forEach { (abbreviation, entries) -> - abbreviationIndex[abbreviation] = entries.sortedWith( - compareByDescending { it.text in PRIORITY_PHRASES }.thenByDescending { it.weight }, - ) - } - } - - private companion object { - const val MAX_SYLLABLE_LENGTH = 6 - - // Same size as StrokePhraseRepository's own cache — this one is - // keyed by the exact (short) encoding string rather than a rolling - // suffix, so cardinality is naturally bounded by realistic typing - // patterns without needing a larger budget. - const val CACHE_SIZE = 256 - - // Mirrors generate-pinyin-phrases.mjs's own WHITELIST word list - // (kept as a separate small constant here, not a 5th TSV column, - // since it only ever affects in-app ranking, never the asset's - // own membership or generation) — a whitelisted domain term the - // user confirmed on 2026-09-25 outranks a same-abbreviation - // ordinary corpus phrase on sight, since it was deliberately added - // because the generic frequency corpus has no reason to rank it - // highly on its own. - val PRIORITY_PHRASES = setOf("输入法", "候选词", "剪贴板", "供应商", "物料", "主管") - } -} diff --git a/openless-all/app/android/kotlin/LitePinyinUserFrequency.kt b/openless-all/app/android/kotlin/LitePinyinUserFrequency.kt deleted file mode 100644 index ced1de566..000000000 --- a/openless-all/app/android/kotlin/LitePinyinUserFrequency.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.openless.app - -import android.content.Context -import kotlin.math.exp -import kotlin.math.ln - -/** - * Local-only Pinyin-mode selection-history store — phase 5 of the lite-pinyin - * plan (docs/pinyin-lite/phase-0-audit.md). Same shape and scoring formula as - * EnglishUserFrequency/StrokeUserFrequency (log-scaled hit count + exponential - * recency decay), keyed by "编码 + 候选文本" per plan 8.4 — a selection only - * boosts that exact encoding's ranking, not the candidate text in general - * (picking 国 after typing "guo" shouldn't also boost it for an unrelated - * encoding that happens to also resolve to 国). - */ -internal class LitePinyinUserFrequency(context: Context) { - private val preferences = context.applicationContext.getSharedPreferences(STORE, Context.MODE_PRIVATE) - - fun score(encoding: String, text: String, now: Long = System.currentTimeMillis()): Double { - val value = preferences.getString(key(encoding, text), null) ?: return 0.0 - val parts = value.split(',') - val count = parts.getOrNull(0)?.toDoubleOrNull() ?: return 0.0 - val last = parts.getOrNull(1)?.toLongOrNull() ?: return 0.0 - val ageDays = ((now - last).coerceAtLeast(0L)).toDouble() / MILLIS_PER_DAY - return ln(1.0 + count) * COUNT_WEIGHT + exp(-ageDays / RECENCY_HALF_LIFE_DAYS) * RECENCY_WEIGHT - } - - fun record(encoding: String, text: String) { - if (encoding.isBlank() || text.isBlank()) return - val entryKey = key(encoding, text) - val old = preferences.getString(entryKey, null)?.split(',') - val count = (old?.getOrNull(0)?.toIntOrNull() ?: 0) + 1 - val now = System.currentTimeMillis() - preferences.edit().putString(entryKey, "$count,$now").apply() - } - - /** Clearing this app's data (plan 16's own acceptance criterion: "清除应用数据可恢复默认排序") already wipes this preferences file with everything else — this is only for an explicit in-app reset, not currently wired to any UI. */ - fun clear() = preferences.edit().clear().apply() - - private fun key(encoding: String, text: String) = "${encoding.trim().lowercase()}|$text" - - private companion object { - const val STORE = "openless_pinyin_frequency" - const val MILLIS_PER_DAY = 86_400_000L - const val RECENCY_HALF_LIFE_DAYS = 30.0 - const val COUNT_WEIGHT = 80.0 - const val RECENCY_WEIGHT = 24.0 - } -} diff --git a/openless-all/app/android/kotlin/OpenLessImeService.kt b/openless-all/app/android/kotlin/OpenLessImeService.kt index a0c6f445e..98a3381b5 100644 --- a/openless-all/app/android/kotlin/OpenLessImeService.kt +++ b/openless-all/app/android/kotlin/OpenLessImeService.kt @@ -41,7 +41,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt private enum class EnglishLayer { LETTERS, NUMBERS, SYMBOLS } // The English QWERTY panel's letter-input semantics — ENGLISH commits // each letter immediately (see commitEnglishChar()), PINYIN instead - // routes letters into LitePinyinController's own encoding buffer and + // routes letters into RimePinyinController's own encoding buffer and // only ever commits a chosen candidate. Long-press space toggles this // (see buildKeyboardView()'s spaceButton); the panel/View tree itself // is never rebuilt into a second layout for PINYIN — see @@ -248,7 +248,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt // TO, not the current one — i.e. it reads "拼音" while latinInputMode // is ENGLISH, and "英文" while it's PINYIN. private var latinInputMode = LatinInputMode.ENGLISH - private val litePinyinController by lazy { LitePinyinController(this) } + private val rimePinyinController by lazy { RimePinyinController(this) } // The word currently being typed on the English keyboard — appended to // per letter, trimmed per backspace, cleared at every word boundary // (space/return/punctuation/candidate tap/mode or panel switch). Never @@ -563,7 +563,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt private fun toggleLatinInputMode() { englishComposingWord.clear() englishCandidateQueryEpoch++ - litePinyinController.clear() + rimePinyinController.clear() latinInputMode = latinInputMode.toggled() saveLatinInputMode(latinInputMode) refreshLatinCandidateBar() @@ -581,9 +581,8 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt */ private fun refreshLatinCandidateBar() { if (latinInputMode == LatinInputMode.PINYIN) { - litePinyinController.preloadAsync() + rimePinyinController.refresh { renderPinyinCandidates(it) } englishCandidateBarContainer?.visibility = View.VISIBLE - renderPinyinCandidates(emptyList()) } else { updateEnglishCandidates() } @@ -645,7 +644,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt stopRuntimeService() strokeController.shutdown() if (englishCandidateProviderLazy.isInitialized()) englishCandidateProviderLazy.value.shutdown() - litePinyinController.shutdown() + rimePinyinController.shutdown() // Marks this as a clean end-of-session for // OpenLessApplication.recordUncleanShutdownIfAny() — an abrupt // process kill (native crash, OOM) never reaches this line, which @@ -1403,8 +1402,8 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt englishLayer = EnglishLayer.LETTERS englishComposingWord.clear() englishCandidateQueryEpoch++ - litePinyinController.clear() - litePinyinController.resetAssociationContext() + rimePinyinController.clear() + rimePinyinController.resetAssociationContext() strokeController.resetForModeSwitch() clipboardSelectionMode = false clipboardSelectionAnchor = -1 @@ -1553,15 +1552,12 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt // and before that a padding-pushed overlay), both of which fought // this row's actual (tighter than assumed) real height. val spaceButton = keyboardKey("Space", 5f, action = { - // Space never commits a pinyin candidate (see - // toggleLatinInputMode()'s own doc comment) — a short press - // always just inserts a literal space, in both modes, and - // abandons whatever was mid-composition. This is deliberate: - // it's a software keyboard, not a physical one where "space - // selects the first candidate" is a decades-old muscle-memory - // convention worth preserving. + if (latinInputMode == LatinInputMode.PINYIN) { + rimePinyinController.space { renderPinyinCandidates(it) } + return@keyboardKey + } finalizeEnglishComposingWord() - litePinyinController.clear() + rimePinyinController.clear() currentInputConnection?.commitText(" ", 1) }, longPressAction = { toggleLatinInputMode() }).apply { // Same size as Return (17sp — keyboardKey()'s own special case @@ -1614,18 +1610,8 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 5f) } val returnButton = keyboardKey("Return", 1.7f, action = { - // Plan 3.3: "Enter 有候选时提交第一候选,无编码时使用原有回车行为" - // — reuses candidateOverlayEntries (already the current row's - // (label, action) pairs, pinyin or English, set by whichever - // render*Candidates() last ran) rather than a second field - // duplicating the same list. - val firstPinyinCandidate = candidateOverlayEntries.firstOrNull() - if (latinInputMode == LatinInputMode.PINYIN && firstPinyinCandidate != null) { - firstPinyinCandidate.second.invoke() - } else { - finalizeEnglishComposingWord() - sendEnterKey() - } + if (latinInputMode != LatinInputMode.PINYIN) finalizeEnglishComposingWord() + sendEnterKey() }).apply { typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) // Same cherry red as the stroke panel's own right-side action @@ -1710,7 +1696,8 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt /** Backspace on the English keyboard: same field edit as everywhere else, plus keeping englishComposingWord (and therefore the candidate bar) in sync. In Pinyin mode, deletes from the (never-committed) encoding instead — falls back to the normal field edit only once the encoding is already empty (plan 3.3: "编码为空时 Backspace 恢复现有普通删除"). */ private fun englishDeleteBackward() { - if (latinInputMode == LatinInputMode.PINYIN && litePinyinController.backspace { renderPinyinCandidates(it) }) { + if (latinInputMode == LatinInputMode.PINYIN) { + rimePinyinController.backspace({ renderPinyinCandidates(it) }, { deleteBackward() }) return } deleteBackward() @@ -3025,20 +3012,16 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt if (char.length == 1 && char[0].isLetter()) { // Never calls commitText() — a letter only feeds the // encoding buffer in Pinyin mode; see - // LitePinyinController's own doc comment. - litePinyinController.appendLetter(char[0]) { renderPinyinCandidates(it) } + // RimePinyinController's own doc comment. + rimePinyinController.appendLetter(char[0]) { renderPinyinCandidates(it) } if (shiftState == ShiftState.SHIFT_ONCE) { shiftState = ShiftState.OFF refreshInputView() } return } - // A digit/punctuation typed mid-encoding (e.g. from the 123/ - // symbols layer) abandons the encoding the same way space does - // (plan 3.2 point 9) rather than leaving it stranded, then - // falls through to commit the symbol itself normally below. - litePinyinController.clear() - renderPinyinCandidates(emptyList()) + rimePinyinController.insertLiteral(char) { renderPinyinCandidates(it) } + return } if (currentInputConnection?.commitText(char, 1) != true) return if (personalizedLearningAllowed() && char.length == 1 && char[0].isLetter()) { @@ -3232,7 +3215,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } /** - * Renders LitePinyinController's own candidate callback into the same + * Renders RimePinyinController's own candidate callback into the same * candidateItemView()/englishCandidateRow the English candidates use * (plan 5.3's Option B — see docs/pinyin-lite/phase-0-audit.md section * 4) — a non-clickable encoding label ("guo") is prepended when there's @@ -3242,7 +3225,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt private fun renderPinyinCandidates(candidates: List) { val row = englishCandidateRow ?: return row.removeAllViews() - val encoding = litePinyinController.currentEncoding() + val encoding = rimePinyinController.currentEncoding() if (encoding.isNotEmpty()) { val label = TextView(this).apply { text = encoding @@ -3276,15 +3259,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt /** Candidate tap is the ONLY way a pinyin candidate reaches the field — see toggleLatinInputMode()'s doc comment on why Space deliberately never does this. */ private fun selectPinyinCandidate(char: String) { if (ImeLearningPolicy.isPassword(currentInputEditorInfo?.inputType ?: 0)) return - if (currentInputConnection?.commitText(char, 1) != true) return - // observeCommitForLearning needs the encoding still intact; commitSelection - // then records frequency under the real sourceKey and consumes that key - // (full buffer or first syllable) before refreshing the candidate row. - litePinyinController.observeCommitForLearning(char) - litePinyinController.commitSelection(char) { renderPinyinCandidates(it) } - litePinyinController.recordCommittedText(char) - performKeyHaptic() - refreshPinyinAssociations() + rimePinyinController.selectCandidate(char) { renderPinyinCandidates(it) } } /** @@ -3301,13 +3276,13 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt renderPinyinAssociations(emptyList()) return } - litePinyinController.queryAssociations(strokeController.phraseRepository) { results -> + rimePinyinController.queryAssociations(strokeController.phraseRepository) { results -> // isEmpty(): guards against the case where the user already // started typing a fresh encoding by the time this async result // lands — associationEpoch alone only protects against a STALE // association query, not against a completely different // encoding query having taken over the candidate row since. - if (latinInputMode == LatinInputMode.PINYIN && litePinyinController.isEmpty()) renderPinyinAssociations(results) + if (latinInputMode == LatinInputMode.PINYIN && rimePinyinController.isEmpty()) renderPinyinAssociations(results) } } @@ -3348,7 +3323,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt if (personalizedLearningAllowed() && OpenLessAndroidPreferences.strokeUsageEnabled(this)) { strokeController.phraseRepository.recordUsage(candidate.matchedPrefix, candidate.text) } - litePinyinController.recordCommittedText(suffix) + rimePinyinController.recordCommittedText(suffix) performKeyHaptic() refreshPinyinAssociations() } @@ -3371,6 +3346,15 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt } internal fun sendEnterKey() { + if (inputMode == InputMode.ENGLISH && latinInputMode == LatinInputMode.PINYIN && + !ImeLearningPolicy.isPassword(currentInputEditorInfo?.inputType ?: 0)) { + rimePinyinController.confirmFirst({ renderPinyinCandidates(it) }, { sendEditorEnterKey() }) + return + } + sendEditorEnterKey() + } + + private fun sendEditorEnterKey() { currentInputConnection?.sendKeyEvent(android.view.KeyEvent( android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_ENTER, @@ -3392,9 +3376,17 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt englishLayer = EnglishLayer.LETTERS englishComposingWord.clear() englishCandidateQueryEpoch++ - litePinyinController.clear() - litePinyinController.resetAssociationContext() - litePinyinController.setLearningAllowed(personalizedLearningAllowed()) + rimePinyinController.clear() + rimePinyinController.resetAssociationContext() + rimePinyinController.setLearningAllowed(personalizedLearningAllowed()) + rimePinyinController.bindCommit { text -> + if (ImeLearningPolicy.isPassword(currentInputEditorInfo?.inputType ?: 0)) false + else if (currentInputConnection?.commitText(text, 1) == true) { + rimePinyinController.recordCommittedText(text) + performKeyHaptic() + true + } else false + } if (ImeLearningPolicy.isPassword(attribute?.inputType ?: 0)) { inputMode = InputMode.ENGLISH latinInputMode = LatinInputMode.ENGLISH @@ -3438,8 +3430,8 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt cancelImeSession() englishCandidateQueryEpoch++ englishComposingWord.clear() - litePinyinController.clear() - litePinyinController.resetAssociationContext() + rimePinyinController.clear() + rimePinyinController.resetAssociationContext() strokeController.resetForNewInputSession() if (recording) { cancelImeSession() diff --git a/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt b/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt index 8d0a565c5..8a806a7ad 100644 --- a/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt +++ b/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt @@ -252,6 +252,17 @@ class OpenLessKeyboardSettingsActivity : Activity() { }, ) + content.addView(sectionLabel(ui("拼音云候选", "Cloud Pinyin candidates"))) + content.addView(Switch(this).apply { + text = ui("Google 云候选(发送当前拼音;密码和隐私输入禁用)", "Google cloud candidates (sends Pinyin; disabled in private fields)") + isChecked = prefs.getBoolean("cloud_pinyin_enabled", false) + setOnCheckedChangeListener { _, checked -> prefs.edit().putBoolean("cloud_pinyin_enabled", checked).apply() } + }) + content.addView(Switch(this).apply { + text = ui("每周更新公共词库(学习词仅保存在本机)", "Weekly public dictionaries (learned words stay on this device)") + isChecked = prefs.getBoolean("public_dictionary_updates", true) + setOnCheckedChangeListener { _, checked -> prefs.edit().putBoolean("public_dictionary_updates", checked).apply() } + }) content.addView(sectionLabel(ui("英文键盘", "English keyboard"))) val englishSuggestionsRow = LinearLayout(this).apply { gravity = Gravity.CENTER_VERTICAL } englishSuggestionsRow.addView( @@ -468,16 +479,13 @@ class OpenLessKeyboardSettingsActivity : Activity() { setTextColor(tone(Color.rgb(200, 200, 200), Color.rgb(70, 70, 75))) }, ) - // 简拼优选:不是每次输入都记一条,是"连续两次直接打拼音上屏"的组合 - // (比如先打 zg 选中国,紧接着打 rm 选人民)达到 3 次后才计入这里—— - // 见 LitePinyinLearnedPhrases 的文档注释。这里只显示已经达标、正在 - // 生效的组合数,未达标的候选不计入(避免这个数字本身产生误导)。 - val learnedPhrases = LitePinyinLearnedPhrases(this) + // Retain old backups without presenting them as active Rime learning. + val learnedPhrases = LegacyPinyinBackup(this) content.addView( TextView(this).apply { text = ui( - "简拼优选:已生效 ${learnedPhrases.promotedCount()} 条", - "Pinyin combo learning: ${learnedPhrases.promotedCount()} promoted", + "旧版拼音备份:${learnedPhrases.entryCount()} 条(不参与候选)", + "Legacy pinyin backup: ${learnedPhrases.entryCount()} entries (not used for candidates)", ) textSize = 14f setTextColor(tone(Color.rgb(200, 200, 200), Color.rgb(70, 70, 75))) diff --git a/openless-all/app/android/kotlin/OpenLessNative.kt b/openless-all/app/android/kotlin/OpenLessNative.kt index 26f672f12..602595940 100644 --- a/openless-all/app/android/kotlin/OpenLessNative.kt +++ b/openless-all/app/android/kotlin/OpenLessNative.kt @@ -96,6 +96,8 @@ object OpenLessNative { @JvmStatic external fun nativeBackendSnapshot(): String @JvmStatic external fun nativeImeCommand(json: String): String + @JvmStatic external fun nativeCloudPinyin(pinyin: String): String + @JvmStatic external fun nativePublicDictionary(base: String, action: String): String @JvmStatic fun requireBackendContract() { diff --git a/openless-all/app/android/kotlin/OpenLessRime.kt b/openless-all/app/android/kotlin/OpenLessRime.kt new file mode 100644 index 000000000..34d516168 --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessRime.kt @@ -0,0 +1,63 @@ +package com.openless.app + +import android.content.Context +import org.json.JSONObject +import java.io.File + +internal object OpenLessRime { + init { System.loadLibrary("openless_rime") } + private var initialized = false + private val dictionaryWorker = java.util.concurrent.Executors.newSingleThreadExecutor() + private var nextDictionaryCheck = 0L + external fun nativeRequest(operation: String, session: String, argument: String): String + @Synchronized fun initialize(context: Context) { + if (initialized) { refreshPublicDictionary(context); return } + val shared = File(context.filesDir, "rime-shared-1.17.0-ice-da1fbe60-v1").apply { mkdirs() } + val files = context.assets.list("rime") ?: error("Rime assets unavailable") + require(files.isNotEmpty()) + for (name in files) { + require(!name.contains('/') && !name.contains("..")) + val target = File(shared, name) + if (!target.exists()) { + val temporary = File(shared, "$name.tmp") + context.assets.open("rime/$name").use { input -> temporary.outputStream().use { input.copyTo(it) } } + check(temporary.renameTo(target)) + } + } + var selected = runCatching { JSONObject(OpenLessNative.nativePublicDictionary(shared.absolutePath, "select")).optString("data_path", shared.absolutePath) } + .getOrDefault(shared.absolutePath) + val profile = File(context.filesDir, "rime-user").absolutePath + var ready = false + repeat(3) { + if (!ready) { + if (selected != shared.absolutePath && !selected.startsWith(shared.absolutePath + "/public-updates-v1/")) selected = shared.absolutePath + try { request("initialize", selected, profile); ready = true } + catch (error: Exception) { + if (selected == shared.absolutePath) throw error + selected = if (it == 1) shared.absolutePath else runCatching { + JSONObject(OpenLessNative.nativePublicDictionary(shared.absolutePath, "reject")).optString("data_path", shared.absolutePath) + }.getOrDefault(shared.absolutePath) + } + } + } + check(ready) + initialized = true + refreshPublicDictionary(context) + } + private fun refreshPublicDictionary(context: Context) { + if (!context.getSharedPreferences("openless_ime_ui", Context.MODE_PRIVATE).getBoolean("public_dictionary_updates", true)) return + val now = System.currentTimeMillis() + if (now < nextDictionaryCheck) return + nextDictionaryCheck = now + java.util.concurrent.TimeUnit.DAYS.toMillis(1) + val base = File(context.filesDir, "rime-shared-1.17.0-ice-da1fbe60-v1").absolutePath + dictionaryWorker.execute { + runCatching { OpenLessNative.nativePublicDictionary(base, "update") } + .onFailure { android.util.Log.w("OpenLessRime", "public dictionary refresh unavailable; using local data") } + } + } + fun request(operation: String, session: String = "", argument: String = ""): JSONObject { + val response = JSONObject(nativeRequest(operation, session, argument)) + check(response.optBoolean("ok")) { response.optString("error", "Rime request failed") } + return response + } +} diff --git a/openless-all/app/android/kotlin/OpenLessSettingsExport.kt b/openless-all/app/android/kotlin/OpenLessSettingsExport.kt index a959dd6ac..c0a31c886 100644 --- a/openless-all/app/android/kotlin/OpenLessSettingsExport.kt +++ b/openless-all/app/android/kotlin/OpenLessSettingsExport.kt @@ -27,7 +27,7 @@ internal object OpenLessSettingsExport { CLOUD_NOTES("cloudNotes", "云笔记地址 / Token", "Cloud notes URL / token"), HAPTIC("haptic", "震动反馈", "Haptic feedback"), STROKE_FREQUENCY("strokeFrequency", "笔画调频", "Stroke ranking"), - PINYIN_LEARNED_PHRASES("pinyinLearnedPhrases", "简拼优选", "Pinyin combo learning"), + PINYIN_LEARNED_PHRASES("pinyinLearnedPhrases", "旧版拼音备份", "Legacy pinyin backup"), PROVIDER_SELECTION("providerSelection", "ASR / LLM / 风格包 选择", "ASR / LLM / style pack selection"), CREDENTIALS("credentials", "ASR / LLM API Key(明文,敏感)", "ASR / LLM API keys (plaintext, sensitive)"), } @@ -67,7 +67,7 @@ internal object OpenLessSettingsExport { root.put(Category.STROKE_FREQUENCY.key, JSONObject(StrokeUserFrequency(app).exportAll())) } if (Category.PINYIN_LEARNED_PHRASES in selected) { - root.put(Category.PINYIN_LEARNED_PHRASES.key, JSONObject(LitePinyinLearnedPhrases(app).exportAll())) + root.put(Category.PINYIN_LEARNED_PHRASES.key, JSONObject(LegacyPinyinBackup(app).exportAll())) } if (Category.PROVIDER_SELECTION in selected || Category.CREDENTIALS in selected) { val response = JSONObject(OpenLessNative.nativeExportProviderSettings(Category.CREDENTIALS in selected)) @@ -119,7 +119,7 @@ internal object OpenLessSettingsExport { Category.PINYIN_LEARNED_PHRASES -> { require(root.optInt("learningPrivacyVersion", 0) >= ImeLearningPolicy.PRIVACY_VERSION) { "Legacy learned phrases cannot be safely restored" } check(ImeLearningPolicy.migrate(app)) { "Learning data cleanup failed" } - LitePinyinLearnedPhrases(app).importAll(value.toStringMap()) + LegacyPinyinBackup(app).importAll(value.toStringMap()) } else -> Unit } diff --git a/openless-all/app/android/kotlin/RimePinyinController.kt b/openless-all/app/android/kotlin/RimePinyinController.kt new file mode 100644 index 000000000..188831dc1 --- /dev/null +++ b/openless-all/app/android/kotlin/RimePinyinController.kt @@ -0,0 +1,190 @@ +package com.openless.app + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** The common Rime engine owns composition; the host owns target-editor writes. */ +internal class RimePinyinController(context: Context) { + private val context = context.applicationContext + private val main = Handler(Looper.getMainLooper()) + private val worker = Executors.newSingleThreadExecutor() + private val cloudWorker = Executors.newSingleThreadExecutor() + @Volatile private var generation = 0L + @Volatile private var requestId = 0L + @Volatile private var learning = false + @Volatile private var encoding = "" + @Volatile private var candidates: List = emptyList() + @Volatile private var cloudChoices: Set = emptySet() + @Volatile private var candidateRevision = 0L + private var session = "" + private var pending = "" + private var pendingLiteral = "" + private var commit: (String) -> Boolean = { false } + private val confirmedText = StringBuilder() + private var associationEpoch = 0L + fun bindCommit(handler: (String) -> Boolean) { commit = handler } + fun currentEncoding(): String = encoding + fun isEmpty(): Boolean = encoding.isEmpty() + fun setLearningAllowed(allowed: Boolean) { learning = allowed; clear(); resetAssociationContext() } + fun refresh(update: (List) -> Unit) { submit("state", "", update) } + private fun deliver(text: String, epoch: Long): Boolean = onEditor(epoch) { commit(text) } + private fun onEditor(epoch: Long, action: () -> Boolean): Boolean { + val ready = CountDownLatch(1) + val delivery = AtomicInteger(0) // waiting, executing, complete, cancelled + var accepted = false + if (!main.post { + if (!delivery.compareAndSet(0, 1)) { ready.countDown(); return@post } + try { if (epoch == generation) accepted = runCatching(action).getOrDefault(false) } + finally { delivery.set(2); ready.countDown() } + }) return false + if (!ready.await(5, TimeUnit.SECONDS)) { + if (delivery.compareAndSet(0, 3)) return false + // A write already executing must finish before allowing a retry. + ready.await() + } + return accepted && epoch == generation + } + private fun submit(operation: String, argument: String, update: (List) -> Unit, emptyAction: (() -> Unit)? = null) { + val epoch = generation + val revision = ++requestId + worker.execute { + try { + if (epoch != generation) return@execute + OpenLessRime.initialize(context) + if (session.isEmpty()) session = OpenLessRime.request("create", "", if (learning) "learning" else "private").getString("session") + if (pending.isNotEmpty()) { + check(deliver(pending, epoch)) { "Editor write failed" } + OpenLessRime.request("ack", session); pending = "" + } + if (pendingLiteral.isNotEmpty()) { + check(deliver(pendingLiteral, epoch)) { "Editor write failed" } + pendingLiteral = "" + } + // Inspect the native composition on the worker, after all earlier keys. + // UI snapshots may lag behind fast typing or an in-flight commit. + var state = OpenLessRime.request("state", session) + val composing = state.optString("input").isNotEmpty() + val nativeOperation = when (operation) { + "backspace", "space", "confirmFirst" -> if (composing) "key" else "state" + "literal" -> if (argument == "'" && composing) "key" else "confirm" + else -> operation + } + val nativeArgument = when (operation) { + "backspace" -> "65288" + "space", "confirmFirst" -> "32" + "literal" -> if (argument == "'" && composing) "39" else argument + else -> argument + } + if (!composing && operation in listOf("backspace", "space", "confirmFirst")) { + if (operation == "space") check(deliver(" ", epoch)) { "Editor write failed" } + else check(onEditor(epoch) { emptyAction?.invoke(); true }) { "Editor unavailable" } + } else { + state = OpenLessRime.request(nativeOperation, session, nativeArgument) + } + if (operation == "literal" && nativeOperation == "confirm") pendingLiteral = argument + pending = state.optString("commit") + if (pending.isNotEmpty()) { + check(deliver(pending, epoch)) { "Editor write failed" } + state = OpenLessRime.request("ack", session); pending = "" + } + if (pendingLiteral.isNotEmpty()) { + check(deliver(pendingLiteral, epoch)) { "Editor write failed" } + pendingLiteral = "" + } + if (epoch != generation || revision != requestId) return@execute + state = OpenLessRime.request("candidates", session) + val input = state.optString("input") + val rows = state.optJSONArray("candidates") + val result = if (rows == null) emptyList() else (0 until rows.length()).map { rows.getString(it) } + main.post { + if (epoch != generation || revision != requestId) return@post + encoding = input; candidates = result; cloudChoices = emptySet() + candidateRevision = state.optLong("revision"); update(result) + scheduleCloud(input, result, epoch, revision, update) + } + } catch (error: Throwable) { + Log.e("OpenLessRime", "engine request failed: ${error.javaClass.simpleName}") + // Retain the native composition on a rejected stale selection or + // failed editor write, rather than presenting an empty candidate bar. + runCatching { + val state = OpenLessRime.request("candidates", session) + val rows = state.getJSONArray("candidates") + val result = (0 until rows.length()).map { rows.getString(it) } + main.post { + if (epoch == generation && revision == requestId) { + encoding = state.optString("input"); candidates = result + candidateRevision = state.optLong("revision"); update(result) + } + } + } + } + } + } + fun appendLetter(letter: Char, update: (List) -> Unit) { + submit("key", letter.lowercaseChar().code.toString(), update) + } + fun backspace(update: (List) -> Unit, deleteFromEditor: () -> Unit) { + submit("backspace", "", update, deleteFromEditor) + } + fun selectCandidate(text: String, update: (List) -> Unit) { + if (cloudChoices.contains(text)) { + submit("external", "$candidateRevision@$text", update) + return + } + val index = candidates.indexOf(text) + if (index >= 0) submit("select_absolute", "$index@$candidateRevision", update) + } + fun space(update: (List) -> Unit) { submit("space", "", update) } + fun confirmFirst(update: (List) -> Unit, emptyAction: () -> Unit) { + submit("confirmFirst", "", update, emptyAction) + } + fun insertLiteral(text: String, update: (List) -> Unit) { submit("literal", text, update) } + fun clear() { + generation++; requestId++; encoding = ""; candidates = emptyList(); cloudChoices = emptySet() + worker.execute { + try { if (session.isNotEmpty()) OpenLessRime.request("destroy", session) } + catch (_: Throwable) {} finally { session = ""; pending = ""; pendingLiteral = "" } + } + } + fun shutdown() { clear(); worker.shutdown(); cloudWorker.shutdownNow() } + private fun cloudEnabled(): Boolean = learning && context.getSharedPreferences("openless_ime_ui", Context.MODE_PRIVATE) + .getBoolean("cloud_pinyin_enabled", false) + + private fun scheduleCloud(input: String, local: List, epoch: Long, revision: Long, update: (List) -> Unit) { + if (!cloudEnabled() || !input.matches(Regex("[a-z']{2,64}"))) return + main.postDelayed({ + if (!cloudEnabled() || epoch != generation || revision != requestId) return@postDelayed + cloudWorker.execute { + if (!cloudEnabled() || epoch != generation || revision != requestId) return@execute + val rows = runCatching { + val json = org.json.JSONArray(OpenLessNative.nativeCloudPinyin(input)) + (0 until json.length()).map { json.getString(it) }.filterNot(local::contains) + }.getOrDefault(emptyList()) + main.post { + if (!cloudEnabled() || epoch != generation || revision != requestId || encoding != input) return@post + cloudChoices = rows.toSet() + update(local.take(1) + rows + local.drop(1)) + } + } + }, 180) + } + fun recordCommittedText(text: String) { + if (!learning) return + confirmedText.append(text) + if (confirmedText.length > StrokeInputController.MAX_ASSOCIATION_CONTEXT) + confirmedText.delete(0, confirmedText.length - StrokeInputController.MAX_ASSOCIATION_CONTEXT) + } + fun resetAssociationContext() { confirmedText.clear(); associationEpoch++ } + fun queryAssociations(repository: StrokePhraseRepository, update: (List) -> Unit) { + val text = confirmedText.toString() + if (text.isEmpty()) { update(emptyList()); return } + val epoch = ++associationEpoch + repository.searchAsync(text) { result -> if (epoch == associationEpoch) update(result) } + } +} diff --git a/openless-all/app/android/kotlin/StrokeInputController.kt b/openless-all/app/android/kotlin/StrokeInputController.kt index d5666f3b1..25f7fb564 100644 --- a/openless-all/app/android/kotlin/StrokeInputController.kt +++ b/openless-all/app/android/kotlin/StrokeInputController.kt @@ -30,7 +30,7 @@ import android.widget.TextView internal class StrokeInputController(private val service: OpenLessImeService) { private val strokeRepository by lazy { StrokeInputRepository(service) } // internal, not private: Pinyin mode's own post-commit association - // (LitePinyinController) reuses this exact instance rather than loading + // (RimePinyinController) reuses this exact instance rather than loading // a second copy of the same ~220k-entry phrase index — association data // is keyed by confirmed on-screen Chinese text, not by how it was // typed, so it's equally valid for either input mode. @@ -656,7 +656,7 @@ internal class StrokeInputController(private val service: OpenLessImeService) { updateClearStrokeButtonVisibility() } - // internal, not private: LitePinyinController's own association context + // internal, not private: RimePinyinController's own association context // buffer reuses the same cap for consistency (see phraseRepository's own // doc comment on why the two share one StrokePhraseRepository instance). internal companion object { diff --git a/openless-all/app/android/kotlin/androidTest/RimeEngineIntegrationTest.kt b/openless-all/app/android/kotlin/androidTest/RimeEngineIntegrationTest.kt new file mode 100644 index 000000000..c5a4999d5 --- /dev/null +++ b/openless-all/app/android/kotlin/androidTest/RimeEngineIntegrationTest.kt @@ -0,0 +1,292 @@ +package com.openless.app + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Test +import org.junit.Before +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import org.junit.runner.RunWith + +/** Real packaged JNI + dictionary test, using a non-learning disposable session. */ +@RunWith(AndroidJUnit4::class) +class RimeEngineIntegrationTest { + @Before fun keepDictionaryTestsOffline() { + InstrumentationRegistry.getInstrumentation().targetContext + .getSharedPreferences("openless_ime_ui", android.content.Context.MODE_PRIVATE) + .edit().putBoolean("public_dictionary_updates", false).commit() + } + @Test fun externalCandidateUsesRevisionAndPendingCommitContract() { + OpenLessRime.initialize(InstrumentationRegistry.getInstrumentation().targetContext) + val session = OpenLessRime.request("create", "", "private").getString("session") + try { + val state = typePinyin(session, "nihao") + val revision = state.getLong("revision") + try { + OpenLessRime.request("external", session, "${revision - 1}@你好世界") + fail("A cloud reply from an obsolete composition must be rejected") + } catch (_: IllegalStateException) {} + val selected = OpenLessRime.request("external", session, "$revision@你好世界") + assertEquals("你好世界", selected.getString("commit")) + assertEquals("", selected.getString("input")) + assertEquals("你好世界", OpenLessRime.request("state", session).getString("commit")) + assertEquals("", OpenLessRime.request("ack", session).getString("commit")) + } finally { OpenLessRime.request("destroy", session) } + } + @Test fun candidatesDeletionStaleSelectionAndCommitAcknowledgement() { + OpenLessRime.initialize(InstrumentationRegistry.getInstrumentation().targetContext) + var state = OpenLessRime.request("create", "", "private") + val session = state.getString("session") + try { + for (letter in "nihao") state = OpenLessRime.request("key", session, letter.code.toString()) + val oldRevision = state.getLong("revision") + state = OpenLessRime.request("key", session, "65288") + assertEquals("niha", state.getString("input")) + try { + OpenLessRime.request("select", session, "0@$oldRevision") + fail("Old candidate must be rejected") + } catch (_: IllegalStateException) {} + state = OpenLessRime.request("key", session, "111") + val candidates = state.getJSONArray("candidates") + val index = (0 until candidates.length()).first { candidates.getString(it) == "你好" } + state = OpenLessRime.request("select", session, "$index@${state.getLong("revision")}") + assertEquals("你好", state.getString("commit")) + assertEquals("你好", OpenLessRime.request("state", session).getString("commit")) + assertEquals("", OpenLessRime.request("ack", session).getString("commit")) + state = OpenLessRime.request("clear", session) + assertEquals("", state.getString("input")) + for (letter in "nihao") state = OpenLessRime.request("key", session, letter.code.toString()) + assertEquals("你好", OpenLessRime.request("key", session, "32").getString("commit")) + OpenLessRime.request("ack", session) + } finally { OpenLessRime.request("destroy", session) } + try { + OpenLessRime.request("state", session) + fail("Destroyed session must be rejected") + } catch (_: IllegalStateException) {} + } + @Test fun observingAndExpandingCandidatesPreservesSelectionAndNativeOrder() { + OpenLessRime.initialize(InstrumentationRegistry.getInstrumentation().targetContext) + val session = OpenLessRime.request("create", "", "private").getString("session") + try { + var state = OpenLessRime.request("state", session) + for (letter in "shi") state = OpenLessRime.request("key", session, letter.code.toString()) + val revision = state.getLong("revision") + val firstPage = state.getJSONArray("candidates") + val expanded = OpenLessRime.request("candidates", session) + assertEquals(revision, expanded.getLong("revision")) + assertEquals(revision, OpenLessRime.request("state", session).getLong("revision")) + val rows = expanded.getJSONArray("candidates") + assertTrue("Candidates beyond the first page must be available", rows.length() > firstPage.length()) + assertTrue(rows.length() <= 64) + for (i in 0 until firstPage.length()) assertEquals(firstPage.getString(i), rows.getString(i)) + val index = firstPage.length() + state = OpenLessRime.request("select_absolute", session, "$index@$revision") + assertEquals(rows.getString(index), state.getString("commit")) + OpenLessRime.request("ack", session) + } finally { OpenLessRime.request("destroy", session) } + } + + @Test fun rangedCandidatesDoNotChangeSpaceSelectionOrPage() { + OpenLessRime.initialize(InstrumentationRegistry.getInstrumentation().targetContext) + for (useSpace in listOf(true, false)) { + val session = OpenLessRime.request("create", "", "private").getString("session") + try { + for (letter in "shi") OpenLessRime.request("key", session, letter.code.toString()) + val before = OpenLessRime.request("state", session) + val revision = before.getLong("revision") + val offset = before.getJSONArray("candidates").length() + val range = OpenLessRime.request("candidates", session, "$offset@$revision") + val rows = range.getJSONArray("candidates") + assertTrue(rows.length() in 1..32) + assertEquals(offset, range.getInt("candidateOffset")) + assertEquals(revision, range.getLong("revision")) + val after = OpenLessRime.request("state", session) + assertEquals(before.getInt("page"), after.getInt("page")) + assertEquals(before.getJSONArray("candidates").toString(), after.getJSONArray("candidates").toString()) + try { + OpenLessRime.request("candidates", session, "$offset@${revision - 1}") + fail("A range from an obsolete composition must be rejected") + } catch (_: IllegalStateException) {} + val selected = if (useSpace) OpenLessRime.request("key", session, "32") + else OpenLessRime.request("select_absolute", session, "$offset@$revision") + assertEquals(if (useSpace) before.getJSONArray("candidates").getString(0) else rows.getString(0), + selected.getString("commit")) + OpenLessRime.request("ack", session) + } finally { OpenLessRime.request("destroy", session) } + } + } + + private fun typePinyin(session: String, input: String): org.json.JSONObject { + for (letter in input) OpenLessRime.request("key", session, letter.code.toString()) + return OpenLessRime.request("candidates", session) + } + + private fun choose(session: String, state: org.json.JSONObject, word: String): org.json.JSONObject { + val rows = state.getJSONArray("candidates") + val index = (0 until rows.length()).firstOrNull { rows.getString(it) == word } + assertNotNull("Expected candidate $word", index) + return OpenLessRime.request("select_absolute", session, "$index@${state.getLong("revision")}") + } + + @Test fun nativeLearningSurvivesSessionRecreationAndPrivateSelectionsDoNotTrainIt() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + OpenLessRime.initialize(context) + var session = OpenLessRime.request("create", "", "private").getString("session") + val baseline: String + try { + val state = typePinyin(session, "ni") + assertFalse(state.getBoolean("learning")) + baseline = state.getJSONArray("candidates").getString(0) + } finally { OpenLessRime.request("destroy", session) } + assertNotEquals("Test needs a non-default candidate", "泥", baseline) + repeat(6) { + val created = OpenLessRime.request("create", "", "learning") + assertTrue(created.getBoolean("learning")) + session = created.getString("session") + try { + val state = choose(session, typePinyin(session, "ni"), "泥") + assertEquals("泥", state.getString("commit")) + OpenLessRime.request("ack", session) + } finally { OpenLessRime.request("destroy", session) } + } + // Each preceding session was destroyed, closing its user dictionary. + // Private sessions neither consult nor update the personal ranking. + repeat(6) { + session = OpenLessRime.request("create", "", "private").getString("session") + try { + val state = typePinyin(session, "ni") + assertEquals(baseline, state.getJSONArray("candidates").getString(0)) + assertEquals("逆", choose(session, state, "逆").getString("commit")) + OpenLessRime.request("ack", session) + } finally { OpenLessRime.request("destroy", session) } + } + session = OpenLessRime.request("create", "", "learning").getString("session") + try { + assertEquals("泥", typePinyin(session, "ni").getJSONArray("candidates").getString(0)) + } finally { OpenLessRime.request("destroy", session) } + val database = java.io.File(context.filesDir, "rime-user/pinyin_simp.userdb") + assertTrue("Learning must be stored in the app's persistent profile", database.isDirectory) + assertTrue(database.listFiles()?.any { it.length() > 0 } == true) + } + + @Test fun selectingSeparateSyllablesLearnsANewPhrase() { + OpenLessRime.initialize(InstrumentationRegistry.getInstrumentation().targetContext) + repeat(3) { + val session = OpenLessRime.request("create", "", "learning").getString("session") + try { + var state = typePinyin(session, "nihao") + state = choose(session, state, "拟") + assertEquals("", state.getString("commit")) + state = OpenLessRime.request("candidates", session) + state = choose(session, state, "蚝") + assertEquals("拟蚝", state.getString("commit")) + OpenLessRime.request("ack", session) + } finally { OpenLessRime.request("destroy", session) } + } + val session = OpenLessRime.request("create", "", "learning").getString("session") + try { + val state = typePinyin(session, "nihao") + assertEquals("拟蚝", state.getJSONArray("candidates").getString(0)) + } finally { OpenLessRime.request("destroy", session) } + val privateSession = OpenLessRime.request("create", "", "private").getString("session") + try { + val rows = typePinyin(privateSession, "nihao").getJSONArray("candidates") + assertFalse((0 until rows.length()).any { rows.getString(it) == "拟蚝" }) + } finally { OpenLessRime.request("destroy", privateSession) } + } + + private fun withController(test: (RimePinyinController, StringBuilder) -> Unit) { + val instrumentation = InstrumentationRegistry.getInstrumentation() + // Cold dictionary compilation is a one-time deployment, not input queue work. + // Complete it on the test thread before applying the queue's 30-second deadline. + OpenLessRime.initialize(instrumentation.targetContext) + val controller = RimePinyinController(instrumentation.targetContext) + val committed = StringBuilder() + instrumentation.runOnMainSync { controller.bindCommit { committed.append(it); true } } + try { test(controller, committed) } + finally { instrumentation.runOnMainSync { controller.shutdown() } } + } + + private fun drain(controller: RimePinyinController, actions: () -> Unit) { + val done = CountDownLatch(1) + InstrumentationRegistry.getInstrumentation().runOnMainSync { + actions() + controller.refresh { done.countDown() } + } + assertTrue("Input queue did not finish", done.await(30, TimeUnit.SECONDS)) + } + + @Test fun fastSpaceAndBackspaceUseQueuedCompositionRatherThanStaleUi() = withController { controller, committed -> + val deletions = AtomicInteger() + drain(controller) { + for (letter in "nihao") controller.appendLetter(letter) {} + controller.space {} + // The second space must reach the editor, not the now-empty Rime session. + controller.space {} + controller.backspace({}, { deletions.incrementAndGet() }) + } + assertEquals("你好 ", committed.toString()) + assertEquals(1, deletions.get()) + assertTrue(controller.isEmpty()) + drain(controller) { + controller.appendLetter('n') {} + controller.backspace({}, { deletions.incrementAndGet() }) + } + assertEquals(1, deletions.get()) + assertTrue(controller.isEmpty()) + } + + @Test fun queuedEnterConfirmsOnlyOnceThenUsesEditorAction() = withController { controller, committed -> + val enters = AtomicInteger() + drain(controller) { + for (letter in "nihao") controller.appendLetter(letter) {} + controller.confirmFirst({}, { enters.incrementAndGet() }) + controller.confirmFirst({}, { enters.incrementAndGet() }) + } + assertEquals("你好", committed.toString()) + assertEquals(1, enters.get()) + assertTrue(controller.isEmpty()) + } + + @Test fun refreshKeepsCompositionAndApostropheSeparatesSyllables() = withController { controller, committed -> + drain(controller) { + for (letter in "xi") controller.appendLetter(letter) {} + controller.insertLiteral("'") {} + for (letter in "an") controller.appendLetter(letter) {} + } + assertEquals("xi'an", controller.currentEncoding()) + assertEquals("", committed.toString()) + drain(controller) { controller.refresh {} } + assertEquals("xi'an", controller.currentEncoding()) + drain(controller) { controller.selectCandidate("西安") {} } + assertEquals("西安", committed.toString()) + } + + @Test fun failedCommitRetriesOnceWithoutLosingFollowingPunctuation() = withController { controller, committed -> + val attempts = AtomicInteger() + drain(controller) { + controller.bindCommit { text -> + if (attempts.incrementAndGet() == 1) false else { committed.append(text); true } + } + for (letter in "nihao") controller.appendLetter(letter) {} + controller.insertLiteral("!") {} + } + assertEquals("你好!", committed.toString()) + assertEquals(3, attempts.get()) + drain(controller) { controller.refresh {} } + assertEquals("你好!", committed.toString()) + } + + @Test fun clearingQueuedInputCannotWriteToNextEditor() = withController { controller, committed -> + drain(controller) { + for (letter in "nihao") controller.appendLetter(letter) {} + controller.space {} + controller.clear() + } + assertEquals("", committed.toString()) + assertTrue(controller.isEmpty()) + } + +} diff --git a/openless-all/app/crates/openless-core/src/ime_cloud.rs b/openless-all/app/crates/openless-core/src/ime_cloud.rs new file mode 100644 index 000000000..bf4e35700 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/ime_cloud.rs @@ -0,0 +1,102 @@ +//! Optional cloud candidates, using the GoogleCN protocol used by Fcitx5. +//! Callers enforce editor privacy and discard replies for obsolete compositions. +use serde_json::Value; +use std::{sync::OnceLock, time::Duration}; + +pub fn valid_query(query: &str) -> bool { + (2..=64).contains(&query.len()) + && query.bytes().all(|c| c.is_ascii_lowercase() || c == b'\'') + && query.bytes().any(|c| c.is_ascii_lowercase()) +} + +fn candidates(value: &Value, query: &str) -> Vec { + if value[0] != "SUCCESS" || value[1][0][0] != query { + return vec![]; + } + let mut result = Vec::new(); + if let Some(rows) = value[1][0][1].as_array() { + for text in rows.iter().filter_map(Value::as_str) { + if !text.is_empty() + && text.chars().count() <= 128 + && !text.chars().any(char::is_control) + && !result.iter().any(|item| item == text) + { + result.push(text.to_owned()); + if result.len() == 5 { + break; + } + } + } + } + result +} + +pub async fn query(query: &str) -> Vec { + if !valid_query(query) { + return vec![]; + } + static CLIENT: OnceLock> = OnceLock::new(); + let Some(client) = CLIENT.get_or_init(|| { + reqwest::Client::builder() + .timeout(Duration::from_millis(1200)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .ok() + }) else { + return vec![]; + }; + // Never include URLs, queries or server response text in diagnostics. + let Ok(mut response) = client + .get("https://www.google.cn/inputtools/request") + .query(&[("ime", "pinyin"), ("text", query)]) + .send() + .await + else { + return vec![]; + }; + if !response.status().is_success() { + return vec![]; + } + let mut bytes = Vec::new(); + loop { + match response.chunk().await { + Ok(Some(chunk)) if bytes.len() + chunk.len() <= 65536 => { + bytes.extend_from_slice(&chunk) + } + Ok(None) => break, + _ => return vec![], + } + } + serde_json::from_slice(&bytes) + .map(|value| candidates(&value, query)) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + #[tokio::test] + #[ignore = "explicit live provider smoke test; never sends user input"] + async fn live_known_pinyin() { + assert!(query("nihao").await.iter().any(|text| text == "你好")); + } + #[test] + fn query_boundary() { + assert!(valid_query("ni'hao")); + for invalid in ["a", "hello world", "秘密", "https://x", "\nnihao"] { + assert!(!valid_query(invalid)); + } + assert!(!valid_query(&"a".repeat(65))); + } + #[test] + fn validate_response_identity_and_candidates() { + let reply = json!([ + "SUCCESS", + [["nihao", ["你好", "你好", "你号", "bad\ntext"]]] + ]); + assert_eq!(candidates(&reply, "nihao"), vec!["你好", "你号"]); + assert!(candidates(&reply, "nihaoma").is_empty()); + assert!(candidates(&json!(["ERROR"]), "nihao").is_empty()); + } +} diff --git a/openless-all/app/crates/openless-core/src/ime_dictionary.rs b/openless-all/app/crates/openless-core/src/ime_dictionary.rs new file mode 100644 index 000000000..d743368ba --- /dev/null +++ b/openless-all/app/crates/openless-core/src/ime_dictionary.rs @@ -0,0 +1,444 @@ +//! Public Ice dictionary refreshes. No editor text or user dictionaries enter this module. +//! A ready snapshot is selected only on the next engine initialization. +use serde::{Deserialize, Serialize}; +use std::{ + fs, + path::{Path, PathBuf}, + sync::atomic::{AtomicBool, Ordering}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +pub const BUNDLED_DIRECTORY: &str = "rime-shared-1.17.0-ice-da1fbe60-v1"; +const BUNDLED_REVISION: &str = "da1fbe602e38f26db846fa10120ee64c2b0324c0"; +const TABLES: [&str; 5] = ["8105", "base", "ext", "tencent", "others"]; +const DAY: u64 = 86400; +static RUNNING: AtomicBool = AtomicBool::new(false); +static META: parking_lot::Mutex<()> = parking_lot::Mutex::new(()); + +#[derive(Default, Serialize, Deserialize)] +#[serde(default)] +struct State { + ready: String, + active: String, + previous: String, + checked: u64, + attempted: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DictionaryStatus { + pub data_path: PathBuf, + pub revision: String, + pub pending: bool, + pub checked: u64, +} + +fn root(base: &Path) -> PathBuf { + base.join("public-updates-v1") +} +fn revision(value: &str) -> &str { + if value.is_empty() { + BUNDLED_REVISION + } else { + value + } +} +fn valid_revision(value: &str) -> bool { + value.len() == 40 + && value + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) +} +fn read_state(base: &Path) -> State { + fs::read(root(base).join("state.json")) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default() +} +fn save_state(base: &Path, state: &State) -> Result<(), String> { + fs::create_dir_all(root(base)).map_err(|_| "create dictionary cache")?; + crate::persistence::atomic_write( + &root(base).join("state.json"), + &serde_json::to_vec(state).map_err(|_| "encode dictionary metadata")?, + ) + .map_err(|_| "save dictionary metadata".into()) +} +fn snapshot(base: &Path, sha: &str) -> Option { + if sha.is_empty() || sha == BUNDLED_REVISION { + return Some(base.to_owned()); + } + if !valid_revision(sha) { + return None; + } + let path = root(base).join(sha); + if fs::symlink_metadata(&path).ok()?.file_type().is_symlink() { + return None; + } + if fs::read_to_string(path.join("openless-public-revision.txt")) + .ok()? + .trim() + != sha + { + return None; + } + for name in TABLES + .map(|t| format!("ice_{t}.dict.yaml")) + .into_iter() + .chain([ + "openless_pinyin_private.schema.yaml".into(), + "openless_pinyin_learning.schema.yaml".into(), + "rime_ice.dict.yaml".into(), + ]) + { + let meta = fs::symlink_metadata(path.join(name)).ok()?; + if !meta.is_file() || meta.len() == 0 { + return None; + } + } + Some(path) +} +fn status(base: &Path, state: &State) -> DictionaryStatus { + DictionaryStatus { + data_path: snapshot(base, &state.ready).unwrap_or_else(|| base.to_owned()), + revision: revision(&state.ready).to_owned(), + pending: revision(&state.ready) != revision(&state.active), + checked: state.checked, + } +} + +/// Purely local, fast startup selection. Invalid/incomplete snapshots fall back to bundled data. +pub fn select(base: &Path) -> DictionaryStatus { + let _guard = META.lock(); + let mut state = read_state(base); + let repair = snapshot(base, &state.ready).is_none(); + if repair { + state.ready.clear(); + } + if repair || revision(&state.active) != revision(&state.ready) { + state.active = state.ready.clone(); + let _ = save_state(base, &state); + } + status(base, &state) +} + +/// Called if Rime rejects a staged public snapshot; never touches rime-user or its databases. +pub fn reject(base: &Path) -> DictionaryStatus { + let _guard = META.lock(); + let mut state = read_state(base); + state.ready = if snapshot(base, &state.previous).is_some() { + state.previous.clone() + } else { + String::new() + }; + state.active = state.ready.clone(); + state.previous.clear(); + state.checked = 0; + let _ = save_state(base, &state); + status(base, &state) +} + +fn due(state: &State, now: u64) -> bool { + let elapsed = |stamp: u64, interval: u64| { + stamp == 0 || now.saturating_sub(stamp) >= interval || stamp > now.saturating_add(DAY) + }; + revision(&state.ready) == revision(&state.active) + && elapsed(state.checked, 7 * DAY) + && elapsed(state.attempted, DAY) +} + +async fn download(client: &reqwest::Client, url: &str, limit: usize) -> Result, String> { + let mut response = client + .get(url) + .send() + .await + .map_err(|_| "public dictionary network unavailable")?; + if !response.status().is_success() { + return Err("public dictionary server unavailable".into()); + } + if response.content_length().is_some_and(|n| n > limit as u64) { + return Err("public dictionary response too large".into()); + } + let mut data = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| "public dictionary download interrupted")? + { + if data.len().saturating_add(chunk.len()) > limit { + return Err("public dictionary response too large".into()); + } + data.extend_from_slice(&chunk); + } + Ok(data) +} + +fn table(source: &[u8], name: &str, sha: &str) -> Result, String> { + let text = std::str::from_utf8(source).map_err(|_| "invalid dictionary encoding")?; + if text.contains('\0') { + return Err("invalid dictionary data".into()); + } + let (header, body) = text + .split_once("\n...\n") + .ok_or("missing dictionary header")?; + // The base table has a substantial upstream change log before its YAML header. + if header.len() > 1024 * 1024 || header.contains("import_tables:") { + return Err("unsupported dictionary header".into()); + } + let expected = [format!("name: {name}"), format!("name: cn_dicts/{name}")]; + let line = header + .lines() + .find(|line| expected.iter().any(|n| line.trim() == n)) + .ok_or("unexpected dictionary name")?; + if !body + .lines() + .any(|line| !line.starts_with('#') && line.contains('\t')) + { + return Err("empty dictionary".into()); + } + let header = header.replacen(line, &format!("name: ice_{name}"), 1); + Ok(format!("# OpenLess: flattened public table name; upstream iDvel/rime-ice {sha}.\n{header}\n...\n{body}").into_bytes()) +} + +fn copy_base(base: &Path, stage: &Path) -> Result<(), String> { + fs::create_dir(stage).map_err(|_| "create dictionary snapshot")?; + for entry in fs::read_dir(base).map_err(|_| "read bundled dictionaries")? { + let entry = entry.map_err(|_| "read dictionary resource")?; + if entry + .file_type() + .map_err(|_| "read dictionary resource type")? + .is_file() + { + fs::copy(entry.path(), stage.join(entry.file_name())) + .map_err(|_| "copy bundled dictionary resource")?; + } + } + Ok(()) +} + +/// At most weekly on success and daily after failures. This only stages public data; +/// it never reloads a live composition or writes learned words to the network. +pub async fn update(base: &Path, force: bool) -> Result { + if RUNNING + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed) + .is_err() + { + return Ok(status(base, &read_state(base))); + } + struct Running; + impl Drop for Running { + fn drop(&mut self) { + RUNNING.store(false, Ordering::Release); + } + } + let _running = Running; + if fs::read_to_string(base.join("openless-data-version.txt")) + .map_err(|_| "bundled dictionaries not ready")? + .trim() + != "ice-da1fbe60-v1" + { + return Err("unsupported bundled dictionary version".into()); + } + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + { + let _guard = META.lock(); + let mut state = read_state(base); + if (!force && !due(&state, now)) || revision(&state.ready) != revision(&state.active) { + return Ok(status(base, &state)); + } + state.attempted = now; + save_state(base, &state)?; + } + let client = reqwest::Client::builder() + .user_agent("OpenLess-public-dictionary/1") + .timeout(Duration::from_secs(180)) + .connect_timeout(Duration::from_secs(10)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| "create dictionary download client")?; + let head: serde_json::Value = serde_json::from_slice( + &download( + &client, + "https://api.github.com/repos/iDvel/rime-ice/commits/main", + 1024 * 1024, + ) + .await?, + ) + .map_err(|_| "invalid public revision metadata")?; + let sha = head["sha"] + .as_str() + .filter(|sha| valid_revision(sha)) + .ok_or("invalid public revision")?; + if sha == revision(&read_state(base).ready) { + let _guard = META.lock(); + let mut state = read_state(base); + state.checked = now; + save_state(base, &state)?; + return Ok(status(base, &state)); + } + let stage = root(base).join(format!(".stage-{}", uuid::Uuid::new_v4())); + let result = async { + copy_base(base, &stage)?; + for name in TABLES { + let bytes = download(&client, &format!("https://raw.githubusercontent.com/iDvel/rime-ice/{sha}/cn_dicts/{name}.dict.yaml"), 40 * 1024 * 1024).await?; + fs::write(stage.join(format!("ice_{name}.dict.yaml")), table(&bytes, name, sha)?).map_err(|_| "write public dictionary table")?; + } + let license = download(&client, &format!("https://raw.githubusercontent.com/iDvel/rime-ice/{sha}/LICENSE"), 65536).await?; + if license.is_empty() { return Err("missing public dictionary license".into()); } + fs::write(stage.join("rime-ice-LICENSE"), license).map_err(|_| "write dictionary license")?; + fs::write(stage.join("openless-public-revision.txt"), sha).map_err(|_| "write dictionary revision")?; + let destination = root(base).join(sha); + if destination.exists() { + // Never overwrite a snapshot an existing engine might still read. + if snapshot(base, sha).is_none() { return Err("dictionary snapshot already exists but is incomplete".into()); } + } else { fs::rename(&stage, &destination).map_err(|_| "publish dictionary snapshot")?; } + let _guard = META.lock(); + let mut state = read_state(base); + state.previous = state.ready.clone(); state.ready = sha.to_owned(); state.checked = now; + save_state(base, &state)?; + let result = status(base, &state); + let keep = [revision(&state.active).to_owned(), revision(&state.ready).to_owned(), revision(&state.previous).to_owned()]; + drop(_guard); + // Retain the running, pending and rollback versions only. This root + // contains public snapshots, never the adjacent personal profile. + if let Ok(entries) = fs::read_dir(root(base)) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if valid_revision(&name) && !keep.contains(&name) && snapshot(base, &name).is_some() { + let _ = fs::remove_dir_all(entry.path()); + } + } + } + Ok(result) + }.await; + // Only our unique temporary cache is removed. Bundled data and personal databases are untouched. + let _ = fs::remove_dir_all(&stage); + result +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + #[ignore = "explicit public upstream download; never reads or sends personal dictionaries"] + async fn live_public_snapshot() { + let source = PathBuf::from( + std::env::var_os("OPENLESS_IME_TEST_RESOURCES") + .expect("bundled public resources required"), + ); + let retained = std::env::var_os("OPENLESS_IME_TEST_OUTPUT"); + let temp = retained.clone().map(PathBuf::from).unwrap_or_else(|| { + std::env::temp_dir().join(format!("openless-public-live-{}", uuid::Uuid::new_v4())) + }); + fs::create_dir_all(&temp).unwrap(); + let base = temp.join(BUNDLED_DIRECTORY); + copy_base(&source, &base).unwrap(); + // Represent an older installed release so the full download/stage path + // is exercised even when upstream currently matches the bundled revision. + save_state( + &base, + &State { + ready: "0".repeat(40), + active: "0".repeat(40), + ..Default::default() + }, + ) + .unwrap(); + let result = update(&base, true).await.unwrap(); + let staged = root(&base).join(&result.revision); + assert!(result.pending); + assert!(staged.join("openless-public-revision.txt").is_file()); + for name in TABLES { + assert!( + fs::metadata(staged.join(format!("ice_{name}.dict.yaml"))) + .unwrap() + .len() + > 1000 + ); + } + assert!(select(&base).data_path.is_dir()); + println!("Public snapshot validated: {}", result.revision); + if retained.is_none() { + fs::remove_dir_all(temp).unwrap(); + } + } + #[test] + fn public_table_validation_and_flattening() { + let source = b"# upstream\n---\nname: base\nversion: x\n...\nword\tword\t1\n"; + let output = String::from_utf8(table(source, "base", BUNDLED_REVISION).unwrap()).unwrap(); + assert!(output.contains("name: ice_base")); + assert!(table(source, "others", BUNDLED_REVISION).is_err()); + assert!(table(b"name: base\n...\n", "base", BUNDLED_REVISION).is_err()); + assert!(!valid_revision("../../profile")); + } + #[test] + fn refresh_schedule_and_pending_snapshot() { + let mut state = State::default(); + assert!(due(&state, 10 * DAY)); + state.attempted = 10 * DAY; + assert!(!due(&state, 10 * DAY + 10)); + assert!(due(&state, 11 * DAY)); + state.checked = 10 * DAY; + assert!(!due(&state, 16 * DAY)); + assert!(due(&state, 17 * DAY)); + state.ready = "a".repeat(40); + assert!(!due(&state, 30 * DAY)); + } + #[test] + fn snapshot_selection_and_rollback_preserve_personal_data() { + let temp = + std::env::temp_dir().join(format!("openless-ime-update-{}", uuid::Uuid::new_v4())); + let base = temp.join(BUNDLED_DIRECTORY); + fs::create_dir_all(&base).unwrap(); + let personal = temp.join("rime-user"); + fs::create_dir(&personal).unwrap(); + fs::write(personal.join("pinyin_simp.userdb"), b"personal sentinel").unwrap(); + assert_eq!(select(&base).data_path, base); + let sha = "a".repeat(40); + let dir = root(&base).join(&sha); + fs::create_dir_all(&dir).unwrap(); + for name in TABLES + .map(|t| format!("ice_{t}.dict.yaml")) + .into_iter() + .chain([ + "openless_pinyin_private.schema.yaml".into(), + "openless_pinyin_learning.schema.yaml".into(), + "rime_ice.dict.yaml".into(), + ]) + { + fs::write(dir.join(name), b"fixture").unwrap(); + } + fs::write(dir.join("openless-public-revision.txt"), &sha).unwrap(); + save_state( + &base, + &State { + ready: sha, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(select(&base).data_path, dir); + assert!(!select(&base).pending); + assert_eq!(reject(&base).data_path, base); + save_state( + &base, + &State { + ready: "../../rime-user".into(), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(select(&base).data_path, base); + assert!( + read_state(&base).ready.is_empty(), + "bad metadata must not prevent future updates" + ); + assert_eq!( + fs::read(personal.join("pinyin_simp.userdb")).unwrap(), + b"personal sentinel" + ); + fs::remove_dir_all(temp).unwrap(); + } +} diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index acbbd2432..7b4afbaad 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -380,4 +380,6 @@ pub use voice_edit_session::{ VoiceEditSession, VoiceEditSnapshot, VoiceEditTarget, VoiceEditTurn, }; +pub mod ime_cloud; +pub mod ime_dictionary; pub mod preference_patch; diff --git a/openless-all/app/e2e/refinements.spec.ts b/openless-all/app/e2e/refinements.spec.ts index 37abbcdbb..ec3ebe434 100644 --- a/openless-all/app/e2e/refinements.spec.ts +++ b/openless-all/app/e2e/refinements.spec.ts @@ -274,17 +274,27 @@ test('text height grows with contents and chips give the selection actual layout await page.getByRole('button', { name: '展开全文' }).click(); await expect(page.getByRole('button', { name: '收起' })).toHaveAttribute('aria-expanded', 'true'); await expect.poll(async () => (await clip.boundingBox())!.height).toBeGreaterThan(initial * 3); - const before = (await page.getByRole('button', { name: 'Three', exact: true }).boundingBox())!; + // This assertion concerns layout space, not the temporary FLIP/scale + // transforms applied by Framer Motion (which WebKit samples fractionally). + const before = await page + .getByRole('button', { name: 'Three', exact: true }) + .evaluate((el) => (el as HTMLElement).offsetLeft); await page.getByRole('button', { name: 'Two', exact: true }).click(); await expect(page.getByRole('button', { name: 'Two', exact: true })).toHaveAttribute( 'aria-pressed', 'true', ); - await page.waitForTimeout(350); - const selected = await page.getByRole('button', { name: 'Two', exact: true }).boundingBox(); - const last = await page.getByRole('button', { name: 'Three', exact: true }).boundingBox(); - expect(selected!.x + selected!.width).toBeLessThan(last!.x); - expect(last!.x).toBeGreaterThanOrEqual(before.x - 1); + const selected = await page + .getByRole('button', { name: 'Two', exact: true }) + .evaluate((el) => ({ + x: (el as HTMLElement).offsetLeft, + width: (el as HTMLElement).offsetWidth, + })); + const last = await page + .getByRole('button', { name: 'Three', exact: true }) + .evaluate((el) => (el as HTMLElement).offsetLeft); + expect(selected.x + selected.width).toBeLessThan(last); + expect(last).toBeGreaterThanOrEqual(before - 1); }); test('swipe departure is directional, removable and undoable', async ({ page }) => { diff --git a/openless-all/app/harmony/.gitignore b/openless-all/app/harmony/.gitignore new file mode 100644 index 000000000..47beba66e --- /dev/null +++ b/openless-all/app/harmony/.gitignore @@ -0,0 +1,7 @@ +/.hvigor/ +/build/ +/entry/build/ +/oh_modules/ +/entry/oh_modules/ +/local.properties +/entry/src/main/resources/rawfile/web/ diff --git a/openless-all/app/harmony/AppScope/app.json5 b/openless-all/app/harmony/AppScope/app.json5 new file mode 100644 index 000000000..ae396b3d6 --- /dev/null +++ b/openless-all/app/harmony/AppScope/app.json5 @@ -0,0 +1,10 @@ +{ + "app": { + "bundleName": "openless.hm.com", + "vendor": "OpenLess", + "versionCode": 1, + "versionName": "0.1.0", + "icon": "$media:openless_layered_icon", + "label": "$string:app_name" + } +} diff --git a/openless-all/app/harmony/AppScope/resources/base/element/string.json b/openless-all/app/harmony/AppScope/resources/base/element/string.json new file mode 100644 index 000000000..b3d50e707 --- /dev/null +++ b/openless-all/app/harmony/AppScope/resources/base/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "app_name", + "value": "OpenLess" + } + ] +} diff --git a/openless-all/app/harmony/AppScope/resources/base/media/openless_icon.png b/openless-all/app/harmony/AppScope/resources/base/media/openless_icon.png new file mode 100755 index 000000000..3bdcb6b84 Binary files /dev/null and b/openless-all/app/harmony/AppScope/resources/base/media/openless_icon.png differ diff --git a/openless-all/app/harmony/AppScope/resources/base/media/openless_icon_background.svg b/openless-all/app/harmony/AppScope/resources/base/media/openless_icon_background.svg new file mode 100644 index 000000000..466335600 --- /dev/null +++ b/openless-all/app/harmony/AppScope/resources/base/media/openless_icon_background.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/AppScope/resources/base/media/openless_layered_icon.json b/openless-all/app/harmony/AppScope/resources/base/media/openless_layered_icon.json new file mode 100644 index 000000000..11181e675 --- /dev/null +++ b/openless-all/app/harmony/AppScope/resources/base/media/openless_layered_icon.json @@ -0,0 +1,6 @@ +{ + "layered-image": { + "background": "$media:openless_icon_background", + "foreground": "$media:openless_icon" + } +} diff --git a/openless-all/app/harmony/README.md b/openless-all/app/harmony/README.md new file mode 100644 index 000000000..2f6eadaac --- /dev/null +++ b/openless-all/app/harmony/README.md @@ -0,0 +1,111 @@ +# OpenLess 鸿蒙前端宿主 + +ArkTS / ArkWeb Stage 工程,将 React 前端离线打包进 HAP。 +已通过 ArkWeb JavaScript Proxy → ArkTS → Node-API → Rust C ABI 接入 `openless-core`。 +已接通启动快照、能力查询、设置读取及字段事务写入、历史及活动统计、词典增删改和启用状态、纠正规则读取、风格列表及启动视频标记。 +数据使用应用沙箱真实文件存储,鸿蒙 IPC 不回退到浏览器模拟数据。未实现命令明确报错。 +已接通系统麦克风枚举、授权查询和 PCM 音量监测;支持提供商目录、渠道管理、模型列表和连接测试。凭据通过鸿蒙 Asset Store 加密保存,启动时恢复到 Rust 内存适配器;渠道元数据独立持久化。 +网络状态沿用桌面端的真实 HTTP 探测;外部链接交给系统浏览器,WebView 拦截外站导航。调试日志通过系统文件选择器导出。 +已接通输入法 AudioCapturer → PCM 原生桥 → 核心 ASR/润色管线 → 结果预览 → InputClient 确认写入。真实联网识别仍需配置可用渠道后验收。 + +## 当前完工边界 + +风格市场 ZIP 下载、渠道测试结果记录、词库建议接受/拒绝和普通云备份状态/上传/恢复/删除接口已接通。历史 WAV 读取、重识别、重新润色、历史删除及清空、纠错规则新增/删除/启停、手改词语收集、词库预设读写、系统剪贴板复制已补齐。隔离沙箱自检验证词语去重、规则启停与删除、预设读写以及重新打开存储后的持久化,测试不修改用户数据。另有离线语音检查,使用隔离的 fixture ASR/润色验证 PCM、结果预览、不自动插入、取消与过期音频拒绝、WAV 读取、重识别不覆盖完成的历史及重新润色;它不等同于真实联网识别。 + +仍未完成的移动端业务: + +- 输入法录音与真实联网 ASR/润色的设备验收;实时转写逐字显示和渠道录音测试。 +- 端到端加密云同步的安全凭据事务、状态事件及界面适配;普通云备份已接到共享服务,远程操作仍需账号配置验收。 +- 鸿蒙更新检测及安装流程;不能直接调用安卓 APK 更新。 + +桌面悬浮窗口、全局快捷键、Windows IME、Wayland、桌面 CLI / Less Computer 等命令不作为当前手机和平板版本的完工条件。`ipc-audit.json` 是共享 IPC 静态覆盖清单,包含这些桌面接口;覆盖比例不等于移动端完成比例,也不证明端到端功能可用。 + +跨应用输入建议以 IME Kit 为主:输入法通过系统分配的 InputClient 向绑定且获得焦点的编辑框插入文字。基础权限沿用 INTERNET 和 MICROPHONE;输入法需额外声明扩展并由用户在系统设置启用。键盘面板使用输入法框架,无须为了显示面板申请普通悬浮窗。 + +本项目仅走输入法,不接入无障碍扩展。悬浮窗本身不提供跨应用写入能力;不启用输入法时可用复制后手动粘贴作为退路。 + +## 输入法面板 + +`OpenLessInputMethod` 是系统输入法扩展,`pages/InputMethodPanel` 是原生 ArkUI 键盘面板,不经过主设置页的 WebView。已通过 Node-API 接入与 Android 共用的 librime 1.17.0,支持离线拼音、候选词、翻页、空格确认、中英文切换、数字与标点、编码退格及系统输入框删除。没有引入 Fcitx5,也不使用无障碍。 + +在系统设置的输入法管理中启用“OpenLess 输入法”,然后在普通输入框切换到它。仅声明输入法扩展及既有的网络/麦克风权限,不注册无障碍服务。系统分配的 InputClient 在 inputStop 时解绑;排队按键不会发送到已经解绑的客户端;日志不记录输入内容。 + +语音按钮使用 16 kHz / 单声道 / 16 位 PCM 录音。识别完成后直接写入当前绑定的输入框;写入失败保留结果供重试。取消、收起或解绑会释放麦克风,拒绝迟到结果;密码输入框禁用语音。扩展复用 Asset Store 凭据,并在新会话前读取已保存的渠道设置。主应用概览页提供应用内录音入口,与输入法复用 VoiceClient 和相同的音频/ASR/润色后端。开始、停止、取消、音量事件和结果预览已接通,结果可复制并刷新历史;页面离开或进入后台时取消录音,拒绝迟到结果,麦克风音量测试与录音互斥。真实联网识别仍需配置可用渠道后验收,不把模拟回归当作联网验收。普通和分离布局的空格键均支持 450ms 长按语音,短按/长按互斥,长按不额外输入空格。按住期间保留空格键以接收松开事件,松开后结束录音、识别并直接写入;准备阶段松开的操作也会保留,手势取消、解绑及过期结果不会写入。工具栏语音仍通过结束按钮停止,成功结果同样直接写入。 + +参考:[IME Kit](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-v5/ime-kit-V5)、[输入法插入与预上屏示例](https://developer.huawei.com/consumer/cn/doc/doccenter-dev-faq/faqs-ime-7)、[官方扩展能力模板(包含 Accessibility 和 InputMethod)](https://developer.huawei.com/consumer/cn/doc/doccenter-deveco-studio/ide-hmos-add-new-ability)。 + +输入法扩展具有独立沙箱;主应用和输入法的 `filesDir` 字符串相同,也不代表共享配置文件。主应用启动和配置写入后,将白名单内的语音设置与渠道元数据发布到应用专属 Asset Store,键盘录音前导入自己的后端,再恢复既有安全凭据。快照分片遵守 Asset Store 单条 1024 字节限制,提交清单后才对读者可见;读取期间更新会重试,不接受混合版本。快照不包含 API Key、用户词库、历史或云同步配置。未改变的快照不会覆盖键盘单独选择的润色模式。首次使用须先打开主应用发布配置。`harmony-voice-configuration.test.mjs` 覆盖分片、并发更新和大小限制,原生测试以两个独立数据目录验证主应用多模态设置经导入后可启动键盘语音会话;它不代替真实联网识别验收。 + +从仓库根目录构建: + +```bash +bash openless-all/app/scripts/build-harmony-frontend.sh +``` + +依赖:已安装前端 npm 依赖、HarmonyOS Command Line Tools、Python 3。 +工具链默认 `~/.local/share/harmony/current`,可通过 `OPENLESS_HARMONY_TOOLCHAIN` 指定。 +Rust 首次构建需要 `rustup target add aarch64-unknown-linux-ohos`。 +缓存默认 `/data-win/openless-cache`,可通过 `OPENLESS_CACHE_ROOT` 指定。 + +- `harmony/web-dist`:React 离线资源,使用相对 URL。 +- `harmony/openless-project`:构建工程副本、项目缓存和 HAP。 +- `harmony/rust-target`:Rust 鸿蒙交叉编译产物。 +- `harmony/ohpm`:ohpm 下载缓存。 +- `cargo`:Rust 依赖下载缓存。 +- `harmony/hvigor`:Hvigor 用户缓存及工具依赖。 +- `npm`:本脚本使用的 npm 下载缓存。 + +HAP 位于 `harmony/openless-project/entry/build/default/outputs/default/`。 +仓库只维护鸿蒙源文件,构建中间文件不写回鸿蒙源码工程。 +前端以 `VITE_OPENLESS_PLATFORM=harmony` 构建,采用移动端能力配置。 + +包名 `openless.hm.com`,compatible API 12,target API 20,compile SDK 使用本机默认版本。 +先生成未签名 HAP;若本地签名目录证书与 Profile 齐全,则使用官方工具生成 `entry-default-signed.hap`。 +签名目录默认 `~/.local/share/harmony/signing/local-test`,可用 `OPENLESS_HARMONY_SIGNING_DIR` 覆盖;密码从本地文件读取,不写入源码配置。真机安装需要该包名对应的华为调试证书,以及包含目标设备的调试 Profile。 +其他包名的 Profile 不能直接使用。签名材料不能提交到仓库。 +编译成功不代表真机运行已验证。 + +正常构建不在 WebView 启动时自动运行系统探测和词库/语音/风格包自检,避免与首屏和用户操作争用后端。需要定位问题时可显式设置 `OPENLESS_HARMONY_STARTUP_PROBES=1`,仅诊断构建注入原有启动探测脚本。 + +鸿蒙构建使用单文件 IIFE 和延迟执行的普通脚本,去除资源标签的 crossorigin 属性,避免 rawfile 不透明来源导致的模块加载失败;桌面及安卓构建不受影响。 + +使用 `$rawfile('web/index.html')` 加载页面,开启 JavaScript 和 DOM Storage。 +参考:[华为 Web 页面加载文档](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/web-page-loading)。 + +设备运行时数据位于应用 `filesDir/openless`,运行时缓存位于 `cacheDir/openless`;电脑上的统一目录用于开发构建缓存。 +WebView 通过同步 request/takeResult 传递请求票据,ArkTS 异步执行系统操作,Rust 命令通过 Node-API async work 在后台执行。避免直接将 Promise 从 JavaScript Proxy 返回导致不同 WebView 版本错误解析。 +麦克风音量通过本机 WebView 自定义事件返回,关闭列表后释放采集器。API 24 上输入设备切换遵循系统路由,无法强制切换不在当前路由上的麦克风。 + +面板参考小艺输入法布局:浅灰底、白色圆角键帽、紧凑工具栏,去掉常驻品牌文字。工具栏的布局按钮可切换标准、分离和悬浮键盘,选择保存在本地;标准和分离模式带独立数字行,横屏空间不足时隐藏数字行并压缩按键高度。分离模式包含中间留空、两侧空格、重复的 G/V 键及底部输入法切换、标点、表情、数字入口。 + +悬浮模式使用 IME Kit 原生 `FLG_FLOATING` 面板,顶部横条可拖动,− / + 调整大小,↙ 恢复固定键盘;位置与尺寸保存在本地,并限制在当前屏幕范围内。无需普通悬浮窗权限或无障碍。面板在屏幕方向改变和重新显示时重新适配尺寸。 + +兼容运行环境可能不支持 `getEditorAttribute`。该接口报错或超过 1.2 秒未返回时,使用 Rime 私密会话继续提供中文候选,不读取/训练个人词典,禁用语音,回车采用换行;迟到的属性结果不能重新开启学习。已识别的密码框仍只提供英文。文字写入仍由系统分配的 `InputClient` 完成,兼容环境是否支持写入需要真机验证。`scripts/harmony-editor-compat.test.mjs` 覆盖属性报错、超时、迟到结果、密码框和过期客户端。 + +所有宿主使用 IME Kit 的异步写入/删除接口,避免兼容桥接的同步 IPC 阻塞键盘。除下述兼容桥接策略外,写入只发送一次:接口成功或系统 `textChange` 通知严格匹配本次选区替换后的完整文字时确认提交。后者要求事先收到当前输入框的文字和选区通知;仅光标移动、无基线或不匹配的文字不能证明成功。用于匹配的文字仅在内存保留最多 4096 个 UTF-16 单元,切换输入框时清除;手动恢复只清除本次确认目标,保留当前输入框基线供下一次写入匹配;密码框和属性未知的输入框不保留文字通知。日志不记录输入内容。写入/删除等待上限 800 毫秒;未确认的词保留在 Rime,工具栏提供“已写入”(只 ack,不再次插入)、“重试写入”和“取消待提交”。卓易通是否提供足够的通知完成自动确认仍需设备验收。 + +设备反馈确认卓易通的文字通知和回读均不可用。对于属性可读、包名为空或为 `com.droi.tong` 的非密码编辑框,提交、删除及搜索/完成等编辑框动作采用单次异步发送,Rime 随后释放已交给系统的文字并继续组词;不将缺失的宿主回执作为下一次输入的前置条件,也不要求首次手动确认。不进行自动重发。这里的 Rime ack 表示文字已交给系统接口,不代表宿主确认落字;明确返回 false 或非预期错误时提示检查输入框,桥接对已实际落字的操作也会返回的 `12800003` 仅记录诊断,避免迟到回执重新锁住键盘。旧输入框结果不影响新输入框。属性未知和密码框不采用这一提交策略。未确认回执的兼容桥接仍无法提供与正常原生编辑框相同的写入成功保证,真实连续输入、连续删除需要设备验证。 + +“已写入”和“取消待提交”不等待挂起的客户端写入队列,恢复时使旧任务失效;旧确认/失败不能覆盖恢复后的状态或确认新词。等待确认期间禁用“重试”;超时也不自动重发,用户应检查输入框,已出现文字时选择“已写入”。切换输入框会建立新队列、释放旧 Rime 会话,不让旧客户端的无响应请求拖住新输入框初始化。回归检查包含精确文字通知、错误通知、无基线、永不返回的写入、手动确认后继续输入、迟到结果、密码框和输入框重绑定。 + +候选栏内容左对齐,停止输入 60ms 后预取至至少 32 个候选(不足时显示全部),向右滚动到末尾每次再追加约 32 个原生候选,保留已加载候选和原生顺序;通过原生候选迭代器只读追加,不改变菜单页、输入版本或首选词,追加后按原生绝对索引选词,新拼音重置滚动位置。加载请求串行、去重并检查输入版本,云候选不改变原生索引。展开后按可用宽度显示 2–6 列候选,上下滚动到末尾继续追加,加载过程中防止重复请求和选词索引变化。数字九宫格与符号页独立,逗号、句号、常用符号和长按标点随中英文切换。字母和数字键使用右上角符号提示:长按 350ms 或明显上滑后显示预览,松开只输入一次,不额外输入原字母;上滑开关在识别和提交时读取实时存储,移动超过 10vp 会取消长按,避免慢滑落入长按路径。上滑距离随键高限制在 22–30vp,并限制横向偏移,滑回可取消。顶部反馈区区分原生编辑器确认的“已输入”和兼容编辑器单次派发的“已发送”。`scripts/harmony-symbol-gesture.test.mjs` 验证手势互斥、取消、切换编辑器、误触距离及分离键盘映射。支持长按删除、单次大写及大写锁定、中英文切换与长按系统输入法切换。表情和工具页均接到实际操作;语音保留录音、结束、预览确认和取消。颜色通过 ArkUI 深浅色资源跟随系统,不改主应用设置布局。`scripts/harmony-panel-layout.test.mjs` 检查模式、持久化、缩放、拖动边界、横屏和原生切换失败回退;真实触摸与显示仍以设备验证为准。 + +字母键和顶部数字键按面板可用宽度填满,只保留左右各 6vp 的正常边距。普通、拇指(分离)与单手布局共用紧凑行高和面板高度,切换时不改变高度;宽屏上允许按键呈横向矩形,避免铺满宽度时把键盘拉高;标准布局横向、纵向间距均为 7vp。高度同时受屏幕可用空间限制,确保横屏各行完整可用;功能键保留较宽尺寸。面板高度跟随实际键盘行高,避免悬浮键盘缩窄后留下大块空白。按键圆角为 10vp,灰底白键配合 2vp 底边阴影;工具栏使用深浅色各自的圆头 SVG 图标。所有按钮提供 90ms 按下缩放/透明度过渡,不改变写入时机或长按/上滑识别。符号页两页、底部标点及角标根据当前中英文模式切换,数字小数点仍为半角。 + +左上角菜单顶部居中横向显示“常用功能 / 输入方式 / 键盘模式”,返回按钮与更多入口共用左侧固定位置。分类使用较深胶囊底板,方形图标选项按实际数量和宽度自适应:一行能放下时不强制第二行,否则均分为两行,窄屏可横向滚动。图标块水平间距为 24vp。原生 Swiper 提供 220ms 动画,顶部选中块逐帧同步。普通候选提交不显示手动恢复按钮,仅在超时或失败后显示。 + +常用功能包含设置、剪贴板、语音润色模式、字体大小、按键音、定制工具栏、常用语、表情、拍照输入、繁体输入、上滑输入和英文联想。自动读取实现已保留:启用该能力的构建在输入法连接普通输入框时读取系统当前文字,监听复制变化并自动收录,打开面板也会刷新,无需先点系统粘贴按钮。通过 READ_PASTEBOARD 权限和主应用运行时授权接线,首次未授权时点击“剪贴板授权”后返回输入框。保存最多 30 条本地记录,支持点击粘贴、复制、固定、单项删除和清空,去重并保留固定项;密码和未核验字段不收集,离开输入框会注销监听并丢弃过期读取,不上传记录。系统接口只提供当前剪贴板,无法追回应用未记录的历史。单条历史最多 16384 字符;“粘贴当前内容”直接读取完整文字,不受历史长度限制。自动读取作为可选构建能力保留,默认包不声明 READ_PASTEBOARD、不申请该权限、不监听复制事件;当前仍可通过系统粘贴按钮使用已有本地记录功能。取得包含 READ_PASTEBOARD 的签名 Profile 后,以 OPENLESS_HARMONY_CLIPBOARD_ACCESS=1 构建才会同时启用权限声明和自动读取。该选项不代替系统授权,也不修改签名 Profile;未获准时安装会返回 9568289。保存队列串行,切换输入框前等候既有保存完成,避免清空被迟到写入恢复;润色模式接共用后端;字体、按键音和上滑开关本地保存,按键音使用本地短音效并跟随媒体音量。常用语提供预置短语。设置入口直接打开主应用。输入方式下的“词库与云候选”保留 Google 云候选、公共词库更新及系统输入法切换。未组词时中间四个固定快捷槽位可长按编辑并保存。 + +输入方式显示 26 键、9 键和手写输入法;当前 26 键已接线。9 键、手写、拍照、繁体和英文联想仅展示明确的“待接入”禁用入口,本轮不实现这些新引擎。键盘模式提供拇指(已有分离布局)、普通、悬浮和右侧单手布局;单手内容宽度最多 420vp 且不超过屏幕宽度的 72%,所有面板内容随布局靠右。 + +### 离线词库与云候选升级 + +扩展创建时并行预热词库,与输入框初始化共用同一个 Promise,不创建学习会话或云查询。首次资源复制使用异步分段写入和临时文件原子重命名,避免大文件同步写入阻塞界面;不在输入框就绪路径运行完整打字自检,主应用诊断仍可调用 `testRime`。首次安装/词库升级仍需 Rime 部署编译,后续复用缓存。`scripts/harmony-rime-startup.test.mjs` 覆盖并行初始化、短写、资源缓冲区边界和失败重试。 + +两端共用固定修订的雾凇中文词库和拼写规则,保留本机已有 `rime-user/pinyin_simp.userdb`。不捆绑雾凇 Lua 工具或语法模型。新资源目录单独版本化,避免已安装应用跳过词库更新。 + +键盘工具中的“云候选”默认关闭;说明明确当前拼音将发送至 Google,用户开启后才查询。仅对属性已知的非密码框生效,失败保留本地候选;不将输入框周围文字交给云服务。候选回复及选择分别验证输入版本,云结果插入后仍按原生索引选择本地候选。更多说明见 `mobile-ime/README.md`。 + +启动生命周期回归覆盖系统未传 Want、空参数及授权入口;EntryAbility 对缺失启动参数做空值处理。修复版已真机安装,用户确认主应用恢复打开。主应用语音宿主回归覆盖开始/停止、音量、重复开始、录音与音量测试互斥、处理中取消、退出页面清理、权限拒绝和迟到回调。 + +多模态凭据保存时将未指定 provider 的 omni.* 字段绑定当前服务商;Asset Store 的旧全局别名在恢复时绑定当前服务商,已有明确服务商的记录优先,避免恢复顺序覆盖新值。保存和清空会清理旧别名,防止重启后旧值复活。宿主测试覆盖阿里云凭据保存/恢复、无凭据状态、清空、服务商切换,以及真实共享多模态引擎开始录音/接收 PCM/取消(不发网络请求)。 diff --git a/openless-all/app/harmony/build-profile.json5 b/openless-all/app/harmony/build-profile.json5 new file mode 100644 index 000000000..fbc256e72 --- /dev/null +++ b/openless-all/app/harmony/build-profile.json5 @@ -0,0 +1,34 @@ +{ + "app": { + "products": [ + { + "name": "default", + "compatibleSdkVersion": "5.0.0(12)", + "targetSdkVersion": "6.0.0(20)", + "runtimeOS": "HarmonyOS" + } + ], + "buildModeSet": [ + { + "name": "debug" + }, + { + "name": "release" + } + ] + }, + "modules": [ + { + "name": "entry", + "srcPath": "./entry", + "targets": [ + { + "name": "default", + "applyToProducts": [ + "default" + ] + } + ] + } + ] +} diff --git a/openless-all/app/harmony/entry/build-profile.json5 b/openless-all/app/harmony/entry/build-profile.json5 new file mode 100644 index 000000000..fcd71c16e --- /dev/null +++ b/openless-all/app/harmony/entry/build-profile.json5 @@ -0,0 +1,16 @@ +{ + "apiType": "stageMode", + "buildOption": { + "externalNativeOptions": { + "path": "./src/main/cpp/CMakeLists.txt", + "abiFilters": [ + "arm64-v8a" + ] + } + }, + "targets": [ + { + "name": "default" + } + ] +} diff --git a/openless-all/app/harmony/entry/hvigorfile.ts b/openless-all/app/harmony/entry/hvigorfile.ts new file mode 100644 index 000000000..d178d79a9 --- /dev/null +++ b/openless-all/app/harmony/entry/hvigorfile.ts @@ -0,0 +1,2 @@ +import { hapTasks } from '@ohos/hvigor-ohos-plugin'; +export default { system: hapTasks, plugins: [] }; diff --git a/openless-all/app/harmony/entry/oh-package.json5 b/openless-all/app/harmony/entry/oh-package.json5 new file mode 100644 index 000000000..02e421dfe --- /dev/null +++ b/openless-all/app/harmony/entry/oh-package.json5 @@ -0,0 +1,9 @@ +{ + "name": "entry", + "version": "0.1.0", + "description": "ArkWeb host", + "dependencies": { + "libopenless_harmony.so": "file:./src/main/cpp/types/libopenless_harmony" + }, + "modelVersion": "6.0.0" +} diff --git a/openless-all/app/harmony/entry/src/main/cpp/CMakeLists.txt b/openless-all/app/harmony/entry/src/main/cpp/CMakeLists.txt new file mode 100644 index 000000000..faed2db44 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/cpp/CMakeLists.txt @@ -0,0 +1,10 @@ +cmake_minimum_required(VERSION 3.5.0) +project(openless_harmony) +set(OPENLESS_RIME_PREFIX "$ENV{OPENLESS_RIME_PREFIX}") +add_subdirectory("${CMAKE_CURRENT_SOURCE_DIR}/../../../../mobile-ime" "${CMAKE_BINARY_DIR}/mobile-ime") +add_library(openless_harmony_host SHARED IMPORTED) +set_target_properties(openless_harmony_host PROPERTIES + IMPORTED_LOCATION "${CMAKE_CURRENT_SOURCE_DIR}/../libs/arm64-v8a/libopenless_harmony_host.so" + IMPORTED_NO_SONAME TRUE) +add_library(openless_harmony SHARED bridge.cpp) +target_link_libraries(openless_harmony PUBLIC openless_harmony_host openless_rime_engine libace_napi.z.so) diff --git a/openless-all/app/harmony/entry/src/main/cpp/bridge.cpp b/openless-all/app/harmony/entry/src/main/cpp/bridge.cpp new file mode 100644 index 000000000..9a1b0f29b --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/cpp/bridge.cpp @@ -0,0 +1,120 @@ +#include +#include +#include +#include +#include "rime_engine.h" +extern "C" char* openless_init(const char*, const char*); +extern "C" char* openless_invoke(const char*, const char*); +extern "C" void openless_free(char*); +extern "C" char* openless_feed_pcm(const char*, const uint8_t*, size_t); +static bool Read(napi_env env, napi_value value, std::string& out) { + size_t n = 0; + if (napi_get_value_string_utf8(env, value, nullptr, 0, &n) != napi_ok) return false; + std::vector buf(n + 1); + if (napi_get_value_string_utf8(env, value, buf.data(), buf.size(), &n) != napi_ok) return false; + out.assign(buf.data(), n); return true; +} +static napi_value Call(napi_env env, napi_callback_info info, bool init) { + size_t argc = 2; napi_value args[2]; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + std::string first, second; + if (argc != 2 || !Read(env, args[0], first) || !Read(env, args[1], second)) { + napi_throw_type_error(env, nullptr, "Expected two string arguments"); return nullptr; + } + char* response = init ? openless_init(first.c_str(), second.c_str()) : openless_invoke(first.c_str(), second.c_str()); + napi_value result; napi_create_string_utf8(env, response, NAPI_AUTO_LENGTH, &result); + openless_free(response); return result; +} +static napi_value InitBackend(napi_env env, napi_callback_info info) { return Call(env, info, true); } +static napi_value Invoke(napi_env env, napi_callback_info info) { return Call(env, info, false); } +static napi_value InvokeAsync(napi_env env, napi_callback_info info); +static napi_value RimeAsync(napi_env env, napi_callback_info info); +static napi_value FeedPcm(napi_env env, napi_callback_info info); +static napi_value Register(napi_env env, napi_value exports) { + napi_property_descriptor props[] = { + {"initBackend", nullptr, InitBackend, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"invoke", nullptr, Invoke, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"invokeAsync", nullptr, InvokeAsync, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"rimeAsync", nullptr, RimeAsync, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"feedPcm", nullptr, FeedPcm, nullptr, nullptr, nullptr, napi_default, nullptr} + }; + napi_define_properties(env, exports, 5, props); return exports; +} +static napi_module module = {1, 0, nullptr, Register, "openless_harmony", nullptr, {0}}; +extern "C" __attribute__((constructor)) void RegisterOpenLess() { napi_module_register(&module); } + +struct AsyncCall { + std::string command, args, third; + bool rime = false; + bool audio = false; + std::vector pcm; + char* response = nullptr; + napi_deferred deferred; + napi_async_work work; +}; +static void ExecuteAsync(napi_env env, void* data) { + auto call = static_cast(data); + call->response = call->audio ? openless_feed_pcm(call->command.c_str(), call->pcm.data(), call->pcm.size()) + : call->rime ? openless_rime_request(call->command.c_str(), call->args.c_str(), call->third.c_str()) + : openless_invoke(call->command.c_str(), call->args.c_str()); +} +static void CompleteAsync(napi_env env, napi_status status, void* data) { + auto call = static_cast(data); + napi_value value; + if (status == napi_ok && call->response) { + napi_create_string_utf8(env, call->response, NAPI_AUTO_LENGTH, &value); + napi_resolve_deferred(env, call->deferred, value); + } else { + napi_create_string_utf8(env, "native async command failed", NAPI_AUTO_LENGTH, &value); + napi_reject_deferred(env, call->deferred, value); + } + if (call->rime) openless_rime_free(call->response); else openless_free(call->response); + napi_delete_async_work(env, call->work); + delete call; +} +static napi_value InvokeAsync(napi_env env, napi_callback_info info) { + size_t argc = 2; napi_value args[2]; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + auto call = new AsyncCall(); + if (argc != 2 || !Read(env, args[0], call->command) || !Read(env, args[1], call->args)) { + delete call; napi_throw_type_error(env, nullptr, "Expected two string arguments"); return nullptr; + } + napi_value promise, name; + napi_create_promise(env, &call->deferred, &promise); + napi_create_string_utf8(env, "OpenLessCommand", NAPI_AUTO_LENGTH, &name); + napi_create_async_work(env, nullptr, name, ExecuteAsync, CompleteAsync, call, &call->work); + napi_queue_async_work(env, call->work); + return promise; +} +static napi_value RimeAsync(napi_env env, napi_callback_info info) { + size_t argc = 3; napi_value args[3]; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + auto call = new AsyncCall(); call->rime = true; + if (argc != 3 || !Read(env, args[0], call->command) || !Read(env, args[1], call->args) || !Read(env, args[2], call->third)) { + delete call; napi_throw_type_error(env, nullptr, "Expected three string arguments"); return nullptr; + } + napi_value promise, name; + napi_create_promise(env, &call->deferred, &promise); + napi_create_string_utf8(env, "OpenLessRime", NAPI_AUTO_LENGTH, &name); + napi_create_async_work(env, nullptr, name, ExecuteAsync, CompleteAsync, call, &call->work); + napi_queue_async_work(env, call->work); + return promise; +} +static napi_value FeedPcm(napi_env env, napi_callback_info info) { + size_t argc = 2; napi_value args[2]; void* bytes = nullptr; size_t length = 0; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + auto call = new AsyncCall(); call->audio = true; + if (argc != 2 || !Read(env, args[0], call->command) || + napi_get_arraybuffer_info(env, args[1], &bytes, &length) != napi_ok || !bytes || + !length || length > 32768 || length % 2) { + delete call; napi_throw_type_error(env, nullptr, "Expected session and PCM16 ArrayBuffer"); return nullptr; + } + auto first = static_cast(bytes); + call->pcm.assign(first, first + length); + napi_value promise, name; + napi_create_promise(env, &call->deferred, &promise); + napi_create_string_utf8(env, "OpenLessPcm", NAPI_AUTO_LENGTH, &name); + napi_create_async_work(env, nullptr, name, ExecuteAsync, CompleteAsync, call, &call->work); + napi_queue_async_work(env, call->work); + return promise; +} diff --git a/openless-all/app/harmony/entry/src/main/cpp/types/libopenless_harmony/index.d.ts b/openless-all/app/harmony/entry/src/main/cpp/types/libopenless_harmony/index.d.ts new file mode 100644 index 000000000..e262a0a81 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/cpp/types/libopenless_harmony/index.d.ts @@ -0,0 +1,5 @@ +export const initBackend: (dataDir: string, cacheDir: string) => string; +export const invoke: (command: string, args: string) => string; +export const invokeAsync: (command: string, args: string) => Promise; +export const rimeAsync: (operation: string, session: string, argument: string) => Promise; +export const feedPcm: (session: string, pcm: ArrayBuffer) => Promise; diff --git a/openless-all/app/harmony/entry/src/main/cpp/types/libopenless_harmony/oh-package.json5 b/openless-all/app/harmony/entry/src/main/cpp/types/libopenless_harmony/oh-package.json5 new file mode 100644 index 000000000..8ee3dac75 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/cpp/types/libopenless_harmony/oh-package.json5 @@ -0,0 +1 @@ +{"name":"libopenless_harmony.so","version":"0.1.0","types":"./index.d.ts"} diff --git a/openless-all/app/harmony/entry/src/main/ets/CredentialVault.ets b/openless-all/app/harmony/entry/src/main/ets/CredentialVault.ets new file mode 100644 index 000000000..3cf8dad48 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/ets/CredentialVault.ets @@ -0,0 +1,31 @@ +import { asset } from '@kit.AssetStoreKit'; +import { util } from '@kit.ArkTS'; +import { BusinessError } from '@kit.BasicServicesKit'; +import nativeHost from 'libopenless_harmony.so'; + +interface CredentialRecord { account: string; provider?: string; value?: string; } + +export async function restoreHarmonyCredentials(): Promise { + try { + const query = new Map(); + query.set(asset.Tag.DATA_LABEL_NORMAL_1, new util.TextEncoder().encodeInto('openless.credentials')); + query.set(asset.Tag.RETURN_TYPE, asset.ReturnType.ATTRIBUTES); + const records = await asset.query(query); + for (const record of records) { + const args: CredentialRecord = JSON.parse(util.TextDecoder.create('utf-8').decodeWithStream(record.get(asset.Tag.ALIAS) as Uint8Array)); + const secretQuery = new Map(); + secretQuery.set(asset.Tag.ALIAS, record.get(asset.Tag.ALIAS) as Uint8Array); + secretQuery.set(asset.Tag.RETURN_TYPE, asset.ReturnType.ALL); + const secrets = await asset.query(secretQuery); + args.value = util.TextDecoder.create('utf-8').decodeWithStream(secrets[0].get(asset.Tag.SECRET) as Uint8Array); + const wire = await nativeHost.invokeAsync('__hydrate_credential', JSON.stringify(args)); + const result: Record = JSON.parse(wire); + if (result['ok'] !== true) throw new Error('凭据恢复失败'); + } + console.info('[OpenLess Vault] secure credentials restored'); + return records.length; + } catch (error) { + if ((error as BusinessError).code !== 24000002) throw new Error(String(error)); + return 0; + } +} diff --git a/openless-all/app/harmony/entry/src/main/ets/HarmonyApi.ets b/openless-all/app/harmony/entry/src/main/ets/HarmonyApi.ets new file mode 100644 index 000000000..11dd9eb05 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/ets/HarmonyApi.ets @@ -0,0 +1,392 @@ +import { common, abilityAccessCtrl } from '@kit.AbilityKit'; +import { audio } from '@kit.AudioKit'; +import { fileIo as fs, picker } from '@kit.CoreFileKit'; +import { webview } from '@kit.ArkWeb'; +import { asset } from '@kit.AssetStoreKit'; +import { util } from '@kit.ArkTS'; +import { BusinessError, pasteboard } from '@kit.BasicServicesKit'; +import nativeHost from 'libopenless_harmony.so'; +import { restoreHarmonyCredentials } from './CredentialVault'; +import { testRime } from './inputmethod/RimeClient'; +import { VoiceClient, VoiceResult } from './inputmethod/VoiceClient'; +import { publishVoiceConfiguration } from './VoiceConfiguration'; + +interface Revisions { preferencesRevision: number; stylePackRevision: number; historyRevision: number; vocabularyRevision: number; } +interface RevisionReply { ok: boolean; value: Revisions; } +interface CredentialChange { account: string; provider?: string | null; value: string | null; } +interface CredentialChangesReply { ok: boolean; value: CredentialChange[]; } +interface NetworkReply { ok: boolean; value: NetworkState; } +interface NetworkState { online: boolean; latencyMs: number | null; } +interface SystemState { microphone: string; deviceCount: number; online: boolean; dataDir: string; cacheDir: string; } +interface Reply { ok: boolean; value?: Object | null; error?: string; } +interface ArchiveReply { ok: boolean; value: string; error?: string; } +interface DeviceInfo { name: string; isDefault: boolean; } +interface DictationEvent { phase: string; level: number; error: string; } +interface VoiceCredentialStatus { pipelineMode: string; asrConfigured: boolean; omniConfigured: boolean; } +interface VoiceCredentialReply { ok: boolean; value: VoiceCredentialStatus; } +class CommandArgs { deviceName: string = ''; pane: string = ''; account: string = ''; provider?: string; value: string = ''; fileName: string = ''; message: string = ''; url: string = ''; id: string = ''; zipPath: string = ''; targetPath: string = ''; text: string = ''; } + +export class HarmonyApi { + private logs: string[] = []; + + appendLog(message: string): void { + this.logs.push(new Date().toISOString() + ' ' + message); + if (this.logs.length > 400) this.logs.shift(); + } + + async openExternal(url: string): Promise { + if (!this.context) throw new Error('应用上下文未就绪'); + if (!/^https?:\/\//i.test(url)) throw new Error('外部链接必须是 HTTP 或 HTTPS'); + await this.context.startAbility({action: 'ohos.want.action.viewData', uri: url, entities: ['entity.system.browsable']}); + } + + private async exportLog(fileName: string): Promise { + if (!this.context) throw new Error('应用上下文未就绪'); + const document = new picker.DocumentViewPicker(this.context); + const options = new picker.DocumentSaveOptions(); + options.newFileNames = [fileName || 'openless-harmony.log']; + const uris = await document.save(options); + if (!uris.length) return null; + const file = fs.openSync(uris[0], fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE); + try { fs.writeSync(file.fd, this.logs.join('\n')); } finally { fs.closeSync(file); } + return uris[0]; + } + + private revisions?: Revisions; + private nativeQueue: Promise = Promise.resolve(); + + private async native(command: string, args: string): Promise { + const task = this.nativeQueue.then(async (): Promise => { + const wire = await nativeHost.invokeAsync(command, args); + const changes: CredentialChangesReply = JSON.parse(await nativeHost.invokeAsync('__credential_writes', '{}')); + if (!changes.ok) throw new Error('凭据持久化队列读取失败'); + for (const record of changes.value) { + const entry = new CommandArgs(); + entry.account = record.account; + entry.provider = record.provider || undefined; + entry.value = record.value || ''; + await this.persistCredential(entry); + } + if (changes.value.length) await nativeHost.invokeAsync('__ack_credential_writes', '{}'); + const snapshot: RevisionReply = JSON.parse(await nativeHost.invokeAsync('__revision_snapshot', '{}')); + if (snapshot.ok) { + if (this.revisions && JSON.stringify(this.revisions) !== JSON.stringify(snapshot.value)) { + this.controller?.runJavaScript("window.dispatchEvent(new CustomEvent('openless:harmony-backend-change'))").catch(() => {}); + } + this.revisions = snapshot.value; + } + await publishVoiceConfiguration(); + return wire; + }); + this.nativeQueue = task.then(() => {}, () => {}); + return task; + } + + private async pickZip(save: boolean, fileName: string): Promise { + if (!this.context) throw new Error('应用上下文未就绪'); + const document = new picker.DocumentViewPicker(this.context); + if (save) { + const options = new picker.DocumentSaveOptions(); + options.newFileNames = [fileName || 'style-pack.zip']; + const files = await document.save(options); + return files.length ? files[0] : null; + } + const options = new picker.DocumentSelectOptions(); + options.maxSelectNumber = 1; + options.fileSuffixFilters = ['.zip']; + const files = await document.select(options); + return files.length ? files[0] : null; + } + + private async importZip(args: CommandArgs): Promise { + if (!this.context) throw new Error('应用上下文未就绪'); + const target = this.context.cacheDir + '/openless/import-style-pack.zip'; + const source = fs.openSync(args.zipPath, fs.OpenMode.READ_ONLY); + try { + if (fs.statSync(source.fd).size > 512 * 1024) throw new Error('风格包 ZIP 最大为 512 KiB'); + const destination = fs.openSync(target, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE | fs.OpenMode.TRUNC); + try { fs.copyFileSync(source.fd, destination.fd); } finally { fs.closeSync(destination); } + } finally { fs.closeSync(source); } + args.zipPath = target; + return await this.native('import_style_pack_from_zip', JSON.stringify(args)); + } + + private async exportZip(args: CommandArgs): Promise { + if (!this.context) throw new Error('应用上下文未就绪'); + const uri = args.targetPath; + args.targetPath = this.context.cacheDir + '/openless/export-style-pack.zip'; + const wire = await this.native('export_style_pack_to_zip', JSON.stringify(args)); + const result: Reply = JSON.parse(wire); + if (!result.ok) return wire; + const source = fs.openSync(args.targetPath, fs.OpenMode.READ_ONLY); + try { + const destination = fs.openSync(uri, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC); + try { fs.copyFileSync(source.fd, destination.fd); } finally { fs.closeSync(destination); } + } finally { fs.closeSync(source); } + const reply: Reply = {ok: true, value: uri}; + return JSON.stringify(reply); + } + + private async downloadArchive(argsJson: string): Promise { + const args: CommandArgs = JSON.parse(argsJson); + if (!args.targetPath) throw new Error('请选择风格包保存位置'); + const wire: ArchiveReply = JSON.parse(await this.native('__marketplace_download_archive', argsJson)); + if (!wire.ok) throw new Error(wire.error || '风格包下载失败'); + const bytes = new util.Base64Helper().decodeSync(wire.value); + const destination = fs.openSync(args.targetPath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE | fs.OpenMode.TRUNC); + try { fs.writeSync(destination.fd, bytes.buffer); } finally { fs.closeSync(destination); } + } + + private sequence: number = 0; + private results: Map = new Map(); + + request(command: string, args: string): string { + const id = String(++this.sequence); + this.invokeAsync(command, args).then((result: string) => { this.results.set(id, result); }); + return id; + } + + takeResult(id: string): string { + const result = this.results.get(id); + if (result === undefined) return ''; + this.results.delete(id); + return result; + } + + private context?: common.UIAbilityContext; + private controller?: webview.WebviewController; + private capturer?: audio.AudioCapturer; + private lastLevelEvent: number = 0; + private voice: VoiceClient = new VoiceClient(); + private voiceEpoch: number = 0; + private voicePhase: string = 'idle'; + private voiceLevelAt: number = 0; + + private publishVoice(phase: string, level: number = 0, error: string = ''): void { + this.voicePhase = phase; + const state: DictationEvent = { phase: phase, level: level, error: error }; + this.controller?.runJavaScript("window.dispatchEvent(new CustomEvent('openless:harmony-dictation',{detail:" + + JSON.stringify(state) + '}))').catch(() => {}); + } + + private async startDictation(): Promise { + if (!this.context) throw new Error('应用上下文未就绪'); + if (this.voicePhase !== 'idle') throw new Error('请先结束当前录音'); + const epoch = ++this.voiceEpoch; + this.publishVoice('starting'); + try { + await this.stopMonitor(); + if (epoch !== this.voiceEpoch) throw new Error('录音已取消'); + if (this.microphoneStatus() !== 'granted' && await this.requestMicrophone() !== 'granted') { + throw new Error('未获得麦克风权限,请在权限页面授权'); + } + if (epoch !== this.voiceEpoch) throw new Error('录音已取消'); + await this.voice.start(this.context, (level: number) => { + if (epoch !== this.voiceEpoch || !['starting', 'recording'].includes(this.voicePhase) || + Date.now() - this.voiceLevelAt < 100) return; + this.voiceLevelAt = Date.now(); + this.publishVoice('recording', level); + }, (message: string) => { + if (epoch === this.voiceEpoch) { this.voiceEpoch++; this.publishVoice('idle', 0, message); } + }); + if (epoch !== this.voiceEpoch) throw new Error('录音已取消'); + this.publishVoice('recording'); + } catch (error) { + if (epoch === this.voiceEpoch) { this.voiceEpoch++; this.publishVoice('idle', 0, String(error)); } + throw new Error((error as Error).message || String(error)); + } + } + + private async stopDictation(): Promise { + if (this.voicePhase !== 'recording') throw new Error('当前没有可停止的录音'); + const epoch = this.voiceEpoch; + this.publishVoice('processing'); + try { + const result = await this.voice.stop(); + if (epoch !== this.voiceEpoch) throw new Error('录音已取消'); + this.voiceEpoch++; + this.publishVoice('idle'); + this.controller?.runJavaScript("window.dispatchEvent(new CustomEvent('openless:harmony-backend-change'))").catch(() => {}); + return result; + } catch (error) { + if (epoch === this.voiceEpoch) { this.voiceEpoch++; this.publishVoice('idle', 0, String(error)); } + throw new Error((error as Error).message || String(error)); + } + } + + private async cancelDictation(): Promise { + const epoch = ++this.voiceEpoch; + this.publishVoice('cancelling'); + try { await this.voice.cancel(); } finally { if (epoch === this.voiceEpoch) this.publishVoice('idle'); } + } + + configure(context: common.UIAbilityContext, controller: webview.WebviewController): void { + this.context = context; + this.controller = controller; + } + + private bytes(value: string): Uint8Array { return new util.TextEncoder().encodeInto(value); } + private decode(value: Uint8Array): string { return util.TextDecoder.create('utf-8').decodeWithStream(value); } + private alias(args: CommandArgs): Uint8Array { return this.bytes(JSON.stringify({account: args.account, provider: args.provider})); } + + async restoreCredentials(): Promise { + const restored = await restoreHarmonyCredentials(); + await publishVoiceConfiguration(); + const status: VoiceCredentialReply = JSON.parse(await nativeHost.invokeAsync('get_credentials', '{}')); + if (status.ok) console.info('[OpenLess Main Voice] restored=' + restored + ' mode=' + status.value.pipelineMode + + ' asrConfigured=' + status.value.asrConfigured + ' omniConfigured=' + status.value.omniConfigured); + } + + private async persistCredential(args: CommandArgs): Promise { + if (!args.account) throw new Error('凭据字段不能为空'); + const attributes = new Map(); + attributes.set(asset.Tag.ALIAS, this.alias(args)); + attributes.set(asset.Tag.SECRET, this.bytes(args.value)); + attributes.set(asset.Tag.DATA_LABEL_NORMAL_1, this.bytes('openless.credentials')); + attributes.set(asset.Tag.ACCESSIBILITY, asset.Accessibility.DEVICE_UNLOCKED); + attributes.set(asset.Tag.CONFLICT_RESOLUTION, asset.ConflictResolution.OVERWRITE); + if (!args.value) { + const query = new Map(); + query.set(asset.Tag.ALIAS, this.alias(args)); + try { await asset.remove(query); } catch (error) { if ((error as BusinessError).code !== 24000002) throw new Error(String(error)); } + } else { + await asset.add(attributes); + } + + } + + invoke(command: string, args: string): string { return nativeHost.invoke(command, args); } + + private microphoneStatus(): string { + if (!this.context) throw new Error('应用上下文未就绪'); + const token = this.context.applicationInfo.accessTokenId; + if (token === undefined) throw new Error('应用权限标识未就绪'); + const status = abilityAccessCtrl.createAtManager().checkAccessTokenSync(token, 'ohos.permission.MICROPHONE'); + return status === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED ? 'granted' : 'indeterminate'; + } + + private devices(): audio.AudioDeviceDescriptors { + return audio.getAudioManager().getRoutingManager().getDevicesSync(audio.DeviceFlag.INPUT_DEVICES_FLAG); + } + + private deviceName(device: audio.AudioDeviceDescriptor): string { + return device.displayName || device.name || ('麦克风 ' + device.id); + } + + private async requestMicrophone(): Promise { + if (!this.context) throw new Error('应用上下文未就绪'); + const result = await abilityAccessCtrl.createAtManager().requestPermissionsFromUser(this.context, ['ohos.permission.MICROPHONE']); + return result.authResults[0] === 0 ? 'granted' : 'denied'; + } + + private async stopMonitor(): Promise { + const capturer = this.capturer; + this.capturer = undefined; + if (capturer) { + try { await capturer.stop(); } finally { await capturer.release(); } + } + } + + private async startMonitor(name: string): Promise { + if (this.voicePhase !== 'idle') throw new Error('请先结束当前录音'); + await this.stopMonitor(); + if (this.microphoneStatus() !== 'granted' && await this.requestMicrophone() !== 'granted') { + throw new Error('未获得麦克风权限,请在权限页面授权'); + } + const capturer = await audio.createAudioCapturer({ + streamInfo: { samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_16000, channels: audio.AudioChannel.CHANNEL_1, sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE, encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW }, + capturerInfo: { source: audio.SourceType.SOURCE_TYPE_MIC, capturerFlags: 0 } + }); + this.capturer = capturer; + try { + if (name) { + const selected = this.devices().filter((device: audio.AudioDeviceDescriptor) => this.deviceName(device) === name); + if (selected.length === 0) throw new Error('选中的麦克风已断开'); + const preferred = audio.getAudioManager().getRoutingManager().getPreferredInputDeviceForCapturerInfoSync({source: audio.SourceType.SOURCE_TYPE_MIC, capturerFlags: 0}); + if (!preferred.some((device: audio.AudioDeviceDescriptor) => device.id === selected[0].id)) throw new Error('当前系统使用其他输入设备,请在系统中切换麦克风或选择系统默认'); + } + capturer.on('readData', (buffer: ArrayBuffer) => { + const now = Date.now(); + if (now - this.lastLevelEvent < 100) return; + this.lastLevelEvent = now; + const pcm = new Int16Array(buffer); + let sum = 0; + for (let i = 0; i < pcm.length; i++) sum += pcm[i] * pcm[i]; + const level = pcm.length ? Math.min(1, Math.sqrt(sum / pcm.length) / 32768 * 4) : 0; + this.controller?.runJavaScript("window.dispatchEvent(new CustomEvent('openless:harmony-level',{detail:" + level + '}))').catch(() => {}); + }); + await capturer.start(); + console.info('[OpenLess Audio] microphone monitor started'); + } catch (error) { + await this.stopMonitor(); + throw new Error(String(error)); + } + } + + async invokeAsync(command: string, argsJson: string): Promise { + try { + const args: CommandArgs = JSON.parse(argsJson); + let value: Object | null = null; + switch (command) { + case 'start_dictation': await this.startDictation(); break; + case 'stop_dictation': value = await this.stopDictation(); break; + case 'cancel_dictation': await this.cancelDictation(); break; + case 'check_network': { + const wire = await this.native('check_network', '{}'); + const reply: NetworkReply = JSON.parse(wire); + console.info('[OpenLess Network] online=' + reply.value.online + ' latencyMs=' + reply.value.latencyMs); + return wire; + } + case 'copy_text_to_clipboard': + case 'copy_text': await pasteboard.getSystemPasteboard().setData(pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, args.text)); break; + case '__test_rime_roundtrip': { + if (!this.context) throw new Error('应用上下文未就绪'); + value = await testRime(this.context.filesDir, this.context.resourceManager, this.context.cacheDir); + break; + } + case 'open_external_url': await this.openExternal(args.url); break; + case 'marketplace_download': await this.downloadArchive(argsJson); break; + case 'export_error_log': + case 'export_error_log_to_downloads': value = await this.exportLog(args.fileName); break; + case 'export_harmony_error_log': value = await this.exportLog(args.fileName); break; + case 'log_client_error': this.appendLog(args.message); break; + case 'get_harmony_status': { + if (!this.context) throw new Error('应用上下文未就绪'); + const network: NetworkReply = JSON.parse(await this.native('check_network', '{}')); + const online = network.ok && network.value.online; + const systemState: SystemState = {microphone: this.microphoneStatus(), deviceCount: this.devices().length, online: online, dataDir: this.context.filesDir + '/openless', cacheDir: this.context.cacheDir + '/openless'}; + value = systemState; + break; + } + case 'pick_style_pack_zip': value = await this.pickZip(false, ''); break; + case 'pick_style_pack_zip_target': value = await this.pickZip(true, args.fileName); break; + case 'import_style_pack_from_zip': return await this.importZip(args); + case 'export_style_pack_to_zip': return await this.exportZip(args); + case 'list_microphone_devices': + value = this.devices().map((device: audio.AudioDeviceDescriptor, index: number): DeviceInfo => ({name: this.deviceName(device), isDefault: index === 0})); + break; + case 'check_microphone_permission': value = this.microphoneStatus(); break; + case 'request_microphone_permission': value = await this.requestMicrophone(); break; + case 'trigger_microphone_prompt': await this.requestMicrophone(); break; + case 'check_accessibility_permission': value = 'notApplicable'; break; + case 'open_system_settings': { + if (!this.context) throw new Error('应用上下文未就绪'); + await abilityAccessCtrl.createAtManager().requestPermissionOnSetting(this.context, ['ohos.permission.MICROPHONE']); + break; + } + case 'start_microphone_level_monitor': await this.startMonitor(args.deviceName || ''); break; + case 'stop_microphone_level_monitor': await this.stopMonitor(); break; + default: return await this.native(command, argsJson); + } + const reply: Reply = {ok: true, value: value}; + return JSON.stringify(reply); + } catch (error) { + const reply: Reply = {ok: false, error: String(error)}; + return JSON.stringify(reply); + } + } + + async release(): Promise { + try { await this.cancelDictation(); } finally { await this.stopMonitor(); } + } +} diff --git a/openless-all/app/harmony/entry/src/main/ets/VoiceConfiguration.ets b/openless-all/app/harmony/entry/src/main/ets/VoiceConfiguration.ets new file mode 100644 index 000000000..b04e9afcf --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/ets/VoiceConfiguration.ets @@ -0,0 +1,89 @@ +import { asset } from '@kit.AssetStoreKit'; +import { util } from '@kit.ArkTS'; +import nativeHost from 'libopenless_harmony.so'; + +interface SnapshotReply { ok: boolean; value: Object; } +interface SnapshotManifest { generation: string; chunks: number; bytes: number; } +const label = 'openless.voice.configuration.v1'; +const manifestAlias = label + '.manifest'; +let published = ''; +let sequence = 0; +function bytes(text: string): Uint8Array { return new util.TextEncoder().encodeInto(text); } +function text(value: Uint8Array): string { return util.TextDecoder.create('utf-8').decodeWithStream(value); } +function query(alias: string): Map { + const result = new Map(); + result.set(asset.Tag.ALIAS, bytes(alias)); + result.set(asset.Tag.RETURN_TYPE, asset.ReturnType.ALL); + return result; +} +async function read(alias: string): Promise { + const records = await asset.query(query(alias)); + if (records.length !== 1) throw new Error('语音配置尚未同步,请打开 OpenLess 主应用'); + return records[0].get(asset.Tag.SECRET) as Uint8Array; +} +async function write(alias: string, secret: Uint8Array): Promise { + const attributes = new Map(); + attributes.set(asset.Tag.ALIAS, bytes(alias)); + attributes.set(asset.Tag.SECRET, secret); + attributes.set(asset.Tag.DATA_LABEL_NORMAL_1, bytes(label)); + attributes.set(asset.Tag.ACCESSIBILITY, asset.Accessibility.DEVICE_UNLOCKED); + attributes.set(asset.Tag.CONFLICT_RESOLUTION, asset.ConflictResolution.OVERWRITE); + await asset.add(attributes); +} + +// Only the main app publishes. Secrets remain in the existing credential vault. +export async function publishVoiceConfiguration(): Promise { + const reply: SnapshotReply = JSON.parse(await nativeHost.invokeAsync('__export_voice_configuration', '{}')); + if (!reply.ok) throw new Error('语音配置读取失败'); + const snapshot = JSON.stringify(reply.value); + if (snapshot === published) return; + const payload = bytes(snapshot); + if (!payload.length || payload.length > 65536) throw new Error('语音配置超过同步大小限制'); + const generation = Date.now().toString() + '-' + (++sequence).toString(); + const chunks = Math.ceil(payload.length / 1024); + for (let index = 0; index < chunks; index++) { + await write(label + '.' + generation + '.' + index.toString(), payload.slice(index * 1024, (index + 1) * 1024)); + } + const manifest: SnapshotManifest = { generation: generation, chunks: chunks, bytes: payload.length }; + // Publish the pointer last. Readers never accept an incomplete generation. + await write(manifestAlias, bytes(JSON.stringify(manifest))); + published = snapshot; + const oldQuery = new Map(); + oldQuery.set(asset.Tag.DATA_LABEL_NORMAL_1, bytes(label)); + oldQuery.set(asset.Tag.RETURN_TYPE, asset.ReturnType.ATTRIBUTES); + try { + for (const record of await asset.query(oldQuery)) { + const alias = text(record.get(asset.Tag.ALIAS) as Uint8Array); + if (alias !== manifestAlias && !alias.startsWith(label + '.' + generation + '.')) { + const remove = new Map(); + remove.set(asset.Tag.ALIAS, bytes(alias)); + await asset.remove(remove); + } + } + } catch (_) { console.warn('[OpenLess Voice Config] old snapshot cleanup deferred'); } +} + +export async function restoreVoiceConfiguration(): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + try { + const before = text(await read(manifestAlias)); + const manifest: SnapshotManifest = JSON.parse(before); + if (manifest.bytes < 1 || manifest.bytes > 65536 || manifest.chunks !== Math.ceil(manifest.bytes / 1024) || + !/^[0-9]+-[0-9]+$/.test(manifest.generation)) throw new Error('语音配置快照无效'); + const payload = new Uint8Array(manifest.bytes); + for (let index = 0; index < manifest.chunks; index++) { + const chunk = await read(label + '.' + manifest.generation + '.' + index.toString()); + if (chunk.length !== Math.min(1024, manifest.bytes - index * 1024)) throw new Error('语音配置快照不完整'); + payload.set(chunk, index * 1024); + } + if (text(await read(manifestAlias)) !== before) continue; + const reply: SnapshotReply = JSON.parse(await nativeHost.invokeAsync('__import_voice_configuration', text(payload))); + if (!reply.ok) throw new Error('语音配置应用失败'); + console.info('[OpenLess Voice Config] main application configuration restored'); + return; + } catch (error) { + if (attempt === 2) throw new Error('语音配置同步失败,请先打开 OpenLess 主应用'); + } + } + throw new Error('语音配置正在更新,请重试'); +} diff --git a/openless-all/app/harmony/entry/src/main/ets/entryability/EntryAbility.ets b/openless-all/app/harmony/entry/src/main/ets/entryability/EntryAbility.ets new file mode 100644 index 000000000..c915f6497 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/ets/entryability/EntryAbility.ets @@ -0,0 +1,32 @@ +import { UIAbility, Want, abilityAccessCtrl } from '@kit.AbilityKit'; +import { window } from '@kit.ArkUI'; + +export default class EntryAbility extends UIAbility { + private clipboardRequested: boolean = false; + + onCreate(want?: Want): void { + this.clipboardRequested = want?.parameters?.requestClipboardPermission === true; + } + + onNewWant(want?: Want): void { + this.clipboardRequested = want?.parameters?.requestClipboardPermission === true; + } + + onForeground(): void { + if (this.clipboardRequested) this.requestClipboard(); + } + + private requestClipboard(): void { + this.clipboardRequested = false; + if (!this.context.resourceManager.getBooleanByName('ime_clipboard_auto_read')) return; + abilityAccessCtrl.createAtManager().requestPermissionsFromUser(this.context, ['ohos.permission.READ_PASTEBOARD']) + .then((result) => { console.info('[OpenLess] clipboard permission result=' + result.authResults.join(',')); }) + .catch(() => { console.warn('[OpenLess] clipboard permission request unavailable'); }); + } + + onWindowStageCreate(windowStage: window.WindowStage): void { + windowStage.loadContent('pages/Index', () => { + if (this.clipboardRequested) { this.clipboardRequested = false; this.requestClipboard(); } + }); + } +} diff --git a/openless-all/app/harmony/entry/src/main/ets/inputmethod/ClipboardHistory.ets b/openless-all/app/harmony/entry/src/main/ets/inputmethod/ClipboardHistory.ets new file mode 100644 index 000000000..7bb98fa7e --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/ets/inputmethod/ClipboardHistory.ets @@ -0,0 +1,64 @@ +export class ClipboardEntry { + id: string = ''; + text: string = ''; + pinned: boolean = false; + usedAt: number = 0; +} + +// App-local text history populated by the active input method's clipboard reader. +export class ClipboardHistory { + private rows: ClipboardEntry[] = []; + + load(wire: string): void { + this.rows = []; + try { + const saved: ClipboardEntry[] = JSON.parse(wire); + if (!Array.isArray(saved)) return; + for (const item of saved) { + if (!item || typeof item.id !== 'string' || !item.id || typeof item.text !== 'string' || + !item.text || item.text.length > 16384 || !Number.isFinite(item.usedAt) || + this.rows.some((row: ClipboardEntry) => row.id === item.id || row.text === item.text)) continue; + const row = new ClipboardEntry(); + row.id = item.id; row.text = item.text; row.pinned = item.pinned === true; row.usedAt = item.usedAt; + this.rows.push(row); + if (this.rows.length === 30) break; + } + this.sort(); + } catch (_) { this.rows = []; } + } + + add(text: string): void { + if (!text || text.length > 16384) return; + let row = this.rows.find((item: ClipboardEntry) => item.text === text); + if (!row) { + if (this.rows.length === 30 && this.rows.every((item: ClipboardEntry) => item.pinned)) return; + row = new ClipboardEntry(); + row.id = Date.now().toString() + '-' + Math.random().toString(36).slice(2, 10); + row.text = text; + this.rows.unshift(row); + } + row.usedAt = Date.now(); + this.sort(); + this.rows = this.rows.slice(0, 30); + } + + private sort(): void { + this.rows.sort((a: ClipboardEntry, b: ClipboardEntry) => Number(b.pinned) - Number(a.pinned) || b.usedAt - a.usedAt); + } + + list(): ClipboardEntry[] { + return this.rows.map((row: ClipboardEntry): ClipboardEntry => { + const item = new ClipboardEntry(); + item.id = row.id; item.text = row.text; item.pinned = row.pinned; item.usedAt = row.usedAt; + return item; + }); + } + get(id: string): string { return this.rows.find((row: ClipboardEntry) => row.id === id)?.text || ''; } + pin(id: string): void { + const row = this.rows.find((item: ClipboardEntry) => item.id === id); + if (row) { row.pinned = !row.pinned; this.sort(); } + } + remove(id: string): void { this.rows = this.rows.filter((row: ClipboardEntry) => row.id !== id); } + clear(): void { this.rows = []; } + serialize(): string { return JSON.stringify(this.rows); } +} diff --git a/openless-all/app/harmony/entry/src/main/ets/inputmethod/InputSession.ets b/openless-all/app/harmony/entry/src/main/ets/inputmethod/InputSession.ets new file mode 100644 index 000000000..92e074e1d --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/ets/inputmethod/InputSession.ets @@ -0,0 +1,897 @@ +import { inputMethodEngine, InputMethodExtensionContext } from '@kit.IMEKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { initializeRime, rimeRequest, RimeState, cloudCandidates, refreshPublicDictionary, PublicDictionaryInfo } from './RimeClient'; +import { preferences } from '@kit.ArkData'; +import { VoiceClient } from './VoiceClient'; +import { KeySound } from './KeySound'; +import { pasteboard } from '@kit.BasicServicesKit'; +import { ClipboardEntry, ClipboardHistory } from './ClipboardHistory'; + +class InputPanelState { + connected: boolean = false; + status: string = '等待输入框'; + enterLabel: string = '换行'; + chinese: boolean = true; + preedit: string = ''; + candidates: string[] = []; + candidatesLoading: boolean = false; + toolbarActions: string[] = ['layout', 'emoji', 'voice', 'settings']; + keyFontSize: number = 22; + swipeSymbols: boolean = true; + keySound: boolean = false; + clipboardItems: ClipboardEntry[] = []; + clipboardStatus: string = ''; + clipboardAutoRead: boolean = false; + rimeReady: boolean = false; + page: number = 0; + hasNext: boolean = false; + password: boolean = false; + editorVerified: boolean = false; + voicePhase: string = 'idle'; + voiceHolding: boolean = false; + voiceText: string = ''; + voiceLevel: number = 0; + pendingCommit: boolean = false; + writeInFlight: boolean = false; + commitNeedsConfirmation: boolean = false; + cloudEnabled: boolean = false; + symbolFeedback: string = ''; + keyboardWidth: number = 574; + keyWidth: number = 52; + dictionaryStatus: string = '公共词库:自动更新'; + dictionaryAuto: boolean = true; +} + +export const imeStorage: LocalStorage = new LocalStorage(new InputPanelState()); + +// Only the system-provided client may edit the target app. Never retain a +// queued operation across inputStop/inputStart or inspect another app's text. +class InputSession { + private client?: inputMethodEngine.InputClient; + private keyboard?: inputMethodEngine.KeyboardController; + private context?: InputMethodExtensionContext; + private action: number = inputMethodEngine.ENTER_KEY_TYPE_UNSPECIFIED; + private queue: Promise = Promise.resolve(); + private generation: number = 0; + private rimeSession: string = ''; + private encoding: string = ''; + private candidateRevision: number = 0; + private voice: VoiceClient = new VoiceClient(true); + private voiceGeneration: number = 0; + private voiceReleaseRequested: boolean = false; + private observedText?: string; + private selectionBegin: number = -1; + private selectionEnd: number = -1; + private expectedText?: string; + private confirmWrite?: () => void; + private compatibilityEditor: boolean = false; + private queueEpoch: number = 0; + private writeId: number = 0; + private cloudEpoch: number = 0; + private cloudTimer: number = -1; + private cloudInFlight: boolean = false; + private feedbackTimer: number = -1; + private localCandidates: string[] = []; + private candidatePageSize: number = 0; + private candidateBase: number = 0; + private candidatesAppended: boolean = false; + private candidateTimer: number = -1; + private cloudChoices: string[] = []; + private cloudSettings?: preferences.Preferences; + private sound?: KeySound; + private clipboard?: ClipboardHistory; + private clipboardSaving: Promise = Promise.resolve(); + private clipboardBoard?: pasteboard.SystemPasteboard; + private clipboardChanged: () => void = () => { this.refreshClipboard(); }; + private clipboardRead: number = 0; + + attach(context: InputMethodExtensionContext, keyboard: inputMethodEngine.KeyboardController, + client: inputMethodEngine.InputClient): void { + if (this.client) this.detach(); + this.generation++; + const generation = this.generation; + this.context = context; + this.keyboard = keyboard; + this.client = client; + this.cloudSettings = undefined; + this.clipboard = undefined; + let clipboardAutoRead = false; + try { clipboardAutoRead = context.resourceManager.getBooleanByName('ime_clipboard_auto_read'); } catch (_) {} + imeStorage.setOrCreate('clipboardAutoRead', clipboardAutoRead); + imeStorage.setOrCreate('clipboardStatus', clipboardAutoRead ? '' : '自动读取待权限开放;当前可主动粘贴并保存本地记录'); + imeStorage.setOrCreate('cloudEnabled', false); + preferences.getPreferences(context, { name: 'ime_cloud_candidates' }).then(async (settings: preferences.Preferences) => { + if (generation !== this.generation) return; + this.cloudSettings = settings; + const enabled = await settings.get('enabled', false); + const publicUpdates = await settings.get('public_updates', true); + const toolbar = await settings.get('toolbar_actions', 'layout,emoji,voice,settings'); + const font = Number(await settings.get('key_font_size', 22)); + const swipe = await settings.get('swipe_symbols', true); + const sound = await settings.get('key_sound', false); + await this.clipboardSaving; + const clipboard = await settings.get('clipboard_history', '[]'); + if (generation === this.generation) { + if (typeof clipboard === 'string') { + this.clipboard = new ClipboardHistory(); + this.clipboard.load(clipboard); + imeStorage.setOrCreate('clipboardItems', this.clipboard.list()); + this.refreshClipboard(); + } + if ([20, 22, 24, 26].includes(font)) imeStorage.setOrCreate('keyFontSize', font); + imeStorage.setOrCreate('swipeSymbols', swipe !== false); + imeStorage.setOrCreate('keySound', sound === true); + if (typeof toolbar === 'string') { + const actions = toolbar.split(','); + if (actions.length === 4 && actions.every((action: string) => this.toolbarActionAllowed(action))) { + imeStorage.setOrCreate('toolbarActions', actions); + } + } + imeStorage.setOrCreate('cloudEnabled', enabled === true); + imeStorage.setOrCreate('dictionaryAuto', publicUpdates === true); + if (imeStorage.get('rimeReady')) this.checkPublicDictionary(false); + } + }).catch(() => {}); + this.action = inputMethodEngine.ENTER_KEY_TYPE_UNSPECIFIED; + this.resetObservation(); + this.compatibilityEditor = false; + imeStorage.setOrCreate('connected', true); + imeStorage.setOrCreate('status', '可输入'); + imeStorage.setOrCreate('enterLabel', '换行'); + imeStorage.setOrCreate('rimeReady', false); + imeStorage.setOrCreate('password', true); + imeStorage.setOrCreate('editorVerified', false); + this.resetCandidates(); + this.readAttributes(client).then((attribute: inputMethodEngine.EditorAttribute) => { + if (this.client !== client || generation !== this.generation) return; + const password = [inputMethodEngine.PATTERN_PASSWORD, inputMethodEngine.PATTERN_PASSWORD_NUMBER, + inputMethodEngine.PATTERN_PASSWORD_SCREEN_LOCK, inputMethodEngine.PATTERN_NEW_PASSWORD].includes(attribute.inputPattern); + imeStorage.setOrCreate('chinese', !password); + imeStorage.setOrCreate('password', password); + imeStorage.setOrCreate('editorVerified', true); + if (!password) this.watchClipboard(); + this.compatibilityEditor = !attribute.bundleName || attribute.bundleName === 'com.droi.tong'; + console.info('[OpenLess IME] editor transport=async-observed' + + ' bundle=' + (attribute.bundleName || 'unknown')); + if (!password) this.prepareRime(context, generation, true); + this.action = attribute.enterKeyType; + let label = '换行'; + if (this.action === inputMethodEngine.ENTER_KEY_TYPE_SEND) label = '发送'; + else if (this.action === inputMethodEngine.ENTER_KEY_TYPE_SEARCH) label = '搜索'; + else if (this.action === inputMethodEngine.ENTER_KEY_TYPE_DONE) label = '完成'; + else if (this.action === inputMethodEngine.ENTER_KEY_TYPE_NEXT) label = '下一项'; + else if (this.action === inputMethodEngine.ENTER_KEY_TYPE_GO) label = '前往'; + imeStorage.setOrCreate('enterLabel', label); + }).catch((error: BusinessError) => { + if (this.client !== client || generation !== this.generation) return; + // Compatibility runtimes may not implement editor attributes. Keep + // typing available, but never learn or record audio in an unknown field. + console.warn('[OpenLess IME] editor attributes unavailable code=' + error.code); + imeStorage.setOrCreate('password', false); + imeStorage.setOrCreate('chinese', true); + this.prepareRime(context, generation, false); + }); + } + + private async readAttributes(client: inputMethodEngine.InputClient): Promise { + let timer: number = -1; + const deadline = new Promise((_resolve, reject) => { + timer = setTimeout(() => { reject(new Error('editor attributes timeout')); }, 1200); + }); + try { + return await Promise.race([Promise.resolve().then(() => client.getEditorAttribute()), deadline]); + } finally { clearTimeout(timer); } + } + + private prepareRime(context: InputMethodExtensionContext, generation: number, learning: boolean): void { + const epoch = this.queueEpoch; + this.queue = this.queue.then(async (): Promise => { + if (generation !== this.generation || epoch !== this.queueEpoch) return; + await initializeRime(context.filesDir, context.resourceManager, 'rime-user', context.cacheDir); + if (generation !== this.generation || epoch !== this.queueEpoch) return; + const state = await rimeRequest('create', '', learning ? 'learning' : 'private'); + if (generation !== this.generation || epoch !== this.queueEpoch) { await rimeRequest('destroy', state.session); return; } + this.rimeSession = state.session; + imeStorage.setOrCreate('rimeReady', true); + this.publish(state); + console.info('[OpenLess IME] editor ready learning=' + learning); + this.checkPublicDictionary(false); + }).catch(() => { if (generation === this.generation && epoch === this.queueEpoch) imeStorage.setOrCreate('status', '中文引擎加载失败,可切换英文'); }); + } + + detach(): void { + this.clipboardRead++; + try { this.clipboardBoard?.off('update', this.clipboardChanged); } catch (_) {} + this.clipboardBoard = undefined; + clearTimeout(this.feedbackTimer); + imeStorage.setOrCreate('symbolFeedback', ''); + this.cancelVoice(); + this.resetObservation(); + this.generation++; + this.client = undefined; + this.keyboard = undefined; + this.context = undefined; + const session = this.rimeSession; + this.rimeSession = ''; + // A compatibility client's unfinished IPC must not block the next field. + this.queueEpoch++; + this.writeId++; + this.queue = Promise.resolve(); + imeStorage.setOrCreate('writeInFlight', false); + if (session) rimeRequest('destroy', session).catch(() => {}); + this.resetCandidates(); + imeStorage.setOrCreate('connected', false); + imeStorage.setOrCreate('editorVerified', false); + imeStorage.setOrCreate('status', '等待输入框'); + } + + private perform(kind: string, operation: (client: inputMethodEngine.InputClient) => Promise): void { + const client = this.client; + const generation = this.generation; + const epoch = this.queueEpoch; + if (!client) return; + const task = this.queue.then(async (): Promise => { + if (this.client !== client || this.generation !== generation || epoch !== this.queueEpoch) return; + try { + const ok = await operation(client); + console.info('[OpenLess IME] ' + kind + ' result=' + ok); + if (this.client === client && this.generation === generation && epoch === this.queueEpoch) { + if (!ok && imeStorage.get('pendingCommit')) imeStorage.setOrCreate('commitNeedsConfirmation', true); + imeStorage.setOrCreate('status', ok ? '可输入' : '输入未成功,请重新选择输入框'); + } + } catch (error) { + if (this.client === client && this.generation === generation && epoch === this.queueEpoch) { + if (imeStorage.get('pendingCommit')) imeStorage.setOrCreate('commitNeedsConfirmation', true); + imeStorage.setOrCreate('status', '输入未成功,请重新选择输入框'); + } + console.error('[OpenLess IME] ' + kind + ' failed code=' + (error as BusinessError).code); + } + }); + this.queue = task.catch(() => {}); + } + + private resetObservation(): void { + this.observedText = undefined; + this.selectionBegin = -1; + this.selectionEnd = -1; + this.expectedText = undefined; + this.confirmWrite = undefined; + } + + observeSelection(begin: number, end: number): void { + if (!this.client || !imeStorage.get('editorVerified') || imeStorage.get('password')) return; + this.selectionBegin = Math.min(begin, end); + this.selectionEnd = Math.max(begin, end); + } + + observeText(text: string): void { + if (!this.client || !imeStorage.get('editorVerified') || imeStorage.get('password')) return; + // Only a bounded, transient copy of the active editor's notification is + // retained. Never log text or infer success from cursor movement alone. + if (this.expectedText !== undefined && text === this.expectedText) this.confirmWrite?.(); + this.observedText = text.length <= 4096 ? text : undefined; + } + + private async writeText(client: inputMethodEngine.InputClient, text: string): Promise { + if (this.compatibilityEditor && + imeStorage.get('editorVerified') && !imeStorage.get('password')) { + // Advancing Rime means the commit was dispatched, not that the host + // acknowledged it. A missing bridge reply must not lock composition. + // Dispatch once and never replay a late or failed operation. + this.observeDispatched(client, client.insertText(text), 'write'); + console.info('[OpenLess IME] compatibility write dispatched without acknowledgement'); + return true; + } + const writeId = ++this.writeId; + const baseline = this.observedText; + const begin = this.selectionBegin; + const end = this.selectionEnd; + this.expectedText = baseline !== undefined && begin >= 0 && end <= baseline.length ? + baseline.slice(0, begin) + text + baseline.slice(end) : undefined; + if (this.expectedText === baseline) this.expectedText = undefined; + console.info('[OpenLess IME] write observation available=' + (this.expectedText !== undefined)); + let timer: number = -1; + const observed = new Promise((resolve) => { this.confirmWrite = () => resolve(true); }); + const deadline = new Promise((resolve) => { timer = setTimeout(() => resolve(false), 800); }); + imeStorage.setOrCreate('writeInFlight', true); + try { + // Send once. The bridge may apply the edit without returning its IPC + // acknowledgement. Exact text notifications can independently confirm it. + const sent = Promise.resolve().then(() => client.insertText(text)).then((ok: boolean) => { + if (ok) return true; + return observed; + }).catch(() => observed); + const ok = await Promise.race([sent, observed, deadline]); + console.info('[OpenLess IME] write transport=async-observed result=' + ok); + if (!ok && writeId === this.writeId && imeStorage.get('pendingCommit')) { + imeStorage.setOrCreate('commitNeedsConfirmation', true); + } + return ok; + } catch (error) { + console.error('[OpenLess IME] write transport=async-observed' + + ' failed code=' + (error as BusinessError).code); + throw new Error('文字提交未确认;若已出现在输入框,请点“已写入”'); + } finally { + clearTimeout(timer); + if (writeId === this.writeId) { + this.expectedText = undefined; + this.confirmWrite = undefined; + imeStorage.setOrCreate('writeInFlight', false); + } + } + } + + private async deleteText(client: inputMethodEngine.InputClient): Promise { + if (this.compatibilityEditor && imeStorage.get('editorVerified') && + !imeStorage.get('password')) { + this.observeDispatched(client, client.deleteForward(1), 'delete'); + return true; + } + let timer: number = -1; + const deadline = new Promise((resolve) => { timer = setTimeout(() => resolve(false), 800); }); + try { return await Promise.race([Promise.resolve().then(() => client.deleteForward(1)), deadline]); } + finally { clearTimeout(timer); } + } + + private observeDispatched(client: inputMethodEngine.InputClient, operation: Promise, kind: string): void { + const generation = this.generation; + const epoch = this.queueEpoch; + operation.then((ok: boolean) => { + if (!ok && this.client === client && generation === this.generation && epoch === this.queueEpoch) { + imeStorage.setOrCreate('status', '兼容编辑框未确认操作,请检查输入框'); + } + }).catch((error: BusinessError) => { + // This bridge reports 12800003 even for edits the user confirmed were + // applied. Do not replay them or let a missing reply stall the queue. + console.warn('[OpenLess IME] compatibility ' + kind + ' reply unavailable code=' + error.code); + if (error.code !== 12800003 && this.client === client && generation === this.generation && epoch === this.queueEpoch) { + imeStorage.setOrCreate('status', '兼容编辑框操作失败,请检查输入框'); + } + }); + } + + private async sendAction(client: inputMethodEngine.InputClient, action: number): Promise { + if (this.compatibilityEditor && imeStorage.get('editorVerified') && + !imeStorage.get('password')) { + this.observeDispatched(client, client.sendKeyFunction(action), 'action'); + return true; + } + return client.sendKeyFunction(action); + } + + insert(text: string): void { + this.playKeySound(); + if (imeStorage.get('chinese') && /^[a-z]$/i.test(text)) { + this.rimeOperation('key', text.toLowerCase().charCodeAt(0).toString()); + return; + } + if (imeStorage.get('chinese')) { this.rimeOperation('literal', text); return; } + if (text.length) this.perform('insert', (client: inputMethodEngine.InputClient) => this.writeText(client, text)); + } + + bindingVersion(): number { return this.generation; } + + private symbolMessage(message: string): void { + clearTimeout(this.feedbackTimer); + imeStorage.setOrCreate('symbolFeedback', message); + this.feedbackTimer = setTimeout(() => { imeStorage.setOrCreate('symbolFeedback', ''); }, 1400); + } + + insertSymbol(text: string): void { + this.playKeySound(); + if (!this.client) { this.symbolMessage('请先选择输入框'); return; } + this.symbolMessage('正在输入「' + text + '」'); + if (imeStorage.get('chinese')) { this.rimeOperation('literal', text, true); return; } + const generation = this.generation; + this.perform('symbol', async (client: inputMethodEngine.InputClient): Promise => { + let ok = false; + try { ok = await this.writeText(client, text); } catch (_) {} + if (generation === this.generation && client === this.client) { + this.symbolMessage(ok ? (this.compatibilityEditor ? '已发送「' : '已输入「') + text + '」' : '未能确认,请检查输入框'); + } + return ok; + }); + } + + backspace(): void { + this.playKeySound(); + // Check encoding inside the serial queue; fast typing may still be pending. + this.rimeOperation('backspace', ''); + } + + private resetCandidates(): void { + clearTimeout(this.candidateTimer); + this.cloudEpoch++; + clearTimeout(this.cloudTimer); + this.cloudChoices = []; + this.localCandidates = []; + this.candidatePageSize = 0; + this.candidateBase = 0; + this.candidatesAppended = false; + imeStorage.setOrCreate('candidatesLoading', false); + this.encoding = ''; + imeStorage.setOrCreate('preedit', ''); + imeStorage.setOrCreate('candidates', [] as string[]); + imeStorage.setOrCreate('pendingCommit', false); + imeStorage.setOrCreate('commitNeedsConfirmation', false); + } + + private publish(state: RimeState): void { + clearTimeout(this.candidateTimer); + this.cloudEpoch++; + clearTimeout(this.cloudTimer); + this.cloudChoices = []; + this.localCandidates = state.candidates; + if (state.page === 0 && state.candidates.length > 0) this.candidatePageSize = state.candidates.length; + this.candidateBase = state.candidateOffset ?? state.page * this.candidatePageSize; + this.candidatesAppended = false; + this.candidateRevision = state.revision; + this.encoding = state.input; + imeStorage.setOrCreate('preedit', state.preedit || state.input); + imeStorage.setOrCreate('candidates', state.candidates); + imeStorage.setOrCreate('page', state.page); + imeStorage.setOrCreate('hasNext', state.hasNext); + imeStorage.setOrCreate('pendingCommit', !!state.commit); + // A normal asynchronous commit is not an error recovery screen. + imeStorage.setOrCreate('commitNeedsConfirmation', false); + imeStorage.setOrCreate('status', 'Rime 拼音'); + this.scheduleCloud(); + if (state.input && !state.commit && state.hasNext && state.page === 0) { + this.candidateTimer = setTimeout(() => { this.loadMoreCandidates(); }, 60); + } + } + + private scheduleCloud(): void { + clearTimeout(this.cloudTimer); + const context = this.context; + const generation = this.generation; + const epoch = this.cloudEpoch; + const input = this.encoding; + if (context && input.length >= 2 && this.candidateBase === 0 && !imeStorage.get('pendingCommit') && + imeStorage.get('chinese') && + imeStorage.get('cloudEnabled') && imeStorage.get('editorVerified') && + !imeStorage.get('password') && /^[a-z']{2,64}$/.test(input)) { + this.cloudTimer = setTimeout(() => { + if (this.cloudInFlight || epoch !== this.cloudEpoch || generation !== this.generation || !imeStorage.get('cloudEnabled')) return; + this.cloudInFlight = true; + cloudCandidates(context.filesDir, context.cacheDir, input).then((rows: string[]) => { + console.info('[OpenLess IME] cloud response candidates=' + rows.length); + if (epoch !== this.cloudEpoch || generation !== this.generation || !imeStorage.get('cloudEnabled')) return; + this.cloudChoices = rows.filter((text: string) => !this.localCandidates.includes(text)); + imeStorage.setOrCreate('candidates', this.localCandidates.slice(0, 1).concat(this.cloudChoices).concat(this.localCandidates.slice(1))); + }).catch(() => {}).finally(() => { + this.cloudInFlight = false; + if (epoch !== this.cloudEpoch) this.scheduleCloud(); + }); + }, 180); + } + } + + toggleCloudCandidates(): void { + if (!this.cloudSettings) { imeStorage.setOrCreate('status', '云候选设置暂不可用'); return; } + const enabled = !imeStorage.get('cloudEnabled'); + imeStorage.setOrCreate('cloudEnabled', enabled); + this.cloudEpoch++; + clearTimeout(this.cloudTimer); + this.cloudChoices = []; + imeStorage.setOrCreate('candidates', this.localCandidates); + if (this.cloudSettings) { + this.cloudSettings.put('enabled', enabled).then(() => this.cloudSettings?.flush()).catch(() => {}); + } + } + + checkPublicDictionary(force: boolean = true): void { + if (!force && (!this.cloudSettings || !imeStorage.get('dictionaryAuto'))) return; + const context = this.context; + if (!context) return; + imeStorage.setOrCreate('dictionaryStatus', '公共词库:正在检查'); + refreshPublicDictionary(context.filesDir, context.cacheDir, force).then((info: PublicDictionaryInfo | undefined) => { + imeStorage.setOrCreate('dictionaryStatus', !info ? '公共词库:离线可用,稍后重试' : info.pending ? + '公共词库:已更新,下次启动生效' : '公共词库:已检查'); + }).catch(() => { imeStorage.setOrCreate('dictionaryStatus', '公共词库:离线可用,稍后重试'); }); + } + + togglePublicDictionaryUpdates(): void { + const settings = this.cloudSettings; + if (!settings) return; + const enabled = !imeStorage.get('dictionaryAuto'); + imeStorage.setOrCreate('dictionaryAuto', enabled); + settings.put('public_updates', enabled).then(() => settings.flush()).catch(() => {}); + if (enabled) this.checkPublicDictionary(false); + } + + private toolbarActionAllowed(action: string): boolean { + return ['layout', 'emoji', 'voice', 'settings', 'symbols', 'numbers', 'language', 'inputMethods'].includes(action); + } + + setToolbarAction(slot: number, action: string): void { + const settings = this.cloudSettings; + if (!settings || slot < 0 || slot > 3 || !Number.isInteger(slot) || !this.toolbarActionAllowed(action)) return; + const actions = (imeStorage.get('toolbarActions') || ['layout', 'emoji', 'voice', 'settings']).slice(); + actions[slot] = action; + imeStorage.setOrCreate('toolbarActions', actions); + settings.put('toolbar_actions', actions.join(',')).then(() => settings.flush()).catch(() => { + imeStorage.setOrCreate('status', '快捷入口保存失败,请重试'); + }); + } + + setKeyFontSize(size: number): void { + if (![20, 22, 24, 26].includes(size)) return; + imeStorage.setOrCreate('keyFontSize', size); + this.cloudSettings?.put('key_font_size', size).then(() => this.cloudSettings?.flush()).catch(() => {}); + } + + toggleKeyOption(option: string): void { + if (option !== 'swipeSymbols' && option !== 'keySound') return; + const enabled = !imeStorage.get(option); + imeStorage.setOrCreate(option, enabled); + this.cloudSettings?.put(option === 'swipeSymbols' ? 'swipe_symbols' : 'key_sound', enabled) + .then(() => this.cloudSettings?.flush()).catch(() => {}); + if (option === 'keySound' && enabled) this.playKeySound(); + } + + private playKeySound(): void { + if (!imeStorage.get('keySound') || !this.context) return; + if (!this.sound) this.sound = new KeySound(); + this.sound.play(this.context); + } + + pasteClipboard(): void { + if (!this.client || imeStorage.get('pendingCommit')) return; + const generation = this.generation; + pasteboard.getSystemPasteboard().getData().then((data: pasteboard.PasteData) => { + if (generation !== this.generation) return; + const text = data.getPrimaryText(); + if (text) { + if (imeStorage.get('editorVerified') && !imeStorage.get('password')) { + this.clipboard?.add(text); + this.saveClipboard(); + } + this.insert(text); + } else imeStorage.setOrCreate('status', '剪贴板中没有文字'); + }).catch(() => { + if (generation === this.generation) imeStorage.setOrCreate('clipboardStatus', '无法读取剪贴板,请检查剪贴板授权后重试'); + }); + } + + private watchClipboard(): void { + if (!imeStorage.get('clipboardAutoRead')) return; + if (!this.clipboardBoard) { + try { + this.clipboardBoard = pasteboard.getSystemPasteboard(); + this.clipboardBoard.on('update', this.clipboardChanged); + } catch (_) { this.clipboardBoard = undefined; } + } + this.refreshClipboard(); + } + + refreshClipboard(): void { + if (!imeStorage.get('clipboardAutoRead') || !this.client || !this.clipboard || !imeStorage.get('editorVerified') || + imeStorage.get('password')) return; + const generation = this.generation; + const read = ++this.clipboardRead; + pasteboard.getSystemPasteboard().getData().then((data: pasteboard.PasteData) => { + if (generation !== this.generation || read !== this.clipboardRead) return; + const text = data.getPrimaryText(); + if (text) { this.clipboard?.add(text); this.saveClipboard(); } + imeStorage.setOrCreate('clipboardStatus', text ? '已读取系统剪贴板,复制新文字后自动更新' : '系统剪贴板中没有文字'); + }).catch((error: BusinessError) => { + if (generation !== this.generation || read !== this.clipboardRead) return; + imeStorage.setOrCreate('clipboardStatus', error.code === 201 ? + '请先授权读取剪贴板,再返回输入框' : '暂时无法读取系统剪贴板,请刷新重试'); + console.warn('[OpenLess IME] clipboard read failed code=' + error.code); + }); + } + + authorizeClipboard(): void { + if (!imeStorage.get('clipboardAutoRead')) return; + this.context?.startAbility({ bundleName: 'openless.hm.com', abilityName: 'EntryAbility', + parameters: { requestClipboardPermission: true } }).catch(() => { + imeStorage.setOrCreate('clipboardStatus', '无法打开授权页面,请打开 OpenLess 应用重试'); + }); + } + + private saveClipboard(): void { + if (!this.clipboard) return; + imeStorage.setOrCreate('clipboardItems', this.clipboard.list()); + const settings = this.cloudSettings; + const generation = this.generation; + if (!settings) return; + const saved = this.clipboard.serialize(); + this.clipboardSaving = this.clipboardSaving.then(() => settings.put('clipboard_history', saved)).then(() => settings.flush()).catch(() => { + if (generation === this.generation) imeStorage.setOrCreate('status', '剪贴板记录保存失败'); + }); + } + + pasteClipboardItem(id: string): void { + const text = this.clipboard?.get(id); + if (!text || !this.client || imeStorage.get('pendingCommit')) return; + this.clipboard?.add(text); this.saveClipboard(); this.insert(text); + } + + copyClipboardItem(id: string): void { + const text = this.clipboard?.get(id); + if (!text) return; + const generation = this.generation; + pasteboard.getSystemPasteboard().setData(pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text)).then(() => { + if (generation === this.generation) imeStorage.setOrCreate('status', '已复制到系统剪贴板'); + }).catch(() => { + if (generation === this.generation) imeStorage.setOrCreate('status', '复制失败,请重试'); + }); + } + + pinClipboardItem(id: string): void { this.clipboard?.pin(id); this.saveClipboard(); } + deleteClipboardItem(id: string): void { this.clipboardRead++; this.clipboard?.remove(id); this.saveClipboard(); } + clearClipboardHistory(): void { this.clipboardRead++; this.clipboard?.clear(); this.saveClipboard(); } + + setPolishMode(mode: string): void { + if (!this.context || !['raw', 'light', 'structured', 'formal'].includes(mode)) return; + const generation = this.generation; + this.voice.setPolishMode(this.context, mode).then(() => { + if (generation === this.generation) imeStorage.setOrCreate('status', '已更新语音润色模式'); + }).catch((error: Error) => { + if (generation === this.generation) imeStorage.setOrCreate('status', error.message); + }); + } + + private rimeOperation(operation: string, argument: string, feedback: boolean = false): void { + const generation = this.generation; + const epoch = this.queueEpoch; + const client = this.client; + if (!client) return; + this.queue = this.queue.then(async (): Promise => { + if (generation !== this.generation || client !== this.client || epoch !== this.queueEpoch) return; + if (operation === 'backspace' && (!this.rimeSession || !this.encoding)) { + const ok = await this.deleteText(client); + console.info('[OpenLess IME] backspace ' + (this.compatibilityEditor ? 'dispatched=' : 'result=') + ok); + return; + } + if (operation === 'space' && !this.encoding) { await this.writeText(client, ' '); return; } + if (!this.rimeSession) throw new Error('中文引擎未就绪'); + const delimiter = operation === 'literal' && argument === "'" && this.encoding.length > 0; + const key = delimiter ? '39' : operation === 'backspace' ? '65288' : operation === 'space' ? '32' : argument; + let state = await rimeRequest(operation === 'backspace' || operation === 'space' ? 'key' : delimiter ? 'key' : operation === 'literal' ? 'confirm' : operation, this.rimeSession, key); + if (generation !== this.generation || client !== this.client || epoch !== this.queueEpoch) return; + if (state.commit) { + this.publish(state); + if (!await this.writeText(client, state.commit)) throw new Error('文字写入未确认,请重试或取消待提交'); + if (generation !== this.generation || epoch !== this.queueEpoch) return; + state = await rimeRequest('ack', this.rimeSession); + if (generation !== this.generation || epoch !== this.queueEpoch) return; + this.publish(state); + } + if (operation === 'literal' && !delimiter && argument && !await this.writeText(client, argument)) { + throw new Error('标点写入失败,请重新选择输入框'); + } + if (generation === this.generation && client === this.client && epoch === this.queueEpoch) { + this.publish(state); + if (feedback) this.symbolMessage((delimiter ? '已加入拼音「' : this.compatibilityEditor ? '已发送「' : '已输入「') + argument + '」'); + } + }).catch((error: Error) => { + console.error('[OpenLess IME] operation=' + operation + ' failed code=' + (error as BusinessError).code); + if (generation === this.generation && epoch === this.queueEpoch) { + if (imeStorage.get('pendingCommit')) imeStorage.setOrCreate('commitNeedsConfirmation', true); + imeStorage.setOrCreate('status', error.message); + if (feedback) this.symbolMessage('未能确认,请检查输入框'); + } + }); + } + + selectCandidate(index: number, expectedText: string = ''): void { + if (imeStorage.get('candidatesLoading')) return; + const displayed = imeStorage.get('candidates') || []; + const text = expectedText || displayed[index]; + if (text === undefined || !displayed.includes(text)) return; + this.playKeySound(); + const local = this.localCandidates.indexOf(text); + if (local >= 0) this.rimeOperation(this.candidatesAppended ? 'select_absolute' : 'select', + (this.candidatesAppended ? this.candidateBase + local : local).toString() + '@' + this.candidateRevision.toString()); + else if (this.cloudChoices.includes(text)) this.rimeOperation('external', this.candidateRevision.toString() + '@' + text); + } + + loadMoreCandidates(): void { + if (!this.client || !this.rimeSession || !this.encoding || !imeStorage.get('hasNext') || + imeStorage.get('pendingCommit') || imeStorage.get('candidatesLoading')) return; + const generation = this.generation; + const epoch = this.queueEpoch; + const revision = this.candidateRevision; + const input = this.encoding; + const session = this.rimeSession; + imeStorage.setOrCreate('candidatesLoading', true); + this.queue = this.queue.then(async (): Promise => { + if (generation !== this.generation || epoch !== this.queueEpoch || revision !== this.candidateRevision) return; + const offset = this.candidateBase + this.localCandidates.length; + const state = await rimeRequest('candidates', session, offset.toString() + '@' + revision.toString()); + if (generation !== this.generation || epoch !== this.queueEpoch || session !== this.rimeSession) return; + if (state.input !== input || state.commit) { + this.publish(state); + return; + } + if (state.revision !== revision || state.candidateOffset !== offset) throw new Error('候选版本已变化'); + this.localCandidates = this.localCandidates.concat(state.candidates); + this.candidatesAppended = true; + this.cloudChoices = this.cloudChoices.filter((text: string) => !this.localCandidates.includes(text)); + imeStorage.setOrCreate('candidates', this.localCandidates.slice(0, 1).concat(this.cloudChoices).concat(this.localCandidates.slice(1))); + imeStorage.setOrCreate('hasNext', state.candidateHasNext === true && state.candidates.length > 0); + }).catch(() => { + if (generation === this.generation && epoch === this.queueEpoch) imeStorage.setOrCreate('status', '候选加载失败,可再次滑动重试'); + }).finally(() => { + if (generation === this.generation && epoch === this.queueEpoch) imeStorage.setOrCreate('candidatesLoading', false); + }); + } + space(): void { this.playKeySound(); this.rimeOperation('space', ''); } + page(next: boolean): void { this.rimeOperation('key', next ? '65366' : '65365'); } + toggleLanguage(): void { + this.cloudEpoch++; + clearTimeout(this.cloudTimer); + const chinese = !imeStorage.get('chinese'); + imeStorage.setOrCreate('chinese', chinese); + if (this.rimeSession) this.rimeOperation('clear', ''); + } + + enter(): void { + this.playKeySound(); + const action = this.action; + const generation = this.generation; + const epoch = this.queueEpoch; + this.perform('enter', async (client: inputMethodEngine.InputClient): Promise => { + if (this.encoding && this.rimeSession) { + const state = await rimeRequest('key', this.rimeSession, '65293'); + if (generation !== this.generation || client !== this.client || epoch !== this.queueEpoch) return false; + this.publish(state); + if (state.commit) { + if (!await this.writeText(client, state.commit)) return false; + if (generation !== this.generation || client !== this.client || epoch !== this.queueEpoch) return false; + const acknowledged = await rimeRequest('ack', this.rimeSession); + if (generation === this.generation && client === this.client && epoch === this.queueEpoch) this.publish(acknowledged); + } + return true; + } + return action === inputMethodEngine.ENTER_KEY_TYPE_UNSPECIFIED || action === inputMethodEngine.ENTER_KEY_TYPE_NEWLINE ? + await this.writeText(client, '\n') : await this.sendAction(client, action); + }); + } + + retryCommit(): void { this.rimeOperation('state', ''); } + cancelPendingCommit(): void { this.recoverPending('clear'); } + acknowledgePendingCommit(): void { this.recoverPending('ack'); } + + private recoverPending(operation: string): void { + if (!imeStorage.get('pendingCommit') || !this.client || !this.rimeSession) return; + const generation = this.generation; + const session = this.rimeSession; + const epoch = ++this.queueEpoch; + this.writeId++; + this.expectedText = undefined; + this.confirmWrite = undefined; + imeStorage.setOrCreate('writeInFlight', false); + // Only these explicit user decisions can release an uncertain submission. + // They never resend text, and late IPC results cannot ack a newer word. + this.queue = rimeRequest(operation, session).then((state: RimeState): void => { + if (generation === this.generation && session === this.rimeSession && epoch === this.queueEpoch) this.publish(state); + }).catch(() => { + if (generation === this.generation && epoch === this.queueEpoch) imeStorage.setOrCreate('status', '恢复失败,请重新选择输入框'); + }); + } + + async startVoice(holdToTalk: boolean = false): Promise { + const context = this.context; + const client = this.client; + if (!context || !client || imeStorage.get('password') || imeStorage.get('voicePhase') !== 'idle') return; + if (!imeStorage.get('editorVerified')) { + imeStorage.setOrCreate('status', '当前输入框不支持语音,请在系统原生应用中使用'); + return; + } + if (imeStorage.get('pendingCommit')) { imeStorage.setOrCreate('status', '请先重新确认待写入的候选词'); return; } + const generation = this.generation; + const voiceGeneration = ++this.voiceGeneration; + this.voiceReleaseRequested = false; + imeStorage.setOrCreate('voiceHolding', holdToTalk); + imeStorage.setOrCreate('voicePhase', 'starting'); + imeStorage.setOrCreate('voiceText', ''); + imeStorage.setOrCreate('status', '正在准备麦克风'); + try { + // Clear the composition in the same queue as normal keyboard operations. + await this.queue; + if (generation !== this.generation || voiceGeneration !== this.voiceGeneration) return; + if (this.rimeSession) this.publish(await rimeRequest('clear', this.rimeSession)); + if (generation !== this.generation || voiceGeneration !== this.voiceGeneration) return; + await this.voice.start(context, (level: number) => { + if (generation === this.generation && voiceGeneration === this.voiceGeneration) imeStorage.setOrCreate('voiceLevel', level); + }, (message: string) => { + if (generation === this.generation && voiceGeneration === this.voiceGeneration) { + imeStorage.setOrCreate('voicePhase', 'idle'); + imeStorage.setOrCreate('voiceHolding', false); + imeStorage.setOrCreate('status', message); + } + }); + if (generation !== this.generation || voiceGeneration !== this.voiceGeneration) return; + imeStorage.setOrCreate('voicePhase', 'recording'); + imeStorage.setOrCreate('status', holdToTalk ? '正在录音,松开空格识别并输入' : '正在录音,点结束识别并输入'); + if (this.voiceReleaseRequested) await this.stopVoice(); + } catch (error) { + if (generation === this.generation && voiceGeneration === this.voiceGeneration) { + imeStorage.setOrCreate('voicePhase', 'idle'); + imeStorage.setOrCreate('voiceHolding', false); + imeStorage.setOrCreate('status', (error as Error).message); + } + } + } + + releaseVoiceHold(): void { + if (!imeStorage.get('voiceHolding')) return; + imeStorage.setOrCreate('voiceHolding', false); + this.voiceReleaseRequested = true; + if (imeStorage.get('voicePhase') === 'recording') this.stopVoice(); + } + + cancelVoiceHold(): void { + if (imeStorage.get('voiceHolding')) this.cancelVoice(); + } + + async stopVoice(): Promise { + if (imeStorage.get('voicePhase') !== 'recording') return; + const generation = this.generation; + const voiceGeneration = this.voiceGeneration; + imeStorage.setOrCreate('voicePhase', 'processing'); + imeStorage.setOrCreate('voiceLevel', 0); + imeStorage.setOrCreate('status', '正在识别'); + try { + const result = await this.voice.stop(); + if (generation !== this.generation || voiceGeneration !== this.voiceGeneration) return; + const text = result.polishedText || result.rawText; + imeStorage.setOrCreate('voiceText', text); + imeStorage.setOrCreate('voicePhase', text ? 'review' : 'idle'); + imeStorage.setOrCreate('status', text ? '正在写入识别结果' : '未识别到文字,请重新录音'); + if (text) this.confirmVoice(); + } catch (error) { + if (generation === this.generation && voiceGeneration === this.voiceGeneration) { + imeStorage.setOrCreate('voicePhase', 'idle'); + imeStorage.setOrCreate('status', (error as Error).message); + } + } + } + + confirmVoice(): void { + if (imeStorage.get('voicePhase') !== 'review') return; + const text = imeStorage.get('voiceText') || ''; + const generation = this.generation; + const voiceGeneration = this.voiceGeneration; + imeStorage.setOrCreate('voicePhase', 'inserting'); + this.perform('voice insert', async (client: inputMethodEngine.InputClient): Promise => { + if (voiceGeneration !== this.voiceGeneration || imeStorage.get('voicePhase') !== 'inserting') return false; + let ok = false; + try { ok = await this.writeText(client, text); return ok; } finally { + if (generation === this.generation && voiceGeneration === this.voiceGeneration) { + imeStorage.setOrCreate('voicePhase', ok ? 'idle' : 'review'); + if (ok) imeStorage.setOrCreate('voiceText', ''); + } + } + }); + } + + cancelVoice(): void { + const phase = imeStorage.get('voicePhase'); + this.voiceGeneration++; + this.voiceReleaseRequested = false; + this.voice.cancel().catch(() => {}); + imeStorage.setOrCreate('voicePhase', 'idle'); + imeStorage.setOrCreate('voiceHolding', false); + imeStorage.setOrCreate('voiceText', ''); + imeStorage.setOrCreate('voiceLevel', 0); + if (phase !== 'idle') imeStorage.setOrCreate('status', '录音已取消'); + } + + hide(): void { + this.cancelVoice(); + this.keyboard?.hide().catch(() => {}); + } + + settings(): void { + this.cancelVoice(); + this.context?.startAbility({ bundleName: 'openless.hm.com', abilityName: 'EntryAbility' }) + .catch(() => { imeStorage.setOrCreate('status', '无法打开设置'); }); + } +} + +export const inputSession: InputSession = new InputSession(); diff --git a/openless-all/app/harmony/entry/src/main/ets/inputmethod/KeySound.ets b/openless-all/app/harmony/entry/src/main/ets/inputmethod/KeySound.ets new file mode 100644 index 000000000..b9e57c97a --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/ets/inputmethod/KeySound.ets @@ -0,0 +1,40 @@ +import { media } from '@kit.MediaKit'; +import { audio } from '@kit.AudioKit'; +import { InputMethodExtensionContext } from '@kit.IMEKit'; + +// One small local sound, shared across key presses. No recording or network. +export class KeySound { + private pool?: media.SoundPool; + private sound: number = -1; + private loading?: Promise; + + play(context: InputMethodExtensionContext): void { + if (!this.loading) this.loading = this.load(context).catch(() => { + this.pool?.release().catch(() => {}); + this.pool = undefined; + this.sound = -1; + this.loading = undefined; + }); + this.loading.then(() => { + if (this.pool && this.sound >= 0) this.pool.play(this.sound, { leftVolume: 0.18, rightVolume: 0.18 }).catch(() => {}); + }).catch(() => {}); + } + + private async load(context: InputMethodExtensionContext): Promise { + const pool = await media.createSoundPool(2, { usage: audio.StreamUsage.STREAM_USAGE_MUSIC, rendererFlags: 0 }); + this.pool = pool; + let timer: number = -1; + const ready = new Promise((resolve) => { + pool.on('loadComplete', () => { clearTimeout(timer); resolve(); }); + timer = setTimeout(() => { this.sound = -1; resolve(); }, 1500); + }); + const fd = await context.resourceManager.getRawFd('ime_click.wav'); + try { + this.sound = await pool.load(fd.fd, fd.offset, fd.length); + await ready; + } finally { + clearTimeout(timer); + await context.resourceManager.closeRawFd('ime_click.wav'); + } + } +} diff --git a/openless-all/app/harmony/entry/src/main/ets/inputmethod/OpenLessInputMethod.ets b/openless-all/app/harmony/entry/src/main/ets/inputmethod/OpenLessInputMethod.ets new file mode 100644 index 000000000..5b3a3a5d2 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/ets/inputmethod/OpenLessInputMethod.ets @@ -0,0 +1,116 @@ +import { InputMethodExtensionAbility, inputMethodEngine } from '@kit.IMEKit'; +import { display } from '@kit.ArkUI'; +import { panelLayout } from './PanelLayout'; +import { imeStorage, inputSession } from './InputSession'; +import { initializeRime } from './RimeClient'; + +export default class OpenLessInputMethod extends InputMethodExtensionAbility { + private engine: inputMethodEngine.InputMethodAbility = inputMethodEngine.getInputMethodAbility(); + private delegate: inputMethodEngine.KeyboardDelegate = inputMethodEngine.getKeyboardDelegate(); + private onText = (text: string): void => { inputSession.observeText(text); }; + private onSelection = (_oldBegin: number, _oldEnd: number, begin: number, end: number): void => { + inputSession.observeSelection(begin, end); + }; + private panel?: inputMethodEngine.Panel; + private ready?: Promise; + private active: boolean = false; + private visible: boolean = false; + private onDisplayChange = (): void => { + if (this.active && this.panel) panelLayout.refresh(); + }; + + private onStart = (keyboard: inputMethodEngine.KeyboardController, client: inputMethodEngine.InputClient): void => { + inputSession.attach(this.context, keyboard, client); + console.info('[OpenLess IME] inputStart'); + }; + private onStop = (): void => { + inputSession.detach(); + this.visible = false; + this.panel?.hide().catch(() => {}); + console.info('[OpenLess IME] inputStop'); + }; + private onShow = (): void => { + this.visible = true; + this.ensurePanel().then(() => { + if (this.active && this.visible) { + return panelLayout.refresh().then(() => { + if (this.active && this.visible) return this.panel?.show(); + return undefined; + }); + } + return undefined; + }).catch(() => { imeStorage.setOrCreate('status', '键盘加载失败'); }); + }; + private onHide = (): void => { + inputSession.cancelVoice(); + this.visible = false; + this.panel?.hide().catch(() => {}); + }; + + onCreate(): void { + this.active = true; + this.delegate.on('textChange', this.onText); + this.delegate.on('selectionChange', this.onSelection); + this.engine.on('inputStart', this.onStart); + this.engine.on('inputStop', this.onStop); + this.engine.on('keyboardShow', this.onShow); + this.engine.on('keyboardHide', this.onHide); + display.on('change', this.onDisplayChange); + // Prepare dictionaries in parallel with panel creation and editor attributes. + // This creates no session and never enables learning or network queries. + initializeRime(this.context.filesDir, this.context.resourceManager, 'rime-user', this.context.cacheDir).catch(() => { + console.warn('[OpenLess IME] dictionary prewarm failed; retry on input'); + }); + console.info('[OpenLess IME] created'); + } + + private ensurePanel(): Promise { + if (this.ready) return this.ready; + this.ready = this.createPanel().catch(() => { + this.ready = undefined; + console.error('[OpenLess IME] panel creation failed'); + throw new Error('panel creation failed'); + }); + return this.ready; + } + + private async createPanel(): Promise { + const panel = await this.engine.createPanel(this.context, { + type: inputMethodEngine.PanelType.SOFT_KEYBOARD, + flag: inputMethodEngine.PanelFlag.FLG_FIXED + }); + if (!this.active) { + await this.engine.destroyPanel(panel); + return; + } + this.panel = panel; + try { + await panelLayout.attach(panel, this.context); + await panel.setUiContent('pages/InputMethodPanel', imeStorage); + console.info('[OpenLess IME] panel ready'); + } catch (_) { + this.panel = undefined; + panelLayout.detach(); + await this.engine.destroyPanel(panel); + throw new Error('panel content failed'); + } + } + + onDestroy(): void { + this.active = false; + this.delegate.off('textChange', this.onText); + this.delegate.off('selectionChange', this.onSelection); + this.visible = false; + this.engine.off('inputStart', this.onStart); + this.engine.off('inputStop', this.onStop); + this.engine.off('keyboardShow', this.onShow); + this.engine.off('keyboardHide', this.onHide); + display.off('change', this.onDisplayChange); + inputSession.detach(); + const panel = this.panel; + this.panel = undefined; + panelLayout.detach(); + if (panel) this.engine.destroyPanel(panel).catch(() => {}); + console.info('[OpenLess IME] destroyed'); + } +} diff --git a/openless-all/app/harmony/entry/src/main/ets/inputmethod/PanelLayout.ets b/openless-all/app/harmony/entry/src/main/ets/inputmethod/PanelLayout.ets new file mode 100644 index 000000000..7549eb13c --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/ets/inputmethod/PanelLayout.ets @@ -0,0 +1,157 @@ +import { inputMethodEngine, InputMethodExtensionContext } from '@kit.IMEKit'; +import { display } from '@kit.ArkUI'; +import { preferences } from '@kit.ArkData'; +import { imeStorage } from './InputSession'; + +// These are IME panel modes, not application overlay windows. +class PanelLayout { + private panel?: inputMethodEngine.Panel; + private settings?: preferences.Preferences; + private mode: string = 'standard'; + private scale: number = 1; + private x: number = 0.5; + private y: number = 0.7; + private width: number = 0; + private height: number = 0; + private queue: Promise = Promise.resolve(); + private dragX: number = 0; + private dragY: number = 0; + private nextX: number = 0; + private nextY: number = 0; + private moving: boolean = false; + private geometry: string = ''; + private flag: inputMethodEngine.PanelFlag = inputMethodEngine.PanelFlag.FLG_FIXED; + + async attach(panel: inputMethodEngine.Panel, context: InputMethodExtensionContext): Promise { + this.panel = panel; + this.geometry = ''; + this.flag = inputMethodEngine.PanelFlag.FLG_FIXED; + try { + this.settings = await preferences.getPreferences(context, { name: 'ime_panel_layout' }); + const saved = await this.settings.get('mode', 'standard'); + this.mode = saved === 'split' || saved === 'floating' || saved === 'onehand' ? saved : 'standard'; + this.scale = this.clamp(Number(await this.settings.get('scale', 1)), 0.8, 1.2); + this.x = this.clamp(Number(await this.settings.get('x', 0.5)), 0, 1); + this.y = this.clamp(Number(await this.settings.get('y', 0.7)), 0, 1); + } catch (_) { console.warn('[OpenLess IME] layout preferences unavailable'); } + try { await this.apply(); } + catch (_) { this.mode = 'standard'; await this.apply(); } + } + + detach(): void { this.panel = undefined; } + + private clamp(value: number, min: number, max: number): number { + return Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : min; + } + + private async apply(): Promise { + const panel = this.panel; + if (!panel) return; + const screen = display.getDefaultDisplaySync(); + const density = screen.densityPixels; + const floating = this.mode === 'floating'; + const geometry = screen.width + ':' + screen.height + ':' + density + ':' + this.mode + ':' + this.scale; + // A resize can itself emit display/keyboard notifications. Do not resize + // again unless the screen or requested layout actually changed. + if (this.geometry === geometry) return; + this.geometry = ''; + // Keep all five rows visible in landscape as well as portrait. + const requestedHeight = floating ? Math.round(304 * this.scale) : 328; + const maxPanelVp = Math.min(requestedHeight, Math.floor(screen.height * 0.65 / density)); + const showNumberRow = !floating && maxPanelVp >= 300; + const rows = showNumberRow ? 5 : 4; + this.width = floating ? Math.round(Math.min(screen.width * 0.9, 400 * this.scale * density)) : screen.width; + const split = this.mode === 'split'; + const gap = this.mode === 'standard' ? 7 : 5; + const columns = split ? 12 : 10; + const gaps = split ? 10 : 9; + // Fill the panel width, but keep a compact row height. On a wide tablet + // this deliberately produces wider keys instead of a taller keyboard. + const keyboardWidth = this.mode === 'onehand' ? Math.min(420, this.width / density * 0.72) - 12 : this.width / density - 12; + const keyWidth = (keyboardWidth - gaps * gap) / columns; + const keyHeight = Math.max(12, Math.floor(Math.min(floating ? keyWidth : maxPanelVp, + (maxPanelVp - 66 - (floating ? 26 : 0) - (rows - 1) * 7) / rows))); + const fittedHeight = rows * keyHeight + (rows - 1) * 7 + 66 + (floating ? 26 : 0); + this.height = Math.round(Math.min(maxPanelVp, fittedHeight) * density); + const flag = floating ? inputMethodEngine.PanelFlag.FLG_FLOATING : inputMethodEngine.PanelFlag.FLG_FIXED; + if (flag !== this.flag) { panel.changeFlag(flag); this.flag = flag; } + await panel.resize(this.width, this.height); + if (floating) await panel.moveTo(Math.round(this.x * (screen.width - this.width)), + Math.round(this.y * (screen.height - this.height))); + if (this.panel !== panel) return; + this.geometry = geometry; + imeStorage.setOrCreate('keyboardLayout', this.mode); + imeStorage.setOrCreate('keyHeight', keyHeight); + imeStorage.setOrCreate('keyWidth', keyWidth); + imeStorage.setOrCreate('keyboardWidth', keyboardWidth); + imeStorage.setOrCreate('showNumberRow', showNumberRow); + console.info('[OpenLess IME] layout=' + this.mode + ' width=' + this.width + ' height=' + this.height); + } + + refresh(): Promise { + this.queue = this.queue.then(() => this.apply()).catch(() => { + imeStorage.setOrCreate('status', '键盘尺寸调整失败,请重新选择布局'); + }); + return this.queue; + } + + select(mode: string): void { + if (!['standard', 'split', 'floating', 'onehand'].includes(mode)) return; + console.info('[OpenLess IME] layout requested=' + mode + ' panel=' + !!this.panel); + this.queue = this.queue.then(async (): Promise => { + const previous = this.mode; + this.mode = mode; + try { await this.apply(); } + catch (error) { + console.error('[OpenLess IME] layout failed: ' + JSON.stringify(error)); + this.mode = previous; await this.apply(); throw new Error('layout failed'); + } + await this.save(); + }).catch(() => { imeStorage.setOrCreate('status', '布局切换失败,请重试'); }); + } + + resize(delta: number): void { + this.queue = this.queue.then(async (): Promise => { + if (this.mode !== 'floating') return; + this.scale = this.clamp(this.scale + delta, 0.8, 1.2); + await this.apply(); + await this.save(); + }).catch(() => { imeStorage.setOrCreate('status', '悬浮键盘调整失败'); }); + } + + beginMove(): void { this.dragX = this.x; this.dragY = this.y; } + + drag(dx: number, dy: number): void { + if (this.mode !== 'floating' || !this.panel) return; + const screen = display.getDefaultDisplaySync(); + this.nextX = this.clamp(this.dragX + dx * screen.densityPixels / Math.max(1, screen.width - this.width), 0, 1); + this.nextY = this.clamp(this.dragY + dy * screen.densityPixels / Math.max(1, screen.height - this.height), 0, 1); + if (this.moving) return; + this.moving = true; + this.queue = this.queue.then(async (): Promise => { + try { + while (this.mode === 'floating' && this.panel) { + this.x = this.nextX; this.y = this.nextY; + await this.panel.moveTo(Math.round(this.x * (screen.width - this.width)), + Math.round(this.y * (screen.height - this.height))); + if (this.x === this.nextX && this.y === this.nextY) break; + } + } finally { this.moving = false; } + }).catch(() => { this.moving = false; }); + } + + endMove(): void { this.queue = this.queue.then(() => this.save()).catch(() => {}); } + + private async save(): Promise { + try { + if (!this.settings) return; + await this.settings.put('mode', this.mode); + await this.settings.put('scale', this.scale); + await this.settings.put('x', this.x); + await this.settings.put('y', this.y); + await this.settings.flush(); + } catch (_) { console.warn('[OpenLess IME] layout preferences not saved'); } + } +} + +export const panelLayout: PanelLayout = new PanelLayout(); diff --git a/openless-all/app/harmony/entry/src/main/ets/inputmethod/RimeClient.ets b/openless-all/app/harmony/entry/src/main/ets/inputmethod/RimeClient.ets new file mode 100644 index 000000000..df0d4b24f --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/ets/inputmethod/RimeClient.ets @@ -0,0 +1,132 @@ +import { resourceManager } from '@kit.LocalizationKit'; +import { fileIo as fs } from '@kit.CoreFileKit'; +import nativeHost from 'libopenless_harmony.so'; +import { util } from '@kit.ArkTS'; + +export interface RimeState { + ok: boolean; + error?: string; + session: string; + revision: number; + input: string; + preedit: string; + candidates: string[]; + commit: string; + page: number; + hasNext: boolean; + candidateOffset?: number; + candidateHasNext?: boolean; +} +let initialization: Promise | undefined = undefined; +let validated: boolean = false; +let publicRefresh: Promise | undefined = undefined; +let nextPublicCheck: number = 0; +let publicInfo: PublicDictionaryInfo | undefined = undefined; + +export interface PublicDictionaryInfo { data_path: string; revision: string; pending: boolean; checked: number; } +interface DictionaryReply { ok: boolean; value?: PublicDictionaryInfo; } + +async function dictionaryOperation(filesDir: string, cacheDir: string, command: string, force: boolean = false): Promise { + if (!cacheDir) return undefined; + const initialized: CloudReply = JSON.parse(nativeHost.initBackend(filesDir + '/openless', cacheDir + '/openless')); + if (!initialized.ok) return undefined; + const reply: DictionaryReply = JSON.parse(await nativeHost.invokeAsync(command, JSON.stringify({ force: force }))); + return reply.ok ? reply.value : undefined; +} + +export function refreshPublicDictionary(filesDir: string, cacheDir: string, force: boolean = false): Promise { + if (publicRefresh) return publicRefresh; + if (!force && Date.now() < nextPublicCheck) return Promise.resolve(publicInfo); + publicRefresh = dictionaryOperation(filesDir, cacheDir, 'ime_dictionary_update', force).then((info: PublicDictionaryInfo | undefined) => { + publicInfo = info; return info; + }).finally(() => { nextPublicCheck = Date.now() + 86400000; publicRefresh = undefined; }); + return publicRefresh; +} + +export async function rimeRequest(operation: string, session: string = '', argument: string = ''): Promise { + const state: RimeState = JSON.parse(await nativeHost.rimeAsync(operation, session, argument)); + if (!state.ok) throw new Error(state.error || 'Rime 请求失败'); + return state; +} + +interface CloudReply { ok: boolean; value?: string[]; } +export async function cloudCandidates(filesDir: string, cacheDir: string, pinyin: string): Promise { + const initialized: CloudReply = JSON.parse(nativeHost.initBackend(filesDir + '/openless', cacheDir + '/openless')); + if (!initialized.ok) return []; + const reply: CloudReply = JSON.parse(await nativeHost.invokeAsync('ime_cloud_candidates', JSON.stringify({ pinyin: pinyin }))); + return reply.ok ? reply.value || [] : []; +} + +export function initializeRime(filesDir: string, resources: resourceManager.ResourceManager, profile: string = 'rime-user', cacheDir: string = ''): Promise { + if (initialization) return initialization; + initialization = (async (): Promise => { + const shared = filesDir + '/rime-shared-1.17.0-ice-da1fbe60-v1'; + if (!fs.accessSync(shared)) fs.mkdirSync(shared); + const decoder = util.TextDecoder.create('utf-8'); + const files: string[] = JSON.parse(decoder.decodeToString(await resources.getRawFileContent('rime/manifest.json'))); + for (const name of files) { + if (name.includes('/') || name.includes('..')) throw new Error('Rime 资源名称无效'); + const path = shared + '/' + name; + if (fs.accessSync(path)) continue; + const bytes = await resources.getRawFileContent('rime/' + name); + const file = await fs.open(path + '.tmp', fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY | fs.OpenMode.TRUNC); + try { + let offset = 0; + while (offset < bytes.byteLength) { + const chunk = bytes.buffer.slice(bytes.byteOffset + offset, + bytes.byteOffset + Math.min(bytes.byteLength, offset + 262144)); + const written = await fs.write(file.fd, chunk); + if (written <= 0 || written > chunk.byteLength) throw new Error('Rime 资源写入不完整'); + offset += written; + } + } finally { await fs.close(file); } + await fs.rename(path + '.tmp', path); + } + const selected = await dictionaryOperation(filesDir, cacheDir, 'ime_dictionary_select').catch(() => undefined); + let data = selected?.data_path || shared; + if (data !== shared && !data.startsWith(shared + '/public-updates-v1/')) data = shared; + for (let attempt = 0; attempt < 3; attempt++) { + try { await rimeRequest('initialize', data, filesDir + '/' + profile); break; } + catch (error) { + if (data === shared) throw new Error((error as Error).message || 'Rime 字典加载失败'); + const fallback = await dictionaryOperation(filesDir, cacheDir, 'ime_dictionary_reject').catch(() => undefined); + data = attempt === 1 ? shared : fallback?.data_path || shared; + if (data !== shared && !data.startsWith(shared + '/public-updates-v1/')) data = shared; + } + } + console.info('[OpenLess Rime] initialized'); + })().catch((error: Error) => { initialization = undefined; throw error; }); + return initialization; +} + +export async function testRime(filesDir: string, resources: resourceManager.ResourceManager, cacheDir: string = ''): Promise { + if (validated) return true; + // The private schema used below cannot learn. Keep initialization on the + // real profile so a main-window probe cannot redirect future IME sessions. + await initializeRime(filesDir, resources, 'rime-user', cacheDir); + let state = await rimeRequest('create', '', 'private'); + const id = state.session; + try { + for (const key of 'nihao') state = await rimeRequest('key', id, key.charCodeAt(0).toString()); + if (!state.candidates.includes('你好')) throw new Error('Rime 中文候选自检失败'); + const revision = state.revision; + state = await rimeRequest('key', id, '65288'); + if (state.input !== 'niha') throw new Error('Rime 编码退格自检失败'); + let rejected: boolean = false; + try { await rimeRequest('select', id, '0@' + revision.toString()); } catch (_) { rejected = true; } + if (!rejected) throw new Error('Rime 过期候选自检失败'); + state = await rimeRequest('key', id, '111'); + state = await rimeRequest('select', id, state.candidates.indexOf('你好').toString() + '@' + state.revision.toString()); + if (state.commit !== '你好') throw new Error('Rime 中文确认自检失败'); + await rimeRequest('ack', id); + state = await rimeRequest('clear', id); + if (state.input || state.commit) throw new Error('Rime 取消自检失败'); + for (const key of 'nihao') state = await rimeRequest('key', id, key.charCodeAt(0).toString()); + state = await rimeRequest('key', id, '32'); + if (state.commit !== '你好') throw new Error('Rime 空格确认自检失败'); + await rimeRequest('ack', id); + console.info('[OpenLess Rime] candidate/backspace/commit/cancel/stale/space test passed'); + validated = true; + return true; + } finally { await rimeRequest('destroy', id); } +} diff --git a/openless-all/app/harmony/entry/src/main/ets/inputmethod/SymbolGesture.ets b/openless-all/app/harmony/entry/src/main/ets/inputmethod/SymbolGesture.ets new file mode 100644 index 000000000..81bf36044 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/ets/inputmethod/SymbolGesture.ets @@ -0,0 +1,120 @@ +class SymbolPress { + context: string; + symbol: string = ''; + consumed: boolean = false; + armed: boolean = false; + mode: string = ''; + x: number = 0; + y: number = 0; + moved: boolean = false; + constructor(context: string) { this.context = context; } +} + +// Touch and recognizer callbacks can both finish a press. Consume it once, +// and never carry a held key into another editor, language or layout. +export class SymbolGesture { + private presses: Map = new Map(); + begin(key: string, context: string, x: number = 0, y: number = 0): void { + const press = new SymbolPress(context); press.x = x; press.y = y; + this.presses.set(key, press); + } + moveTouch(key: string, x: number, y: number): boolean { + const press = this.presses.get(key); + if (!press) return false; + if ((x - press.x) * (x - press.x) + (y - press.y) * (y - press.y) <= 100) return false; + press.moved = true; + if (press.mode !== 'hold') return false; + press.armed = false; press.symbol = ''; press.consumed = true; + return true; + } + arm(key: string, symbol: string, context: string, mode: string, swipeEnabled: boolean = true): boolean { + const press = this.presses.get(key); + if (!press || press.context !== context || (mode === 'swipe' && !swipeEnabled) || (mode === 'hold' && press.moved)) return false; + press.consumed = true; + press.symbol = symbol; + press.mode = mode; + press.armed = symbol.length > 0; + return press.armed; + } + move(key: string, x: number, y: number, distance: number): boolean { + const press = this.presses.get(key); + if (!press || !press.consumed || press.mode !== 'swipe') return false; + press.armed = press.symbol.length > 0 && y <= -distance && Math.abs(x) <= -y * 0.55; + return press.armed; + } + finish(key: string, context: string, mode: string, swipeEnabled: boolean = true): string { + const press = this.presses.get(key); + if (!press || press.mode !== mode) return ''; + const symbol = press.context === context && press.armed && (mode !== 'swipe' || swipeEnabled) ? press.symbol : ''; + press.armed = false; + press.symbol = ''; + return symbol; + } + tap(key: string, context: string): boolean { + const press = this.presses.get(key); + if (!press || press.context !== context || press.consumed) return false; + press.consumed = true; + return true; + } + cancel(key: string, mode: string = ''): boolean { + const press = this.presses.get(key); + if (!press || (mode && press.mode !== mode)) return false; + press.armed = false; press.symbol = ''; press.consumed = true; + return true; + } +} + +export function letterSymbol(row: number, position: number, split: boolean, chinese: boolean): string { + const hints: string[][] = chinese ? [ + ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], + ['~', '@', '#', '¥', '%', '&', '*', '(', ')'], + [',', '。', '?', '!', ':', ';', "'"] + ] : [ + ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], + ['~', '@', '#', '$', '%', '&', '*', '(', ')'], + [',', '.', '?', '!', ':', ';', "'"] + ]; + const duplicated = split && ((row === 1 && position >= 5) || (row === 2 && position >= 4)); + if (row < 0 || row >= hints.length) return ''; + return hints[row][duplicated ? position - 1 : position] || ''; +} + +export function digitSymbol(digit: string, chinese: boolean): string { + const index = '1234567890'.indexOf(digit); + const symbols: string[] = chinese ? ['!', '@', '#', '¥', '%', '……', '&', '*', '(', ')'] : + ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')']; + return index >= 0 ? symbols[index] : ''; +} + +export function symbolRows(page: number, chinese: boolean): string[][] { + if (page === 1) return [ + [chinese ? '¥' : '$', '€', '£', '¥', '℃', '°', '×', '÷', '√', 'π'], + chinese ? ['【', '】', '{', '}', '《', '》', '=', '—', '~'] : ['[', ']', '{', '}', '<', '>', '=', '_', '~'], + chinese ? ['……', '——', '、', '·', '|', '\\', '^'] : ['...', '--', ',', '·', '|', '\\', '^'] + ]; + return chinese ? [ + [',', '。', '?', '!', ':', ';', '、', '…', '—', '·'], + ['(', ')', '【', '】', '《', '》', '“', '”', '¥'], + ['@', '#', '%', '&', '*', '+', '/'] + ] : [ + [',', '.', '?', '!', ':', ';', "'", '"', '-', '_'], + ['(', ')', '[', ']', '{', '}', '<', '>', '$'], + ['@', '#', '%', '&', '*', '+', '/'] + ]; +} + +// Builder event closures can retain their initial label. Resolve punctuation +// against the live language both while rendering and when dispatching a press. +export function localizedSymbol(text: string, chinese: boolean): string { + const english: string[] = [',', '.', '?', '!', ':', ';', '(', ')', '[', ']', '{', '}', '<', '>', '$', '~', '...', '--', '-', '_', '"']; + const full: string[] = [',', '。', '?', '!', ':', ';', '(', ')', '【', '】', '{', '}', '《', '》', '¥', '~', '……', '——', '—', '_', '“']; + const index = (chinese ? english : full).indexOf(text); + if (index >= 0) return (chinese ? full : english)[index]; + if (!chinese && text === '”') return '"'; + if (!chinese && text === '…') return '...'; + return text; +} + +export function liveSymbolHint(id: string, hint: string, chinese: boolean): string { + return id.startsWith('digit:') ? digitSymbol(id.slice(6), chinese) : localizedSymbol(hint, chinese); +} diff --git a/openless-all/app/harmony/entry/src/main/ets/inputmethod/VoiceClient.ets b/openless-all/app/harmony/entry/src/main/ets/inputmethod/VoiceClient.ets new file mode 100644 index 000000000..908d1e208 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/ets/inputmethod/VoiceClient.ets @@ -0,0 +1,208 @@ +import { abilityAccessCtrl, common } from '@kit.AbilityKit'; +import { audio } from '@kit.AudioKit'; +import nativeHost from 'libopenless_harmony.so'; +import { restoreHarmonyCredentials } from '../CredentialVault'; +import { restoreVoiceConfiguration } from '../VoiceConfiguration'; + +interface NativeReply { ok: boolean; error?: string; } +interface StartReply extends NativeReply { value: string; } +export interface VoiceResult { rawText: string; polishedText: string; } +interface StopReply extends NativeReply { value: VoiceResult; } +interface VoiceSettings { microphoneDeviceName: string; } +interface SettingsReply extends NativeReply { value: VoiceSettings; } +interface CredentialStatus { pipelineMode: string; asrConfigured: boolean; omniConfigured: boolean; } +interface CredentialReply extends NativeReply { value: CredentialStatus; } + +class VoiceRun { + session: string = ''; + capturer?: audio.AudioCapturer; + cancelled: boolean = false; + accepting: boolean = false; + pendingBytes: number = 0; + delivery: Promise = Promise.resolve(); + startup: Promise = Promise.resolve(); + failure: string = ''; + stage: string = 'permission'; +} + +function requireSuccess(wire: string): void { + const reply: NativeReply = JSON.parse(wire); + if (!reply.ok) throw new Error(reply.error || '语音后端调用失败'); +} + +// AudioKit owns capture; openless-core owns sessions, providers and history. +// Every run retains its own delivery queue, even when cancellation races stop. +export class VoiceClient { + private readonly fromKeyboard: boolean; + constructor(fromKeyboard: boolean = false) { this.fromKeyboard = fromKeyboard; } + private run?: VoiceRun; + private cleanup: Promise = Promise.resolve(); + + async setPolishMode(context: common.Context, mode: string): Promise { + requireSuccess(nativeHost.initBackend(context.filesDir + '/openless', context.cacheDir + '/openless')); + if (this.fromKeyboard) await restoreVoiceConfiguration(); + requireSuccess(await nativeHost.invokeAsync('set_default_polish_mode', JSON.stringify({ mode: mode }))); + } + + async start(context: common.Context, level: (value: number) => void, + failed: (message: string) => void): Promise { + if (this.run) throw new Error('录音尚未结束'); + const run = new VoiceRun(); + this.run = run; + run.startup = this.begin(run, context, level, failed); + try { await run.startup; } catch (error) { + console.warn('[OpenLess Voice] startup failed stage=' + run.stage); + if (this.run === run) this.run = undefined; + try { await this.dispose(run, true); } finally { throw new Error((error as Error).message); } + } + } + + private check(run: VoiceRun): void { + if (run.cancelled || this.run !== run) throw new Error('录音已取消'); + } + + private async begin(run: VoiceRun, context: common.Context, + level: (value: number) => void, failed: (message: string) => void): Promise { + await this.cleanup; + this.check(run); + const token = context.applicationInfo.accessTokenId; + if (token === undefined || abilityAccessCtrl.createAtManager().checkAccessTokenSync(token, + 'ohos.permission.MICROPHONE') !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) { + throw new Error('请打开 OpenLess 设置,在权限与数据中授权麦克风'); + } + run.stage = 'backend_init'; + requireSuccess(nativeHost.initBackend(context.filesDir + '/openless', context.cacheDir + '/openless')); + console.info('[OpenLess Voice] backend dataDir=' + context.filesDir + '/openless'); + run.stage = 'configuration_restore'; + if (this.fromKeyboard) await restoreVoiceConfiguration(); + this.check(run); + run.stage = 'backend_reload'; + requireSuccess(await nativeHost.invokeAsync('__reload_voice_backend', '{}')); + this.check(run); + run.stage = 'credential_restore'; + const restored = await restoreHarmonyCredentials(); + this.check(run); + run.stage = 'credential_status'; + const status: CredentialReply = JSON.parse(await nativeHost.invokeAsync('get_credentials', '{}')); + if (status.ok) { + console.info('[OpenLess Voice] restored=' + restored + ' mode=' + status.value.pipelineMode + + ' asrConfigured=' + status.value.asrConfigured + ' omniConfigured=' + status.value.omniConfigured); + } else { console.warn('[OpenLess Voice] credential status unavailable'); } + run.stage = 'settings'; + const settings: SettingsReply = JSON.parse(await nativeHost.invokeAsync('get_settings', '{}')); + if (!settings.ok) throw new Error(settings.error || '麦克风设置读取失败'); + const name = settings.value.microphoneDeviceName; + if (name) { + const routing = audio.getAudioManager().getRoutingManager(); + const selected = routing.getDevicesSync(audio.DeviceFlag.INPUT_DEVICES_FLAG) + .filter((device: audio.AudioDeviceDescriptor) => (device.displayName || device.name || ('麦克风 ' + device.id)) === name); + if (!selected.length) throw new Error('选中的麦克风已断开,请在设置中选择系统默认'); + const preferred = routing.getPreferredInputDeviceForCapturerInfoSync({source: audio.SourceType.SOURCE_TYPE_MIC, capturerFlags: 0}); + if (!preferred.some((device: audio.AudioDeviceDescriptor) => device.id === selected[0].id)) { + throw new Error('当前系统使用其他麦克风,请切换系统输入设备或选择系统默认'); + } + } + this.check(run); + run.stage = 'session_start'; + const reply: StartReply = JSON.parse(await nativeHost.invokeAsync('start_dictation', '{}')); + if (!reply.ok) throw new Error(reply.error || '请在设置中配置可用的语音识别渠道'); + run.session = reply.value; + this.check(run); + run.stage = 'capturer_create'; + const capturer = await audio.createAudioCapturer({ + streamInfo: { samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_16000, + channels: audio.AudioChannel.CHANNEL_1, sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE, + encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW }, + capturerInfo: { source: audio.SourceType.SOURCE_TYPE_MIC, capturerFlags: 0 } + }); + run.capturer = capturer; + this.check(run); + run.accepting = true; + capturer.on('readData', (buffer: ArrayBuffer) => { + if (!run.accepting || run.cancelled) return; + if (buffer.byteLength % 2 !== 0 || run.pendingBytes + buffer.byteLength > 65536) { + run.failure = '音频传输未能及时完成,请重新录音'; + run.accepting = false; + failed(run.failure); + this.cancel().catch(() => {}); + return; + } + const samples = new Int16Array(buffer); + let sum = 0; + for (let i = 0; i < samples.length; i++) sum += samples[i] * samples[i]; + level(samples.length ? Math.min(1, Math.sqrt(sum / samples.length) / 8192) : 0); + // Copy AudioKit's callback buffer before returning ownership to AudioKit. + for (let offset = 0; offset < buffer.byteLength; offset += 32768) { + const frame = buffer.slice(offset, Math.min(offset + 32768, buffer.byteLength)); + run.pendingBytes += frame.byteLength; + run.delivery = run.delivery.then(async (): Promise => { + if (!run.cancelled && !run.failure) requireSuccess(await nativeHost.feedPcm(run.session, frame)); + }).catch(() => { + if (!run.cancelled && !run.failure) { + run.failure = '音频传输失败,请重新录音'; + failed(run.failure); + this.cancel().catch(() => {}); + } + }).then(() => { run.pendingBytes -= frame.byteLength; }); + } + }); + run.stage = 'capturer_start'; + await capturer.start(); + this.check(run); + run.stage = 'recording'; + console.info('[OpenLess Voice] recording started'); + } + + private async release(run: VoiceRun): Promise { + const capturer = run.capturer; + run.capturer = undefined; + if (capturer) { + try { if (capturer.state === audio.AudioState.STATE_RUNNING) await capturer.stop(); } finally { + run.accepting = false; + try { capturer.off('readData'); } finally { await capturer.release(); } + } + } + run.accepting = false; + } + + private async dispose(run: VoiceRun, cancel: boolean): Promise { + try { await this.release(run); } finally { + await run.delivery; + if (cancel && run.session) { + requireSuccess(await nativeHost.invokeAsync('cancel_dictation', JSON.stringify({session: run.session}))); + } + } + } + + async stop(): Promise { + const run = this.run; + if (!run) throw new Error('录音已结束'); + try { + await run.startup; + this.check(run); + await this.dispose(run, false); + this.check(run); + if (run.failure) throw new Error(run.failure); + const reply: StopReply = JSON.parse(await nativeHost.invokeAsync('stop_dictation', JSON.stringify({session: run.session}))); + this.check(run); + if (!reply.ok) throw new Error(reply.error || '识别失败,请检查渠道设置'); + return reply.value; + } catch (error) { + try { if (!run.cancelled) await this.dispose(run, true); } + finally { throw new Error((error as Error).message); } + } finally { if (this.run === run) this.run = undefined; } + } + + async cancel(): Promise { + const run = this.run; + if (!run) return; + run.cancelled = true; + run.accepting = false; + this.run = undefined; + const cleanup = run.startup.catch(() => {}).then(async (): Promise => { await this.dispose(run, true); }); + // Wait for release without permanently poisoning starts after a system + // capture error. Core still rejects a genuinely busy recording. + this.cleanup = cleanup.catch(() => {}); + await cleanup; + } +} diff --git a/openless-all/app/harmony/entry/src/main/ets/pages/Index.ets b/openless-all/app/harmony/entry/src/main/ets/pages/Index.ets new file mode 100644 index 000000000..ed1ab10b0 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/ets/pages/Index.ets @@ -0,0 +1,93 @@ +import { webview } from '@kit.ArkWeb'; +import { common } from '@kit.AbilityKit'; +import nativeHost from 'libopenless_harmony.so'; + +import { HarmonyApi } from '../HarmonyApi'; + +@Entry +@Component +struct Index { + private controller: webview.WebviewController = new webview.WebviewController(); + + private api: HarmonyApi = new HarmonyApi(); + @State private backendReady: boolean = false; + @State private startupError: string = ''; + + aboutToAppear(): void { + try { + const context = this.getUIContext().getHostContext() as common.UIAbilityContext; + console.info('[OpenLess Native] backend dataDir=' + context.filesDir + '/openless'); + this.api.configure(context, this.controller); + const result = nativeHost.initBackend(context.filesDir + '/openless', context.cacheDir + '/openless'); + const response: Record = JSON.parse(result); + if (response['ok'] !== true) { + this.startupError = String(response['error']); + } else { + this.api.restoreCredentials().then(() => { + this.backendReady = true; + console.info('[OpenLess Native] Rust core and secure vault ready'); + }).catch((error: Error) => { this.startupError = String(error); }); + } + } catch (error) { + this.startupError = String(error); + } + } + + aboutToDisappear(): void { + this.api.release().catch(() => {}); + } + + onPageHide(): void { + this.api.release().catch(() => {}); + } + + build() { + Column() { + if (this.startupError) { + Text('后端启动失败:' + this.startupError).padding(24) + } else if (!this.backendReady) { + Text('正在启动后端…').padding(24) + } else { + Web({ src: $rawfile('web/index.html'), controller: this.controller }) + .javaScriptProxy({ object: this.api, name: 'OpenLessHarmonyApi', methodList: ['invoke', 'request', 'takeResult'], controller: this.controller }) + .javaScriptAccess(true) + .domStorageAccess(true) + .width('100%') + .height('100%') + .onLoadIntercept((event) => { + if (event && event.data.isMainFrame()) { + const url = event.data.getRequestUrl(); + if (/^https?:\/\//i.test(url)) { + this.api.openExternal(url).catch((error: Error) => { console.error('[OpenLess External] ' + String(error)); }); + return true; + } + } + return false; + }) + .onWindowNew((event) => { + if (event) this.api.openExternal(event.targetUrl).catch((error: Error) => { console.error('[OpenLess External] ' + String(error)); }); + }) + .onPageEnd(() => { + this.controller.runJavaScript("JSON.stringify({url:location.href,root:!!document.getElementById('root'),children:document.getElementById('root')?.childElementCount,scripts:Array.from(document.scripts).map(s=>s.src)})") + .then((result: string) => { + console.error('[OpenLess Page] ' + result); + }); + }) + .onErrorReceive((event) => { + if (event) { + console.error('[OpenLess Load] ' + event.request.getRequestUrl() + ' ' + event.error.getErrorInfo()); + } + }) + .onConsole((event) => { + if (event) { + this.api.appendLog(event.message.getMessage()); + console.info('[OpenLess Web] ' + event.message.getMessage()); + } + return false; + }) + } + } + .width('100%') + .height('100%') + } +} diff --git a/openless-all/app/harmony/entry/src/main/ets/pages/InputMethodPanel.ets b/openless-all/app/harmony/entry/src/main/ets/pages/InputMethodPanel.ets new file mode 100644 index 000000000..2ae75bc9e --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/ets/pages/InputMethodPanel.ets @@ -0,0 +1,997 @@ +import { inputMethod } from '@kit.IMEKit'; +import { imeStorage, inputSession } from '../inputmethod/InputSession'; +import { panelLayout } from '../inputmethod/PanelLayout'; +import { SymbolGesture, letterSymbol, digitSymbol, symbolRows, localizedSymbol, liveSymbolHint } from '../inputmethod/SymbolGesture'; +import { ClipboardEntry } from '../inputmethod/ClipboardHistory'; + +const BACKGROUND: Resource = $r('app.color.ime_panel_background'); +const KEY: Resource = $r('app.color.ime_key_background'); +const FUNCTION_KEY: Resource = $r('app.color.ime_function_background'); +const INK: Resource = $r('app.color.ime_foreground'); +const MUTED: Resource = $r('app.color.ime_secondary'); +const ACCENT: Resource = $r('app.color.ime_accent'); +const KEY_SHADOW: Resource = $r('app.color.ime_key_shadow'); + +@Entry(imeStorage) +@Component +struct InputMethodPanel { + @LocalStorageLink('connected') connected: boolean = false; + @LocalStorageLink('status') status: string = '等待输入框'; + @LocalStorageLink('enterLabel') enterLabel: string = '换行'; + @LocalStorageLink('chinese') chinese: boolean = true; + @LocalStorageLink('preedit') @Watch('resetCandidateScroll') preedit: string = ''; + @LocalStorageLink('candidates') candidates: string[] = []; + @LocalStorageLink('candidatesLoading') candidatesLoading: boolean = false; + @LocalStorageLink('toolbarActions') toolbarActions: string[] = ['layout', 'emoji', 'voice', 'settings']; + @LocalStorageLink('keyFontSize') keyFontSize: number = 22; + @LocalStorageLink('swipeSymbols') swipeSymbols: boolean = true; + @LocalStorageLink('keySound') keySound: boolean = false; + @LocalStorageLink('clipboardItems') clipboardItems: ClipboardEntry[] = []; + @LocalStorageLink('clipboardStatus') clipboardStatus: string = ''; + @LocalStorageLink('clipboardAutoRead') clipboardAutoRead: boolean = false; + @LocalStorageLink('cloudEnabled') cloudEnabled: boolean = false; + @LocalStorageLink('symbolFeedback') symbolFeedback: string = ''; + @LocalStorageLink('dictionaryStatus') dictionaryStatus: string = '公共词库:自动更新'; + @LocalStorageLink('dictionaryAuto') dictionaryAuto: boolean = true; + @LocalStorageLink('rimeReady') rimeReady: boolean = false; + @LocalStorageLink('page') page: number = 0; + @LocalStorageLink('hasNext') hasNext: boolean = false; + @LocalStorageLink('password') password: boolean = false; + @LocalStorageLink('editorVerified') editorVerified: boolean = false; + @LocalStorageLink('voicePhase') voicePhase: string = 'idle'; + @LocalStorageLink('voiceHolding') voiceHolding: boolean = false; + @LocalStorageLink('voiceText') voiceText: string = ''; + @LocalStorageLink('voiceLevel') voiceLevel: number = 0; + @LocalStorageLink('pendingCommit') pendingCommit: boolean = false; + @LocalStorageLink('writeInFlight') writeInFlight: boolean = false; + @LocalStorageLink('commitNeedsConfirmation') commitNeedsConfirmation: boolean = false; + @LocalStorageLink('keyboardLayout') keyboardLayout: string = 'standard'; + @LocalStorageLink('keyHeight') keyHeight: number = 52; + @LocalStorageLink('keyWidth') keyWidth: number = 52; + @LocalStorageLink('keyboardWidth') keyboardWidth: number = 574; + @LocalStorageLink('showNumberRow') showNumberRow: boolean = true; + @State uppercase: boolean = false; + @State capsLock: boolean = false; + @State layer: number = 0; + @State utility: number = 0; + @State toolTab: number = 0; + @State toolPosition: number = 0; + @State shortcutSlot: number = 0; + @State expanded: boolean = false; + @State symbolPage: number = 0; + @State previewId: string = ''; + @State previewSymbol: string = ''; + @State previewArmed: boolean = false; + private symbolGesture: SymbolGesture = new SymbolGesture(); + private toolSwiper: SwiperController = new SwiperController(); + private candidateScroller: Scroller = new Scroller(); + private expandedScroller: Scroller = new Scroller(); + + private resetCandidateScroll(): void { + this.candidateScroller.scrollTo({ xOffset: 0, yOffset: 0 }); + this.expandedScroller.scrollTo({ xOffset: 0, yOffset: 0 }); + } + + @Styles + keySurface() { + .borderRadius(10) + .shadow({ radius: 1.25, color: KEY_SHADOW, offsetX: 0, offsetY: 2 }) + } + + @Styles + pressMotion() { + .stateStyles({ + normal: { .scale({ x: 1, y: 1 }).opacity(1) }, + pressed: { .scale({ x: 0.94, y: 0.94 }).opacity(0.78) } + }) + .animation({ duration: 90, curve: Curve.EaseOut }) + } + + private row(index: number): string[] { + if (this.layer === 2) { + return symbolRows(this.symbolPage, this.chinese)[index]; + } + const letters: string[] = this.keyboardLayout === 'split' ? + ['qwertyuiop', 'asdfgghjkl', 'zxcvvbnm'] : ['qwertyuiop', 'asdfghjkl', 'zxcvbnm']; + return (!this.chinese && this.uppercase ? letters[index].toUpperCase() : letters[index]).split(''); + } + + private hint(index: number, position: number): string { + if (this.layer !== 0) return ''; + return letterSymbol(index, position, this.keyboardLayout === 'split', this.chinese); + } + + private keyContext(): string { + return inputSession.bindingVersion().toString() + ':' + this.chinese + ':' + this.layer + ':' + this.symbolPage + ':' + this.keyboardLayout + ':' + this.utility + ':' + this.liveSwipeEnabled(); + } + + private liveSwipeEnabled(): boolean { return imeStorage.get('swipeSymbols') !== false; } + + private clearPreview(id: string): void { + if (this.previewId === id) { this.previewId = ''; this.previewSymbol = ''; this.previewArmed = false; } + } + + private armSymbol(id: string, symbol: string, mode: string): void { + const current = liveSymbolHint(id, symbol, this.chinese); + if (this.symbolGesture.arm(id, current, this.keyContext(), mode, this.liveSwipeEnabled())) { + this.previewId = id; this.previewSymbol = current; this.previewArmed = true; + } + } + + private moveSymbol(id: string, event: GestureEvent): void { + const armed = this.symbolGesture.move(id, event.offsetX, event.offsetY, this.swipeDistance()); + if (this.previewId === id) this.previewArmed = armed; + } + + private swipeDistance(): number { return Math.max(22, Math.min(30, this.keyHeight * 0.48)); } + + private keyGap(): number { return this.keyboardLayout === 'standard' ? 7 : 5; } + + private finishSymbol(id: string, mode: string): void { + const symbol = this.symbolGesture.finish(id, this.keyContext(), mode, this.liveSwipeEnabled()); + this.clearPreview(id); + if (symbol) this.insertSymbol(symbol); + } + + private cancelSymbol(id: string, mode: string = ''): void { + if (this.symbolGesture.cancel(id, mode)) this.clearPreview(id); + } + + private insertSymbol(symbol: string): void { + inputSession.insertSymbol(localizedSymbol(symbol, this.chinese)); + if (this.uppercase && !this.capsLock) this.uppercase = false; + } + + private tapKey(id: string, key: string, letter: boolean): void { + if (!this.symbolGesture.tap(id, this.keyContext())) return; + this.clearPreview(id); + if (letter) this.insert(key); else this.insertSymbol(key); + } + + @Builder + characterKey(key: string, hint: string, id: string, weight: number, letter: boolean) { + Stack({ alignContent: Alignment.Top }) { + Button({ type: ButtonType.Normal }) { + Stack({ alignContent: Alignment.Center }) { + Text(letter ? (this.chinese ? key.toUpperCase() : key) : localizedSymbol(key, this.chinese)).fontSize(letter ? this.keyFontSize : this.keyFontSize + 2) + .fontColor(this.previewId === id && this.previewArmed ? '#FFFFFF' : INK) + if (hint) { + Column() { + Text(liveSymbolHint(id, hint, this.chinese)).fontSize(10).fontColor(this.previewId === id && this.previewArmed ? '#FFFFFF' : MUTED) + .height(13).width('100%').textAlign(TextAlign.End) + }.width('100%').height('100%').padding({ top: 3, right: 6 }) + .hitTestBehavior(HitTestMode.None) + } + }.width('100%').height('100%') + }.width('100%').height('100%').keySurface().padding(0) + .backgroundColor(this.previewId === id && this.previewArmed ? ACCENT : KEY) + .enabled(this.connected && !this.pendingCommit && (!this.chinese || this.rimeReady)).pressMotion().focusable(false) + .accessibilityText(hint ? localizedSymbol(key, this.chinese) + (this.swipeSymbols ? ',长按或上滑输入' : ',长按输入') + liveSymbolHint(id, hint, this.chinese) : localizedSymbol(key, this.chinese)) + .onTouch((event: TouchEvent) => { + if (event.type === TouchType.Down && event.touches.length) this.symbolGesture.begin(id, this.keyContext(), event.touches[0].x, event.touches[0].y); + else if (event.type === TouchType.Move && event.touches.length) { + if (this.symbolGesture.moveTouch(id, event.touches[0].x, event.touches[0].y)) this.clearPreview(id); + } + else if (event.type === TouchType.Cancel) this.cancelSymbol(id); + }) + .gesture(GestureGroup(GestureMode.Exclusive, + PanGesture({ direction: PanDirection.Up, distance: this.swipeDistance() }) + .onActionStart((event: GestureEvent) => { this.armSymbol(id, hint, 'swipe'); this.moveSymbol(id, event); }) + .onActionUpdate((event: GestureEvent) => { this.moveSymbol(id, event); }) + .onActionEnd((event: GestureEvent) => { this.moveSymbol(id, event); this.finishSymbol(id, 'swipe'); }) + .onActionCancel(() => { this.cancelSymbol(id, 'swipe'); }), + LongPressGesture({ duration: 350, repeat: false }) + .onAction(() => { this.armSymbol(id, hint || key, 'hold'); }) + .onActionEnd(() => { this.finishSymbol(id, 'hold'); }) + .onActionCancel(() => { this.cancelSymbol(id, 'hold'); }), + TapGesture().onAction(() => { this.tapKey(id, key, letter); }) + )) + if (this.previewId === id && this.previewSymbol) { + Column({ space: 1 }) { + Text(this.previewSymbol).fontSize(27).fontColor(this.previewArmed ? ACCENT : MUTED) + Text(this.previewArmed ? '松开输入' : '松开取消').fontSize(10).fontColor(MUTED) + }.height(50).constraintSize({ minWidth: 60 }).padding({ left: 8, right: 8 }) + .justifyContent(FlexAlign.Center).backgroundColor(KEY).borderRadius(9) + .shadow({ radius: 8, color: '#33000000', offsetY: 2 }).translate({ y: -50 }) + .hitTestBehavior(HitTestMode.None) + } + }.height(this.keyHeight).width(this.keyWidth * weight).zIndex(this.previewId === id ? 10 : 0) + } + + private insert(text: string): void { + inputSession.insert(text); + if (this.uppercase && !this.capsLock) this.uppercase = false; + } + + private language(): void { + inputSession.toggleLanguage(); + this.uppercase = false; + this.capsLock = false; + } + + private shift(): void { + if (this.layer === 2) { this.symbolPage = this.symbolPage === 0 ? 1 : 0; return; } + if (this.chinese) inputSession.toggleLanguage(); + this.uppercase = !this.uppercase; + this.capsLock = false; + } + + private inputMethods(): void { + inputMethod.getSetting().showOptionalInputMethods().catch(() => { + this.status = '请在系统输入法菜单切换'; + }); + } + + private voice(): void { + this.utility = 0; + this.expanded = false; + inputSession.startVoice(); + } + + private openTools(tab: number): void { + if (this.utility === 1 && this.toolTab === tab) this.utility = 0; + else { this.toolTab = tab; this.toolPosition = tab; this.utility = 1; this.expanded = false; } + } + + private inputMode(chinese: boolean): void { + if (this.chinese !== chinese) this.language(); + this.layer = 0; this.utility = 0; this.expanded = false; + } + + @Builder + toolbarButton(icon: Resource, description: string, action: () => void) { + Button({ type: ButtonType.Normal }) { + Image(icon).width(25).height(25).objectFit(ImageFit.Contain) + }.height(34).layoutWeight(1) + .backgroundColor(Color.Transparent).borderRadius(12).padding(0).pressMotion().focusable(false) + .accessibilityText(description).onClick(() => { action(); }) + } + + @Builder + keyboardToolbar() { + Row({ space: 6 }) { + Button({ type: ButtonType.Normal }) { + Image($r('app.media.ime_tools')).width(25).height(25) + }.width(38).height(34).padding(0).borderRadius(999) + .backgroundColor(Color.Transparent).pressMotion().focusable(false) + .accessibilityText('输入法工具').onClick(() => { this.openTools(0); }) + ForEach([0, 1, 2, 3], (slot: number) => { + Button({ type: ButtonType.Normal }) { + Image(this.shortcutIcon(this.toolbarActions[slot])).width(25).height(25).objectFit(ImageFit.Contain) + }.height(34).layoutWeight(1).borderRadius(999).padding(0).backgroundColor(Color.Transparent) + .pressMotion().focusable(false).accessibilityText(this.shortcutLabel(this.toolbarActions[slot]) + ',长按编辑') + .enabled(this.toolbarActions[slot] !== 'voice' || + (this.connected && this.editorVerified && !this.password && !this.pendingCommit && this.voicePhase === 'idle')) + .gesture(GestureGroup(GestureMode.Exclusive, + LongPressGesture({ duration: 450 }).onAction(() => { this.shortcutSlot = slot; this.utility = 3; }), + TapGesture().onAction(() => { this.runShortcut(this.toolbarActions[slot]); }))) + }, (slot: number) => slot.toString()) + Button({ type: ButtonType.Normal }) { + Image($r('app.media.ime_chevron_down')).width(25).height(25) + }.width(38).height(34).padding(0).borderRadius(999).backgroundColor(Color.Transparent) + .pressMotion().focusable(false).accessibilityText('收起键盘').onClick(() => { inputSession.hide(); }) + }.width('100%').height(34) + } + + private shortcutIcon(action: string): Resource { + switch (action) { + case 'emoji': return $r('app.media.ime_emoji'); + case 'voice': return $r('app.media.ime_microphone'); + case 'settings': return $r('app.media.ime_settings'); + case 'symbols': return $r('app.media.ime_symbols'); + case 'numbers': return $r('app.media.ime_number'); + case 'language': return $r('app.media.ime_language'); + case 'inputMethods': return $r('app.media.ime_switch'); + default: return $r('app.media.ime_keyboard'); + } + } + + private shortcutLabel(action: string): string { + switch (action) { + case 'emoji': return '表情'; + case 'voice': return '语音输入'; + case 'settings': return '应用设置'; + case 'symbols': return '符号键盘'; + case 'numbers': return '数字键盘'; + case 'language': return '中英切换'; + case 'inputMethods': return '切换输入法'; + default: return '键盘模式'; + } + } + + private runShortcut(action: string): void { + switch (action) { + case 'emoji': this.utility = 2; break; + case 'voice': this.voice(); break; + case 'settings': inputSession.settings(); break; + case 'symbols': this.layer = 2; this.symbolPage = 0; this.utility = 0; break; + case 'numbers': this.layer = 1; this.utility = 0; break; + case 'language': this.language(); break; + case 'inputMethods': this.inputMethods(); break; + default: this.openTools(2); break; + } + } + + @Builder + shortcutEditor() { + Column({ space: 12 }) { + Row({ space: 8 }) { + ForEach([0, 1, 2, 3], (slot: number) => { + Button({ type: ButtonType.Normal }) { + Column({ space: 4 }) { + Image(this.shortcutIcon(this.toolbarActions[slot])).width(25).height(25) + Text('位置 ' + (slot + 1).toString()).fontSize(12).fontColor(INK) + } + }.layoutWeight(1).height(64).borderRadius(12).backgroundColor(this.shortcutSlot === slot ? FUNCTION_KEY : KEY) + .pressMotion().focusable(false).onClick(() => { this.shortcutSlot = slot; }) + }, (slot: number) => slot.toString()) + }.width('100%') + Text('选择要放到第 ' + (this.shortcutSlot + 1).toString() + ' 个位置的功能').fontSize(13).fontColor(MUTED) + Scroll() { + Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) { + ForEach(['layout', 'emoji', 'voice', 'settings', 'symbols', 'numbers', 'language', 'inputMethods'], (action: string) => { + this.menuTile(this.shortcutIcon(action), this.shortcutLabel(action), () => { + inputSession.setToolbarAction(this.shortcutSlot, action); this.utility = 0; + }) + }, (action: string) => action) + }.width('100%') + }.width('100%').layoutWeight(1).scrollBar(BarState.Auto) + }.width('100%').layoutWeight(1) + } + + @Builder + shortcutEditorHeader() { + Row() { + Button({ type: ButtonType.Normal }) { + Image($r('app.media.ime_chevron_down')).width(22).height(22).rotate({ angle: 90 }) + }.width(38).height(34).padding(0).borderRadius(999).backgroundColor(KEY).pressMotion().focusable(false) + .accessibilityText('返回键盘').onClick(() => { this.utility = 0; }) + Text(this.utilityTitle()).layoutWeight(1).textAlign(TextAlign.Center).fontSize(14).fontColor(INK) + Blank().width(38) + }.width('100%').height(34) + } + + private utilityTitle(): string { + switch (this.utility) { + case 4: return '词库与云候选'; + case 5: return '剪贴板'; + case 6: return '润色模式'; + case 7: return '字体大小'; + case 8: return '常用语'; + default: return '编辑快捷入口'; + } + } + + @Builder + keyboardSettings() { + Scroll() { + Column({ space: 10 }) { + Button('打开应用设置').width('100%').height(40).backgroundColor(KEY).fontColor(INK) + .pressMotion().focusable(false).onClick(() => { inputSession.settings(); }) + Button(this.cloudEnabled ? '云候选:开(Google)' : '云候选:关').width('100%').height(40).backgroundColor(KEY).fontColor(INK) + .pressMotion().focusable(false).onClick(() => { inputSession.toggleCloudCandidates(); }) + Text('开启后将当前拼音发送给 Google,密码框不查询').fontSize(12).fontColor(MUTED) + Button(this.dictionaryAuto ? '公共词库自动更新:开' : '公共词库自动更新:关').width('100%').height(40).backgroundColor(KEY).fontColor(INK) + .pressMotion().focusable(false).onClick(() => { inputSession.togglePublicDictionaryUpdates(); }) + Button(this.dictionaryStatus).width('100%').height(40).backgroundColor(KEY).fontColor(INK) + .pressMotion().focusable(false).onClick(() => { inputSession.checkPublicDictionary(); }) + Text('公共词库每周检查;学习词仅保存在本机。按键音跟随媒体音量。').fontSize(12).fontColor(MUTED) + Button('切换系统输入法').width('100%').height(40).backgroundColor(KEY).fontColor(INK) + .pressMotion().focusable(false).onClick(() => { this.inputMethods(); }) + }.width('100%').padding({ top: 6, bottom: 8 }) + }.width('100%').layoutWeight(1).scrollBar(BarState.Auto) + } + + @Builder + clipboardPanel() { + Column({ space: 6 }) { + Row({ space: 10 }) { + if (this.clipboardAutoRead) { + Button('粘贴当前内容').height(32).fontSize(13).fontColor(INK).backgroundColor(KEY).pressMotion().focusable(false) + .enabled(this.connected && !this.pendingCommit).onClick(() => { inputSession.pasteClipboard(); }) + Button('刷新').height(32).fontSize(13).fontColor(INK).backgroundColor(KEY).pressMotion().focusable(false) + .onClick(() => { inputSession.refreshClipboard(); }) + Button('剪贴板授权').height(32).fontSize(13).fontColor(INK).backgroundColor(KEY).pressMotion().focusable(false) + .onClick(() => { inputSession.authorizeClipboard(); }) + } else { + PasteButton({ icon: PasteIconStyle.LINES, text: PasteDescription.PASTE }).width(140).height(40) + .enabled(this.connected && !this.pendingCommit) + .onClick((_event: ClickEvent, result: PasteButtonOnClickResult) => { + if (result === PasteButtonOnClickResult.SUCCESS) inputSession.pasteClipboard(); + }) + } + Text('本地记录 ' + this.clipboardItems.length.toString() + '/30').layoutWeight(1).fontSize(12).fontColor(MUTED) + Button('清空').height(32).fontSize(13).fontColor(INK).backgroundColor(KEY).pressMotion().focusable(false) + .enabled(this.clipboardItems.length > 0).accessibilityText('清空本地剪贴板记录') + .onClick(() => { inputSession.clearClipboardHistory(); }) + }.width('100%') + Text(this.clipboardStatus || '自动读取复制的文字,仅存本机;点击记录粘贴,私密输入不记录。').fontSize(11).fontColor(MUTED).width('100%') + if (this.clipboardItems.length === 0) { + Text(this.clipboardAutoRead ? '复制文字后自动显示在这里;首次使用请授权读取剪贴板' : '暂无本地记录,自动收录待权限开放') + .fontSize(14).fontColor(MUTED).layoutWeight(1) + } else { + Scroll() { + Column({ space: 8 }) { + ForEach(this.clipboardItems, (item: ClipboardEntry) => { + Row({ space: 6 }) { + Button({ type: ButtonType.Normal }) { + Text(item.text).fontSize(15).fontColor(INK).maxLines(3).textAlign(TextAlign.Start) + .textOverflow({ overflow: TextOverflow.Ellipsis }).width('100%') + }.layoutWeight(1).height(76).padding(8).backgroundColor(Color.Transparent).pressMotion().focusable(false) + .enabled(this.connected && !this.pendingCommit).onClick(() => { inputSession.pasteClipboardItem(item.id); }) + this.clipboardAction($r('app.media.ime_pin'), item.pinned ? '取消固定' : '固定记录', + () => { inputSession.pinClipboardItem(item.id); }, item.pinned) + this.clipboardAction($r('app.media.ime_clipboard'), '复制记录', () => { inputSession.copyClipboardItem(item.id); }) + this.clipboardAction($r('app.media.ime_trash'), '删除记录', () => { inputSession.deleteClipboardItem(item.id); }) + }.width('100%').backgroundColor(KEY).borderRadius(12).padding({ left: 4, right: 4 }) + }, (item: ClipboardEntry) => item.id + ':' + item.pinned + ':' + item.usedAt) + }.width('100%').padding({ top: 2, bottom: 6 }) + }.width('100%').layoutWeight(1).scrollBar(BarState.Auto) + } + }.width('100%').layoutWeight(1).onAppear(() => { inputSession.refreshClipboard(); }) + } + + @Builder + clipboardAction(icon: Resource, label: string, action: () => void, active: boolean = false) { + Button({ type: ButtonType.Normal }) { Image(icon).width(20).height(20) } + .width(32).height(32).padding(0).borderRadius(999).backgroundColor(active ? FUNCTION_KEY : Color.Transparent) + .pressMotion().focusable(false).accessibilityText(label).onClick(() => { action(); }) + } + + @Builder + polishPanel() { + Column({ space: 6 }) { + Text('选择语音输入使用的润色模式').fontSize(13).fontColor(MUTED) + Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) { + this.menuTile($r('app.media.ime_polish'), '保留原文', () => { inputSession.setPolishMode('raw'); this.utility = 0; }) + this.menuTile($r('app.media.ime_polish'), '轻度润色', () => { inputSession.setPolishMode('light'); this.utility = 0; }) + this.menuTile($r('app.media.ime_polish'), '结构整理', () => { inputSession.setPolishMode('structured'); this.utility = 0; }) + this.menuTile($r('app.media.ime_polish'), '正式表达', () => { inputSession.setPolishMode('formal'); this.utility = 0; }) + }.width('100%') + }.width('100%').layoutWeight(1) + } + + @Builder + fontPanel() { + Column({ space: 16 }) { + Text('中英文 Aa').fontSize(this.keyFontSize).fontColor(INK) + Row() { + ForEach([20, 22, 24, 26], (size: number, index: number) => { + Button(['小', '标准', '大', '特大'][index]).layoutWeight(1).height(48) + .fontColor(INK).backgroundColor(this.keyFontSize === size ? FUNCTION_KEY : KEY).pressMotion().focusable(false) + .onClick(() => { inputSession.setKeyFontSize(size); }) + }, (size: number) => size.toString()) + }.width('100%') + }.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center).padding(10) + } + + @Builder + phrasePanel() { + Scroll() { + Column({ space: 8 }) { + ForEach(['你好!', '谢谢!', '好的,收到。', '请稍等一下。', '稍后回复你。', '辛苦了!'], (phrase: string) => { + Button(phrase).width('100%').height(42).fontColor(INK).backgroundColor(KEY).pressMotion().focusable(false) + .enabled(this.connected && !this.pendingCommit).onClick(() => { inputSession.insert(phrase); this.utility = 0; }) + }, (phrase: string) => phrase) + }.width('100%').padding({ top: 4, bottom: 8 }) + }.width('100%').layoutWeight(1).scrollBar(BarState.Auto) + } + + @Builder + pendingBar() { + Row({ space: 8 }) { + Button('已写入', { type: ButtonType.Normal }).height(32).layoutWeight(1).fontSize(14) + .fontColor(ACCENT).backgroundColor(KEY).padding(0).pressMotion().focusable(false) + .accessibilityText('文字已出现在输入框,确认并继续输入,不重复写入') + .onClick(() => { inputSession.acknowledgePendingCommit(); }) + Button('重试写入', { type: ButtonType.Normal }).height(32).layoutWeight(1).fontSize(14) + .fontColor(INK).backgroundColor(KEY).padding(0).pressMotion().focusable(false) + .enabled(!this.writeInFlight) + .onClick(() => { inputSession.retryCommit(); }) + Button('取消待提交', { type: ButtonType.Normal }).height(32).layoutWeight(1).fontSize(14) + .fontColor(INK).backgroundColor(KEY).padding(0).pressMotion().focusable(false) + .onClick(() => { inputSession.cancelPendingCommit(); }) + }.height(34).width('100%') + } + + @Builder + candidateBar() { + Row({ space: 4 }) { + Button({ type: ButtonType.Normal }) { + Image($r('app.media.ime_tools')).width(25).height(25) + }.width(38).height(34).padding(0).borderRadius(999) + .backgroundColor(Color.Transparent).pressMotion().focusable(false) + .accessibilityText('输入法工具').onClick(() => { this.openTools(0); }) + Scroll(this.candidateScroller) { + Row({ space: 2 }) { + ForEach(this.candidates, (candidate: string, index: number) => { + Button(candidate, { type: ButtonType.Normal }).height(32).fontSize(21) + .fontWeight(index === 0 ? FontWeight.Medium : FontWeight.Regular) + .fontColor(index === 0 ? ACCENT : INK).backgroundColor(Color.Transparent) + .padding({ left: 12, right: 12 }).pressMotion().focusable(false) + .enabled(this.connected && !this.pendingCommit && !this.candidatesLoading && (!this.chinese || this.rimeReady)) + .onClick(() => { inputSession.selectCandidate(index, candidate); this.expanded = false; this.utility = 0; }) + }, (candidate: string, index: number) => index.toString() + ':' + candidate) + }.justifyContent(FlexAlign.Start) + }.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).layoutWeight(1).height(34) + .align(Alignment.Start).onReachEnd(() => { inputSession.loadMoreCandidates(); }) + .onScrollStop(() => { if (this.candidateScroller.isAtEnd()) inputSession.loadMoreCandidates(); }) + Button({ type: ButtonType.Normal }) { + Image(this.expanded ? $r('app.media.ime_chevron_up') : $r('app.media.ime_chevron_down')).width(25).height(25) + }.width(38).height(32).padding(0).borderRadius(12) + .backgroundColor(Color.Transparent).pressMotion().focusable(false) + .accessibilityText(this.expanded ? '收起候选词' : '展开候选词') + .onClick(() => { + this.expanded = !this.expanded; this.utility = 0; + if (this.expanded) inputSession.loadMoreCandidates(); + }) + }.width('100%').height(34) + } + + @Builder + keyRow(index: number) { + Row({ space: this.keyGap() }) { + if (index === 1 && this.layer === 0 && this.keyboardLayout !== 'split') { Blank().layoutWeight(0.5) } + if (index === 2) { + Button(this.layer === 2 ? (this.symbolPage === 0 ? '更多' : '常用') : this.capsLock ? '⇪' : '⇧', { type: ButtonType.Normal }) + .height(this.keyHeight).layoutWeight(this.keyboardLayout === 'split' ? 1 : 1.5).fontSize(this.layer > 0 ? 15 : 25) + .fontColor(this.uppercase && this.layer === 0 ? ACCENT : INK) + .backgroundColor(FUNCTION_KEY).keySurface().padding(0).pressMotion().focusable(false).enabled(this.connected) + .accessibilityText(this.layer > 0 ? '切换符号页' : '切换大写') + .onClick(() => { this.shift(); }) + .gesture(LongPressGesture().onAction(() => { + if (this.layer === 0) { + if (this.chinese) inputSession.toggleLanguage(); + this.uppercase = true; this.capsLock = true; + } + })) + } + ForEach(this.row(index), (key: string, position: number) => { + if (this.keyboardLayout === 'split' && position === (index === 2 ? 4 : 5)) { Blank().layoutWeight(2) } + this.characterKey(key, this.hint(index, position), 'letter:' + index + ':' + position + ':' + key, 1, this.layer === 0) + }, (key: string, position: number) => position.toString() + ':' + key) + if (index === 1 && this.layer === 0 && this.keyboardLayout !== 'split') { Blank().layoutWeight(0.5) } + if (index === 2) { + Button('⌫', { type: ButtonType.Normal }).height(this.keyHeight).layoutWeight(this.keyboardLayout === 'split' ? 1 : 1.5).fontSize(25) + .fontColor(INK).backgroundColor(FUNCTION_KEY).keySurface().padding(0).pressMotion().focusable(false) + .enabled(this.connected && !this.pendingCommit && (!this.chinese || this.rimeReady)).accessibilityText('删除光标前的文字') + .onClick(() => { inputSession.backspace(); }) + .gesture(LongPressGesture({ repeat: true }).onAction(() => { inputSession.backspace(); })) + } + }.width('100%').height(this.keyHeight).justifyContent(FlexAlign.Center) + } + + @Builder + bottomKey(label: string, weight: number, action: () => void) { + Button(label, { type: ButtonType.Normal }).height(this.keyHeight).layoutWeight(weight) + .fontSize(15).fontColor(INK).backgroundColor(FUNCTION_KEY).keySurface().padding(0).pressMotion().focusable(false) + .onClick(() => { action(); }) + } + + @Builder + punctuation(comma: boolean) { + this.characterKey(comma ? (this.chinese ? ',' : ',') : (this.chinese ? '。' : '.'), + comma ? (this.chinese ? '!' : '!') : (this.chinese ? '?' : '?'), + comma ? 'comma' : 'period', 0.85, false) + } + + @Builder + spaceKey(weight: number) { + Button({ type: ButtonType.Normal }) { + if (this.keyboardLayout === 'split') { + Column({ space: 0 }) { + Text(this.voiceHolding ? '松开输入' : '语音').fontSize(10).fontColor(MUTED) + Text('⎵').fontSize(28).fontColor(INK) + }.width('100%').height('100%').justifyContent(FlexAlign.Center) + } else { + Column({ space: 1 }) { + Text(this.chinese ? '空格' : 'space').fontSize(15).fontColor(MUTED) + if (!this.password) { Text(this.voiceHolding ? '松开输入' : '长按语音').fontSize(9).fontColor(MUTED) } + }.width('100%').height('100%').justifyContent(FlexAlign.Center) + } + }.height(this.keyHeight).layoutWeight(weight).backgroundColor(KEY).keySurface().padding(0).pressMotion().focusable(false) + .accessibilityText('空格,长按语音输入') + .enabled(this.connected && !this.pendingCommit && (!this.chinese || this.rimeReady)) + .gesture(GestureGroup(GestureMode.Exclusive, + LongPressGesture({ duration: 450, repeat: false }) + .onAction(() => { if (!this.password && this.voicePhase === 'idle') inputSession.startVoice(true); }) + .onActionEnd(() => { inputSession.releaseVoiceHold(); }) + .onActionCancel(() => { inputSession.cancelVoiceHold(); }), + TapGesture().onAction(() => { if (this.voicePhase === 'idle') inputSession.space(); }) + )) + } + + @Builder + bottomRow() { + Row({ space: this.keyGap() }) { + this.bottomKey(this.layer > 0 ? '返回' : '符号', this.keyboardLayout === 'split' ? 1.2 : 1.1, + () => { this.layer = this.layer > 0 ? 0 : 2; this.symbolPage = 0; }) + Button(this.chinese ? '中/英' : '英/中', { type: ButtonType.Normal }).height(this.keyHeight).layoutWeight(0.95) + .fontSize(14).fontColor(INK).backgroundColor(FUNCTION_KEY).keySurface().padding(0).pressMotion().focusable(false) + .enabled(this.connected && !this.password).onClick(() => { this.language(); }) + .gesture(LongPressGesture().onAction(() => { this.inputMethods(); })) + if (this.keyboardLayout === 'split') { this.bottomKey('◎', 0.8, () => { this.inputMethods(); }) } + else { this.bottomKey('123', 0.9, () => { this.layer = this.layer === 1 ? 0 : 1; }) } + this.punctuation(true) + if (this.keyboardLayout === 'split') { + this.spaceKey(1.2) + Blank().layoutWeight(2) + this.spaceKey(1.2) + } else { this.spaceKey(3) } + this.punctuation(false) + if (this.keyboardLayout === 'split') { + this.bottomKey('☺', 0.8, () => { this.utility = this.utility === 2 ? 0 : 2; }) + this.bottomKey('123', 0.9, () => { this.layer = this.layer === 1 ? 0 : 1; }) + } + Button(this.enterLabel, { type: ButtonType.Normal }).height(this.keyHeight).layoutWeight(1.25) + .fontSize(16).fontColor(INK) + .backgroundColor(FUNCTION_KEY).keySurface().padding(0).pressMotion().focusable(false) + .enabled(this.connected && !this.pendingCommit && (!this.chinese || this.rimeReady)).onClick(() => { inputSession.enter(); }) + }.width('100%').height(this.keyHeight) + } + + @Builder + digitRow() { + Row({ space: this.keyGap() }) { + ForEach(['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], (digit: string, index: number) => { + if (this.keyboardLayout === 'split' && index === 5) { Blank().layoutWeight(2) } + this.characterKey(digit, digitSymbol(digit, this.chinese), 'digit:' + digit, 1, false) + }, (digit: string) => digit) + }.height(this.keyHeight).width('100%') + } + + @Builder + layoutOptions() { + Column({ space: 0 }) { + Row() { + ForEach([0, 1, 2, 3].slice(0, this.menuSplit(4)), (index: number) => { this.layoutTool(index) }, (index: number) => index.toString()) + } + if (this.menuSplit(4) < 4) { + Row() { ForEach([0, 1, 2, 3].slice(this.menuSplit(4)), (index: number) => { this.layoutTool(index) }, (index: number) => index.toString()) } + } + }.width('100%').padding({ top: 8, bottom: 8 }) + } + + @Builder + layoutTool(index: number) { + if (index === 0) { this.menuTile($r('app.media.ime_split'), '拇指模式', () => { this.selectLayout('split'); }) } + else if (index === 1) { this.menuTile($r('app.media.ime_standard'), '普通模式', () => { this.selectLayout('standard'); }) } + else if (index === 2) { this.menuTile($r('app.media.ime_floating'), '悬浮模式', () => { this.selectLayout('floating'); }) } + else { this.menuTile($r('app.media.ime_onehand'), '单手模式', () => { this.selectLayout('onehand'); }) } + } + + private menuSplit(count: number): number { + return count * (this.menuTileSize() + 24) <= this.keyboardWidth ? count : Math.ceil(count / 2); + } + + private selectLayout(mode: string): void { + this.layer = 0; this.expanded = false; this.utility = 0; panelLayout.select(mode); + } + + private menuTileSize(): number { + return Math.min(92, Math.max(64, (this.keyboardWidth - 72) / 4)); + } + + @Builder + menuTile(icon: Resource, label: string, action: () => void, available: boolean = true, note: string = '') { + Button({ type: ButtonType.Normal }) { + Column({ space: note ? 4 : 9 }) { + Image(icon).width(note ? 22 : 28).height(note ? 22 : 28).objectFit(ImageFit.Contain) + Text(label).fontSize(label.length > 4 ? 11 : 13).fontColor(INK).maxLines(1) + if (note) { + Text(label === '上滑输入' ? (this.swipeSymbols ? '已开启' : '已关闭') : + label === '按键音' ? (this.keySound ? '已开启' : '已关闭') : note).fontSize(10).fontColor(MUTED).maxLines(1) + } + }.width('100%').height('100%').justifyContent(FlexAlign.Center) + }.width(this.menuTileSize()).height(this.menuTileSize()).margin({ left: 12, right: 12, top: 6, bottom: 6 }).padding(4) + .backgroundColor(KEY).borderRadius(12).pressMotion().focusable(false).enabled(available) + .shadow({ radius: 1.25, color: KEY_SHADOW, offsetX: 0, offsetY: 2 }) + .accessibilityText(label).onClick(() => { action(); }) + } + + @Builder + commonTool(index: number) { + if (index === 0) { this.menuTile($r('app.media.ime_settings'), '设置', () => { inputSession.settings(); }) } + else if (index === 1) { this.menuTile($r('app.media.ime_clipboard'), '剪贴板', () => { this.utility = 5; }) } + else if (index === 2) { this.menuTile($r('app.media.ime_polish'), '润色模式', () => { this.utility = 6; }) } + else if (index === 3) { this.menuTile($r('app.media.ime_font'), '字体大小', () => { this.utility = 7; }) } + else if (index === 4) { this.menuTile($r('app.media.ime_sound'), '按键音', () => { inputSession.toggleKeyOption('keySound'); }, true, '状态') } + else if (index === 5) { this.menuTile($r('app.media.ime_tools'), '定制工具栏', () => { this.shortcutSlot = 0; this.utility = 3; }) } + else if (index === 6) { this.menuTile($r('app.media.ime_phrase'), '常用语', () => { this.utility = 8; }) } + else if (index === 7) { this.menuTile($r('app.media.ime_emoji'), '表情', () => { this.utility = 2; }) } + else if (index === 8) { this.menuTile($r('app.media.ime_camera'), '拍照输入', () => {}, false, '待接入') } + else if (index === 9) { this.menuTile($r('app.media.ime_language'), '繁体输入', () => {}, false, '待接入') } + else if (index === 10) { this.menuTile($r('app.media.ime_swipe'), '上滑输入', () => { inputSession.toggleKeyOption('swipeSymbols'); }, true, '状态') } + else { this.menuTile($r('app.media.ime_language'), '英文联想', () => {}, false, '待接入') } + } + + @Builder + commonTools() { + Scroll() { + Column({ space: 0 }) { + Row() { ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].slice(0, this.menuSplit(12)), (index: number) => { this.commonTool(index) }, (index: number) => index.toString()) } + if (this.menuSplit(12) < 12) { + Row() { ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].slice(this.menuSplit(12)), (index: number) => { this.commonTool(index) }, (index: number) => index.toString()) } + } + }.width(Math.max(this.keyboardWidth, (this.menuTileSize() + 24) * this.menuSplit(12))) + }.width('100%').height((this.menuTileSize() + 12) * (this.menuSplit(12) === 12 ? 1 : 2)).scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off) + .align(Alignment.TopStart) + } + + @Builder + inputTool(index: number) { + if (index === 0) { this.menuTile($r('app.media.ime_keyboard'), '26键', () => { this.inputMode(true); }, this.connected && !this.password && !this.pendingCommit) } + else if (index === 1) { this.menuTile($r('app.media.ime_number'), '9键', () => {}, false, '待接入') } + else { this.menuTile($r('app.media.ime_handwrite'), '手写输入法', () => {}, false, '待接入') } + } + + @Builder + floatingHandle() { + Row({ space: 4 }) { + Button('−').height(24).layoutWeight(1).fontSize(18).padding(0).pressMotion().focusable(false) + .fontColor(INK).backgroundColor(Color.Transparent).accessibilityText('缩小悬浮键盘') + .onClick(() => { panelLayout.resize(-0.1); }) + Row() { Text('━━━━').fontSize(16).fontColor(MUTED) } + .height(24).layoutWeight(5).justifyContent(FlexAlign.Center).accessibilityText('拖动悬浮键盘') + .gesture(PanGesture().onActionStart(() => { panelLayout.beginMove(); }) + .onActionUpdate((event: GestureEvent) => { panelLayout.drag(event.offsetX, event.offsetY); }) + .onActionEnd(() => { panelLayout.endMove(); }) + .onActionCancel(() => { panelLayout.endMove(); })) + Button('+').height(24).layoutWeight(1).fontSize(18).padding(0).pressMotion().focusable(false) + .fontColor(INK).backgroundColor(Color.Transparent).accessibilityText('放大悬浮键盘') + .onClick(() => { panelLayout.resize(0.1); }) + Button('↙').height(24).layoutWeight(1).fontSize(18).padding(0).pressMotion().focusable(false) + .fontColor(INK).backgroundColor(Color.Transparent).accessibilityText('恢复固定键盘') + .onClick(() => { panelLayout.select('standard'); }) + }.height(24).width('100%') + } + + @Builder + numberPad() { + Row({ space: 6 }) { + Column({ space: 6 }) { + ForEach(['+', '-', '×', '÷'], (value: string) => { + Button(value, { type: ButtonType.Normal }).height(this.keyHeight).width('100%').fontSize(22) + .fontColor(INK).backgroundColor(FUNCTION_KEY).keySurface().padding(0).pressMotion().focusable(false) + .enabled(this.connected && !this.pendingCommit && (!this.chinese || this.rimeReady)).onClick(() => { this.insert(value); }) + }, (value: string) => value) + }.layoutWeight(1) + Column({ space: 6 }) { + ForEach([['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9']], (row: string[], index: number) => { + Row({ space: 6 }) { + ForEach(row, (value: string) => { + Button(value, { type: ButtonType.Normal }).height(this.keyHeight).layoutWeight(1).fontSize(25) + .fontColor(INK).backgroundColor(KEY).keySurface().padding(0).pressMotion().focusable(false) + .enabled(this.connected && !this.pendingCommit && (!this.chinese || this.rimeReady)).onClick(() => { this.insert(value); }) + }, (value: string) => value) + }.width('100%').height(this.keyHeight) + }, (row: string[], index: number) => index.toString()) + Row({ space: 6 }) { + Button('返回', { type: ButtonType.Normal }).height(this.keyHeight).layoutWeight(1).fontSize(14) + .fontColor(INK).backgroundColor(FUNCTION_KEY).keySurface().padding(0).pressMotion().focusable(false) + .onClick(() => { this.layer = 0; }) + Button('0', { type: ButtonType.Normal }).height(this.keyHeight).layoutWeight(1).fontSize(25) + .fontColor(INK).backgroundColor(KEY).keySurface().padding(0).pressMotion().focusable(false) + .enabled(this.connected && !this.pendingCommit && (!this.chinese || this.rimeReady)).onClick(() => { this.insert('0'); }) + Button('00', { type: ButtonType.Normal }).height(this.keyHeight).layoutWeight(1).fontSize(22) + .fontColor(INK).backgroundColor(KEY).keySurface().padding(0).pressMotion().focusable(false) + .enabled(this.connected && !this.pendingCommit && (!this.chinese || this.rimeReady)).onClick(() => { this.insert('00'); }) + }.height(this.keyHeight).width('100%') + }.layoutWeight(3.6) + Column({ space: 6 }) { + Button('⌫', { type: ButtonType.Normal }).height(this.keyHeight).width('100%').fontSize(25) + .fontColor(INK).backgroundColor(FUNCTION_KEY).keySurface().padding(0).pressMotion().focusable(false) + .enabled(this.connected && !this.pendingCommit && (!this.chinese || this.rimeReady)).accessibilityText('删除光标前的文字') + .onClick(() => { inputSession.backspace(); }) + .gesture(LongPressGesture({ repeat: true }).onAction(() => { inputSession.backspace(); })) + Button('.', { type: ButtonType.Normal }).height(this.keyHeight).width('100%').fontSize(25) + .fontColor(INK).backgroundColor(KEY).keySurface().padding(0).pressMotion().focusable(false) + .enabled(this.connected && !this.pendingCommit && (!this.chinese || this.rimeReady)).onClick(() => { this.insert('.'); }) + Button(this.enterLabel, { type: ButtonType.Normal }).height(this.keyHeight * 2 + 6).width('100%').fontSize(17) + .fontColor(INK).backgroundColor(FUNCTION_KEY).keySurface().padding(0).pressMotion().focusable(false) + .enabled(this.connected && !this.pendingCommit && (!this.chinese || this.rimeReady)).onClick(() => { inputSession.enter(); }) + }.layoutWeight(1.2) + }.width('100%').layoutWeight(1).alignItems(VerticalAlign.Center) + } + + @Builder + expandedCandidates() { + Column({ space: 8 }) { + Grid(this.expandedScroller) { + ForEach(this.candidates, (candidate: string, index: number) => { + GridItem() { + Button({ type: ButtonType.Normal }) { + Text(candidate).fontSize(20).fontColor(index === 0 ? ACCENT : INK).maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + }.height(54).width('100%').backgroundColor(KEY).keySurface() + .padding({ left: 8, right: 8 }).pressMotion().focusable(false).accessibilityText(candidate) + .enabled(this.connected && !this.pendingCommit && !this.candidatesLoading && (!this.chinese || this.rimeReady)) + .onClick(() => { inputSession.selectCandidate(index, candidate); this.expanded = false; this.utility = 0; }) + } + }, (candidate: string, index: number) => index.toString() + ':' + candidate) + }.columnsTemplate('1fr '.repeat(Math.max(2, Math.min(6, Math.floor(this.keyboardWidth / 152)))).trim()) + .columnsGap(6).rowsGap(6).width('100%').layoutWeight(1).scrollBar(BarState.Auto) + .onReachEnd(() => { inputSession.loadMoreCandidates(); }) + .onScrollStop(() => { if (this.expandedScroller.isAtEnd()) inputSession.loadMoreCandidates(); }) + Text(this.candidatesLoading ? '正在加载候选…' : this.hasNext ? '向上滑动查看更多候选' : '全部候选已显示') + .fontSize(12).fontColor(MUTED).height(18) + }.width('100%').layoutWeight(1).padding({ top: 8 }) + } + + @Builder + toolTabs() { + Row() { + Button({ type: ButtonType.Normal }) { + Image($r('app.media.ime_chevron_down')).width(22).height(22).rotate({ angle: 90 }) + }.width(38).height(34).borderRadius(999).padding(0) + .backgroundColor(KEY).pressMotion().focusable(false).accessibilityText('返回键盘') + .onClick(() => { this.utility = 0; }) + Blank().layoutWeight(1) + Stack({ alignContent: Alignment.Start }) { + Row() { + Blank().width(this.toolTabWidth()).height(28).backgroundColor(KEY).borderRadius(999) + .translate({ x: this.toolPosition * (this.toolTabWidth() + 6) }) + }.width('100%').height(34).padding({ left: 4, right: 4 }) + Row({ space: 6 }) { + ForEach(['常用功能', '输入方式', '键盘模式'], (label: string, index: number) => { + Button(label, { type: ButtonType.Normal }).height(34).width(this.toolTabWidth()).fontSize(14) + .fontWeight(this.toolTab === index ? FontWeight.Medium : FontWeight.Regular) + .fontColor(this.toolTab === index ? INK : MUTED).backgroundColor(Color.Transparent) + .borderRadius(999).padding(0).pressMotion().focusable(false) + .accessibilityText(label).onClick(() => { this.toolSwiper.changeIndex(index, true); }) + }, (label: string) => label) + }.padding({ left: 4, right: 4 }) + }.width(this.toolTabWidth() * 3 + 20).height(34).backgroundColor(FUNCTION_KEY).borderRadius(999) + .shadow({ radius: 1.25, color: KEY_SHADOW, offsetX: 0, offsetY: 2 }) + Blank().layoutWeight(1) + Blank().width(38) + }.width('100%').height(34) + } + + private toolTabWidth(): number { + return Math.min(112, (this.keyboardWidth - 108) / 3); + } + + @Builder + toolPage(tab: number) { + Column({ space: 8 }) { + Scroll() { + Column({ space: 10 }) { + if (tab === 0) { + this.commonTools() + } else if (tab === 1) { + Column({ space: 0 }) { + Row() { + ForEach([0, 1, 2].slice(0, this.menuSplit(3)), (index: number) => { this.inputTool(index) }, (index: number) => index.toString()) + } + if (this.menuSplit(3) < 3) { + Row() { ForEach([0, 1, 2].slice(this.menuSplit(3)), (index: number) => { this.inputTool(index) }, (index: number) => index.toString()) } + } + Button('词库与云候选').height(32).backgroundColor(Color.Transparent).fontColor(MUTED) + .pressMotion().focusable(false).onClick(() => { this.utility = 4; }) + }.width('100%') + } else { + this.layoutOptions() + } + }.width('100%').padding({ top: 4, bottom: 8 }) + }.width('100%').layoutWeight(1).scrollBar(BarState.Off) + }.width('100%').layoutWeight(1) + } + + @Builder + tools() { + Swiper(this.toolSwiper) { + this.toolPage(0) + this.toolPage(1) + this.toolPage(2) + }.width('100%').layoutWeight(1).index(this.toolTab).indicator(false).loop(false) + .duration(220).curve(Curve.EaseOut).onChange((index: number) => { this.toolTab = index; }) + .onContentDidScroll((_selected: number, index: number, position: number, _length: number) => { + this.toolPosition = Math.max(0, Math.min(2, index - position)); + }) + .onAnimationEnd((index: number) => { this.toolPosition = index; }) + } + + @Builder + emoji() { + Column({ space: 8 }) { + Scroll() { + Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) { + ForEach(['😀', '😄', '😊', '🥰', '😍', '😎', '🤔', '😂', '🥹', '😭', '😅', '😴', '👍', '👏', '🙏', '💪', '❤️', '🎉', '🌹', '✨', '✅', '💡', '☀️', '🎂'], (value: string) => { + Button(value, { type: ButtonType.Normal }).width(52).height(50).fontSize(27) + .backgroundColor(Color.Transparent).pressMotion().focusable(false).enabled(this.connected && !this.pendingCommit && (!this.chinese || this.rimeReady)) + .onClick(() => { inputSession.insert(value); }) + }, (value: string) => value) + }.width('100%') + }.width('100%').layoutWeight(1).scrollBar(BarState.Off) + Button('返回键盘').pressMotion().focusable(false).fontColor(ACCENT).backgroundColor(KEY) + .onClick(() => { this.utility = 0; }) + }.width('100%').layoutWeight(1) + } + + @Builder + voicePanel() { + Column({ space: 16 }) { + if (this.voicePhase === 'review' || this.voicePhase === 'inserting') { + Scroll() { Text(this.voiceText).fontSize(22).fontColor(INK).width('100%') } + .width('100%').layoutWeight(1).padding(12) + Row({ space: 12 }) { + Button('重新输入').pressMotion().focusable(false).enabled(this.voicePhase === 'review') + .fontColor(INK).backgroundColor(KEY).onClick(() => { inputSession.cancelVoice(); }) + Button(this.voicePhase === 'inserting' ? '正在写入' : '确认写入').pressMotion().focusable(false) + .enabled(this.voicePhase === 'review').fontColor('#FFFFFF').backgroundColor(ACCENT) + .onClick(() => { inputSession.confirmVoice(); }) + } + } else { + Text(this.voicePhase === 'recording' ? '正在听你说' : this.voicePhase === 'processing' ? '正在识别…' : '正在准备…') + .fontSize(22).fontColor(INK) + if (this.voicePhase === 'recording') { + Progress({ value: this.voiceLevel * 100, total: 100, type: ProgressType.Linear }) + .width('64%').color(ACCENT).backgroundColor(FUNCTION_KEY) + Button('说完了').pressMotion().focusable(false).fontColor('#FFFFFF').backgroundColor(ACCENT) + .onClick(() => { inputSession.stopVoice(); }) + } + Button('返回键盘').pressMotion().focusable(false).fontColor(MUTED).backgroundColor(Color.Transparent) + .onClick(() => { inputSession.cancelVoice(); }) + } + }.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center).padding(12) + } + + private headerHint(): string { + if (this.previewId) return (this.previewArmed ? '松开输入「' : '松开取消「') + this.previewSymbol + '」'; + if (this.symbolFeedback) return this.symbolFeedback; + if (this.preedit) return this.preedit; + if (this.connected && this.chinese && !this.rimeReady && this.status === '可输入') return '正在准备中文输入…'; + if (this.status === '可输入' || this.status === 'Rime 拼音') return this.swipeSymbols ? '长按或上滑按键,输入上方符号' : '长按按键,输入上方符号'; + return this.status; + } + + build() { + Column({ space: 6 }) { + if (this.keyboardLayout === 'floating') { this.floatingHandle() } + Column({ space: 6 }) { + Column({ space: 0 }) { + if (this.previewId || this.symbolFeedback || this.preedit || this.pendingCommit || + (this.connected && this.voicePhase === 'idle' && this.utility === 0 && this.layer === 0 && this.rimeReady) || + (this.status !== '可输入' && this.status !== 'Rime 拼音' && this.status !== '等待输入框') || + (this.connected && this.chinese && !this.rimeReady)) { + Row() { + Text(this.headerHint()) + .fontSize(12).fontColor(MUTED).maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1) + }.height(14).width('100%').padding({ left: 8, right: 8 }) + } + if (this.utility >= 3) { this.shortcutEditorHeader() } + else if (this.utility === 1) { this.toolTabs() } + else if (this.commitNeedsConfirmation) { this.pendingBar() } + else if (this.candidates.length > 0 && this.voicePhase === 'idle') { this.candidateBar() } + else { this.keyboardToolbar() } + }.height(48).width(this.keyboardWidth) + .justifyContent(FlexAlign.End) + if (this.voicePhase !== 'idle' && !this.voiceHolding) { this.voicePanel() } + else if (this.utility === 3) { this.shortcutEditor() } + else if (this.utility === 4) { this.keyboardSettings() } + else if (this.utility === 5) { this.clipboardPanel() } + else if (this.utility === 6) { this.polishPanel() } + else if (this.utility === 7) { this.fontPanel() } + else if (this.utility === 8) { this.phrasePanel() } + else if (this.utility === 1) { this.tools() } + else if (this.utility === 2) { this.emoji() } + else if (this.expanded && this.candidates.length > 0) { this.expandedCandidates() } + else if (this.layer === 1) { this.numberPad() } + else { + Column({ space: 7 }) { + if (this.showNumberRow) { this.digitRow() } + this.keyRow(0) + this.keyRow(1) + this.keyRow(2) + this.bottomRow() + }.width(this.keyboardWidth) + .layoutWeight(1).justifyContent(FlexAlign.End) + } + }.width(this.keyboardWidth).layoutWeight(1) + }.width('100%').height('100%').padding({ left: 6, right: 6, top: 4, bottom: 8 }).backgroundColor(BACKGROUND) + .alignItems(this.keyboardLayout === 'onehand' ? HorizontalAlign.End : HorizontalAlign.Center) + .borderRadius(this.keyboardLayout === 'floating' ? 12 : 0).clip(true) + } +} diff --git a/openless-all/app/harmony/entry/src/main/module.json5 b/openless-all/app/harmony/entry/src/main/module.json5 new file mode 100644 index 000000000..77ed2fea7 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/module.json5 @@ -0,0 +1,65 @@ +{ + "module": { + "name": "entry", + "type": "entry", + "srcEntry": "./ets/entryability/EntryAbility.ets", + "description": "$string:app_name", + "mainElement": "EntryAbility", + "deviceTypes": [ + "phone", + "tablet" + ], + "deliveryWithInstall": true, + "installationFree": false, + "pages": "$profile:main_pages", + "abilities": [ + { + "name": "EntryAbility", + "srcEntry": "./ets/entryability/EntryAbility.ets", + "description": "$string:app_name", + "icon": "$media:openless_layered_icon", + "label": "$string:app_name", + "exported": true, + "skills": [ + { + "entities": [ + "entity.system.home" + ], + "actions": [ + "ohos.want.action.home" + ] + } + ], + "startWindowIcon": "$media:openless_icon", + "startWindowBackground": "$color:start_window_background" + } + ], + "extensionAbilities": [ + { + "name": "OpenLessInputMethod", + "srcEntry": "./ets/inputmethod/OpenLessInputMethod.ets", + "type": "inputMethod", + "label": "$string:ime_name", + "description": "$string:ime_description", + "icon": "$media:openless_layered_icon", + "exported": true + } + ], + "requestPermissions": [ + { + "name": "ohos.permission.INTERNET" + }, + { + "name": "ohos.permission.MICROPHONE", + "reason": "$string:microphone_reason", + "usedScene": { + "abilities": [ + "EntryAbility", + "OpenLessInputMethod" + ], + "when": "inuse" + } + } + ] + } +} diff --git a/openless-all/app/harmony/entry/src/main/resources/base/element/boolean.json b/openless-all/app/harmony/entry/src/main/resources/base/element/boolean.json new file mode 100644 index 000000000..475d8995c --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/element/boolean.json @@ -0,0 +1,5 @@ +{ + "boolean": [ + { "name": "ime_clipboard_auto_read", "value": false } + ] +} diff --git a/openless-all/app/harmony/entry/src/main/resources/base/element/color.json b/openless-all/app/harmony/entry/src/main/resources/base/element/color.json new file mode 100644 index 000000000..a7f5aa259 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/element/color.json @@ -0,0 +1,36 @@ +{ + "color": [ + { + "name": "start_window_background", + "value": "#FFFFFF" + }, + { + "name": "ime_panel_background", + "value": "#E3E4E6" + }, + { + "name": "ime_key_background", + "value": "#FFFFFF" + }, + { + "name": "ime_function_background", + "value": "#BDC1C5" + }, + { + "name": "ime_foreground", + "value": "#17191C" + }, + { + "name": "ime_secondary", + "value": "#666B72" + }, + { + "name": "ime_accent", + "value": "#0A59F7" + }, + { + "name": "ime_key_shadow", + "value": "#33000000" + } + ] +} diff --git a/openless-all/app/harmony/entry/src/main/resources/base/element/string.json b/openless-all/app/harmony/entry/src/main/resources/base/element/string.json new file mode 100644 index 000000000..21ad7e8fa --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/element/string.json @@ -0,0 +1,24 @@ +{ + "string": [ + { + "name": "clipboard_reason", + "value": "用于输入法读取复制的文字并保存本地剪贴板历史,便于连续粘贴" + }, + { + "name": "ime_name", + "value": "OpenLess 输入法" + }, + { + "name": "ime_description", + "value": "OpenLess 文字与语音输入" + }, + { + "name": "app_name", + "value": "OpenLess" + }, + { + "name": "microphone_reason", + "value": "用于语音输入和麦克风音量测试" + } + ] +} diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_camera.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_camera.svg new file mode 100644 index 000000000..077258307 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_camera.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_chevron_down.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_chevron_down.svg new file mode 100644 index 000000000..4cbceea79 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_chevron_down.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_chevron_up.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_chevron_up.svg new file mode 100644 index 000000000..631b8a1b9 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_chevron_up.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_clipboard.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_clipboard.svg new file mode 100644 index 000000000..eb15205e7 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_clipboard.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_cloud.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_cloud.svg new file mode 100644 index 000000000..a2dc9bbff --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_cloud.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_dictionary.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_dictionary.svg new file mode 100644 index 000000000..bb6367289 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_dictionary.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_emoji.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_emoji.svg new file mode 100644 index 000000000..ec3bd9ca4 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_emoji.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_floating.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_floating.svg new file mode 100644 index 000000000..1858c633b --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_floating.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_font.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_font.svg new file mode 100644 index 000000000..c30757f76 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_font.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_handwrite.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_handwrite.svg new file mode 100644 index 000000000..2f1be7ee4 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_handwrite.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_keyboard.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_keyboard.svg new file mode 100644 index 000000000..3eebc7013 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_keyboard.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_language.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_language.svg new file mode 100644 index 000000000..53e1c5197 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_language.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_microphone.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_microphone.svg new file mode 100644 index 000000000..ef82dce5b --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_microphone.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_number.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_number.svg new file mode 100644 index 000000000..35ef0a8f3 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_number.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_onehand.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_onehand.svg new file mode 100644 index 000000000..dc8e6ea9b --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_onehand.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_phrase.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_phrase.svg new file mode 100644 index 000000000..cf3023007 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_phrase.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_pin.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_pin.svg new file mode 100644 index 000000000..6e0c90723 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_pin.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_polish.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_polish.svg new file mode 100644 index 000000000..a914618e3 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_polish.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_refresh.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_refresh.svg new file mode 100644 index 000000000..ddd55b237 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_refresh.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_settings.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_settings.svg new file mode 100644 index 000000000..b4d00a87b --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_settings.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_sound.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_sound.svg new file mode 100644 index 000000000..cc23bd7bb --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_sound.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_split.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_split.svg new file mode 100644 index 000000000..492da7cd0 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_split.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_standard.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_standard.svg new file mode 100644 index 000000000..79efcd001 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_standard.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_swipe.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_swipe.svg new file mode 100644 index 000000000..76c43b78a --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_swipe.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_switch.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_switch.svg new file mode 100644 index 000000000..0e01c5fc6 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_switch.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_symbols.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_symbols.svg new file mode 100644 index 000000000..7c52e9b3b --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_symbols.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_tools.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_tools.svg new file mode 100644 index 000000000..3e04ffaab --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_tools.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/media/ime_trash.svg b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_trash.svg new file mode 100644 index 000000000..8f2339f62 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/media/ime_trash.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/base/profile/main_pages.json b/openless-all/app/harmony/entry/src/main/resources/base/profile/main_pages.json new file mode 100644 index 000000000..8a021c60c --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/base/profile/main_pages.json @@ -0,0 +1,6 @@ +{ + "src": [ + "pages/Index", + "pages/InputMethodPanel" + ] +} diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/element/color.json b/openless-all/app/harmony/entry/src/main/resources/dark/element/color.json new file mode 100644 index 000000000..b9ce3127f --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/element/color.json @@ -0,0 +1,32 @@ +{ + "color": [ + { + "name": "ime_panel_background", + "value": "#1C1D1F" + }, + { + "name": "ime_key_background", + "value": "#393B3E" + }, + { + "name": "ime_function_background", + "value": "#2D2F32" + }, + { + "name": "ime_foreground", + "value": "#F1F3F6" + }, + { + "name": "ime_secondary", + "value": "#A6ADB8" + }, + { + "name": "ime_accent", + "value": "#0A59F7" + }, + { + "name": "ime_key_shadow", + "value": "#66000000" + } + ] +} diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_camera.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_camera.svg new file mode 100644 index 000000000..1947bd303 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_camera.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_chevron_down.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_chevron_down.svg new file mode 100644 index 000000000..79eb203b2 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_chevron_down.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_chevron_up.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_chevron_up.svg new file mode 100644 index 000000000..7944bb5d1 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_chevron_up.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_clipboard.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_clipboard.svg new file mode 100644 index 000000000..6105a3f45 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_clipboard.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_cloud.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_cloud.svg new file mode 100644 index 000000000..dd940248a --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_cloud.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_dictionary.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_dictionary.svg new file mode 100644 index 000000000..f906ccb81 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_dictionary.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_emoji.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_emoji.svg new file mode 100644 index 000000000..3fc3f3c1b --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_emoji.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_floating.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_floating.svg new file mode 100644 index 000000000..1f12e1bcd --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_floating.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_font.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_font.svg new file mode 100644 index 000000000..dd0e46b72 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_font.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_handwrite.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_handwrite.svg new file mode 100644 index 000000000..2b6046653 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_handwrite.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_keyboard.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_keyboard.svg new file mode 100644 index 000000000..3fd9ce7e9 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_keyboard.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_language.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_language.svg new file mode 100644 index 000000000..ee178fbb5 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_language.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_microphone.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_microphone.svg new file mode 100644 index 000000000..7b1b5bf93 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_microphone.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_number.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_number.svg new file mode 100644 index 000000000..38c73b411 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_number.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_onehand.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_onehand.svg new file mode 100644 index 000000000..0d0510295 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_onehand.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_phrase.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_phrase.svg new file mode 100644 index 000000000..cc301a536 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_phrase.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_pin.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_pin.svg new file mode 100644 index 000000000..d72806b07 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_pin.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_polish.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_polish.svg new file mode 100644 index 000000000..ee724a2ff --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_polish.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_refresh.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_refresh.svg new file mode 100644 index 000000000..f2815c411 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_refresh.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_settings.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_settings.svg new file mode 100644 index 000000000..58a8d05e5 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_settings.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_sound.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_sound.svg new file mode 100644 index 000000000..3dc1b69ec --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_sound.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_split.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_split.svg new file mode 100644 index 000000000..36d0b5972 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_split.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_standard.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_standard.svg new file mode 100644 index 000000000..6278a25a5 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_standard.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_swipe.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_swipe.svg new file mode 100644 index 000000000..8b4ff9da7 --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_swipe.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_switch.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_switch.svg new file mode 100644 index 000000000..f77acbd7b --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_switch.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_symbols.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_symbols.svg new file mode 100644 index 000000000..13bc9610e --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_symbols.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_tools.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_tools.svg new file mode 100644 index 000000000..12a54116b --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_tools.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_trash.svg b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_trash.svg new file mode 100644 index 000000000..4eef555cf --- /dev/null +++ b/openless-all/app/harmony/entry/src/main/resources/dark/media/ime_trash.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/harmony/entry/src/main/resources/rawfile/ime_click.wav b/openless-all/app/harmony/entry/src/main/resources/rawfile/ime_click.wav new file mode 100644 index 000000000..05bcfeae0 Binary files /dev/null and b/openless-all/app/harmony/entry/src/main/resources/rawfile/ime_click.wav differ diff --git a/openless-all/app/harmony/hvigor/hvigor-config.json5 b/openless-all/app/harmony/hvigor/hvigor-config.json5 new file mode 100644 index 000000000..eab9ccb6d --- /dev/null +++ b/openless-all/app/harmony/hvigor/hvigor-config.json5 @@ -0,0 +1,4 @@ +{ + "modelVersion": "6.0.0", + "dependencies": {} +} diff --git a/openless-all/app/harmony/hvigorfile.ts b/openless-all/app/harmony/hvigorfile.ts new file mode 100644 index 000000000..58a734550 --- /dev/null +++ b/openless-all/app/harmony/hvigorfile.ts @@ -0,0 +1,2 @@ +import { appTasks } from '@ohos/hvigor-ohos-plugin'; +export default { system: appTasks, plugins: [] }; diff --git a/openless-all/app/harmony/ipc-audit.json b/openless-all/app/harmony/ipc-audit.json new file mode 100644 index 000000000..2d3391d8e --- /dev/null +++ b/openless-all/app/harmony/ipc-audit.json @@ -0,0 +1,1338 @@ +{ + "scope": "Static shared IPC command coverage; excludes event subscriptions, dynamic commands and desktop-only calls outside src/lib/ipc.", + "total": 190, + "wired": 100, + "missing": 90, + "commands": [ + { + "name": "accept_pending_correction", + "calledFrom": [ + "src/lib/ipc/vocab.ts" + ], + "implementation": "rust" + }, + { + "name": "add_correction_rule", + "calledFrom": [ + "src/lib/ipc/vocab.ts" + ], + "implementation": "rust" + }, + { + "name": "add_learned_vocab", + "calledFrom": [ + "src/lib/ipc/vocab.ts" + ], + "implementation": "rust" + }, + { + "name": "add_vocab", + "calledFrom": [ + "src/lib/ipc/vocab.ts" + ], + "implementation": "rust" + }, + { + "name": "app_check_update_with_channel", + "calledFrom": [ + "src/lib/ipc/updater.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "app_download_and_install_android_update", + "calledFrom": [ + "src/lib/ipc/updater.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "apply_quick_note_repolish", + "calledFrom": [ + "src/lib/ipc/history.ts" + ], + "implementation": "rust" + }, + { + "name": "cancel_dictation", + "calledFrom": [ + "src/lib/ipc/dictation.ts" + ], + "implementation": "rust" + }, + { + "name": "cancel_selection_polish_preview", + "calledFrom": [ + "src/lib/ipc/selection-polish-preview.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "cancel_selection_voice_intent_prompt", + "calledFrom": [ + "src/lib/ipc/selection-voice-preview.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "cancel_voice_edit_session", + "calledFrom": [ + "src/lib/ipc/voice-edit-session.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "chat_panel_focus_keyboard", + "calledFrom": [ + "src/lib/ipc/chat-panel.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "check_accessibility_permission", + "calledFrom": [ + "src/lib/ipc/permissions.ts" + ], + "implementation": "arkts" + }, + { + "name": "check_microphone_permission", + "calledFrom": [ + "src/lib/ipc/permissions.ts" + ], + "implementation": "arkts" + }, + { + "name": "check_network", + "calledFrom": [ + "src/lib/ipc/devices.ts" + ], + "implementation": "arkts" + }, + { + "name": "clear_history", + "calledFrom": [ + "src/lib/ipc/history.ts" + ], + "implementation": "rust" + }, + { + "name": "cloud_sync_delete", + "calledFrom": [ + "src/lib/ipc/cloud-sync.ts" + ], + "implementation": "rust" + }, + { + "name": "cloud_sync_e2ee_apply_restore", + "calledFrom": [ + "src/lib/ipc/cloud-sync-e2ee.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "cloud_sync_e2ee_cancel", + "calledFrom": [ + "src/lib/ipc/cloud-sync-e2ee.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "cloud_sync_e2ee_change_password", + "calledFrom": [ + "src/lib/ipc/cloud-sync-e2ee.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "cloud_sync_e2ee_claim_setup_prompt", + "calledFrom": [ + "src/lib/ipc/cloud-sync-e2ee.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "cloud_sync_e2ee_configure_server", + "calledFrom": [ + "src/lib/ipc/cloud-sync-e2ee.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "cloud_sync_e2ee_create", + "calledFrom": [ + "src/lib/ipc/cloud-sync-e2ee.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "cloud_sync_e2ee_delete_remote", + "calledFrom": [ + "src/lib/ipc/cloud-sync-e2ee.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "cloud_sync_e2ee_get_ui_preferences", + "calledFrom": [ + "src/lib/ipc/cloud-sync-e2ee.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "cloud_sync_e2ee_lock", + "calledFrom": [ + "src/lib/ipc/cloud-sync-e2ee.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "cloud_sync_e2ee_prepare_enable", + "calledFrom": [ + "src/lib/ipc/cloud-sync-e2ee.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "cloud_sync_e2ee_preview_restore", + "calledFrom": [ + "src/lib/ipc/cloud-sync-e2ee.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "cloud_sync_e2ee_set_enabled", + "calledFrom": [ + "src/lib/ipc/cloud-sync-e2ee.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "cloud_sync_e2ee_sign_in_with_token", + "calledFrom": [ + "src/lib/ipc/cloud-sync-e2ee.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "cloud_sync_e2ee_sign_out", + "calledFrom": [ + "src/lib/ipc/cloud-sync-e2ee.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "cloud_sync_e2ee_status", + "calledFrom": [ + "src/lib/ipc/cloud-sync-e2ee.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "cloud_sync_e2ee_sync_now", + "calledFrom": [ + "src/lib/ipc/cloud-sync-e2ee.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "cloud_sync_e2ee_unlock", + "calledFrom": [ + "src/lib/ipc/cloud-sync-e2ee.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "cloud_sync_restore", + "calledFrom": [ + "src/lib/ipc/cloud-sync.ts" + ], + "implementation": "rust" + }, + { + "name": "cloud_sync_status", + "calledFrom": [ + "src/lib/ipc/cloud-sync.ts" + ], + "implementation": "rust" + }, + { + "name": "cloud_sync_upload", + "calledFrom": [ + "src/lib/ipc/cloud-sync.ts" + ], + "implementation": "rust" + }, + { + "name": "coding_agent_cancel_test", + "calledFrom": [ + "src/lib/ipc/coding-agent.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "coding_agent_command_risk", + "calledFrom": [ + "src/lib/ipc/coding-agent.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "coding_agent_detect", + "calledFrom": [ + "src/lib/ipc/coding-agent.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "coding_agent_detect_cli", + "calledFrom": [ + "src/lib/ipc/coding-agent.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "coding_agent_detect_opencode", + "calledFrom": [ + "src/lib/ipc/coding-agent.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "coding_agent_list_opencode_models", + "calledFrom": [ + "src/lib/ipc/coding-agent.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "coding_agent_run_test", + "calledFrom": [ + "src/lib/ipc/coding-agent.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "commit_voice_edit", + "calledFrom": [ + "src/lib/ipc/voice-edit-session.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "confirm_selection_polish_preview", + "calledFrom": [ + "src/lib/ipc/selection-polish-preview.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "confirm_selection_voice_intent_prompt", + "calledFrom": [ + "src/lib/ipc/selection-voice-preview.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "confirm_selection_voice_preview", + "calledFrom": [ + "src/lib/ipc/selection-voice-preview.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "copy_text_to_clipboard", + "calledFrom": [ + "src/lib/ipc/vocab.ts" + ], + "implementation": "arkts" + }, + { + "name": "create_channel", + "calledFrom": [ + "src/lib/ipc/channels.ts" + ], + "implementation": "rust" + }, + { + "name": "create_style_pack_from_template", + "calledFrom": [ + "src/lib/ipc/style-packs.ts" + ], + "implementation": "rust" + }, + { + "name": "debug_read_cursor_context", + "calledFrom": [ + "src/lib/ipc/utils.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "delete_channel", + "calledFrom": [ + "src/lib/ipc/channels.ts" + ], + "implementation": "rust" + }, + { + "name": "delete_channel_if_blank", + "calledFrom": [ + "src/lib/ipc/channels.ts" + ], + "implementation": "rust" + }, + { + "name": "delete_history_entry", + "calledFrom": [ + "src/lib/ipc/history.ts" + ], + "implementation": "rust" + }, + { + "name": "delete_style_pack", + "calledFrom": [ + "src/lib/ipc/style-packs.ts" + ], + "implementation": "rust" + }, + { + "name": "dismiss_insert_fallback_card", + "calledFrom": [ + "src/lib/ipc/vocab.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "dismiss_vocab_suggestions", + "calledFrom": [ + "src/lib/ipc/vocab.ts" + ], + "implementation": "rust" + }, + { + "name": "export_error_log", + "calledFrom": [ + "src/lib/ipc/utils.ts" + ], + "implementation": "arkts" + }, + { + "name": "export_error_log_to_downloads", + "calledFrom": [ + "src/lib/ipc/utils.ts" + ], + "implementation": "arkts" + }, + { + "name": "export_style_pack_to_zip", + "calledFrom": [ + "src/lib/ipc/style-packs.ts" + ], + "implementation": "arkts" + }, + { + "name": "fetch_latest_beta_release", + "calledFrom": [ + "src/lib/ipc/updater.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "finalize_voice_edit_dictation", + "calledFrom": [ + "src/lib/ipc/voice-edit-session.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "get_activity_stats", + "calledFrom": [ + "src/lib/ipc/history.ts" + ], + "implementation": "rust" + }, + { + "name": "get_capsule_snapshot", + "calledFrom": [ + "src/lib/ipc/dictation.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "get_credentials", + "calledFrom": [ + "src/lib/ipc/asr-credentials.ts" + ], + "implementation": "rust" + }, + { + "name": "get_default_style_system_prompts", + "calledFrom": [ + "src/lib/ipc/settings.ts" + ], + "implementation": "rust" + }, + { + "name": "get_hotkey_capability", + "calledFrom": [ + "src/lib/ipc/hotkeys.ts" + ], + "implementation": "frontend mobile capability adapter" + }, + { + "name": "get_hotkey_status", + "calledFrom": [ + "src/lib/ipc/hotkeys.ts" + ], + "implementation": "frontend mobile capability adapter" + }, + { + "name": "get_qa_hotkey_label", + "calledFrom": [ + "src/lib/ipc/qa.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "get_remote_input_status", + "calledFrom": [ + "src/lib/ipc/remote-server.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "get_selection_polish_preview", + "calledFrom": [ + "src/lib/ipc/selection-polish-preview.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "get_selection_voice_intent_prompt", + "calledFrom": [ + "src/lib/ipc/selection-voice-preview.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "get_selection_voice_preview", + "calledFrom": [ + "src/lib/ipc/selection-voice-preview.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "get_settings", + "calledFrom": [ + "src/lib/ipc/settings.ts" + ], + "implementation": "rust" + }, + { + "name": "get_settings_snapshot", + "calledFrom": [ + "src/lib/ipc/settings.ts" + ], + "implementation": "rust" + }, + { + "name": "get_startup_snapshot", + "calledFrom": [ + "src/lib/ipc/shared.ts" + ], + "implementation": "rust" + }, + { + "name": "get_update_channel", + "calledFrom": [ + "src/lib/ipc/updater.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "get_voice_edit_state", + "calledFrom": [ + "src/lib/ipc/voice-edit-session.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "get_windows_ime_status", + "calledFrom": [ + "src/lib/ipc/hotkeys.ts" + ], + "implementation": "frontend mobile capability adapter" + }, + { + "name": "github_device_flow_cancel", + "calledFrom": [ + "src/lib/ipc/github-oauth.ts" + ], + "implementation": "rust" + }, + { + "name": "github_device_flow_poll", + "calledFrom": [ + "src/lib/ipc/github-oauth.ts" + ], + "implementation": "rust" + }, + { + "name": "github_device_flow_start", + "calledFrom": [ + "src/lib/ipc/github-oauth.ts" + ], + "implementation": "rust" + }, + { + "name": "handle_window_hotkey_event", + "calledFrom": [ + "src/lib/ipc/dictation.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "import_style_pack_from_zip", + "calledFrom": [ + "src/lib/ipc/style-packs.ts" + ], + "implementation": "arkts" + }, + { + "name": "is_wayland_cli_mode", + "calledFrom": [ + "src/lib/ipc/devices.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "less_computer_approve", + "calledFrom": [ + "src/lib/ipc/less-computer.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "less_computer_submit_text", + "calledFrom": [ + "src/lib/ipc/less-computer.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "less_computer_sync", + "calledFrom": [ + "src/lib/ipc/less-computer.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "less_computer_task_cancel", + "calledFrom": [ + "src/lib/ipc/less-computer.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "less_computer_voice_cancel", + "calledFrom": [ + "src/lib/ipc/less-computer.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "less_computer_voice_start", + "calledFrom": [ + "src/lib/ipc/less-computer.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "less_computer_voice_stop", + "calledFrom": [ + "src/lib/ipc/less-computer.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "less_computer_window_dismiss", + "calledFrom": [ + "src/lib/ipc/less-computer.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "less_computer_window_open", + "calledFrom": [ + "src/lib/ipc/less-computer.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "list_channels", + "calledFrom": [ + "src/lib/ipc/channels.ts" + ], + "implementation": "rust" + }, + { + "name": "list_correction_rules", + "calledFrom": [ + "src/lib/ipc/vocab.ts" + ], + "implementation": "rust" + }, + { + "name": "list_history", + "calledFrom": [ + "src/lib/ipc/history.ts" + ], + "implementation": "rust" + }, + { + "name": "list_local_ips", + "calledFrom": [ + "src/lib/ipc/remote-server.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "list_microphone_devices", + "calledFrom": [ + "src/lib/ipc/devices.ts" + ], + "implementation": "arkts" + }, + { + "name": "list_provider_descriptors", + "calledFrom": [ + "src/lib/ipc/providers.ts" + ], + "implementation": "rust" + }, + { + "name": "list_provider_models", + "calledFrom": [ + "src/lib/ipc/asr-credentials.ts" + ], + "implementation": "rust" + }, + { + "name": "list_style_packs", + "calledFrom": [ + "src/lib/ipc/style-packs.ts" + ], + "implementation": "rust" + }, + { + "name": "list_vocab", + "calledFrom": [ + "src/lib/ipc/vocab.ts" + ], + "implementation": "rust" + }, + { + "name": "list_vocab_presets", + "calledFrom": [ + "src/lib/ipc/vocab.ts" + ], + "implementation": "rust" + }, + { + "name": "log_client_error", + "calledFrom": [ + "src/lib/ipc/utils.ts" + ], + "implementation": "arkts" + }, + { + "name": "marketplace_auth_status", + "calledFrom": [ + "src/lib/ipc/github-oauth.ts" + ], + "implementation": "rust" + }, + { + "name": "marketplace_delete", + "calledFrom": [ + "src/lib/ipc/marketplace.ts" + ], + "implementation": "rust" + }, + { + "name": "marketplace_detail", + "calledFrom": [ + "src/lib/ipc/marketplace.ts" + ], + "implementation": "rust" + }, + { + "name": "marketplace_download", + "calledFrom": [ + "src/lib/ipc/marketplace.ts" + ], + "implementation": "arkts" + }, + { + "name": "marketplace_install", + "calledFrom": [ + "src/lib/ipc/marketplace.ts" + ], + "implementation": "rust" + }, + { + "name": "marketplace_like", + "calledFrom": [ + "src/lib/ipc/marketplace.ts" + ], + "implementation": "rust" + }, + { + "name": "marketplace_list", + "calledFrom": [ + "src/lib/ipc/marketplace.ts" + ], + "implementation": "rust" + }, + { + "name": "marketplace_logout", + "calledFrom": [ + "src/lib/ipc/github-oauth.ts" + ], + "implementation": "rust" + }, + { + "name": "marketplace_my_likes", + "calledFrom": [ + "src/lib/ipc/marketplace.ts" + ], + "implementation": "rust" + }, + { + "name": "marketplace_my_packs", + "calledFrom": [ + "src/lib/ipc/marketplace.ts" + ], + "implementation": "rust" + }, + { + "name": "marketplace_upload", + "calledFrom": [ + "src/lib/ipc/marketplace.ts" + ], + "implementation": "rust" + }, + { + "name": "open_external_url", + "calledFrom": [ + "src/lib/ipc/utils.ts" + ], + "implementation": "arkts" + }, + { + "name": "open_system_settings", + "calledFrom": [ + "src/lib/ipc/permissions.ts" + ], + "implementation": "arkts" + }, + { + "name": "preview_style_pack_runtime", + "calledFrom": [ + "src/lib/ipc/style-packs.ts" + ], + "implementation": "rust" + }, + { + "name": "qa_get_snapshot", + "calledFrom": [ + "src/lib/ipc/qa.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "qa_set_edit_instruction_mode", + "calledFrom": [ + "src/lib/ipc/qa.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "qa_submit_text", + "calledFrom": [ + "src/lib/ipc/qa.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "qa_toggle_recording", + "calledFrom": [ + "src/lib/ipc/qa.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "qa_window_dismiss", + "calledFrom": [ + "src/lib/ipc/qa.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "qa_window_set_expanded", + "calledFrom": [ + "src/lib/ipc/qa.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "read_audio_recording", + "calledFrom": [ + "src/lib/ipc/history.ts" + ], + "implementation": "rust" + }, + { + "name": "read_credential", + "calledFrom": [ + "src/lib/ipc/asr-credentials.ts" + ], + "implementation": "rust" + }, + { + "name": "read_style_pack_icon", + "calledFrom": [ + "src/lib/ipc/style-packs.ts" + ], + "implementation": "rust" + }, + { + "name": "record_channel_test", + "calledFrom": [ + "src/lib/ipc/channels.ts" + ], + "implementation": "rust" + }, + { + "name": "regenerate_remote_pin", + "calledFrom": [ + "src/lib/ipc/remote-server.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "reject_pending_correction", + "calledFrom": [ + "src/lib/ipc/vocab.ts" + ], + "implementation": "rust" + }, + { + "name": "remove_correction_rule", + "calledFrom": [ + "src/lib/ipc/vocab.ts" + ], + "implementation": "rust" + }, + { + "name": "remove_vocab", + "calledFrom": [ + "src/lib/ipc/vocab.ts" + ], + "implementation": "rust" + }, + { + "name": "rename_channel", + "calledFrom": [ + "src/lib/ipc/channels.ts" + ], + "implementation": "rust" + }, + { + "name": "reorder_channels", + "calledFrom": [ + "src/lib/ipc/channels.ts" + ], + "implementation": "rust" + }, + { + "name": "repolish", + "calledFrom": [ + "src/lib/ipc/style-packs.ts" + ], + "implementation": "rust" + }, + { + "name": "report_insert_fallback_card_height", + "calledFrom": [ + "src/lib/ipc/vocab.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "request_accessibility_permission", + "calledFrom": [ + "src/lib/ipc/permissions.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "request_microphone_permission", + "calledFrom": [ + "src/lib/ipc/permissions.ts" + ], + "implementation": "arkts" + }, + { + "name": "reset_accessibility_permission_and_restart_app", + "calledFrom": [ + "src/lib/ipc/permissions.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "reset_builtin_style_pack", + "calledFrom": [ + "src/lib/ipc/style-packs.ts" + ], + "implementation": "rust" + }, + { + "name": "restart_app", + "calledFrom": [ + "src/lib/ipc/permissions.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "retranscribe_recording", + "calledFrom": [ + "src/lib/ipc/history.ts" + ], + "implementation": "rust" + }, + { + "name": "revert_selection_voice_preview", + "calledFrom": [ + "src/lib/ipc/selection-voice-preview.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "save_style_pack", + "calledFrom": [ + "src/lib/ipc/style-packs.ts" + ], + "implementation": "rust" + }, + { + "name": "save_vocab_presets", + "calledFrom": [ + "src/lib/ipc/vocab.ts" + ], + "implementation": "rust" + }, + { + "name": "set_active_asr_provider", + "calledFrom": [ + "src/lib/ipc/asr-credentials.ts" + ], + "implementation": "rust" + }, + { + "name": "set_active_llm_provider", + "calledFrom": [ + "src/lib/ipc/asr-credentials.ts" + ], + "implementation": "rust" + }, + { + "name": "set_active_omni_provider", + "calledFrom": [ + "src/lib/ipc/asr-credentials.ts" + ], + "implementation": "rust" + }, + { + "name": "set_active_style_pack", + "calledFrom": [ + "src/lib/ipc/style-packs.ts" + ], + "implementation": "rust" + }, + { + "name": "set_capsule_transcript_visible", + "calledFrom": [ + "src/lib/ipc/dictation.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "set_channel_enabled", + "calledFrom": [ + "src/lib/ipc/channels.ts" + ], + "implementation": "rust" + }, + { + "name": "set_channel_provider_type", + "calledFrom": [ + "src/lib/ipc/channels.ts" + ], + "implementation": "rust" + }, + { + "name": "set_combo_hotkey", + "calledFrom": [ + "src/lib/ipc/hotkeys.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "set_correction_rule_enabled", + "calledFrom": [ + "src/lib/ipc/vocab.ts" + ], + "implementation": "rust" + }, + { + "name": "set_credential", + "calledFrom": [ + "src/lib/ipc/asr-credentials.ts" + ], + "implementation": "rust" + }, + { + "name": "set_default_polish_mode", + "calledFrom": [ + "src/lib/ipc/style-packs.ts" + ], + "implementation": "rust" + }, + { + "name": "set_dictation_hotkey", + "calledFrom": [ + "src/lib/ipc/hotkeys.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "set_open_app_hotkey", + "calledFrom": [ + "src/lib/ipc/hotkeys.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "set_qa_hotkey", + "calledFrom": [ + "src/lib/ipc/qa.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "set_quick_note_hotkey", + "calledFrom": [ + "src/lib/ipc/hotkeys.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "set_remote_locale", + "calledFrom": [ + "src/lib/ipc/remote-server.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "set_selection_polish_hotkey", + "calledFrom": [ + "src/lib/ipc/hotkeys.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "set_settings", + "calledFrom": [ + "src/lib/ipc/settings.ts" + ], + "implementation": "rust" + }, + { + "name": "set_shortcut_recording_active", + "calledFrom": [ + "src/lib/ipc/hotkeys.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "set_style_enabled", + "calledFrom": [ + "src/lib/ipc/style-packs.ts" + ], + "implementation": "rust" + }, + { + "name": "set_style_pack_enabled", + "calledFrom": [ + "src/lib/ipc/style-packs.ts" + ], + "implementation": "rust" + }, + { + "name": "set_style_pack_hotkeys", + "calledFrom": [ + "src/lib/ipc/hotkeys.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "set_style_pack_icon", + "calledFrom": [ + "src/lib/ipc/style-packs.ts" + ], + "implementation": "rust" + }, + { + "name": "set_switch_style_hotkey", + "calledFrom": [ + "src/lib/ipc/hotkeys.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "set_translation_hotkey", + "calledFrom": [ + "src/lib/ipc/hotkeys.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "set_update_channel", + "calledFrom": [ + "src/lib/ipc/updater.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "set_vocab_enabled", + "calledFrom": [ + "src/lib/ipc/vocab.ts" + ], + "implementation": "rust" + }, + { + "name": "start_dictation", + "calledFrom": [ + "src/lib/ipc/dictation.ts" + ], + "implementation": "rust" + }, + { + "name": "start_microphone_level_monitor", + "calledFrom": [ + "src/lib/ipc/devices.ts" + ], + "implementation": "arkts" + }, + { + "name": "start_voice_edit_instruction", + "calledFrom": [ + "src/lib/ipc/voice-edit-session.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "start_voice_edit_session", + "calledFrom": [ + "src/lib/ipc/voice-edit-session.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "stop_dictation", + "calledFrom": [ + "src/lib/ipc/dictation.ts" + ], + "implementation": "rust" + }, + { + "name": "stop_microphone_level_monitor", + "calledFrom": [ + "src/lib/ipc/devices.ts" + ], + "implementation": "arkts" + }, + { + "name": "stop_voice_edit_instruction", + "calledFrom": [ + "src/lib/ipc/voice-edit-session.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "take_splash_playback", + "calledFrom": [ + "src/lib/ipc/splash.ts" + ], + "implementation": "rust" + }, + { + "name": "trigger_microphone_prompt", + "calledFrom": [ + "src/lib/ipc/permissions.ts" + ], + "implementation": "arkts" + }, + { + "name": "update_setting_fields", + "calledFrom": [ + "src/lib/ipc/settings.ts" + ], + "implementation": "rust" + }, + { + "name": "update_vocab", + "calledFrom": [ + "src/lib/ipc/vocab.ts" + ], + "implementation": "rust" + }, + { + "name": "validate_combo_hotkey", + "calledFrom": [ + "src/lib/ipc/hotkeys.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "validate_provider_credentials", + "calledFrom": [ + "src/lib/ipc/asr-credentials.ts" + ], + "implementation": "rust" + }, + { + "name": "validate_shortcut_binding", + "calledFrom": [ + "src/lib/ipc/hotkeys.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "voice_edit_window_close", + "calledFrom": [ + "src/lib/ipc/voice-edit-session.ts" + ], + "implementation": "unimplemented" + }, + { + "name": "voice_edit_window_open", + "calledFrom": [ + "src/lib/ipc/voice-edit-session.ts" + ], + "implementation": "unimplemented" + } + ] +} diff --git a/openless-all/app/harmony/oh-package.json5 b/openless-all/app/harmony/oh-package.json5 new file mode 100644 index 000000000..6c017c74d --- /dev/null +++ b/openless-all/app/harmony/oh-package.json5 @@ -0,0 +1,7 @@ +{ + "name": "openless-harmony", + "version": "0.1.0", + "description": "OpenLess HarmonyOS frontend host", + "dependencies": {}, + "modelVersion": "6.0.0" +} diff --git a/openless-all/app/harmony/rust/Cargo.toml b/openless-all/app/harmony/rust/Cargo.toml new file mode 100644 index 000000000..4625eada0 --- /dev/null +++ b/openless-all/app/harmony/rust/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "openless-harmony-host" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +openless-core = { path = "../../crates/openless-core" } +serde_json = "1" +futures-util = "0.3" +tokio = { version = "1", features = ["rt-multi-thread", "time", "net"] } +base64 = "0.22" diff --git a/openless-all/app/harmony/rust/src/credentials.rs b/openless-all/app/harmony/rust/src/credentials.rs new file mode 100644 index 000000000..cf5298dfc --- /dev/null +++ b/openless-all/app/harmony/rust/src/credentials.rs @@ -0,0 +1,492 @@ +use futures_util::future::BoxFuture; +use openless_core::{ + credentials::*, BackendError, BackendErrorCode, CredentialsStatus, UserPreferences, +}; +use std::{ + collections::HashMap, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +fn failure(e: impl std::fmt::Display) -> BackendError { + BackendError::new(BackendErrorCode::Persistence, e.to_string()) +} +fn read_metadata(path: &std::path::Path) -> Result { + match std::fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(failure), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(CredentialMetadata::default()), + Err(e) => Err(failure(e)), + } +} +type Dirty = Arc>>>; +type Values = Arc>>; +struct DiskMetadata { + path: PathBuf, + values: Values, +} +impl CredentialMetadataStore for DiskMetadata { + fn load_metadata(&self) -> BoxFuture<'static, Result> { + let path = self.path.clone(); + Box::pin(async move { read_metadata(&path) }) + } + fn save_metadata( + &self, + metadata: CredentialMetadata, + ) -> BoxFuture<'static, Result<(), BackendError>> { + let path = self.path.clone(); + Box::pin(async move { + let temporary = path.with_extension("tmp"); + std::fs::write(&temporary, serde_json::to_vec(&metadata).map_err(failure)?) + .map_err(failure)?; + std::fs::rename(temporary, path).map_err(failure) + }) + } + fn channel_has_secrets( + &self, + _kind: ChannelKind, + id: String, + ) -> BoxFuture<'static, Result> { + let values = self.values.clone(); + Box::pin(async move { + Ok(values.lock().map_err(failure)?.iter().any(|(key, value)| { + key.provider_id.as_deref() == Some(&id) && !value.expose_secret().is_empty() + })) + }) + } +} +pub struct HarmonyCredentials { + directory: CredentialDirectory, + metadata: Arc, + values: Values, + dirty: Dirty, +} +impl HarmonyCredentials { + pub fn new(path: PathBuf) -> Self { + let values = Arc::new(Mutex::new(HashMap::new())); + let metadata = Arc::new(DiskMetadata { + path, + values: values.clone(), + }); + Self { + directory: CredentialDirectory::new(metadata.clone()), + metadata, + values, + dirty: Arc::new(Mutex::new(HashMap::new())), + } + } +} +impl HarmonyCredentials { + pub fn hydrate(&self, mut key: CredentialKey, value: SecretValue) -> Result<(), BackendError> { + let legacy_omni = key.namespace == CredentialNamespace::Omni && key.provider_id.is_none(); + if legacy_omni { + key.provider_id = + Some(read_metadata(&self.metadata.path)?.active_provider(ProviderSlot::Omni)); + } + let mut values = self.values.lock().map_err(failure)?; + if legacy_omni { + // A provider-scoped Asset entry always wins over a legacy alias, + // independent of the order returned by the secure vault. + values.entry(key).or_insert(value); + } else { + values.insert(key, value); + } + Ok(()) + } + pub fn dirty_records(&self) -> Result { + Ok(serde_json::Value::Array(self.dirty.lock().map_err(failure)?.iter().map(|(key,value)|serde_json::json!({"account":key.account,"provider":key.provider_id,"value":value.as_ref().map(|v|v.expose_secret())})).collect())) + } + pub fn acknowledge_dirty(&self) -> Result<(), BackendError> { + self.dirty.lock().map_err(failure)?.clear(); + Ok(()) + } +} +impl CredentialStore for HarmonyCredentials { + fn status( + &self, + prefs: UserPreferences, + ) -> BoxFuture<'static, Result> { + let metadata = self.metadata.clone(); + let values = self.values.clone(); + Box::pin(async move { + let meta = metadata.load_metadata().await?; + let asr = meta.active_provider(ProviderSlot::Asr); + let llm = meta.active_provider(ProviderSlot::Llm); + let omni = meta.active_provider(ProviderSlot::Omni); + let values = values.lock().map_err(failure)?; + let get = |provider: &str, account: &str| { + values + .iter() + .find(|(key, _)| { + key.provider_id.as_deref().unwrap_or("") == provider + && key.account == account + }) + .map(|(_, v)| v.expose_secret().to_owned()) + }; + let has = |provider: &str, account: &str| { + get(provider, account).is_some_and(|v| !v.trim().is_empty()) + }; + let config = openless_core::provider_rules::CredentialConfiguration { + asr_api_key: has(&asr, "asr.api_key"), + asr_endpoint: has(&asr, "asr.endpoint"), + asr_model: has(&asr, "asr.model"), + volcengine_service: get(&asr, "volcengine.service"), + volcengine_auth_mode: get(&asr, "volcengine.auth_mode"), + volcengine_app_key: has(&asr, "volcengine.app_key"), + volcengine_access_key: has(&asr, "volcengine.access_key"), + volcengine_api_key: has(&asr, "volcengine.api_key"), + volcengine_resource_id: has(&asr, "volcengine.resource_id"), + xfyun_app_id: has(&asr, "xfyun.app_id"), + xfyun_api_key: has(&asr, "xfyun.api_key"), + tencent_cloud_app_id: has(&asr, "tencent_cloud.app_id"), + tencent_cloud_secret_id: has(&asr, "tencent_cloud.secret_id"), + tencent_cloud_secret_key: has(&asr, "tencent_cloud.secret_key"), + llm_api_key: has(&llm, "ark.api_key"), + llm_endpoint: has(&llm, "ark.endpoint"), + llm_model: has(&llm, "ark.model_id"), + llm_api_key_required: true, + omni_api_key: has(&omni, "omni.api_key"), + omni_endpoint: has(&omni, "omni.endpoint"), + omni_model: has(&omni, "omni.model"), + ..Default::default() + }; + let provider_type = |kind, id: &str| { + meta.list_channels(kind) + .into_iter() + .find(|c| c.id == id) + .map(|c| c.provider_type) + .unwrap_or_else(|| id.into()) + }; + let pipeline_mode = openless_core::shared_types::effective_pipeline_mode( + prefs.multimodal_pipeline_enabled, + prefs.pipeline_mode, + ); + let asr_configured = openless_core::provider_rules::asr_configured( + &provider_type(ChannelKind::Asr, &asr), + &config, + Some(false), + ); + let llm_configured = openless_core::provider_rules::llm_configured( + &provider_type(ChannelKind::Llm, &llm), + &config, + ); + Ok(CredentialsStatus { + active_asr_provider: asr, + active_llm_provider: llm, + pipeline_mode, + asr_configured, + llm_configured, + omni_configured: openless_core::provider_rules::omni_configured(&omni, &config), + volcengine_configured: openless_core::provider_rules::volcengine_configured( + &config, + ), + ark_configured: llm_configured, + }) + }) + } + fn read( + &self, + mut key: CredentialKey, + ) -> BoxFuture<'static, Result, BackendError>> { + let values = self.values.clone(); + let original_key = key.clone(); + let directory = self.directory.clone(); + Box::pin(async move { + if key.provider_id.is_none() + && matches!( + key.namespace, + CredentialNamespace::Asr | CredentialNamespace::Llm | CredentialNamespace::Omni + ) + { + key.provider_id = Some( + directory + .active_provider(match key.namespace { + CredentialNamespace::Llm => ProviderSlot::Llm, + CredentialNamespace::Omni => ProviderSlot::Omni, + _ => ProviderSlot::Asr, + }) + .await?, + ); + } + let values = values.lock().map_err(failure)?; + Ok(values + .get(&key) + .or_else(|| values.get(&original_key)) + .cloned()) + }) + } + fn write( + &self, + mut key: CredentialKey, + value: SecretValue, + ) -> BoxFuture<'static, Result<(), BackendError>> { + let values = self.values.clone(); + let dirty = self.dirty.clone(); + let directory = self.directory.clone(); + Box::pin(async move { + let legacy = key.clone(); + if key.namespace == CredentialNamespace::Omni && key.provider_id.is_none() { + key.provider_id = Some(directory.active_provider(ProviderSlot::Omni).await?); + } + let mut writes = dirty.lock().map_err(failure)?; + if key != legacy { + writes.insert(legacy, None); + } + writes.insert(key.clone(), Some(value.clone())); + drop(writes); + values.lock().map_err(failure)?.insert(key, value); + Ok(()) + }) + } + fn remove(&self, mut key: CredentialKey) -> BoxFuture<'static, Result<(), BackendError>> { + let values = self.values.clone(); + let dirty = self.dirty.clone(); + let directory = self.directory.clone(); + Box::pin(async move { + let legacy = key.clone(); + if key.namespace == CredentialNamespace::Omni && key.provider_id.is_none() { + key.provider_id = Some(directory.active_provider(ProviderSlot::Omni).await?); + } + let mut writes = dirty.lock().map_err(failure)?; + if key != legacy { + writes.insert(legacy, None); + } + writes.insert(key.clone(), None); + drop(writes); + values.lock().map_err(failure)?.remove(&key); + Ok(()) + }) + } + fn list_channels( + &self, + kind: ChannelKind, + ) -> BoxFuture<'static, Result, BackendError>> { + let d = self.directory.clone(); + Box::pin(async move { d.list_channels(kind).await }) + } + fn mutate_channel( + &self, + m: ChannelMutation, + ) -> BoxFuture<'static, Result> { + let d = self.directory.clone(); + let values = self.values.clone(); + let dirty = self.dirty.clone(); + let removed = match &m { + ChannelMutation::Delete { id, .. } | ChannelMutation::DeleteIfBlank { id, .. } => { + Some(id.clone()) + } + _ => None, + }; + Box::pin(async move { + let result = d.mutate_channel(m).await?; + if let Some(id) = + removed.filter(|_| !matches!(result, ChannelMutationResult::DeletedIfBlank(false))) + { + let mut values = values.lock().map_err(failure)?; + let keys: Vec<_> = values + .keys() + .filter(|key| key.provider_id.as_deref() == Some(&id)) + .cloned() + .collect(); + let mut dirty = dirty.lock().map_err(failure)?; + for key in keys { + values.remove(&key); + dirty.insert(key, None); + } + } + Ok(result) + }) + } + fn active_provider(&self, s: ProviderSlot) -> BoxFuture<'static, Result> { + let d = self.directory.clone(); + Box::pin(async move { d.active_provider(s).await }) + } + fn set_active_provider( + &self, + s: ProviderSlot, + id: String, + ) -> BoxFuture<'static, Result<(), BackendError>> { + let d = self.directory.clone(); + Box::pin(async move { d.set_active_provider(s, id).await }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn omni_recording_accepts_restored_settings_credentials_without_a_network_request() { + let root = std::env::temp_dir().join(format!( + "openless-harmony-omni-recording-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let runtime = Arc::new(tokio::runtime::Runtime::new().unwrap()); + let host = crate::build_host(root.clone(), root.join("cache"), runtime).unwrap(); + crate::dispatch( + &host, + "set_active_omni_provider", + serde_json::json!({"provider":"dashscope-omni"}), + ) + .unwrap(); + crate::dispatch( + &host, + "__hydrate_credential", + serde_json::json!({"account":"omni.api_key","value":"fixture-only-not-a-real-key"}), + ) + .unwrap(); + let mut prefs = host.backend.get_preferences(); + prefs.multimodal_pipeline_enabled = true; + prefs.pipeline_mode = openless_core::shared_types::PipelineMode::Multimodal; + { + let _entered = host.runtime.enter(); + host.backend + .update_settings( + prefs, + openless_core::SettingsUpdateOptions::SETTINGS_DOCUMENT, + &openless_core::settings::NoopSettingsRuntime, + ) + .unwrap(); + } + let session = crate::dispatch(&host, "start_dictation", serde_json::json!({})).unwrap(); + let id = serde_json::from_value(session.clone()).unwrap(); + host.backend.feed_external_pcm(id, &[1, 0, 2, 0]).unwrap(); + crate::dispatch( + &host, + "cancel_dictation", + serde_json::json!({"session":session}), + ) + .unwrap(); + host.runtime.block_on(host.backend.shutdown()).unwrap(); + drop(host); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn omni_unscoped_settings_bind_to_active_provider_and_restore_legacy_assets() { + let root = std::env::temp_dir().join(format!( + "openless-harmony-omni-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&root).unwrap(); + tokio::runtime::Runtime::new().unwrap().block_on(async { + let store = HarmonyCredentials::new(root.join("channels.json")); + store + .set_active_provider(ProviderSlot::Omni, "dashscope-omni".into()) + .await + .unwrap(); + let unscoped = + CredentialKey::new(CredentialNamespace::Omni, None, "omni.api_key").unwrap(); + let scoped = CredentialKey::new( + CredentialNamespace::Omni, + Some("dashscope-omni".into()), + "omni.api_key", + ) + .unwrap(); + assert!( + !store + .status(UserPreferences::default()) + .await + .unwrap() + .omni_configured + ); + store + .write(unscoped.clone(), SecretValue::new("fixture-current")) + .await + .unwrap(); + assert!( + store + .status(UserPreferences::default()) + .await + .unwrap() + .omni_configured + ); + assert_eq!( + store + .read(scoped.clone()) + .await + .unwrap() + .unwrap() + .expose_secret(), + "fixture-current" + ); + let dirty = store.dirty_records().unwrap(); + assert!(dirty + .as_array() + .unwrap() + .iter() + .any(|row| row["provider"] == "dashscope-omni" && row["value"].is_string())); + assert!(dirty + .as_array() + .unwrap() + .iter() + .any(|row| row["provider"].is_null() && row["value"].is_null())); + for legacy_first in [true, false] { + let restored = HarmonyCredentials::new(root.join("channels.json")); + if legacy_first { + restored + .hydrate(unscoped.clone(), SecretValue::new("fixture-legacy")) + .unwrap(); + } + restored + .hydrate(scoped.clone(), SecretValue::new("fixture-current")) + .unwrap(); + if !legacy_first { + restored + .hydrate(unscoped.clone(), SecretValue::new("fixture-legacy")) + .unwrap(); + } + assert!( + restored + .status(UserPreferences::default()) + .await + .unwrap() + .omni_configured + ); + assert_eq!( + restored + .read(scoped.clone()) + .await + .unwrap() + .unwrap() + .expose_secret(), + "fixture-current" + ); + } + let legacy = HarmonyCredentials::new(root.join("channels.json")); + legacy + .hydrate(unscoped.clone(), SecretValue::new("fixture-legacy")) + .unwrap(); + assert!( + legacy + .status(UserPreferences::default()) + .await + .unwrap() + .omni_configured + ); + assert!(legacy.read(scoped.clone()).await.unwrap().is_some()); + store.remove(unscoped.clone()).await.unwrap(); + assert!(store.read(scoped).await.unwrap().is_none()); + assert!( + !store + .status(UserPreferences::default()) + .await + .unwrap() + .omni_configured + ); + store + .set_active_provider(ProviderSlot::Omni, "gemini".into()) + .await + .unwrap(); + assert!(store.read(unscoped).await.unwrap().is_none()); + }); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/openless-all/app/harmony/rust/src/history.rs b/openless-all/app/harmony/rust/src/history.rs new file mode 100644 index 000000000..5029a5f5a --- /dev/null +++ b/openless-all/app/harmony/rust/src/history.rs @@ -0,0 +1,129 @@ +use crate::{required, Host}; +use base64::Engine; +use openless_core::{DictationSession, HistorySource, SessionId}; +use serde_json::{json, Value}; + +fn recording(host: &Host, args: &Value) -> Result<(DictationSession, Vec), String> { + // Canonicalize the UUID before constructing a sandbox path. + let id: SessionId = serde_json::from_value(json!(required(args, "sessionId")?)) + .map_err(|_| "invalid session id")?; + let entry = host + .backend + .list_history() + .map_err(|e| e.to_string())? + .into_iter() + .find(|entry| entry.id == id.to_string()) + .ok_or("history entry not found")?; + if host.backend.snapshot().dictation.session_id == Some(id) { + return Err("recording is still active; stop it first".into()); + } + if entry.has_audio_recording != Some(true) { + return Err("history entry has no archived recording".into()); + } + let path = host + .backend + .config() + .data_dir + .join("recordings") + .join(format!("{id}.wav")); + let size = std::fs::metadata(&path) + .map_err(|_| "recording not found")? + .len(); + if size <= 44 || size > 64 * 1024 * 1024 { + return Err("recording is empty or too large".into()); + } + let wav = std::fs::read(path).map_err(|e| e.to_string())?; + validate_wav(&wav)?; + Ok((entry, wav)) +} + +fn validate_wav(wav: &[u8]) -> Result<(), String> { + // ExternalAudioRecorder writes this canonical PCM header. Reject altered + // formats rather than sending arbitrary file bytes to the ASR provider. + if wav.len() <= 44 + || &wav[..4] != b"RIFF" + || &wav[8..12] != b"WAVE" + || &wav[12..16] != b"fmt " + || wav[16..20] != 16u32.to_le_bytes() + || wav[20..22] != 1u16.to_le_bytes() + || wav[22..24] != 1u16.to_le_bytes() + || wav[24..28] != 16000u32.to_le_bytes() + || wav[28..32] != 32000u32.to_le_bytes() + || wav[32..34] != 2u16.to_le_bytes() + || wav[34..36] != 16u16.to_le_bytes() + || &wav[36..40] != b"data" + || (wav.len() - 44) % 2 != 0 + || u32::from_le_bytes(wav[4..8].try_into().unwrap()) as usize != wav.len() - 8 + || u32::from_le_bytes(wav[40..44].try_into().unwrap()) as usize != wav.len() - 44 + { + return Err("recording is corrupt or not 16 kHz mono PCM".into()); + } + Ok(()) +} + +pub fn remove_audio(host: &Host, id: &str) -> Result<(), String> { + let session: SessionId = serde_json::from_value(json!(id)).map_err(|_| "invalid session id")?; + if host.backend.snapshot().dictation.session_id == Some(session) { + return Err("stop the active recording before deleting it".into()); + } + let path = host + .backend + .config() + .data_dir + .join("recordings") + .join(format!("{session}.wav")); + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.to_string()), + } +} + +pub fn read(host: &Host, args: &Value) -> Result { + let (_, wav) = recording(host, args)?; + Ok(json!(format!( + "data:audio/wav;base64,{}", + base64::engine::general_purpose::STANDARD.encode(wav) + ))) +} + +pub fn retranscribe(host: &Host, args: &Value) -> Result { + let (entry, wav) = recording(host, args)?; + if entry.pipeline_mode.as_deref() == Some("multimodal") + && entry.source != HistorySource::QuickNote + { + return Err("multimodal history does not support retranscription".into()); + } + let started = std::time::Instant::now(); + let result = host + .runtime + .block_on( + host.backend + .services() + .auxiliary + .retranscribe_pcm(wav[44..].to_vec()), + ) + .map_err(openless_core::RetranscriptionFailure::into_message)?; + if result.text.trim().is_empty() { + return Err("重新转录仍未识别到语音".into()); + } + let updated = if matches!( + entry.error_code.as_deref(), + Some("transcribeFailed" | "emptyTranscript" | "recording") + ) || entry.source == HistorySource::QuickNote + { + Some( + host.backend + .apply_history_retranscription( + &entry.id, + result.text.clone(), + &result.asr, + started.elapsed().as_millis() as u64, + ) + .map_err(|e| e.to_string())?, + ) + } else { + None + }; + Ok(json!({"text":result.text,"updatedEntry":updated})) +} diff --git a/openless-all/app/harmony/rust/src/lib.rs b/openless-all/app/harmony/rust/src/lib.rs new file mode 100644 index 000000000..503b3176e --- /dev/null +++ b/openless-all/app/harmony/rust/src/lib.rs @@ -0,0 +1,880 @@ +mod credentials; +mod history; +mod self_test; +mod voice_configuration; +use openless_core::{BackendConfig, BackendDependencies, OpenLessBackend}; +use serde_json::{json, Value}; +use std::{ + ffi::{CStr, CString}, + os::raw::c_char, + path::PathBuf, + sync::{Arc, Mutex, OnceLock}, +}; + +#[derive(Clone)] +struct Host { + runtime: Arc, + backend: Arc, + credentials: std::sync::Arc, +} +static HOST: OnceLock> = OnceLock::new(); +// Reload and start must use the same host. Cancellation and PCM delivery stay +// independent so a slow provider cannot block either operation. +static VOICE_CONTROL: Mutex<()> = Mutex::new(()); +fn capabilities() -> openless_core::shared_types::PlatformCapabilities { + openless_core::shared_types::PlatformCapabilities { + platform: "harmony".into(), + supports_ime_input: true, + supports_overlay: false, + supports_desktop_hotkey: false, + supports_tray: false, + supports_local_asr: false, + supports_local_qwen3_mlx: false, + supports_in_app_dictation: true, + supports_auto_update: false, + supports_voice_edit: false, + } +} +fn response(result: Result) -> *mut c_char { + let value = match result { + Ok(value) => json!({"ok":true,"value":value}), + Err(error) => json!({"ok":false,"error":error}), + }; + CString::new(value.to_string()).unwrap().into_raw() +} +unsafe fn text(ptr: *const c_char) -> Result { + if ptr.is_null() { + return Err("null argument".into()); + } + CStr::from_ptr(ptr) + .to_str() + .map(str::to_owned) + .map_err(|e| e.to_string()) +} +#[no_mangle] +pub unsafe extern "C" fn openless_init(data: *const c_char, cache: *const c_char) -> *mut c_char { + response( + std::panic::catch_unwind(|| { + if HOST.get().is_some() { + return Ok(json!(true)); + } + let data = PathBuf::from(text(data)?); + let cache = PathBuf::from(text(cache)?); + std::fs::create_dir_all(&data).map_err(|e| e.to_string())?; + std::fs::create_dir_all(&cache).map_err(|e| e.to_string())?; + let runtime = Arc::new(tokio::runtime::Runtime::new().map_err(|e| e.to_string())?); + let host = build_host(data, cache, runtime)?; + let snapshot = + serde_json::to_value(host.backend.snapshot()).map_err(|e| e.to_string())?; + HOST.set(Mutex::new(host)) + .map_err(|_| "backend already initialized".to_string())?; + Ok(snapshot) + }) + .unwrap_or_else(|_| Err("native initialization failed".into())), + ) +} +fn build_host( + data: PathBuf, + cache: PathBuf, + runtime: Arc, +) -> Result { + let mut dependencies = BackendDependencies::unsupported(); + dependencies.marketplace_config = Some(openless_core::MarketplaceConfig::production()); + let credentials = Arc::new(credentials::HarmonyCredentials::new( + data.join("channels.json"), + )); + dependencies.credential_store = credentials.clone(); + let transcription = Arc::new(openless_core::TranscriptionRouter::default()); + let cloud: Arc = Arc::new( + openless_core::SharedCloudTranscriptionEngine::with_task_spawner( + credentials.clone(), + dependencies.task_spawner.clone(), + ), + ); + for provider in openless_core::SHARED_CLOUD_ASR_PROVIDER_TYPES { + transcription + .register(*provider, cloud.clone()) + .map_err(|e| e.to_string())?; + } + let polisher = Arc::new(openless_core::TextPolisherRouter::default()); + let cloud_polisher: Arc = Arc::new( + openless_core::SharedCloudTextPolisher::new(credentials.clone()), + ); + for provider in openless_core::SHARED_CLOUD_LLM_PROVIDER_TYPES { + polisher + .register(*provider, cloud_polisher.clone()) + .map_err(|e| e.to_string())?; + } + let recorder: Arc = Arc::new( + openless_core::ExternalAudioRecorder::with_recordings_directory(data.join("recordings")), + ); + let traditional = Arc::new(openless_core::PipelineDictationEngine::new( + recorder.clone(), + transcription.clone(), + polisher.clone(), + )); + let engine = Arc::new(openless_core::DictationEngineRouter::new(traditional)); + let omni: Arc = Arc::new( + openless_core::SharedOmniDictationEngine::new(credentials.clone(), recorder), + ); + for provider in openless_core::SHARED_OMNI_PROVIDER_TYPES { + engine + .register_omni(*provider, omni.clone()) + .map_err(|e| e.to_string())?; + } + dependencies.dictation_engine = engine; + dependencies.services.configure_auxiliary_runtime( + Arc::new(openless_core::SharedAuxiliaryTextPolisher::new( + credentials.clone(), + polisher, + )), + transcription, + ); + dependencies.services.provider = Arc::new(openless_core::ProviderService::new( + credentials.clone(), + dependencies.task_spawner.clone(), + )); + let backend = Arc::new( + OpenLessBackend::new( + BackendConfig { + data_dir: data, + cache_dir: cache, + platform: capabilities(), + locale: "zh-CN".into(), + ..BackendConfig::default() + }, + dependencies, + ) + .map_err(|e| e.to_string())?, + ); + runtime + .block_on(backend.start()) + .map_err(|e| e.to_string())?; + Ok(Host { + runtime, + backend, + credentials, + }) +} +fn required<'a>(args: &'a Value, field: &str) -> Result<&'a str, String> { + args[field] + .as_str() + .ok_or_else(|| format!("{field} required")) +} +fn channel_kind(args: &Value) -> Result { + openless_core::ChannelKind::parse(required(args, "kind")?).map_err(|e| e.to_string()) +} +fn credential_key(args: &Value) -> Result { + let account = required(args, "account")?; + let namespace = if account == openless_core::MARKETPLACE_GITHUB_TOKEN_ACCOUNT { + openless_core::CredentialNamespace::Marketplace + } else if account.starts_with("ark.") { + openless_core::CredentialNamespace::Llm + } else if account.starts_with("omni.") { + openless_core::CredentialNamespace::Omni + } else { + openless_core::CredentialNamespace::Asr + }; + openless_core::CredentialKey::new( + namespace, + args["provider"].as_str().map(str::to_owned), + account, + ) + .map_err(|e| e.to_string()) +} + +fn ime_dictionary_base(host: &Host) -> PathBuf { + let config = host.backend.config(); + config + .data_dir + .parent() + .unwrap_or(&config.data_dir) + .join(openless_core::ime_dictionary::BUNDLED_DIRECTORY) +} + +fn dispatch(host: &Host, command: &str, args: Value) -> Result { + let b = &host.backend; + let value = match command { + "__export_voice_configuration" => voice_configuration::export(host)?, + "__import_voice_configuration" => voice_configuration::import(host, args)?, + "ime_dictionary_select" => json!(openless_core::ime_dictionary::select( + &ime_dictionary_base(host) + )), + "ime_dictionary_reject" => json!(openless_core::ime_dictionary::reject( + &ime_dictionary_base(host) + )), + "ime_dictionary_update" => { + json!(host + .runtime + .block_on(openless_core::ime_dictionary::update( + &ime_dictionary_base(host), + args["force"].as_bool().unwrap_or(false) + ))?) + } + "ime_cloud_candidates" => json!(host + .runtime + .block_on(openless_core::ime_cloud::query(required(&args, "pinyin")?))), + "accept_pending_correction" => { + b.accept_pending_correction(required(&args, "id")?) + .map_err(|e| e.to_string())?; + Value::Null + } + "reject_pending_correction" => { + b.reject_pending_correction(required(&args, "id")?); + Value::Null + } + "dismiss_vocab_suggestions" => { + b.dismiss_pending_corrections(); + Value::Null + } + "record_channel_test" => { + host.runtime + .block_on(b.record_channel_test( + channel_kind(&args)?, + required(&args, "id")?.into(), + args["ok"].as_bool().ok_or("ok required")?, + serde_json::from_value(args["latencyMs"].clone()).map_err(|e| e.to_string())?, + args["error"].as_str().map(str::to_owned), + )) + .map_err(|e| e.to_string())?; + Value::Null + } + "cloud_sync_status" => json!(host + .runtime + .block_on(b.cloud_sync_status()) + .map_err(|e| e.to_string())?), + "cloud_sync_upload" => json!(host + .runtime + .block_on( + b.cloud_sync_upload( + args["baseRevision"] + .as_u64() + .ok_or("baseRevision required")?, + serde_json::from_value(args["uiPreferences"].clone()) + .map_err(|e| e.to_string())? + ) + ) + .map_err(|e| e.to_string())?), + "cloud_sync_restore" => json!(host + .runtime + .block_on(b.cloud_sync_restore()) + .map_err(|e| e.to_string())?), + "cloud_sync_delete" => json!(host + .runtime + .block_on( + b.cloud_sync_delete( + args["baseRevision"] + .as_u64() + .ok_or("baseRevision required")? + ) + ) + .map_err(|e| e.to_string())?), + "__marketplace_download_archive" => { + use base64::Engine; + let bytes = host + .runtime + .block_on( + b.services() + .marketplace + .download_archive(required(&args, "packId")?.into()), + ) + .map_err(|e| e.to_string())?; + if bytes.len() > 512 * 1024 { + return Err("风格包 ZIP 最大为 512 KiB".into()); + } + json!(base64::engine::general_purpose::STANDARD.encode(bytes)) + } + "read_audio_recording" => history::read(host, &args)?, + "retranscribe_recording" => history::retranscribe(host, &args)?, + "apply_quick_note_repolish" => json!(b + .apply_history_repolish( + required(&args, "sessionId")?, + required(&args, "text")?.into(), + args["stylePackId"].as_str().map(str::to_owned) + ) + .map_err(|e| e.to_string())?), + "repolish" => json!(host + .runtime + .block_on( + b.services() + .auxiliary + .repolish(openless_core::RepolishRequest { + raw_text: required(&args, "rawText")?.into(), + style_pack_id: args["stylePackId"].as_str().map(str::to_owned), + front_app: None, + }) + ) + .map_err(|e| e.to_string())?), + "start_dictation" => json!(host + .runtime + .block_on(b.start_external_dictation_with_options( + openless_core::DictationStartOptions { + insert_text: false, + ..Default::default() + } + )) + .map_err(|e| e.to_string())?), + "stop_dictation" => json!(host + .runtime + .block_on(b.stop_dictation_session( + serde_json::from_value(args["session"].clone()).map_err(|e| e.to_string())? + )) + .map_err(|e| e.to_string())?), + "cancel_dictation" => { + let id = serde_json::from_value(args["session"].clone()).map_err(|e| e.to_string())?; + if let Err(error) = host.runtime.block_on(b.cancel_dictation(id)) { + // Cleanup may race a completed stop or a previous cancel. + // Never absorb a mismatched ID targeting a newer session. + if error.code != openless_core::BackendErrorCode::InvalidState + || error.message != "no active dictation session" + || b.snapshot().dictation.session_id.is_some() + { + return Err(error.to_string()); + } + } + Value::Null + } + "get_startup_snapshot" => { + json!({"contractVersion":openless_core::BACKEND_CONTRACT_VERSION,"backend":b.snapshot()}) + } + "__test_style_pack_roundtrip" => self_test::styles(host)?, + "__test_voice_roundtrip" => self_test::voice(host)?, + "__revision_snapshot" => json!(b.snapshot()), + "get_platform_capabilities" => json!(capabilities()), + "check_network" => host.runtime.block_on(async { + let start = std::time::Instant::now(); + let probe = openless_core::net::http() + .get(format!( + "{}/packs?limit=1", + openless_core::MARKETPLACE_BASE_URL + )) + .timeout(std::time::Duration::from_secs(8)) + .send() + .await; + match probe { + Ok(_) => json!({"online":true,"latencyMs":start.elapsed().as_millis() as u64}), + Err(_) => json!({"online":false,"latencyMs":null}), + } + }), + "get_settings" => json!(b.get_preferences()), + "get_credentials" => json!(host + .runtime + .block_on(b.get_credentials_status()) + .map_err(|e| e.to_string())?), + "get_settings_snapshot" => { + json!({"preferences":b.get_preferences(),"revision":b.snapshot().preferences_revision}) + } + "list_history" => json!(b.list_history().map_err(|e| e.to_string())?), + "delete_history_entry" => { + let id = required(&args, "id")?; + if b.snapshot() + .dictation + .session_id + .is_some_and(|session| session.to_string() == id) + { + return Err("stop the active recording before deleting it".into()); + } + b.delete_history(id).map_err(|e| e.to_string())?; + history::remove_audio(host, id)?; + Value::Null + } + "clear_history" => { + if b.snapshot().dictation.session_id.is_some() { + return Err("stop the active recording before clearing history".into()); + } + let entries = b.list_history().map_err(|e| e.to_string())?; + b.clear_history().map_err(|e| e.to_string())?; + for entry in entries { + history::remove_audio(host, &entry.id)?; + } + Value::Null + } + "list_vocab" => json!(b.list_vocabulary().map_err(|e| e.to_string())?), + "get_activity_stats" => json!(b.list_activity().map_err(|e| e.to_string())?), + "list_correction_rules" => json!(b.list_correction_rules().map_err(|e| e.to_string())?), + "add_learned_vocab" => { + b.add_learned_vocabulary(required(&args, "phrase")?.into()) + .map_err(|e| e.to_string())?; + Value::Null + } + "add_correction_rule" => json!(b + .add_correction_rule( + required(&args, "pattern")?.into(), + required(&args, "replacement")?.into() + ) + .map_err(|e| e.to_string())?), + "remove_correction_rule" => { + b.remove_correction_rule(required(&args, "id")?) + .map_err(|e| e.to_string())?; + Value::Null + } + "set_correction_rule_enabled" => { + b.set_correction_rule_enabled( + required(&args, "id")?, + args["enabled"].as_bool().ok_or("enabled required")?, + ) + .map_err(|e| e.to_string())?; + Value::Null + } + "list_vocab_presets" => json!(b.list_vocabulary_presets().map_err(|e| e.to_string())?), + "save_vocab_presets" => { + let store = serde_json::from_value(args["store"].clone()).map_err(|e| e.to_string())?; + b.save_vocabulary_presets(&store) + .map_err(|e| e.to_string())?; + Value::Null + } + "add_vocab" => json!(b + .add_vocabulary( + args["phrase"].as_str().ok_or("phrase required")?.to_owned(), + args["note"].as_str().map(str::to_owned) + ) + .map_err(|e| e.to_string())?), + "remove_vocab" => { + b.remove_vocabulary(args["id"].as_str().ok_or("id required")?) + .map_err(|e| e.to_string())?; + Value::Null + } + "set_vocab_enabled" => { + b.set_vocabulary_enabled( + args["id"].as_str().ok_or("id required")?, + args["enabled"].as_bool().ok_or("enabled required")?, + ) + .map_err(|e| e.to_string())?; + Value::Null + } + "update_vocab" => { + b.update_vocabulary_phrase( + args["id"].as_str().ok_or("id required")?, + args["phrase"].as_str().ok_or("phrase required")?.into(), + ) + .map_err(|e| e.to_string())?; + Value::Null + } + "list_style_packs" => json!(b + .list_style_packs(&b.get_preferences().active_style_pack_id) + .map_err(|e| e.to_string())?), + "create_style_pack_from_template" => json!(b + .create_style_pack( + serde_json::from_value(args["template"].clone()).map_err(|e| e.to_string())? + ) + .map_err(|e| e.to_string())?), + "save_style_pack" => json!(b + .update_style_pack( + serde_json::from_value(args["stylePack"].clone()).map_err(|e| e.to_string())? + ) + .map_err(|e| e.to_string())?), + "preview_style_pack_runtime" => json!(b.preview_style_pack_runtime( + &serde_json::from_value(args["stylePack"].clone()).map_err(|e| e.to_string())? + )), + "set_active_style_pack" => json!(b + .activate_style_pack(required(&args, "id")?) + .map_err(|e| e.to_string())?), + "set_style_pack_enabled" => { + b.set_style_pack_enabled( + required(&args, "id")?, + args["enabled"].as_bool().ok_or("enabled required")?, + ) + .map_err(|e| e.to_string())?; + json!(b + .list_style_packs(&b.get_preferences().active_style_pack_id) + .map_err(|e| e.to_string())?) + } + "read_style_pack_icon" => json!(b + .read_style_pack_icon(required(&args, "id")?) + .map_err(|e| e.to_string())?), + "set_style_pack_icon" => { + let png: Option> = + serde_json::from_value(args["png"].clone()).map_err(|e| e.to_string())?; + json!(b + .set_style_pack_icon(required(&args, "id")?, png.as_deref()) + .map_err(|e| e.to_string())?) + } + "reset_builtin_style_pack" => json!(b + .reset_builtin_style_pack(required(&args, "id")?) + .map_err(|e| e.to_string())?), + "delete_style_pack" => { + b.remove_style_pack(required(&args, "id")?) + .map_err(|e| e.to_string())?; + Value::Null + } + "set_default_polish_mode" | "set_style_enabled" => { + let mode = serde_json::from_value(args["mode"].clone()).map_err(|e| e.to_string())?; + let id = openless_core::style_packs::builtin_style_pack_id(mode); + if command == "set_default_polish_mode" { + b.activate_style_pack(id).map_err(|e| e.to_string())?; + } else { + b.set_style_pack_enabled(id, args["enabled"].as_bool().ok_or("enabled required")?) + .map_err(|e| e.to_string())?; + } + Value::Null + } + "import_style_pack_from_zip" => json!(b + .import_style_pack_path(std::path::Path::new(required(&args, "zipPath")?)) + .map_err(|e| e.to_string())?), + "export_style_pack_to_zip" => { + b.export_style_pack_path( + required(&args, "id")?, + std::path::Path::new(required(&args, "targetPath")?), + ) + .map_err(|e| e.to_string())?; + json!(required(&args, "targetPath")?) + } + "take_splash_playback" => json!(b.take_splash_playback("2")), + "set_settings" => { + let prefs = serde_json::from_value(args["prefs"].clone()).map_err(|e| e.to_string())?; + let _entered = host.runtime.enter(); + json!( + b.update_settings( + prefs, + openless_core::SettingsUpdateOptions::SETTINGS_DOCUMENT, + &openless_core::settings::NoopSettingsRuntime + ) + .map_err(|e| e.to_string())? + .preferences + ) + } + "update_setting_fields" => { + let edits = serde_json::from_value(args["edits"].clone()).map_err(|e| e.to_string())?; + let preferences = + openless_core::preference_patch::patch_preferences(&b.get_preferences(), &edits) + .map_err(|e| e.to_string())?; + let _entered = host.runtime.enter(); + let outcome = b + .update_settings( + preferences, + openless_core::SettingsUpdateOptions::STRICT + .at_revision(b.snapshot().preferences_revision), + &openless_core::settings::NoopSettingsRuntime, + ) + .map_err(|e| e.to_string())?; + json!({"preferences":outcome.preferences,"revision":b.snapshot().preferences_revision}) + } + "get_default_style_system_prompts" => json!(openless_core::StyleSystemPrompts::default()), + "list_provider_models" | "validate_provider_credentials" => { + let kind = match required(&args, "kind")? { + "asr" => openless_core::ProviderKind::Asr, + "llm" => openless_core::ProviderKind::Llm, + "omni" => openless_core::ProviderKind::Omni, + _ => return Err("invalid provider kind".into()), + }; + let request = openless_core::ProviderRequest { + kind, + channel_id: args["channelId"].as_str().map(str::to_owned), + thinking_enabled: b.get_preferences().llm_thinking_enabled, + }; + if command == "list_provider_models" { + json!(host + .runtime + .block_on(b.services().provider.list_models(request)) + .map_err(|e| e.to_string())?) + } else { + json!(host + .runtime + .block_on(b.services().provider.validate(request)) + .map_err(|e| e.to_string())?) + } + } + "github_device_flow_start" => { + let flow = host + .runtime + .block_on(b.services().marketplace.start_device_flow()) + .map_err(|e| e.to_string())?; + json!({"flowId":flow.flow_id,"userCode":flow.user_code,"verificationUri":flow.verification_uri,"interval":flow.interval_secs,"expiresIn":flow.expires_in_secs}) + } + "github_device_flow_poll" => json!(host + .runtime + .block_on( + b.services() + .marketplace + .poll_device_flow(required(&args, "flowId")?.into()) + ) + .map_err(|e| e.to_string())?), + "github_device_flow_cancel" => { + host.runtime + .block_on( + b.services() + .marketplace + .cancel_device_flow(args["flowId"].as_str().map(str::to_owned)), + ) + .map_err(|e| e.to_string())?; + Value::Null + } + "marketplace_auth_status" => json!(host + .runtime + .block_on(b.services().marketplace.auth_status()) + .map_err(|e| e.to_string())?), + "marketplace_logout" => { + host.runtime + .block_on(b.sign_out_account()) + .map_err(|e| e.to_string())?; + Value::Null + } + "marketplace_upload" => json!(host + .runtime + .block_on(b.services().marketplace.upload( + required(&args, "packId")?.into(), + args["originPackId"].as_str().map(str::to_owned) + )) + .map_err(|e| e.to_string())?), + "marketplace_like" => json!(host + .runtime + .block_on( + b.services() + .marketplace + .toggle_like(required(&args, "packId")?.into()) + ) + .map_err(|e| e.to_string())?), + "marketplace_my_likes" => json!(host + .runtime + .block_on(b.services().marketplace.my_likes()) + .map_err(|e| e.to_string())?), + "marketplace_my_packs" => json!(host + .runtime + .block_on(b.services().marketplace.my_packs()) + .map_err(|e| e.to_string())?), + "marketplace_delete" => { + host.runtime + .block_on( + b.services() + .marketplace + .delete(required(&args, "packId")?.into()), + ) + .map_err(|e| e.to_string())?; + Value::Null + } + "marketplace_list" => json!(host + .runtime + .block_on( + b.services() + .marketplace + .list(openless_core::MarketplaceQuery { + query: args["query"].as_str().map(str::to_owned), + sort: args["sort"].as_str().map(str::to_owned), + limit: args["limit"].as_u64().map(|v| v as u32) + }) + ) + .map_err(|e| e.to_string())?), + "marketplace_detail" => json!(host + .runtime + .block_on( + b.services() + .marketplace + .detail(required(&args, "packId")?.into()) + ) + .map_err(|e| e.to_string())?), + "marketplace_install" => json!(host + .runtime + .block_on( + b.services() + .marketplace + .install(required(&args, "packId")?.into()) + ) + .map_err(|e| e.to_string())?), + "list_provider_descriptors" => { + let kind = match required(&args, "kind")? { + "asr" => openless_core::ProviderKind::Asr, + "llm" => openless_core::ProviderKind::Llm, + "omni" => openless_core::ProviderKind::Omni, + _ => return Err("invalid provider kind".into()), + }; + json!(openless_core::provider_rules::provider_descriptors(kind)) + } + "list_channels" => json!(host + .runtime + .block_on(b.list_channels(channel_kind(&args)?)) + .map_err(|e| e.to_string())?), + "create_channel" => json!(host + .runtime + .block_on(b.create_channel( + channel_kind(&args)?, + required(&args, "providerType")?.into(), + args["name"].as_str().unwrap_or("").into() + )) + .map_err(|e| e.to_string())?), + "rename_channel" => { + host.runtime + .block_on(b.rename_channel( + channel_kind(&args)?, + required(&args, "id")?.into(), + required(&args, "name")?.into(), + )) + .map_err(|e| e.to_string())?; + Value::Null + } + "delete_channel" => { + host.runtime + .block_on(b.delete_channel(channel_kind(&args)?, required(&args, "id")?.into())) + .map_err(|e| e.to_string())?; + Value::Null + } + "delete_channel_if_blank" => json!(host + .runtime + .block_on( + b.delete_channel_if_blank(channel_kind(&args)?, required(&args, "id")?.into()) + ) + .map_err(|e| e.to_string())?), + "set_channel_enabled" => { + host.runtime + .block_on(b.set_channel_enabled( + channel_kind(&args)?, + required(&args, "id")?.into(), + args["enabled"].as_bool().ok_or("enabled required")?, + )) + .map_err(|e| e.to_string())?; + Value::Null + } + "set_channel_provider_type" => { + host.runtime + .block_on(b.set_channel_provider_type( + channel_kind(&args)?, + required(&args, "id")?.into(), + required(&args, "providerType")?.into(), + )) + .map_err(|e| e.to_string())?; + Value::Null + } + "reorder_channels" => { + host.runtime + .block_on(b.reorder_channels( + channel_kind(&args)?, + serde_json::from_value(args["ids"].clone()).map_err(|e| e.to_string())?, + )) + .map_err(|e| e.to_string())?; + Value::Null + } + "read_credential" => json!(host + .runtime + .block_on(b.read_credential(credential_key(&args)?)) + .map_err(|e| e.to_string())? + .map(|v| v.into_exposed())), + "set_credential" => json!(host + .runtime + .block_on(b.set_credential( + credential_key(&args)?, + openless_core::SecretValue::new(required(&args, "value")?) + )) + .map_err(|e| e.to_string())?), + "__hydrate_credential" => { + host.credentials + .hydrate( + credential_key(&args)?, + openless_core::SecretValue::new(required(&args, "value")?), + ) + .map_err(|e| e.to_string())?; + json!(host + .runtime + .block_on(b.refresh_and_publish_credentials()) + .map_err(|e| e.to_string())?) + } + "__credential_writes" => host + .credentials + .dirty_records() + .map_err(|e| e.to_string())?, + "__ack_credential_writes" => { + host.credentials + .acknowledge_dirty() + .map_err(|e| e.to_string())?; + Value::Null + } + "set_active_asr_provider" | "set_active_llm_provider" | "set_active_omni_provider" => { + let slot = match command { + "set_active_asr_provider" => openless_core::ProviderSlot::Asr, + "set_active_llm_provider" => openless_core::ProviderSlot::Llm, + _ => openless_core::ProviderSlot::Omni, + }; + json!(host + .runtime + .block_on(b.set_active_provider(slot, required(&args, "provider")?.into())) + .map_err(|e| e.to_string())?) + } + _ => return Err(format!("HarmonyOS command not implemented: {command}")), + }; + Ok(value) +} +#[no_mangle] +pub unsafe extern "C" fn openless_invoke( + command: *const c_char, + args: *const c_char, +) -> *mut c_char { + response( + std::panic::catch_unwind(|| { + let command = text(command)?; + let args = serde_json::from_str(&text(args)?).map_err(|e| e.to_string())?; + let _voice_control = if matches!( + command.as_str(), + "__reload_voice_backend" | "__import_voice_configuration" | "start_dictation" + ) { + Some( + VOICE_CONTROL + .lock() + .map_err(|_| "voice control lock poisoned")?, + ) + } else { + None + }; + let host = HOST + .get() + .ok_or("backend unavailable")? + .lock() + .map_err(|_| "backend lock poisoned")? + .clone(); + if command == "__reload_voice_backend" { + if !matches!( + host.backend.snapshot().dictation.phase, + openless_core::DictationPhase::Idle + | openless_core::DictationPhase::Completed + | openless_core::DictationPhase::Cancelled + | openless_core::DictationPhase::Failed + ) { + return Err("voice backend is busy".into()); + } + let config = host.backend.config(); + let refreshed = build_host( + config.data_dir.clone(), + config.cache_dir.clone(), + host.runtime.clone(), + )?; + *HOST + .get() + .unwrap() + .lock() + .map_err(|_| "backend lock poisoned")? = refreshed; + return Ok(Value::Null); + } + dispatch(&host, &command, args) + }) + .unwrap_or_else(|_| Err("native command failed".into())), + ) +} +#[no_mangle] +pub unsafe extern "C" fn openless_feed_pcm( + session: *const c_char, + pcm: *const u8, + length: usize, +) -> *mut c_char { + response( + std::panic::catch_unwind(|| { + if pcm.is_null() || length == 0 || length > 32768 || length % 2 != 0 { + return Err("invalid PCM frame".into()); + } + let id = serde_json::from_value(json!(text(session)?)).map_err(|e| e.to_string())?; + let host = HOST + .get() + .ok_or("backend unavailable")? + .lock() + .map_err(|_| "backend lock poisoned")? + .clone(); + host.backend + .feed_external_pcm(id, std::slice::from_raw_parts(pcm, length)) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + }) + .unwrap_or_else(|_| Err("PCM delivery failed".into())), + ) +} +#[no_mangle] +pub unsafe extern "C" fn openless_free(ptr: *mut c_char) { + if !ptr.is_null() { + drop(CString::from_raw(ptr)); + } +} diff --git a/openless-all/app/harmony/rust/src/self_test.rs b/openless-all/app/harmony/rust/src/self_test.rs new file mode 100644 index 000000000..725db8be2 --- /dev/null +++ b/openless-all/app/harmony/rust/src/self_test.rs @@ -0,0 +1,357 @@ +//! Exercises the real wire dispatcher against an isolated, disposable sandbox. +use crate::{credentials::HarmonyCredentials, dispatch, Host}; +use openless_core::{BackendConfig, BackendDependencies, OpenLessBackend}; +use serde_json::{json, Value}; +use std::{ + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; + +pub fn styles(owner: &Host) -> Result { + let root = owner.backend.config().cache_dir.join(format!( + "native-selftest-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_nanos() + )); + std::fs::create_dir(&root).map_err(|e| e.to_string())?; + let result = (|| { + let data = root.join("data"); + std::fs::create_dir(&data).map_err(|e| e.to_string())?; + let credentials = Arc::new(HarmonyCredentials::new(data.join("channels.json"))); + let mut dependencies = BackendDependencies::unsupported(); + dependencies.credential_store = credentials.clone(); + let config = BackendConfig { + data_dir: data, + cache_dir: root.clone(), + platform: crate::capabilities(), + ..Default::default() + }; + let runtime = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?; + let backend = + OpenLessBackend::new(config.clone(), dependencies).map_err(|e| e.to_string())?; + runtime + .block_on(backend.start()) + .map_err(|e| e.to_string())?; + let test = Host { + runtime: Arc::new(runtime), + backend: Arc::new(backend), + credentials, + }; + let channel = dispatch( + &test, + "create_channel", + json!({"kind":"asr","providerType":"openai","name":"isolated test"}), + )?; + let channel_id = channel.as_str().ok_or("missing channel id")?; + dispatch( + &test, + "record_channel_test", + json!({"kind":"asr","id":channel_id,"ok":false,"latencyMs":23,"error":"fixture failure"}), + )?; + let channels = dispatch(&test, "list_channels", json!({"kind":"asr"}))?; + if !channels.as_array().is_some_and(|items| { + items.iter().any(|item| { + item["id"] == channel_id + && item["lastTest"]["ok"] == false + && item["lastTest"]["latencyMs"] == 23 + }) + }) { + return Err("channel test result was not persisted".into()); + } + // Exercise shared IPC argument names and persistence without touching user data. + dispatch(&test, "add_learned_vocab", json!({"phrase":"鸿蒙接线测试"}))?; + dispatch(&test, "add_learned_vocab", json!({"phrase":"鸿蒙接线测试"}))?; + let vocab = dispatch(&test, "list_vocab", json!({}))?; + if vocab.as_array().ok_or("invalid vocab list")?.len() != 1 { + return Err("learned vocabulary did not deduplicate".into()); + } + let rule = dispatch( + &test, + "add_correction_rule", + json!({"pattern":"测试错词","replacement":"测试正词"}), + )?; + let rule_id = rule["id"].as_str().ok_or("missing rule id")?; + dispatch( + &test, + "set_correction_rule_enabled", + json!({"id":rule_id,"enabled":false}), + )?; + let rules = dispatch(&test, "list_correction_rules", json!({}))?; + if rules[0]["enabled"] != json!(false) { + return Err("correction rule disable did not persist".into()); + } + dispatch(&test, "remove_correction_rule", json!({"id":rule_id}))?; + if dispatch(&test, "list_correction_rules", json!({}))? != json!([]) { + return Err("correction rule delete failed".into()); + } + let mut presets = dispatch(&test, "list_vocab_presets", json!({}))?; + presets["disabledBuiltinPresetIds"] = json!(["harmony-selftest"]); + dispatch(&test, "save_vocab_presets", json!({"store":presets}))?; + if dispatch(&test, "list_vocab_presets", json!({}))? != presets { + return Err("vocabulary presets roundtrip failed".into()); + } + let list = dispatch(&test, "list_style_packs", json!({}))?; + let mut template = list + .as_array() + .and_then(|a| a.first()) + .cloned() + .ok_or("no builtin styles")?; + template["name"] = json!("Harmony isolated self-test"); + let mut created = dispatch( + &test, + "create_style_pack_from_template", + json!({"template":template}), + )?; + let id = created["id"].as_str().ok_or("missing style id")?.to_owned(); + created["prompt"] = json!("Harmony persistence and ZIP roundtrip test."); + dispatch(&test, "save_style_pack", json!({"stylePack":created}))?; + dispatch( + &test, + "preview_style_pack_runtime", + json!({"stylePack":created}), + )?; + dispatch(&test, "set_active_style_pack", json!({"id":id}))?; + if test.backend.get_preferences().active_style_pack_id != id { + return Err("activation did not update preferences".into()); + } + dispatch( + &test, + "set_style_pack_enabled", + json!({"id":id,"enabled":false}), + )?; + let zip = root.join("test.zip"); + dispatch( + &test, + "export_style_pack_to_zip", + json!({"id":id,"targetPath":zip}), + )?; + let imported = dispatch(&test, "import_style_pack_from_zip", json!({"zipPath":zip}))?; + if imported["prompt"] != created["prompt"] { + return Err("ZIP roundtrip changed prompt".into()); + } + let imported_id = imported["id"] + .as_str() + .ok_or("missing imported id")? + .to_owned(); + dispatch(&test, "delete_style_pack", json!({"id":id}))?; + if test.backend.get_style_pack(&id).is_ok() { + return Err("deleted style still present".into()); + } + let reopened = OpenLessBackend::new(config, BackendDependencies::unsupported()) + .map_err(|e| e.to_string())?; + if reopened.list_vocabulary().map_err(|e| e.to_string())?.len() != 1 + || json!(reopened + .list_vocabulary_presets() + .map_err(|e| e.to_string())?) + != presets + { + return Err("vocabulary data did not survive repository reopen".into()); + } + if reopened + .get_style_pack(&imported_id) + .map_err(|e| e.to_string())? + .prompt + != created["prompt"].as_str().ok_or("missing prompt")? + { + return Err("saved prompt did not survive repository reopen".into()); + } + Ok( + json!({"create":true,"save":true,"preview":true,"activate":true,"enabled":true,"export":true,"import":true,"delete":true,"persistence":true,"learnedVocabulary":true,"correctionRules":true,"vocabularyPresets":true}), + ) + })(); + if result.is_ok() { + std::fs::remove_dir_all(root).map_err(|e| e.to_string())?; + } + result +} + +/// Verifies the host's actual dictation dispatcher with fixture providers only. +/// This deliberately has no microphone, network access or user credentials. +pub fn voice(owner: &Host) -> Result { + use openless_core::{credentials::*, testing::*, SessionId}; + let root = owner.backend.config().cache_dir.join(format!( + "voice-selftest-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_nanos() + )); + std::fs::create_dir_all(&root).map_err(|e| e.to_string())?; + let result = (|| { + let transcription = Arc::new(FixtureTranscriptionEngine::successful("语音管线原文", 1000)); + let polisher = Arc::new(FixtureTextPolisher::successful("语音管线结果")); + let mut dependencies = BackendDependencies::unsupported(); + let fixture_credentials = Arc::new(InMemoryCredentialStore::default()); + let runtime = Arc::new(tokio::runtime::Runtime::new().map_err(|e| e.to_string())?); + runtime + .block_on( + fixture_credentials.write( + CredentialKey::new( + CredentialNamespace::Llm, + Some("ark".into()), + LLM_API_KEY_ACCOUNT, + ) + .map_err(|e| e.to_string())?, + SecretValue::new("isolated-fixture"), + ), + ) + .map_err(|e| e.to_string())?; + dependencies.credential_store = fixture_credentials.clone(); + dependencies.services.configure_auxiliary_runtime( + Arc::new(openless_core::SharedAuxiliaryTextPolisher::new( + fixture_credentials, + polisher.clone(), + )), + transcription.clone(), + ); + dependencies.dictation_engine = Arc::new(openless_core::PipelineDictationEngine::new( + Arc::new( + openless_core::ExternalAudioRecorder::with_recordings_directory( + root.join("data/recordings"), + ), + ), + transcription.clone(), + polisher.clone(), + )); + let backend = Arc::new( + OpenLessBackend::new( + BackendConfig { + data_dir: root.join("data"), + cache_dir: root.clone(), + platform: crate::capabilities(), + ..Default::default() + }, + dependencies, + ) + .map_err(|e| e.to_string())?, + ); + runtime + .block_on(backend.start()) + .map_err(|e| e.to_string())?; + let mut prefs = backend.get_preferences(); + prefs.record_audio_for_debug = true; + { + let _entered = runtime.enter(); + backend + .update_settings( + prefs, + openless_core::SettingsUpdateOptions::SETTINGS_DOCUMENT, + &openless_core::settings::NoopSettingsRuntime, + ) + .map_err(|e| e.to_string())?; + } + let test = Host { + runtime, + backend, + credentials: Arc::new(HarmonyCredentials::new(root.join("channels.json"))), + }; + let session = dispatch(&test, "start_dictation", json!({}))?; + let id: SessionId = serde_json::from_value(session.clone()).map_err(|e| e.to_string())?; + let pcm = vec![1u8; 32000]; + test.backend + .feed_external_pcm(id, &pcm) + .map_err(|e| e.to_string())?; + let result = dispatch(&test, "stop_dictation", json!({"session":session}))?; + if result["rawText"] != "语音管线原文" + || result["polishedText"] != "语音管线结果" + || result["inserted"] != json!("notRequested") + || transcription.pcm() != pcm + { + return Err("voice PCM and preview roundtrip failed".into()); + } + if test.backend.feed_external_pcm(id, &[1, 0]).is_ok() { + return Err("completed session accepted stale audio".into()); + } + dispatch(&test, "cancel_dictation", json!({"session":id.to_string()}))?; + let audio = dispatch( + &test, + "read_audio_recording", + json!({"sessionId":id.to_string()}), + )?; + if !audio + .as_str() + .is_some_and(|value| value.starts_with("data:audio/wav;base64,UklGR")) + { + return Err("archived audio was not returned as WAV".into()); + } + if dispatch( + &test, + "read_audio_recording", + json!({"sessionId":"../channels"}), + ) + .is_ok() + { + return Err("recording path escaped session UUID validation".into()); + } + let history_before = test.backend.list_history().map_err(|e| e.to_string())?; + let retried = dispatch( + &test, + "retranscribe_recording", + json!({"sessionId":id.to_string()}), + )?; + if retried["text"] != "语音管线原文" + || !retried["updatedEntry"].is_null() + || json!(history_before) + != json!(test.backend.list_history().map_err(|e| e.to_string())?) + { + return Err("retranscription overwrote completed history".into()); + } + if dispatch(&test, "repolish", json!({"rawText":"再次润色"}))? != "语音管线结果" { + return Err("history repolish did not use the shared runtime".into()); + } + let next = dispatch(&test, "start_dictation", json!({}))?; + let next_id: SessionId = serde_json::from_value(next.clone()).map_err(|e| e.to_string())?; + if dispatch(&test, "cancel_dictation", json!({"session":id.to_string()})).is_ok() { + return Err("stale cancellation targeted a newer session".into()); + } + if test.backend.feed_external_pcm(id, &[1, 0]).is_ok() { + return Err("new session accepted previous session audio".into()); + } + dispatch(&test, "cancel_dictation", json!({"session":next}))?; + dispatch(&test, "cancel_dictation", json!({"session":next}))?; + if test.backend.feed_external_pcm(next_id, &[1, 0]).is_ok() + || transcription.cancel_count() == 0 + { + return Err("cancelled session still accepts audio".into()); + } + dispatch(&test, "delete_history_entry", json!({"id":id.to_string()}))?; + if test + .backend + .config() + .data_dir + .join("recordings") + .join(format!("{id}.wav")) + .exists() + { + return Err("history deletion left archived audio behind".into()); + } + test.runtime + .block_on(test.backend.shutdown()) + .map_err(|e| e.to_string())?; + Ok( + json!({"fixtureProviders":true,"pcm":true,"preview":true,"noAutoInsert":true, + "cancel":true,"staleAudioRejected":true,"audioRead":true,"retranscribe":true,"repolish":true}), + ) + })(); + if result.is_ok() { + std::fs::remove_dir_all(root).map_err(|e| e.to_string())?; + } + result +} + +#[cfg(test)] +mod tests { + #[test] + fn offline_voice_dispatch_and_session_boundaries() { + let root = std::env::temp_dir().join(format!("openless-host-test-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let runtime = std::sync::Arc::new(tokio::runtime::Runtime::new().unwrap()); + let host = crate::build_host(root.join("data"), root.join("cache"), runtime).unwrap(); + assert_eq!(super::voice(&host).unwrap()["staleAudioRejected"], true); + assert_eq!(super::styles(&host).unwrap()["persistence"], true); + host.runtime.block_on(host.backend.shutdown()).unwrap(); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/openless-all/app/harmony/rust/src/voice_configuration.rs b/openless-all/app/harmony/rust/src/voice_configuration.rs new file mode 100644 index 000000000..8e9dbafc5 --- /dev/null +++ b/openless-all/app/harmony/rust/src/voice_configuration.rs @@ -0,0 +1,174 @@ +//! The IME has its own sandbox. Exchange only voice settings through the +//! app-scoped secure vault; never assume identical sandbox paths share files. +use super::*; + +const FIELDS: &[&str] = &[ + "pipelineMode", + "multimodalPipelineEnabled", + "activeAsrProvider", + "activeLlmProvider", + "activeOmniProvider", + "microphoneDeviceName", + "defaultMode", + "activeStylePackId", + "workingLanguages", + "translationTargetLanguage", + "chineseScriptPreference", + "outputLanguagePreference", + "llmThinkingEnabled", + "polishSkipMaxChars", + "stableTranscriptionEnabled", + "audioEnhancementEnabled", + "recordAudioForDebug", +]; + +pub(super) fn export(host: &Host) -> Result { + let all = json!(host.backend.get_preferences()); + let preferences: serde_json::Map = FIELDS + .iter() + .filter_map(|key| all.get(*key).map(|value| ((*key).into(), value.clone()))) + .collect(); + let path = host.backend.config().data_dir.join("channels.json"); + let channels: openless_core::credentials::CredentialMetadata = match std::fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| e.to_string())?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Default::default(), + Err(error) => return Err(error.to_string()), + }; + Ok(json!({"version":1,"preferences":preferences,"channels":channels})) +} + +pub(super) fn import(host: &Host, snapshot: Value) -> Result { + if !matches!( + host.backend.snapshot().dictation.phase, + openless_core::DictationPhase::Idle + | openless_core::DictationPhase::Completed + | openless_core::DictationPhase::Cancelled + | openless_core::DictationPhase::Failed + ) { + return Err("voice backend is busy".into()); + } + if snapshot["version"] != 1 { + return Err("unsupported voice configuration version".into()); + } + let fields = snapshot["preferences"] + .as_object() + .ok_or("voice preferences required")?; + if fields.keys().any(|key| !FIELDS.contains(&key.as_str())) { + return Err("unsupported voice preference".into()); + } + let channels: openless_core::credentials::CredentialMetadata = + serde_json::from_value(snapshot["channels"].clone()).map_err(|e| e.to_string())?; + let directory = &host.backend.config().data_dir; + let marker = directory.join("ime-voice-configuration.json"); + if std::fs::read(&marker) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .as_ref() + == Some(&snapshot) + { + return Ok(json!(false)); + } + let mut preferences = json!(host.backend.get_preferences()); + for (key, value) in fields { + preferences[key] = value.clone(); + } + let preferences = serde_json::from_value(preferences).map_err(|e| e.to_string())?; + match std::fs::remove_file(&marker) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.to_string()), + } + // Each file is atomically replaced; the marker is committed last so an + // interrupted import is retried before any recording can start. + let temporary = directory.join("ime-voice-channels.tmp"); + std::fs::write( + &temporary, + serde_json::to_vec(&channels).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + std::fs::rename(temporary, directory.join("channels.json")).map_err(|e| e.to_string())?; + let _entered = host.runtime.enter(); + host.backend + .update_settings( + preferences, + openless_core::SettingsUpdateOptions::SETTINGS_DOCUMENT, + &openless_core::settings::NoopSettingsRuntime, + ) + .map_err(|e| e.to_string())?; + let temporary = directory.join("ime-voice-configuration.tmp"); + std::fs::write( + &temporary, + serde_json::to_vec(&snapshot).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + std::fs::rename(temporary, marker).map_err(|e| e.to_string())?; + Ok(json!(true)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn voice_configuration_crosses_independent_sandboxes_before_recording() { + let root = std::env::temp_dir().join(format!( + "openless-voice-sandboxes-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let runtime = Arc::new(tokio::runtime::Runtime::new().unwrap()); + let main = build_host(root.join("main"), root.join("main-cache"), runtime.clone()).unwrap(); + let ime = build_host(root.join("ime"), root.join("ime-cache"), runtime.clone()).unwrap(); + dispatch( + &main, + "set_active_omni_provider", + json!({"provider":"dashscope-omni"}), + ) + .unwrap(); + let mut prefs = json!(main.backend.get_preferences()); + prefs["pipelineMode"] = json!("multimodal"); + prefs["multimodalPipelineEnabled"] = json!(true); + dispatch(&main, "set_settings", json!({"prefs":prefs})).unwrap(); + assert_eq!( + dispatch(&ime, "get_credentials", json!({})).unwrap()["pipelineMode"], + "traditional" + ); + let snapshot = export(&main).unwrap(); + assert!(snapshot["preferences"] + .get("syncCustomServerOrigin") + .is_none()); + assert_eq!(import(&ime, snapshot.clone()).unwrap(), true); + assert_eq!(import(&ime, snapshot.clone()).unwrap(), false); + // Reload just like VoiceClient, then restore legacy vault aliases. The + // main app's provider metadata must already be present at this point. + let restored = build_host(root.join("ime"), root.join("ime-cache"), runtime).unwrap(); + dispatch( + &restored, + "__hydrate_credential", + json!({"account":"omni.api_key","value":"fixture-only"}), + ) + .unwrap(); + let status = dispatch(&restored, "get_credentials", json!({})).unwrap(); + assert_eq!(status["pipelineMode"], "multimodal"); + assert_eq!(status["omniConfigured"], true); + let session = dispatch(&restored, "start_dictation", json!({})).unwrap(); + dispatch(&restored, "cancel_dictation", json!({"session":session})).unwrap(); + dispatch(&ime, "set_default_polish_mode", json!({"mode":"raw"})).unwrap(); + assert_eq!( + import(&ime, snapshot.clone()).unwrap(), + false, + "unchanged main settings preserve keyboard mode" + ); + assert_eq!(json!(ime.backend.get_preferences())["defaultMode"], "raw"); + let mut invalid = snapshot; + invalid["preferences"]["syncAutoSync"] = json!(true); + assert!(import(&ime, invalid).is_err()); + for host in [main, ime, restored] { + host.runtime.block_on(host.backend.shutdown()).unwrap(); + } + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/openless-all/app/mobile-ime/CMakeLists.txt b/openless-all/app/mobile-ime/CMakeLists.txt new file mode 100644 index 000000000..570db9181 --- /dev/null +++ b/openless-all/app/mobile-ime/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.16) +project(openless_rime LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +if(NOT OPENLESS_RIME_PREFIX) + message(FATAL_ERROR "Build scripts/build-mobile-rime.py first and set OPENLESS_RIME_PREFIX") +endif() +add_library(openless_rime_engine STATIC rime_engine.cpp) +set_target_properties(openless_rime_engine PROPERTIES POSITION_INDEPENDENT_CODE ON) +target_include_directories(openless_rime_engine PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} ${OPENLESS_RIME_PREFIX}/include) +target_link_libraries(openless_rime_engine PUBLIC + ${OPENLESS_RIME_PREFIX}/lib/librime.a + ${OPENLESS_RIME_PREFIX}/lib/libyaml-cpp.a + ${OPENLESS_RIME_PREFIX}/lib/libleveldb.a + ${OPENLESS_RIME_PREFIX}/lib/libopencc.a + ${OPENLESS_RIME_PREFIX}/lib/libmarisa.a) +if(ANDROID) + add_library(openless_rime SHARED android_jni.cpp) + target_link_libraries(openless_rime PRIVATE openless_rime_engine) +endif() diff --git a/openless-all/app/mobile-ime/README.md b/openless-all/app/mobile-ime/README.md new file mode 100644 index 000000000..ce45fc01c --- /dev/null +++ b/openless-all/app/mobile-ime/README.md @@ -0,0 +1,52 @@ +# OpenLess 移动端 Rime + +Android 和鸿蒙直接链接 librime 1.17.0,共用本目录 C ABI 会话实现;不依赖 Fcitx / Fcitx5。Linux 现有插件不在本次迁移范围。 + +`rime_engine.cpp` 提供初始化、创建会话、按键、候选选择、确认、读取状态、取消、提交确认和销毁。原生客户端对象不跨平台传递。待提交文本保留至平台写入成功后 ack;重试或取消由宿主处理。密码框不进入中文路径,禁用学习时使用 `enable_user_dict: false` 的独立拼音方案。 + +鸿蒙通过 `libopenless_harmony.so` 的 `rimeAsync` 在工作线程调用 C ABI;Android 通过 `libopenless_rime.so` 的 JNI 调用。两端都保留各自原生 IME 生命周期及文本提交接口。 + +构建使用 `scripts/build-mobile-rime.py`,依赖、词库、编译产物在 `/data-win/openless-cache/mobile-rime`,可通过 `OPENLESS_CACHE_ROOT` 覆盖。引擎版本和输入资源修订固定在脚本中;开发构建不下载个人词库或上传按键数据。 + +首次构建: + +```bash +python3 scripts/build-mobile-rime.py harmony +python3 scripts/build-mobile-rime.py android --ndk /path/to/android-ndk +``` + +Android 支持 `--abi arm64-v8a|armeabi-v7a|x86|x86_64`。`--stage` 将对应 ABI 原生库、libc++ 和词库复制到已生成的 Android 工程;正常 `npm run tauri:android:build` 自动执行该步骤。 + +鸿蒙签名包和真机自检已通过;Android arm64 Release APK 已构建,x86_64 的真实 JNI、词库及 Android 控制器通过 CI 模拟器测试。Android 实机手感、其余 ABI 和旧学习词库迁移尚待验证。 + +当前使用官方 `pinyin_simp` 的原生处理链,接入固定修订 `da1fbe60` 的雾凇拼写规则与中文字表、基础、扩展、腾讯、杂项词库,保留上游版权和 GPL-3.0 许可。生成资源将 `cn_dicts/*` 的名称展平,避免移动资源路径差异;没有引入雾凇 Lua 日期、计算器、置顶等工具,也未打包语法模型。它不是 Linux 所有 Rime 方案与自定义词库的完整替代;更多方案、用户词库迁移和键盘体验需要逐步完善。 + +Android 保留现有键盘布局和候选展开列表。后端通过 `candidates` 返回原生顺序的前 64 个候选,通过 `select_absolute` 按绝对索引及版本选择;鸿蒙使用 `candidates(offset@revision)` 按需读取后续最多 32 个候选,并返回 `candidateOffset` 和 `candidateHasNext`,再通过 `select_absolute` 选词。该读取直接使用 librime 候选迭代器,不翻动当前菜单页或改变高亮,确保预加载不影响空格和标点确认;旧版本的范围请求会拒绝。分页 `select` 接口仍按当前页索引工作。只读 `state` / `candidates` 不增加候选版本,避免后台刷新使可见候选失效。Android 现有展开列表仍最多显示 64 个;鸿蒙按需追加范围,直到当前拼音的候选耗尽,不代表穷举词库或增加词条。 + +Android 的空格、删除、回车与字母在同一个串行队列中执行,是否仍有拼音组合由执行时的 Rime 状态决定;拼音中的单引号作为音节分隔符处理。刷新键盘重新读取组合和候选。上屏失败保留待提交文字及其后的标点,成功才确认,输入会话切换则清除队列所属状态。回归测试覆盖快打后连续空格/删除/回车、首屏以外的候选选择、只读刷新、分隔符、写入失败重试及编辑器切换。 + +普通输入框使用共同的 `openless_pinyin_learning` 方案:启用 Rime 原生用户词典、组句和补全,沿用 `rime-user/pinyin_simp.userdb`,保留此前 Android 已学到的数据。鸿蒙普通输入框也使用该方案。选词调频和从分段选词学习新词均由 Rime 完成,宿主不另存完整输入历史或上传按键。会话销毁时由 Rime 提交用户词典事务,下一会话继续使用。当前未打包语法模型,因此不启用依赖该模型的上下文重新排序,也不声称已达到商业输入法的语境预测能力。 + +Android 继续遵守 `IME_FLAG_NO_PERSONALIZED_LEARNING` 和密码字段策略;不学习的会话使用独立 `openless_pinyin_private` 方案,既不训练也不读取个人词典。鸿蒙密码框不创建中文会话;普通字段的本地学习在获取系统输入框属性后才启用,自检始终使用私密会话。共享资源目录升级为 `rime-shared-1.17.0-ice-da1fbe60-v1`,避免已安装应用因旧资源缓存而漏装学习方案;个人词典目录保持原位。 + +旧 Android LitePinyin 算法及内置 TSV 已删除;旧备份格式只保留一个不参与输入的兼容适配器。 + +可评估的上游扩展(当前未启用):[librime-predict](https://github.com/rime/librime-predict) 提供下一个词预测,需额外预测数据;[librime-lua](https://github.com/hchunhui/librime-lua) 用于复用成熟方案脚本;[librime-octagram](https://github.com/lotem/librime-octagram) 及其模型可用于语法加权。扩展与数据需分别验证 OHOS / Android 构建、版本兼容及安装体积,不自行实现平行算法。当前个人学习直接使用 librime 原生用户词典。 + +## 云候选 + +初次核对时,本机默认是开启云拼音的 libime;2026-10-18 再次核对时已改为 Rime / 雾凇,且额外启用万象 LTS 语法模型。移动端保留 librime;两端通过 `openless-core::ime_cloud` 使用 Fcitx5 默认的 GoogleCN 拼音协议。参考来源:[Fcitx5 cloudpinyin](https://github.com/fcitx/fcitx5-chinese-addons/tree/master/modules/cloudpinyin)。仅发送当前 2–64 字节的小写拼音及分隔符,不发送上下文、账号或个人词典,不记录查询或响应内容。没有可用性承诺;失败返回空候选,离线 Rime 不等待网络。 + +默认关闭。鸿蒙在键盘工具中显示 Google 来源和发送说明,Android 在键盘设置中提供相同说明和开关。密码框、属性未知/私密输入及 Android 禁止个性化学习字段不查询。180 毫秒防抖、1.2 秒网络超时、64 KiB 响应上限;每次输入/切框均验证版本,旧结果不得显示或提交。开启开关后,首选本地候选保留,去重的云候选从第二位开始补充。 + +云候选选中后通过共用 `external` 请求携带候选版本,保留待提交文字至宿主 ack;不直接学习为 Rime 用户词条。它不属于原生词典的选词调频,不能声称云候选已自动训练到本地。已有 Rime 用户词典路径和名称保持不变,旧 LitePinyin 数据仍未迁移。 + +## 公共词库更新与本地学习 + +两端通过共用 `openless-core::ime_dictionary` 定期检查上游 `iDvel/rime-ice` 的公开版本,固定到同一个提交后下载五张兼容的中文字表及许可证,不下载或执行上游脚本。自动更新默认开启,可在键盘工具/设置中关闭;成功检查后每周再查,失败每日重试,鸿蒙支持手动检查。调度发生在应用使用期间,不要求系统后台定时权限。 + +公开版本号、词表大小、UTF-8、表名和头部结构经过检查,下载写入独立快照,全部完成后才发布。正在使用的引擎不重载;下次初始化时选择新快照,部署失败退回此前版本或内置资源。保留运行版本和回退版本,清理本模块创建的过期公共快照。公共文件位于共享资源目录的 `public-updates-v1`,不覆盖相邻的 `rime-user`。更新不会改变 Rime 原生用户词典的名字、路径或学习策略。 + +更新网络请求只有公共版本/文件地址,不包含按键、拼音、个人词条或用户词典。不上传个人数据,也不会把逐次云候选下载为一个离线服务。关闭云候选后仍能使用离线词库及原生选词调频、分段造词。万象模型和 octagram 依赖仍未集成,不能仅靠公共词表更新就宣称与本机模型配置具有相同的长句排序。 + +回归包括更新间隔、失败回退、快照选择、错误路径/元数据、保留个人数据及初始化失败重试。真实公共下载验证使用 `ime_dictionary::tests::live_public_snapshot`(默认忽略,显式运行);Android 词库与学习测试禁用网络更新,保持确定性。 diff --git a/openless-all/app/mobile-ime/android_jni.cpp b/openless-all/app/mobile-ime/android_jni.cpp new file mode 100644 index 000000000..4c63bcaae --- /dev/null +++ b/openless-all/app/mobile-ime/android_jni.cpp @@ -0,0 +1,30 @@ +#include +#include "rime_engine.h" +#include +#include +static std::string read(JNIEnv* env, jstring value) { + if (!value) return {}; + // Convert using Java UTF-8, not JNI modified UTF-8 (emoji and embedded NUL). + auto bytes = static_cast(env->CallObjectMethod(value, + env->GetMethodID(env->FindClass("java/lang/String"), "getBytes", "(Ljava/lang/String;)[B"), env->NewStringUTF("UTF-8"))); + if (!bytes || env->ExceptionCheck()) return {}; + auto n = env->GetArrayLength(bytes); + std::string result(n, '\0'); + env->GetByteArrayRegion(bytes, 0, n, reinterpret_cast(result.data())); + env->DeleteLocalRef(bytes); + return result; +} +extern "C" JNIEXPORT jstring JNICALL Java_com_openless_app_OpenLessRime_nativeRequest( + JNIEnv* env, jobject, jstring operation, jstring session, jstring argument) { + auto op = read(env, operation), id = read(env, session), arg = read(env, argument); + if (env->ExceptionCheck()) return nullptr; + auto response = openless_rime_request(op.c_str(), id.c_str(), arg.c_str()); + if (!response) return nullptr; + auto bytes = env->NewByteArray(std::strlen(response)); + env->SetByteArrayRegion(bytes, 0, std::strlen(response), reinterpret_cast(response)); + auto result = static_cast(env->NewObject(env->FindClass("java/lang/String"), + env->GetMethodID(env->FindClass("java/lang/String"), "", "([BLjava/lang/String;)V"), bytes, env->NewStringUTF("UTF-8"))); + env->DeleteLocalRef(bytes); + openless_rime_free(response); + return result; +} diff --git a/openless-all/app/mobile-ime/rime_engine.cpp b/openless-all/app/mobile-ime/rime_engine.cpp new file mode 100644 index 000000000..91ad0b013 --- /dev/null +++ b/openless-all/app/mobile-ime/rime_engine.cpp @@ -0,0 +1,180 @@ +#include "rime_engine.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +std::mutex mutex; +RimeApi* api = nullptr; +std::string shared_dir, user_dir; +struct Session { std::string pending; unsigned long revision = 0; bool learning = false; }; +std::map sessions; +std::string quote(const std::string& value) { + std::string out = "\""; + const char* hex = "0123456789abcdef"; + for (unsigned char c : value) { + if (c == '"' || c == '\\') { out += '\\'; out += c; } + else if (c < 32) { out += "\\u00"; out += hex[c >> 4]; out += hex[c & 15]; } + else out += c; + } + return out + "\""; +} +std::string snapshot(RimeSessionId id, bool expanded = false, int offset = 0, int limit = 64) { + auto& session = sessions.at(id); + RIME_STRUCT(RimeCommit, commit); + if (api->get_commit(id, &commit)) { + if (commit.text) session.pending += commit.text; + api->free_commit(&commit); + } + RIME_STRUCT(RimeContext, context); + if (!api->get_context(id, &context)) throw std::runtime_error("Rime context unavailable"); + std::string out = "{\"ok\":true,\"session\":" + quote(std::to_string(id)) + + ",\"learning\":" + (session.learning ? "true" : "false") + + ",\"revision\":" + std::to_string(session.revision) + + ",\"commit\":" + quote(session.pending) + + ",\"input\":" + quote(api->get_input(id) ? api->get_input(id) : "") + + ",\"preedit\":" + quote(context.composition.preedit ? context.composition.preedit : "") + + ",\"page\":" + std::to_string(context.menu.page_no) + + ",\"hasNext\":" + (context.menu.is_last_page ? "false" : "true") + ",\"candidates\":["; + bool more = !context.menu.is_last_page; + if (expanded) { + // Android's existing overflow list needs more than a single menu page. + // Keep the native order and use absolute indices for this bounded list. + RimeCandidateListIterator iterator{}; + more = false; + if (api->candidate_list_from_index(id, &iterator, offset)) { + int count = 0; + while (count < limit && api->candidate_list_next(&iterator)) { + if (count++) out += ','; + out += quote(iterator.candidate.text ? iterator.candidate.text : ""); + } + more = count == limit && api->candidate_list_next(&iterator); + api->candidate_list_end(&iterator); + } + } else { + for (int i = 0; i < context.menu.num_candidates; ++i) { + if (i) out += ','; + out += quote(context.menu.candidates[i].text ? context.menu.candidates[i].text : ""); + } + } + out += "],\"candidateOffset\":" + std::to_string(expanded ? offset : context.menu.page_no * context.menu.page_size) + + ",\"candidateHasNext\":" + (more ? "true" : "false") + "}"; + api->free_context(&context); + return out; +} +std::string request(const std::string& op, const std::string& handle, const std::string& arg) { + if (op == "initialize") { + if (api) { + if (shared_dir != handle || user_dir != arg) throw std::runtime_error("Rime already initialized with another profile"); + return "{\"ok\":true}"; + } + if (handle.empty() || arg.empty()) throw std::runtime_error("Rime data directories required"); + shared_dir = handle; user_dir = arg; + std::filesystem::create_directories(user_dir); + auto candidate = rime_get_api(); + RIME_STRUCT(RimeTraits, traits); + traits.shared_data_dir = shared_dir.c_str(); traits.user_data_dir = user_dir.c_str(); + traits.distribution_name = "OpenLess"; traits.distribution_code_name = "openless"; + traits.distribution_version = "1.17.0"; traits.app_name = "rime.openless"; + candidate->setup(&traits); + candidate->initialize(&traits); + if (candidate->start_maintenance(True)) candidate->join_maintenance_thread(); + for (const auto* name : {"openless_pinyin_private", "openless_pinyin_learning"}) { + const auto schema = shared_dir + "/" + name + ".schema.yaml"; + if (!candidate->deploy_schema(schema.c_str())) { + candidate->finalize(); + throw std::runtime_error("Rime schema deployment failed"); + } + } + api = candidate; + return "{\"ok\":true}"; + } + if (!api) throw std::runtime_error("Rime is not initialized"); + if (op == "create") { + auto id = api->create_session(); + if (!id) throw std::runtime_error("Rime session creation failed"); + if (!api->select_schema(id, arg == "learning" ? "openless_pinyin_learning" : "openless_pinyin_private")) { + api->destroy_session(id); + throw std::runtime_error("Rime pinyin_simp schema unavailable"); + } + api->set_option(id, "ascii_mode", False); + // Sessions with learning disabled must not update the user dictionary. + Session session; + session.learning = arg == "learning"; + sessions.emplace(id, session); + return snapshot(id); + } + size_t consumed = 0; + auto id = static_cast(std::stoull(handle, &consumed)); + if (consumed != handle.size() || !sessions.count(id) || !api->find_session(id)) + throw std::runtime_error("Rime session is stale"); + if (op == "destroy") { + api->destroy_session(id); sessions.erase(id); return "{\"ok\":true}"; + } + auto& session = sessions.at(id); + int candidate_offset = 0; + int candidate_limit = 64; + if (op == "candidates" && !arg.empty()) { + const auto separator = arg.find('@'); + if (separator == std::string::npos) throw std::runtime_error("Rime candidate revision required"); + size_t n = 0, r = 0; + const auto offset = std::stoul(arg.substr(0, separator), &n); + const auto revision = std::stoul(arg.substr(separator + 1), &r); + if (n != separator || r != arg.size() - separator - 1 || revision != session.revision || + offset > static_cast(std::numeric_limits::max())) + throw std::runtime_error("Rime candidate range is stale or invalid"); + candidate_offset = static_cast(offset); + candidate_limit = 32; + } + if (op == "ack") session.pending.clear(); + else if (op == "clear") { api->clear_composition(id); session.pending.clear(); } + else if (op != "state" && op != "candidates") { + if (!session.pending.empty()) throw std::runtime_error("Rime commit requires acknowledgement"); + if (op == "key") { + size_t n = 0; auto key = std::stoi(arg, &n); + if (n != arg.size()) throw std::runtime_error("Invalid Rime key"); + api->process_key(id, key, 0); + } else if (op == "external") { + const auto separator = arg.find('@'); + if (separator == std::string::npos) throw std::runtime_error("External candidate revision required"); + size_t n = 0; + const auto revision = std::stoul(arg.substr(0, separator), &n); + const auto text = arg.substr(separator + 1); + if (n != separator || revision != session.revision || text.empty() || text.size() > 512 || + text.find_first_of("\r\n\t") != std::string::npos) + throw std::runtime_error("External candidate is stale or invalid"); + api->clear_composition(id); + session.pending = text; + } else if (op == "confirm") { + api->commit_composition(id); + } else if (op == "select" || op == "select_absolute") { + const auto separator = arg.find('@'); + if (separator == std::string::npos) throw std::runtime_error("Rime candidate revision required"); + size_t n = 0; auto index = std::stoul(arg.substr(0, separator), &n); + size_t r = 0; auto revision = std::stoul(arg.substr(separator + 1), &r); + if (n != separator || r != arg.size() - separator - 1 || revision != session.revision || + !(op == "select_absolute" ? api->select_candidate(id, index) : api->select_candidate_on_current_page(id, index))) + throw std::runtime_error("Rime candidate is stale"); + } else throw std::runtime_error("Unknown Rime operation"); + } + // Observing a composition must not invalidate the displayed candidates. + if (op != "state" && op != "candidates") ++session.revision; + return snapshot(id, op == "candidates", candidate_offset, candidate_limit); +} +} +extern "C" char* openless_rime_request(const char* operation, const char* session, const char* argument) { + std::lock_guard lock(mutex); + std::string response; + try { response = request(operation ? operation : "", session ? session : "", argument ? argument : ""); } + catch (const std::exception& error) { response = "{\"ok\":false,\"error\":" + quote(error.what()) + "}"; } + auto result = static_cast(std::malloc(response.size() + 1)); + if (result) std::memcpy(result, response.c_str(), response.size() + 1); + return result; +} +extern "C" void openless_rime_free(char* response) { std::free(response); } diff --git a/openless-all/app/mobile-ime/rime_engine.h b/openless-all/app/mobile-ime/rime_engine.h new file mode 100644 index 000000000..9137d5ccc --- /dev/null +++ b/openless-all/app/mobile-ime/rime_engine.h @@ -0,0 +1,4 @@ +#pragma once +// Returned UTF-8 JSON is owned by the caller and freed with openless_rime_free. +extern "C" char* openless_rime_request(const char* operation, const char* session, const char* argument); +extern "C" void openless_rime_free(char* response); diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 79e57b2f0..24a577ecd 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -7,7 +7,7 @@ "pretest": "npm run build", "test": "node scripts/frontend-test-runner.mjs", "dev": "vite", - "prebuild": "node scripts/android-ipc-import-boundary.test.mjs", + "prebuild": "node scripts/android-ipc-import-boundary.test.mjs && node scripts/harmony-entry-startup.test.mjs && node scripts/harmony-app-dictation.test.mjs && node scripts/harmony-voice-configuration.test.mjs && node scripts/harmony-panel-layout.test.mjs && node scripts/harmony-editor-compat.test.mjs && node scripts/harmony-clipboard-history.test.mjs && node scripts/harmony-symbol-gesture.test.mjs && node scripts/harmony-rime-startup.test.mjs", "build": "tsc && vite build", "preview": "vite preview", "tauri": "tauri", diff --git a/openless-all/app/scripts/audit-harmony-ipc.mjs b/openless-all/app/scripts/audit-harmony-ipc.mjs new file mode 100644 index 000000000..7ceadf0b4 --- /dev/null +++ b/openless-all/app/scripts/audit-harmony-ipc.mjs @@ -0,0 +1,26 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +const root = fileURLToPath(new URL('../', import.meta.url)); +const ipc = path.join(root, 'src/lib/ipc'); +const calls = new Map(); +for (const file of fs.readdirSync(ipc).filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts') && f !== 'mock-data.ts')) { + const text = fs.readFileSync(path.join(ipc, file), 'utf8'); + for (const match of text.matchAll(/(?:invokeOrMock|invoke)(?:<[^;]*?>)?\(\s*['"]([a-z][a-z0-9_]+)['"]/g)) { + const name = match[1]; + if (!calls.has(name)) calls.set(name, []); + calls.get(name).push(`src/lib/ipc/${file}`); + } +} +const rust = fs.readFileSync(path.join(root, 'harmony/rust/src/lib.rs'), 'utf8'); +const supported = new Map(); +for (const arm of rust.matchAll(/^\s*((?:"[a-z][a-z0-9_]+"\s*\|\s*)*"[a-z][a-z0-9_]+")\s*=>/gm)) { + for (const name of arm[1].matchAll(/"([^"]+)"/g)) supported.set(name[1], 'rust'); +} +const ark = fs.readFileSync(path.join(root, 'harmony/entry/src/main/ets/HarmonyApi.ets'), 'utf8'); +for (const match of ark.matchAll(/case '([a-z][a-z0-9_]+)'\s*:/g)) supported.set(match[1], 'arkts'); +for (const name of ['get_windows_ime_status', 'get_hotkey_status', 'get_hotkey_capability']) supported.set(name, 'frontend mobile capability adapter'); +const commands = [...calls].sort(([a], [b]) => a.localeCompare(b)).map(([name, calledFrom]) => ({name, calledFrom: [...new Set(calledFrom)], implementation: supported.get(name) ?? 'unimplemented'})); +const report = {scope:'Static shared IPC command coverage; excludes event subscriptions, dynamic commands and desktop-only calls outside src/lib/ipc.', total:commands.length, wired:commands.filter((x)=>x.implementation!=='unimplemented').length, missing:commands.filter((x)=>x.implementation==='unimplemented').length, commands}; +fs.writeFileSync(path.join(root, 'harmony/ipc-audit.json'), JSON.stringify(report, null, 2) + '\n'); +console.log(`Harmony IPC: ${report.wired}/${report.total} wired; ${report.missing} unimplemented. Report: harmony/ipc-audit.json`); diff --git a/openless-all/app/scripts/build-android-local.sh b/openless-all/app/scripts/build-android-local.sh new file mode 100644 index 000000000..f22a52992 --- /dev/null +++ b/openless-all/app/scripts/build-android-local.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail +APP_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CACHE_ROOT="${OPENLESS_CACHE_ROOT:-/data-win/openless-cache}" +export JAVA_HOME="${OPENLESS_ANDROID_JAVA_HOME:-/usr/lib/jvm/java-21-openjdk-amd64}" +export ANDROID_HOME="${ANDROID_HOME:-$CACHE_ROOT/mobile-rime/android-sdk}" +export ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$ANDROID_HOME/android-ndk-r26b}" +export NDK_HOME="$ANDROID_NDK_HOME" +export ANDROID_USER_HOME="$CACHE_ROOT/android-user" +export GRADLE_USER_HOME="$CACHE_ROOT/gradle" +export CARGO_HOME="$CACHE_ROOT/cargo" +export CARGO_TARGET_DIR="$CACHE_ROOT/android-target" +export npm_config_cache="$CACHE_ROOT/npm" +export OPENLESS_ANDROID_TARGETS="${OPENLESS_ANDROID_TARGETS:-aarch64}" +export PATH="$JAVA_HOME/bin:$PATH" +if [ ! -f "$NDK_HOME/build/cmake/android.toolchain.cmake" ]; then + echo 'Android NDK missing; set ANDROID_NDK_HOME.' >&2 + exit 1 +fi +cd "$APP_ROOT" +if [ ! -f src-tauri/gen/android/app/build.gradle.kts ]; then + npm run tauri -- android init --ci +fi +node scripts/copy-android-scaffolding.mjs +node scripts/merge-android-v1-manifest.mjs +node scripts/merge-android-overlay-manifest.mjs +node scripts/merge-android-shizuku-manifest.mjs +node scripts/patch-android-shizuku-deps.mjs +node scripts/merge-android-updater-manifest.mjs +npm run tauri:android:build:debug diff --git a/openless-all/app/scripts/build-harmony-backend.sh b/openless-all/app/scripts/build-harmony-backend.sh new file mode 100755 index 000000000..90ee36593 --- /dev/null +++ b/openless-all/app/scripts/build-harmony-backend.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +APP_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CACHE_ROOT="${OPENLESS_CACHE_ROOT:-/data-win/openless-cache}" +TOOLCHAIN="${OPENLESS_HARMONY_TOOLCHAIN:-$HOME/.local/share/harmony/current}" +NATIVE="$TOOLCHAIN/sdk/default/openharmony/native" +export CARGO_HOME="$CACHE_ROOT/cargo" +export CARGO_TARGET_DIR="$CACHE_ROOT/harmony/rust-target" +export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_OHOS_LINKER="$NATIVE/llvm/bin/aarch64-unknown-linux-ohos-clang" +export CC_aarch64_unknown_linux_ohos="$CARGO_TARGET_AARCH64_UNKNOWN_LINUX_OHOS_LINKER" +export AR_aarch64_unknown_linux_ohos="$NATIVE/llvm/bin/llvm-ar" +cargo build --manifest-path "$APP_ROOT/Cargo.toml" -p openless-harmony-host --target aarch64-unknown-linux-ohos --release diff --git a/openless-all/app/scripts/build-harmony-frontend.sh b/openless-all/app/scripts/build-harmony-frontend.sh new file mode 100755 index 000000000..de0fc9fca --- /dev/null +++ b/openless-all/app/scripts/build-harmony-frontend.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail +APP_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CACHE_ROOT="${OPENLESS_CACHE_ROOT:-/data-win/openless-cache}" +HARMONY_TOOLCHAIN="${OPENLESS_HARMONY_TOOLCHAIN:-$HOME/.local/share/harmony/current}" +export DEVECO_SDK_HOME="$HARMONY_TOOLCHAIN/sdk" +export DEVECO_NODE_HOME="$HARMONY_TOOLCHAIN/tool/node" +export NODE_HOME="$DEVECO_NODE_HOME" +export PATH="$HARMONY_TOOLCHAIN/bin:$DEVECO_NODE_HOME/bin:$PATH" +export HVIGOR_USER_HOME="$CACHE_ROOT/harmony/hvigor" +export npm_config_cache="$CACHE_ROOT/npm" +export VITE_OPENLESS_PLATFORM=harmony +SIGNING_DIR="${OPENLESS_HARMONY_SIGNING_DIR:-$HOME/.local/share/harmony/signing/local-test}" +export OPENLESS_HARMONY_CLIPBOARD_ACCESS="${OPENLESS_HARMONY_CLIPBOARD_ACCESS:-0}" +export OPENLESS_HARMONY_STARTUP_PROBES="${OPENLESS_HARMONY_STARTUP_PROBES:-0}" +case "$OPENLESS_HARMONY_CLIPBOARD_ACCESS" in + 0|1) ;; + *) echo 'OPENLESS_HARMONY_CLIPBOARD_ACCESS must be 0 or 1' >&2; exit 1 ;; +esac +case "$OPENLESS_HARMONY_STARTUP_PROBES" in + 0|1) ;; + *) echo 'OPENLESS_HARMONY_STARTUP_PROBES must be 0 or 1' >&2; exit 1 ;; +esac +BUILD_PROJECT="$CACHE_ROOT/harmony/openless-project" +WEB_DIST="$CACHE_ROOT/harmony/web-dist" +export OPENLESS_RIME_PREFIX="$CACHE_ROOT/mobile-rime/harmony/install" +if [ ! -f "$OPENLESS_RIME_PREFIX/lib/librime.a" ] || [ ! -f "$CACHE_ROOT/mobile-rime/data/openless-data-version.txt" ]; then + python3 "$APP_ROOT/scripts/build-mobile-rime.py" harmony +fi +mkdir -p "$BUILD_PROJECT" "$HVIGOR_USER_HOME" "$CACHE_ROOT/npm" +bash "$APP_ROOT/scripts/build-harmony-backend.sh" +cd "$APP_ROOT" +npm run build -- --base=./ --outDir="$WEB_DIST" +# Build a disposable copy so every Hvigor intermediate stays in the shared cache. +python3 - "$APP_ROOT/harmony" "$BUILD_PROJECT" "$WEB_DIST" "$SIGNING_DIR" <<'PY' +from pathlib import Path +import json, os, shutil, sys +source, target, web, signing = map(Path, sys.argv[1:]) +shutil.copytree(source, target, dirs_exist_ok=True, ignore=shutil.ignore_patterns('.hvigor', 'build', 'oh_modules', 'local.properties')) +if os.environ['OPENLESS_HARMONY_CLIPBOARD_ACCESS'] == '1': + manifest = target / 'entry/src/main/module.json5' + module = json.loads(manifest.read_text()) + module['module']['requestPermissions'].append({ + 'name': 'ohos.permission.READ_PASTEBOARD', + 'reason': '$string:clipboard_reason', + 'usedScene': {'abilities': ['EntryAbility', 'OpenLessInputMethod'], 'when': 'inuse'}, + }) + manifest.write_text(json.dumps(module, ensure_ascii=False, indent=2)) + flags = target / 'entry/src/main/resources/base/element/boolean.json' + flags.write_text(json.dumps({'boolean': [{'name': 'ime_clipboard_auto_read', 'value': True}]})) +shutil.copytree(source.parent / 'mobile-ime', target / 'mobile-ime', dirs_exist_ok=True) +shutil.copytree(target.parents[1] / 'mobile-rime/data', target / 'entry/src/main/resources/rawfile/rime', dirs_exist_ok=True) +resources = target / 'entry/src/main/resources/rawfile/web' +if resources.exists(): + shutil.rmtree(resources) +shutil.copytree(web, resources) +# ArkWeb rawfile has an opaque origin. Classic scripts/styles load locally; +# ES modules and crossorigin-tagged assets require a supported origin scheme. +index = resources / 'index.html' +html = index.read_text().replace('type="module" ', '').replace(' crossorigin', '') +# Keep execution after the root element is parsed, matching module-script timing. +html = html.replace('''') +index.write_text(html) +libs = target / 'entry/src/main/libs/arm64-v8a' +libs.mkdir(parents=True, exist_ok=True) +shutil.copy2(target.parent / 'rust-target/aarch64-unknown-linux-ohos/release/libopenless_harmony_host.so', libs) + +PY +cd "$BUILD_PROJECT" +ohpm install --all --cache "$CACHE_ROOT/harmony/ohpm" +hvigorw --mode module -p product=default -p module=entry@default -p buildMode=debug assembleHap --no-daemon +printf '\nHAP output: %s/entry/build/default/outputs/default/\n' "$BUILD_PROJECT" + +if [[ -f "$SIGNING_DIR/debug.cer" && -f "$SIGNING_DIR/debug.p7b" ]]; then + python3 "$APP_ROOT/scripts/sign-harmony-hap.py" "$HARMONY_TOOLCHAIN" "$SIGNING_DIR" "$BUILD_PROJECT/entry/build/default/outputs/default" +fi diff --git a/openless-all/app/scripts/build-mobile-rime.py b/openless-all/app/scripts/build-mobile-rime.py new file mode 100644 index 000000000..dfb724ed7 --- /dev/null +++ b/openless-all/app/scripts/build-mobile-rime.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Build the same pinned librime for OHOS and Android, outside the checkout.""" +import argparse +import concurrent.futures +import json +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import urllib.request + +APP = Path(__file__).resolve().parents[1] +CACHE = Path(os.environ.get('OPENLESS_CACHE_ROOT', '/data-win/openless-cache')) / 'mobile-rime' +SOURCES = { + 'rime': ('rime/librime', '1.17.0'), + 'yaml': ('jbeder/yaml-cpp', '0.8.0'), + 'leveldb': ('google/leveldb', '1.23'), + 'opencc': ('BYVoid/OpenCC', 'ver.1.1.9'), + 'pinyin': ('rime/rime-pinyin-simp', '0c6861ef7420ee780270ca6d993d18d4101049d0'), + 'ice': ('iDvel/rime-ice', 'da1fbe602e38f26db846fa10120ee64c2b0324c0'), + 'essay': ('rime/rime-essay', '054920de4f54c9e5994276a96a4fc2a35cb51aa3'), + 'prelude': ('rime/rime-prelude', '082425ea0684bca36474415d4a0e8db9b016487e'), +} + +def fetch(name, url): + dest = CACHE / 'sources' / name + if dest.exists(): + return dest + archive = CACHE / 'downloads' / (name + '.tar.gz') + archive.parent.mkdir(parents=True, exist_ok=True) + if not archive.exists(): + print('Download ' + name, flush=True) + temporary = archive.with_suffix('.partial') + with urllib.request.urlopen(url, timeout=120) as response, temporary.open('wb') as output: + shutil.copyfileobj(response, output) + temporary.rename(archive) + unpack = CACHE / 'sources' / (name + '-unpack') + unpack.mkdir(parents=True, exist_ok=True) + with tarfile.open(archive) as tar: + tar.extractall(unpack, filter='data') + roots = list(unpack.iterdir()) + if len(roots) != 1: + raise RuntimeError('Unexpected archive structure: ' + name) + roots[0].rename(dest) + unpack.rmdir() + return dest + +def run(*args): + subprocess.run([str(x) for x in args], check=True) + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('platform', choices=['harmony', 'android']) + parser.add_argument('--ndk', default=os.environ.get('ANDROID_NDK_HOME')) + parser.add_argument('--jobs', type=int, default=2) + parser.add_argument('--abi', choices=['arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64'], default='arm64-v8a') + parser.add_argument('--stage', action='store_true', help='Stage JNI and data into the generated Android app') + opts = parser.parse_args() + toolchain = Path(os.environ.get('OPENLESS_HARMONY_TOOLCHAIN', Path.home() / '.local/share/harmony/current')) + cmake = toolchain / 'sdk/default/openharmony/native/build-tools/cmake/bin/cmake' + if not cmake.exists(): + cmake = Path(shutil.which('cmake') or 'cmake') + if opts.platform == 'harmony': + cross = ['-DCMAKE_TOOLCHAIN_FILE=' + str(toolchain / 'sdk/default/openharmony/native/build/cmake/ohos.toolchain.cmake'), + '-DOHOS_ARCH=arm64-v8a', '-DOHOS_PLATFORM=OHOS', '-DOHOS_STL=c++_shared'] + else: + if not opts.ndk: + parser.error('Android requires --ndk or ANDROID_NDK_HOME') + cross = ['-DCMAKE_TOOLCHAIN_FILE=' + str(Path(opts.ndk) / 'build/cmake/android.toolchain.cmake'), + '-DANDROID_ABI=' + opts.abi, '-DANDROID_PLATFORM=android-24', '-DANDROID_STL=c++_shared'] + CACHE.mkdir(parents=True, exist_ok=True) + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: + futures = {name: pool.submit(fetch, name + '-' + ref, f'https://codeload.github.com/{repo}/tar.gz/{ref}') + for name, (repo, ref) in SOURCES.items()} + boost_future = pool.submit(fetch, 'boost-1.85.0', 'https://archives.boost.io/release/1.85.0/source/boost_1_85_0.tar.bz2') + src = {name: future.result() for name, future in futures.items()} + boost = boost_future.result() + # OHOS, like Android, has no pthread cancellation API. Keep atomic + # shared_ptr synchronization; skip only cancellation around nanosleep. + compatibility = boost / 'boost/core/detail/sp_thread_sleep.hpp' + code = compatibility.read_text() + if '&& !defined(__OHOS__)' not in code: + code = code.replace('defined(BOOST_HAS_PTHREADS) && !defined(__ANDROID__)', + 'defined(BOOST_HAS_PTHREADS) && !defined(__ANDROID__) && !defined(__OHOS__)') + if compatibility.read_text() != code: + compatibility.write_text(code) + root = CACHE / opts.platform + if opts.platform == 'android': + root = root / opts.abi + prefix = root / 'install' + (prefix / 'lib').mkdir(parents=True, exist_ok=True) + (prefix / 'include').mkdir(exist_ok=True) + common = cross + ['-DCMAKE_BUILD_TYPE=Release', '-DCMAKE_POSITION_INDEPENDENT_CODE=ON', + '-DBUILD_SHARED_LIBS=OFF', '-DCMAKE_INSTALL_PREFIX=' + str(prefix), + '-DCMAKE_PREFIX_PATH=' + str(prefix)] + + def build(name, extra, target=None, install=True): + directory = root / ('build-' + name) + run(cmake, '-S', src[name], '-B', directory, *common, *extra) + command = [cmake, '--build', directory, '--parallel', opts.jobs] + if target: + command += ['--target', target] + run(*command) + if install: + run(cmake, '--install', directory) + return directory + + build('yaml', ['-DYAML_CPP_BUILD_TESTS=OFF', '-DYAML_CPP_BUILD_TOOLS=OFF']) + build('leveldb', ['-DLEVELDB_BUILD_TESTS=OFF', '-DLEVELDB_BUILD_BENCHMARKS=OFF', '-DCMAKE_CXX_FLAGS=-Wno-unused-command-line-argument']) + directory = build('opencc', ['-DENABLE_GTEST=OFF', '-DENABLE_BENCHMARK=OFF'], 'libopencc', False) + for name in ['libopencc.a', 'libmarisa.a']: + matches = list(directory.rglob(name)) + if not matches: + raise RuntimeError('Missing dependency ' + name) + shutil.copy2(matches[0], prefix / 'lib' / name) + headers = prefix / 'include/opencc' + headers.mkdir(exist_ok=True) + for path in list((src['opencc'] / 'src').glob('*.h')) + list((src['opencc'] / 'src').glob('*.hpp')): + shutil.copy2(path, headers / path.name) + for path in directory.rglob('opencc_export.h'): + shutil.copy2(path, headers / path.name) + marisa_headers = src['opencc'] / 'deps/marisa-0.2.6/include' + shutil.copytree(marisa_headers, prefix / 'include', dirs_exist_ok=True) + directory = build('rime', ['-DBUILD_SHARED_LIBS=OFF', '-DBUILD_STATIC=ON', '-DBUILD_TEST=OFF', + '-DBUILD_DATA=OFF', '-DENABLE_LOGGING=OFF', '-DENABLE_EXTERNAL_PLUGINS=OFF', + '-DCMAKE_CXX_FLAGS=-D__OHOS__' if opts.platform == 'harmony' else '-DCMAKE_CXX_FLAGS=', + '-DBOOST_ROOT=' + str(boost), '-DBoost_INCLUDE_DIR=' + str(boost), + '-DYamlCpp_INCLUDE_PATH=' + str(prefix / 'include'), '-DYamlCpp_NEW_API=' + str(prefix / 'include'), '-DYamlCpp_LIBRARY=' + str(prefix / 'lib/libyaml-cpp.a'), + '-DLevelDb_INCLUDE_PATH=' + str(prefix / 'include'), '-DLevelDb_LIBRARY=' + str(prefix / 'lib/libleveldb.a'), + '-DMarisa_INCLUDE_PATH=' + str(prefix / 'include'), '-DMarisa_LIBRARY=' + str(prefix / 'lib/libmarisa.a'), + '-DOpencc_INCLUDE_PATH=' + str(prefix / 'include'), '-DOpencc_LIBRARY=' + str(prefix / 'lib/libopencc.a')], 'rime-static', False) + shutil.copy2(directory / 'lib/librime.a', prefix / 'lib/librime.a') + shutil.copy2(src['rime'] / 'src/rime_api.h', prefix / 'include/rime_api.h') + data = CACHE / 'data' + data.mkdir(exist_ok=True) + for name in ['pinyin', 'prelude']: + for path in src[name].glob('*.yaml'): + shutil.copy2(path, data / path.name) + for filename in ['LICENSE', 'LICENSE.txt', 'COPYING']: + if (src[name] / filename).exists(): + shutil.copy2(src[name] / filename, data / (name + '-' + filename)) + # The bundled stroke dictionary is already attributed by this repository. + shutil.copy2(APP / 'third_party/rime-stroke/stroke.dict.yaml', data / 'stroke.dict.yaml') + (data / 'stroke.schema.yaml').write_text('schema:\n schema_id: stroke\n name: 笔画\nengine:\n translators: [table_translator]\ntranslator:\n dictionary: stroke\n', encoding='utf-8') + (data / 'default.custom.yaml').write_text('patch:\n schema_list:\n - schema: pinyin_simp\n menu/page_size: 8\n', encoding='utf-8') + # Native Rime dictionary/spelling from upstream Ice. Lua utility processors + # are not required for its dictionaries and are intentionally not advertised. + ice = src['ice'] + notice = '# OpenLess modification (2026-10-17): flattened import names for mobile assets.\n' + dictionary = notice + (ice / 'rime_ice.dict.yaml').read_text() + for table in ['8105', 'base', 'ext', 'tencent', 'others']: + dictionary = dictionary.replace('cn_dicts/' + table, 'ice_' + table) + content = (ice / 'cn_dicts' / (table + '.dict.yaml')).read_text() + content = content.replace('name: ' + table, 'name: ice_' + table, 1) + content = content.replace('name: cn_dicts/' + table, 'name: ice_' + table, 1) + (data / ('ice_' + table + '.dict.yaml')).write_text(notice + content) + (data / 'rime_ice.dict.yaml').write_text(dictionary) + shutil.copy2(ice / 'LICENSE', data / 'rime-ice-LICENSE') + shutil.copy2(src['essay'] / 'essay.txt', data / 'essay.txt') + shutil.copy2(src['essay'] / 'LICENSE', data / 'essay-LICENSE') + shutil.copy2(src['essay'] / 'AUTHORS', data / 'essay-AUTHORS') + base_schema = (data / 'pinyin_simp.schema.yaml').read_text() + import re + ice_schema = (ice / 'rime_ice.schema.yaml').read_text() + speller = ice_schema[ice_schema.index('\nspeller:') + 1:] + base_schema = re.sub(r'(?ms)^speller:\n.*?(?=^translator:)', lambda _: speller + '\n', base_schema) + base_schema = base_schema.replace(' dictionary: pinyin_simp', ' dictionary: rime_ice') + base_schema = base_schema.replace('\ntranslator:\n', '\ntranslator:\n enable_word_completion: true\n') + base_schema = base_schema.replace(' segmentors:', ' filters: [uniquifier]\n segmentors:') + (data / 'openless-data-version.txt').write_text('ice-da1fbe60-v1\n') + # Both platforms share the same user dictionary and stock Rime learning. + # No platform-specific frequency overlay competes with native ranking. + learning = base_schema.replace('schema_id: pinyin_simp', 'schema_id: openless_pinyin_learning') + learning = learning.replace('\ntranslator:\n', '\ntranslator:\n enable_user_dict: true\n user_dict: pinyin_simp\n enable_sentence: true\n enable_completion: true\n') + (data / 'openless_pinyin_learning.schema.yaml').write_text(learning) + private = base_schema.replace('schema_id: pinyin_simp', 'schema_id: openless_pinyin_private') + private = private.replace('\ntranslator:\n', '\ntranslator:\n enable_user_dict: false\n contextual_suggestions: false\n') + (data / 'openless_pinyin_private.schema.yaml').write_text(private) + (data / 'manifest.json').write_text(json.dumps([p.name for p in data.iterdir() if p.is_file() and p.name != 'manifest.json'])) + shutil.copy2(APP / 'third_party/rime-stroke/LICENSE', data / 'stroke-LICENSE') + shutil.copy2(src['rime'] / 'LICENSE', data / 'librime-LICENSE') + for name, path in [('boost', boost / 'LICENSE_1_0.txt'), + ('yaml-cpp', src['yaml'] / 'LICENSE'), + ('leveldb', src['leveldb'] / 'LICENSE'), + ('opencc', src['opencc'] / 'LICENSE'), + ('marisa', src['opencc'] / 'deps/marisa-0.2.6/COPYING.md')]: + if not path.is_file(): + raise RuntimeError('Dependency license missing: ' + str(path)) + shutil.copy2(path, data / (name + '-LICENSE')) + (data / 'manifest.json').write_text(json.dumps([p.name for p in data.iterdir() if p.is_file() and p.name != 'manifest.json'])) + (CACHE / 'sources.json').write_text(json.dumps(SOURCES, indent=2) + '\n') + print('Rime prefix: ' + str(prefix), flush=True) + print('Rime data: ' + str(data), flush=True) + if opts.platform == 'android': + bridge = root / 'build-bridge' + run(cmake, '-S', APP / 'mobile-ime', '-B', bridge, *common, + '-DOPENLESS_RIME_PREFIX=' + str(prefix)) + run(cmake, '--build', bridge, '--parallel', opts.jobs) + if not opts.stage: + print('Android JNI: ' + str(bridge / 'libopenless_rime.so'), flush=True) + return + destination = APP / 'src-tauri/gen/android/app/src/main' + if not destination.exists(): + raise RuntimeError('Run tauri android init before staging Rime Android assets') + libraries = destination / ('jniLibs/' + opts.abi) + libraries.mkdir(parents=True, exist_ok=True) + shutil.copy2(bridge / 'libopenless_rime.so', libraries) + triple = {'arm64-v8a':'aarch64-linux-android', 'armeabi-v7a':'arm-linux-androideabi', 'x86':'i686-linux-android', 'x86_64':'x86_64-linux-android'}[opts.abi] + stl = list((Path(opts.ndk) / 'toolchains/llvm/prebuilt').glob('*/sysroot/usr/lib/' + triple + '/libc++_shared.so')) + if not stl: + raise RuntimeError('Android libc++_shared.so missing') + shutil.copy2(stl[0], libraries) + shutil.copytree(data, destination / 'assets/rime', dirs_exist_ok=True) + +if __name__ == '__main__': + main() diff --git a/openless-all/app/scripts/copy-android-scaffolding.mjs b/openless-all/app/scripts/copy-android-scaffolding.mjs index b0295ae8f..b2eaaf4ca 100644 --- a/openless-all/app/scripts/copy-android-scaffolding.mjs +++ b/openless-all/app/scripts/copy-android-scaffolding.mjs @@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readdirSync, + rmSync, readFileSync, statSync, writeFileSync, @@ -66,10 +67,9 @@ const KOTLIN_FILES = [ 'ImePrivacyPolicy.kt', 'StrokeInputController.kt', 'StrokeInput.kt', - 'LitePinyinController.kt', - 'LitePinyinRepository.kt', - 'LitePinyinUserFrequency.kt', - 'LitePinyinLearnedPhrases.kt', + 'OpenLessRime.kt', + 'RimePinyinController.kt', + 'LegacyPinyinBackup.kt', 'OpenLessSettingsExport.kt', 'StrokePhraseRepository.kt', 'StrokeUserFrequency.kt', @@ -102,6 +102,7 @@ const KOTLIN_TEST_FILES = [ const KOTLIN_ANDROID_TEST_FILES = [ 'OpenLessCredentialVaultInstrumentedTest.kt', 'ImeLearningMigrationTest.kt', + 'RimeEngineIntegrationTest.kt', ]; const XML_FILES = [ @@ -399,6 +400,29 @@ function main() { if (existsSync(androidAssetsRoot)) { copyDirectoryContents(androidAssetsRoot, assetsDest, dryRun); } + // Generated projects survive upgrades. Retire only known obsolete files so + // an incremental build cannot keep packaging the removed pinyin engine. + for (const name of [ + 'LitePinyinController.kt', + 'LitePinyinRepository.kt', + 'LitePinyinUserFrequency.kt', + 'LitePinyinLearnedPhrases.kt', + ]) { + const path = join(kotlinDest, name); + if (dryRun) console.log(`[dry-run] Would remove retired source ${path}`); + else rmSync(path, { force: true }); + } + for (const name of [ + 'pinyin_chars.tsv', + 'pinyin_phrases.tsv', + 'pinyin_chars.LICENSE.txt', + 'pinyin_phrases.LICENSE.txt', + 'pinyin_dictionary_meta.json', + ]) { + const path = join(assetsDest, name); + if (dryRun) console.log(`[dry-run] Would remove retired asset ${path}`); + else rmSync(path, { force: true }); + } copyNamedFiles(KOTLIN_FILES, kotlinRoot, kotlinDest, dryRun); ensureMainActivityOpen(dryRun); copyNamedFiles(KOTLIN_TEST_FILES, kotlinTestRoot, kotlinTestDest, dryRun); diff --git a/openless-all/app/scripts/generate-pinyin-characters.mjs b/openless-all/app/scripts/generate-pinyin-characters.mjs deleted file mode 100644 index 8eb624220..000000000 --- a/openless-all/app/scripts/generate-pinyin-characters.mjs +++ /dev/null @@ -1,162 +0,0 @@ -// Generates the Android Pinyin-mode single-character dictionary asset -// (android/assets/pinyin_chars.tsv) from three local, commit-pinned source -// files: -// -// 1. mozillazg/pinyin-data's kMandarin_8105.txt (MIT) — the canonical -// character SET and each character's single primary reading, scoped -// to 《通用规范汉字表》(2013), per the lite-pinyin plan's own 7.1 -// requirement. This file deliberately records only one reading per -// character (its own stated purpose), so it cannot supply polyphones -// by itself — see (2) below. -// 2. mozillazg/pinyin-data's pinyin.txt (MIT) — the same project's full -// Unihan-derived reading list, comma-separated per character, used -// only to supplement ADDITIONAL readings for characters already -// selected via kMandarin_8105.txt (plan 6.1: "从 pinyin.txt 补充多音字"). -// 3. rime/rime-pinyin-simp's pinyin_simp.dict.yaml (Apache-2.0) — supplies -// real usage-frequency weights per (character, reading) pair, which -// neither pinyin-data file has. Also decides which of a polyphone's -// extra readings from (2) are actually "常用" (commonly used) enough -// to keep (plan 7.1 point 4) — any extra reading below -// MIN_ALT_READING_WEIGHT is dropped as too rare to be worth a -// candidate-list slot. -// -// Re-fetch the pinned sources with: -// curl -L -o /tmp/kMandarin_8105.txt \ -// https://raw.githubusercontent.com/mozillazg/pinyin-data/923b108dc5d45dee061324c011b478fb649f8b73/kMandarin_8105.txt -// curl -L -o /tmp/pinyin_full.txt \ -// https://raw.githubusercontent.com/mozillazg/pinyin-data/923b108dc5d45dee061324c011b478fb649f8b73/pinyin.txt -// curl -L -o /tmp/pinyin_simp.dict.yaml \ -// https://raw.githubusercontent.com/rime/rime-pinyin-simp/0c6861ef7420ee780270ca6d993d18d4101049d0/pinyin_simp.dict.yaml -// -// Then regenerate the asset with: -// node scripts/generate-pinyin-characters.mjs /tmp/kMandarin_8105.txt /tmp/pinyin_full.txt /tmp/pinyin_simp.dict.yaml android/assets/pinyin_chars.tsv [LIMIT=5000] -// -// This is a build/dev-time tool only — the generated TSV is committed and -// read as a plain local asset at runtime; nothing on-device ever fetches -// or reprocesses any of the three sources. -import fs from 'node:fs'; - -const kMandarinPath = process.argv[2]; -const pinyinFullPath = process.argv[3]; -const rimeSimpPath = process.argv[4]; -const output = process.argv[5]; -const limit = Number(process.argv[6] ?? 5000); -if (!kMandarinPath || !pinyinFullPath || !rimeSimpPath || !output) { - throw new Error('usage: node generate-pinyin-characters.mjs KMANDARIN_SOURCE PINYIN_FULL_SOURCE RIME_SIMP_SOURCE OUTPUT [LIMIT]'); -} -// An alternate (non-primary) reading needs at least this much rime-pinyin-simp -// usage weight to be considered "常用" and kept as a second/third candidate -// reading; chosen empirically against known polyphones (行 hang=3209, 长 -// chang=13911, 重 chong=4543 all comfortably clear this; 落 lao=23 does not). -const MIN_ALT_READING_WEIGHT = 100; -// Hard cap on readings kept per character, primary reading included — -// avoids a long tail of technically-real but rarely-useful readings for -// the few characters with unusually many alternates. -const MAX_READINGS_PER_CHAR = 3; - -// Strips tone marks to plain ASCII pinyin. ü (and its four toned forms) is -// mapped to 'v' rather than 'u', matching the plan's "ü、v 等规范化" test -// requirement and the convention every Chinese IME uses on a QWERTY layout -// (there's no ü key). -function toAsciiPinyin(raw) { - const uUmlaut = raw.replace(/[üǖǘǚǜÜǕǗǙǛ]/g, (ch) => (ch === ch.toUpperCase() ? 'V' : 'v')); - return uUmlaut - .normalize('NFD') - .replace(/[̀-ͯ]/g, '') - .toLowerCase(); -} - -// --- 1. Parse kMandarin_8105.txt: "U+XXXX: pinyin # 字 ..." (one primary reading) --- -// The character is reconstructed from the codepoint (not scraped from the -// trailing comment) since that field sometimes carries extra "=> U+xxxx" -// (variant-character) or "?-> altpinyin" (disputed-reading) annotations -// that aren't part of the character itself. -const CODEPOINT_LINE = /^U\+([0-9A-Fa-f]+):\s*([^#]+?)\s*#/; -const primaryReading = new Map(); // char -> asciiPinyin (this file's one reading per char) -for (const line of fs.readFileSync(kMandarinPath, 'utf8').split(/\r?\n/)) { - const match = CODEPOINT_LINE.exec(line); - if (!match) continue; - const char = String.fromCodePoint(parseInt(match[1], 16)); - // kMandarin_8105.txt is documented as one reading per line, but defensively - // take only the first if a stray comma ever appears. - const reading = toAsciiPinyin(match[2].split(',')[0].trim()); - if (reading) primaryReading.set(char, reading); -} - -// --- 2. Parse pinyin.txt: "U+XXXX: pinyin1,pinyin2,... # 字" (all known readings) --- -// Only used to look up extra readings for characters already selected via -// kMandarin_8105.txt above — this file's own character coverage (all of -// Unihan) is far broader than the 《通用规范汉字表》 scope this feature wants. -const allReadings = new Map(); // char -> Set -for (const line of fs.readFileSync(pinyinFullPath, 'utf8').split(/\r?\n/)) { - const match = CODEPOINT_LINE.exec(line); - if (!match) continue; - const char = String.fromCodePoint(parseInt(match[1], 16)); - const readings = match[2].split(',').map((p) => toAsciiPinyin(p.trim())).filter(Boolean); - if (readings.length) allReadings.set(char, new Set(readings)); -} - -// --- 3. Parse pinyin_simp.dict.yaml: "字\tpinyin\tweight" data rows only --- -// (single-character rows only here; the same file's multi-character rows -// feed generate-pinyin-phrases.mjs instead.) -const weightByCharPinyin = new Map(); // "char|pinyin" -> max weight seen -let inDataSection = false; -for (const line of fs.readFileSync(rimeSimpPath, 'utf8').split(/\r?\n/)) { - if (!inDataSection) { - if (line.trim() === '...') inDataSection = true; - continue; - } - const parts = line.split('\t'); - if (parts.length < 3) continue; - const word = parts[0]; - if ([...word].length !== 1) continue; - const pinyin = toAsciiPinyin(parts[1].trim()); - const weight = Number(parts[2]); - if (!Number.isFinite(weight)) continue; - const key = `${word}|${pinyin}`; - const previous = weightByCharPinyin.get(key); - if (previous === undefined || weight > previous) weightByCharPinyin.set(key, weight); -} - -// --- 4. For each kMandarin character, build its kept reading list: --- -// the primary reading always, plus any other reading from pinyin.txt whose -// rime weight clears MIN_ALT_READING_WEIGHT, capped at MAX_READINGS_PER_CHAR -// total and ranked by weight (primary reading wins ties so it's never -// bumped out by the cap). -const weightOf = (char, reading) => weightByCharPinyin.get(`${char}|${reading}`) ?? 0; -const keptReadings = new Map(); // char -> [{reading, weight}], sorted by weight desc -const charBestWeight = new Map(); -for (const [char, primary] of primaryReading) { - const candidates = new Map(); // reading -> weight - candidates.set(primary, weightOf(char, primary)); - for (const reading of allReadings.get(char) ?? []) { - if (reading === primary) continue; - const weight = weightOf(char, reading); - if (weight >= MIN_ALT_READING_WEIGHT) candidates.set(reading, weight); - } - const ranked = [...candidates.entries()] - .sort((a, b) => (b[0] === primary ? 1 : 0) - (a[0] === primary ? 1 : 0) || b[1] - a[1]) - .slice(0, MAX_READINGS_PER_CHAR); - // Re-sort for output (weight desc; primary's own weight may be 0 and - // legitimately sort last among kept readings — it's still always present). - ranked.sort((a, b) => b[1] - a[1]); - keptReadings.set(char, ranked.map(([reading, weight]) => ({ reading, weight }))); - charBestWeight.set(char, Math.max(...ranked.map(([, weight]) => weight))); -} - -// --- 5. Rank characters by their best reading's weight, keep the top LIMIT --- -const rankedChars = [...charBestWeight.entries()] - .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) - .slice(0, limit) - .map(([char]) => char); - -// --- 6. Emit one row per (char, reading) for every kept character --- -const rows = []; -for (const char of rankedChars) { - for (const { reading, weight } of keptReadings.get(char)) rows.push([char, reading, weight]); -} -rows.sort((a, b) => b[2] - a[2] || a[0].localeCompare(b[0]) || a[1].localeCompare(b[1])); - -const result = rows.map(([char, pinyin, weight]) => `${char}\t${pinyin}\t${weight}`).join('\n') + '\n'; -fs.writeFileSync(output, result, 'utf8'); -console.log(`generated ${rankedChars.length} characters (${rows.length} character+reading rows, of ${primaryReading.size} candidates in source)`); diff --git a/openless-all/app/scripts/generate-pinyin-phrases.mjs b/openless-all/app/scripts/generate-pinyin-phrases.mjs deleted file mode 100644 index c71cab490..000000000 --- a/openless-all/app/scripts/generate-pinyin-phrases.mjs +++ /dev/null @@ -1,171 +0,0 @@ -// Generates the Android Pinyin-mode high-frequency abbreviation-phrase -// dictionary asset (android/assets/pinyin_phrases.tsv) from three local, -// commit-pinned source files: -// -// 1. rime/rime-pinyin-simp's pinyin_simp.dict.yaml (Apache-2.0) — the base -// candidate pool AND its usage-frequency weight (this file mixes -// single-character and multi-character rows; only multi-character rows -// are used here — see generate-pinyin-characters.mjs for the single- -// character half). -// 2. mozillazg/pinyin-data's kMandarin_8105.txt (MIT) — reused only as a -// membership set (its own character SET, not its readings) to filter -// out any candidate phrase containing a character outside 《通用规范 -// 汉字表》 — i.e. traditional-only, rare, or otherwise non-simplified -// characters (plan 7.2: "只保留全中文词语" / 6.2: "过滤纯简体中文词语"). -// 3. mozillazg/phrase-pinyin-data's pinyin.txt (MIT) — authoritative -// word-level pinyin readings, used to correct a phrase's reading when -// it disagrees with rime's own per-character reading (this is what -// actually resolves "多音字在词语中的正确读音" per plan 6.3 — rime's -// dict.yaml has no notion of a word, so it can't do this on its own). -// -// Re-fetch the pinned sources with: -// curl -L -o /tmp/pinyin_simp.dict.yaml \ -// https://raw.githubusercontent.com/rime/rime-pinyin-simp/0c6861ef7420ee780270ca6d993d18d4101049d0/pinyin_simp.dict.yaml -// curl -L -o /tmp/kMandarin_8105.txt \ -// https://raw.githubusercontent.com/mozillazg/pinyin-data/923b108dc5d45dee061324c011b478fb649f8b73/kMandarin_8105.txt -// curl -L -o /tmp/phrase_pinyin.txt \ -// https://raw.githubusercontent.com/mozillazg/phrase-pinyin-data/cee0ed6e6e4898580cafd2bd5e3723e20b214aa0/pinyin.txt -// -// Then regenerate the asset with: -// node scripts/generate-pinyin-phrases.mjs /tmp/pinyin_simp.dict.yaml /tmp/kMandarin_8105.txt /tmp/phrase_pinyin.txt android/assets/pinyin_phrases.tsv [LIMIT=2000] -// -// This is a build/dev-time tool only — the generated TSV is committed and -// read as a plain local asset at runtime; nothing on-device ever fetches -// or reprocesses any of the three sources. A small manually curated -// whitelist (WHITELIST below) adds domain terms ("输入法"/"剪贴板"/etc) the -// generic frequency corpus has no reason to rank highly — plan 7.2 calls -// for exactly this, with the exact word list confirmed by the user before -// being baked into the generator (as opposed to silently guessed). -import fs from 'node:fs'; - -const rimeSimpPath = process.argv[2]; -const kMandarinPath = process.argv[3]; -const phrasePinyinPath = process.argv[4]; -const output = process.argv[5]; -const limit = Number(process.argv[6] ?? 2000); -if (!rimeSimpPath || !kMandarinPath || !phrasePinyinPath || !output) { - throw new Error('usage: node generate-pinyin-phrases.mjs RIME_SIMP_SOURCE KMANDARIN_SOURCE PHRASE_PINYIN_SOURCE OUTPUT [LIMIT]'); -} -const MIN_LENGTH = 2; -const MAX_LENGTH = 4; - -// Manually curated domain-term whitelist (plan 7.2: "根据 OpenLess 使用场景 -//增加少量人工白名单" — confirmed with the user on 2026-09-25, using the -// plan's own example list verbatim). These are ADDED on top of the -// corpus-ranked top LIMIT, not competed into it — a general-purpose -// frequency corpus has no reason to rank IME/ERP jargon highly, but they're -// exactly the kind of word a stroke-input user reaching for pinyin as a -// fallback is likely typing. Six of the twelve already clear the natural -// weight cutoff on their own (服务器/数据库/客户/订单/生产/审核) and are -// left for the corpus ranking to place normally; only the other six are -// actually injected here. Pinyin is taken from the rime source's own -// per-character weighting where the word exists there at all; 候选词 isn't -// in any of the three sources (it's IME-specific jargon), so its reading is -// hand-supplied — unambiguous, single-reading, no polyphone risk. -const WHITELIST = [ - { word: '输入法', pinyin: 'shu ru fa' }, - { word: '候选词', pinyin: 'hou xuan ci' }, - { word: '剪贴板', pinyin: 'jian tie ban' }, - { word: '供应商', pinyin: 'gong ying shang' }, - { word: '物料', pinyin: 'wu liao' }, - { word: '主管', pinyin: 'zhu guan' }, -]; -// Synthetic weight for a whitelist word this generator can't find any real -// corpus weight for (currently just 候选词) — deliberately low so it never -// outranks a genuinely common word, just guarantees presence in the list. -const WHITELIST_FALLBACK_WEIGHT = 500; - -// Manually curated corpus-noise blacklist — words that are valid simplified -// Chinese and technically common in the source corpus but aren't real -// day-to-day vocabulary (rime-pinyin-simp is derived from an old Android -// IME's usage corpus, which includes forum/UI boilerplate text). Found by -// spot-checking the generated top-2000 list, not an exhaustive audit — see -// docs/pinyin-lite/phase-0-audit.md's sibling phase notes. Removed from the -// candidate pool entirely (so it can't be re-surfaced by a future weight -// change) rather than just skipped at output time. -const BLACKLIST = new Set([ - '回复日期', // forum/IME-corpus UI template text, not a phrase anyone actually types — ranked #11 in the very first generation run despite that -]); - -function toAsciiPinyin(raw) { - const uUmlaut = raw.replace(/[üǖǘǚǜÜǕǗǙǛ]/g, (ch) => (ch === ch.toUpperCase() ? 'V' : 'v')); - return uUmlaut - .normalize('NFD') - .replace(/[̀-ͯ]/g, '') - .toLowerCase(); -} - -function abbreviate(syllables) { - return syllables.map((s) => s[0] ?? '').join(''); -} - -// --- 1. kMandarin_8105.txt character-set membership (readings not needed here) --- -const CODEPOINT_LINE = /^U\+([0-9A-Fa-f]+):/; -const simplifiedCharSet = new Set(); -for (const line of fs.readFileSync(kMandarinPath, 'utf8').split(/\r?\n/)) { - const match = CODEPOINT_LINE.exec(line); - if (match) simplifiedCharSet.add(String.fromCodePoint(parseInt(match[1], 16))); -} -const isAllSimplified = (word) => [...word].every((ch) => simplifiedCharSet.has(ch)); - -// --- 2. phrase-pinyin-data pinyin.txt: "词语: py1 py2 py3" authoritative readings --- -const authoritativeReading = new Map(); // word -> asciiPinyin[] -for (const line of fs.readFileSync(phrasePinyinPath, 'utf8').split(/\r?\n/)) { - const colon = line.indexOf(':'); - if (colon === -1 || line.startsWith('#')) continue; - const word = line.slice(0, colon).trim(); - const syllables = line.slice(colon + 1).trim().split(/\s+/).map(toAsciiPinyin).filter(Boolean); - if (word && syllables.length === [...word].length) authoritativeReading.set(word, syllables); -} - -// --- 3. rime_simp multi-character rows: base candidate pool + frequency weight --- -const candidates = new Map(); // word -> { syllables: string[], weight: number } -let inDataSection = false; -for (const line of fs.readFileSync(rimeSimpPath, 'utf8').split(/\r?\n/)) { - if (!inDataSection) { - if (line.trim() === '...') inDataSection = true; - continue; - } - const parts = line.split('\t'); - if (parts.length < 3) continue; - const word = parts[0]; - const length = [...word].length; - if (length < MIN_LENGTH || length > MAX_LENGTH) continue; - if (!isAllSimplified(word)) continue; - if (BLACKLIST.has(word)) continue; - const weight = Number(parts[2]); - if (!Number.isFinite(weight) || weight <= 0) continue; - const rimeSyllables = parts[1].trim().split(/\s+/).map(toAsciiPinyin).filter(Boolean); - if (rimeSyllables.length !== length) continue; - // Prefer the authoritative phrase-level reading when available (resolves - // polyphones-in-context rime's own per-character weighting can't see); - // fall back to rime's own reading otherwise. - const syllables = authoritativeReading.get(word) ?? rimeSyllables; - const previous = candidates.get(word); - if (!previous || weight > previous.weight) candidates.set(word, { syllables, weight }); -} - -// --- 4. Rank by weight, keep the top LIMIT --- -const rankedEntries = [...candidates.entries()] - .sort((a, b) => b[1].weight - a[1].weight || a[0].localeCompare(b[0])) - .slice(0, limit); -const ranked = new Map(rankedEntries); - -// --- 5. Add any whitelist word not already present, on top of the LIMIT cap --- -for (const { word, pinyin } of WHITELIST) { - if (ranked.has(word)) continue; - const syllables = pinyin.split(/\s+/).map(toAsciiPinyin); - const weight = candidates.get(word)?.weight ?? WHITELIST_FALLBACK_WEIGHT; - ranked.set(word, { syllables, weight }); -} - -const rows = [...ranked.entries()].map(([word, { syllables, weight }]) => { - const fullPinyin = syllables.join(' '); - const abbreviation = abbreviate(syllables); - return `${word}\t${fullPinyin}\t${abbreviation}\t${weight}`; -}); -fs.writeFileSync(output, rows.join('\n') + '\n', 'utf8'); - -const byLength = new Map(); -for (const [word] of ranked) byLength.set([...word].length, (byLength.get([...word].length) ?? 0) + 1); -console.log(`generated ${ranked.size} phrases (of ${candidates.size} candidates in source), by length: ${[...byLength.entries()].sort().map(([l, n]) => `${l}字=${n}`).join(', ')}`); diff --git a/openless-all/app/scripts/harmony-app-dictation.test.mjs b/openless-all/app/scripts/harmony-app-dictation.test.mjs new file mode 100644 index 000000000..b70699d71 --- /dev/null +++ b/openless-all/app/scripts/harmony-app-dictation.test.mjs @@ -0,0 +1,155 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import ts from 'typescript'; + +const source = readFileSync( + new URL('../harmony/entry/src/main/ets/HarmonyApi.ets', import.meta.url), + 'utf8', +) + .replace(/\r\n/g, '\n') + .replace(/^import .*;\n/gm, ''); +const compiled = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, +}).outputText; +function host(granted = true) { + const events = []; + let starts = 0, + cancels = 0, + level, + failed; + let stopped = Promise.resolve({ rawText: '模拟识别', polishedText: '模拟润色' }); + let startup = Promise.resolve(); + class VoiceClient { + async start(_context, onLevel, onFailure) { + starts++; + level = onLevel; + failed = onFailure; + await startup; + } + async stop() { + return stopped; + } + async cancel() { + cancels++; + } + } + const exports = {}; + vm.runInNewContext(compiled, { + exports, + console, + VoiceClient, + abilityAccessCtrl: { + GrantStatus: { PERMISSION_GRANTED: 0 }, + createAtManager: () => ({ + checkAccessTokenSync: () => (granted ? 0 : -1), + requestPermissionsFromUser: async () => ({ authResults: [-1] }), + }), + }, + }); + const api = new exports.HarmonyApi(); + api.configure( + { applicationInfo: { accessTokenId: 1 } }, + { + runJavaScript: async (code) => + vm.runInNewContext(code, { + window: { dispatchEvent: (event) => events.push(event) }, + CustomEvent: class { + constructor(type, options) { + this.type = type; + this.detail = options?.detail; + } + }, + }), + }, + ); + return { + api, + events, + starts: () => starts, + cancels: () => cancels, + level: (value) => level(value), + fail: (message) => failed(message), + stopWith: (reply) => { + stopped = reply; + }, + startWith: (reply) => { + startup = reply; + }, + invoke: async (command) => JSON.parse(await api.invokeAsync(command, '{}')), + last: () => + events.filter((event) => event.type === 'openless:harmony-dictation').at(-1)?.detail, + }; +} +const normal = host(); +assert.equal((await normal.invoke('start_dictation')).ok, true); +assert.equal(normal.last().phase, 'recording'); +assert.equal((await normal.invoke('start_dictation')).ok, false, 'duplicate start is rejected'); +assert.equal(normal.starts(), 1); +normal.level(0.5); +assert.equal(normal.last().level, 0.5); +assert.equal( + (await normal.invoke('start_microphone_level_monitor')).ok, + false, + 'monitor cannot take over the recording microphone', +); +const completed = await normal.invoke('stop_dictation'); +assert.deepEqual(completed.value, { rawText: '模拟识别', polishedText: '模拟润色' }); +assert.equal(normal.last().phase, 'idle'); +assert(normal.events.some((event) => event.type === 'openless:harmony-backend-change')); +normal.fail('迟到的回调'); +assert.equal(normal.last().error, '', 'finished sessions ignore late callbacks'); + +const cancelled = host(); +await cancelled.invoke('start_dictation'); +let finishStop; +cancelled.stopWith( + new Promise((resolve) => { + finishStop = resolve; + }), +); +const pendingStop = cancelled.invoke('stop_dictation'); +assert.equal(cancelled.last().phase, 'processing'); +cancelled.level(0.8); +assert.equal( + cancelled.last().phase, + 'processing', + 'late audio must not revive recording while ASR is processing', +); +await cancelled.invoke('cancel_dictation'); +finishStop({ rawText: '过期结果', polishedText: '' }); +assert.equal( + (await pendingStop).ok, + false, + 'cancelled processing cannot return a result to the UI', +); +assert.equal(cancelled.last().phase, 'idle'); +assert.equal(cancelled.cancels(), 1); + +const startup = host(); +let finishStart; +startup.startWith( + new Promise((resolve) => { + finishStart = resolve; + }), +); +const pendingStart = startup.invoke('start_dictation'); +for (let i = 0; i < 10; i++) await Promise.resolve(); +await startup.api.release(); +finishStart(); +assert.equal((await pendingStart).ok, false, 'leaving the page cancels an unfinished startup'); +assert.equal(startup.last().phase, 'idle'); + +const denied = host(false); +assert.equal((await denied.invoke('start_dictation')).ok, false); +assert.equal(denied.starts(), 0, 'denied microphone permission never starts audio capture'); +assert.equal(denied.last().phase, 'idle'); + +const failed = host(); +await failed.invoke('start_dictation'); +failed.fail('音频传输失败'); +assert.equal(failed.last().error, '音频传输失败'); +assert.equal(failed.last().phase, 'idle'); +console.log( + 'Harmony app dictation: start/stop/results, level feedback, duplicate start, cancellation races, page cleanup and permission denial passed', +); diff --git a/openless-all/app/scripts/harmony-clipboard-history.test.mjs b/openless-all/app/scripts/harmony-clipboard-history.test.mjs new file mode 100644 index 000000000..6e09a4d4d --- /dev/null +++ b/openless-all/app/scripts/harmony-clipboard-history.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import ts from 'typescript'; + +const source = readFileSync(new URL('../harmony/entry/src/main/ets/inputmethod/ClipboardHistory.ets', import.meta.url), 'utf8'); +const exports = {}; +vm.runInNewContext(ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, +}).outputText, { exports }); +const history = new exports.ClipboardHistory(); +history.add('第一条'); +history.add('第一条'); +assert.equal(history.list().length, 1, 'repeated paste must not duplicate a record'); +const pinned = history.list()[0].id; +history.pin(pinned); +for (let i = 0; i < 40; i++) history.add('记录 ' + i); +assert.equal(history.list().length, 30); +assert.equal(history.list()[0].id, pinned, 'pinned entries survive recent-history eviction'); +assert.equal(history.list()[0].pinned, true); +assert(history.list().some((row) => row.text === '记录 39')); +const copy = history.list(); +copy[0].text = '外部修改'; +assert.equal(history.get(pinned), '第一条', 'UI snapshots must not mutate persisted records'); +const restored = new exports.ClipboardHistory(); +restored.load(history.serialize()); +assert.equal(restored.list().length, 30); +assert.equal(restored.get(pinned), '第一条'); +assert.equal(restored.list()[0].pinned, true); +restored.remove(pinned); +assert.equal(restored.get(pinned), ''); +restored.add('x'.repeat(16385)); +assert.equal(restored.list().length, 29, 'oversized text is not silently truncated into history'); +restored.load('[null, {"id":"ok","text":"合法记录","pinned":true,"usedAt":1}, {"id":"ok","text":"重复标识","usedAt":2}]'); +assert.equal(restored.list().length, 1); +assert.equal(restored.get('ok'), '合法记录'); +restored.load('invalid'); +assert.equal(restored.list().length, 0); +history.clear(); +assert.equal(history.serialize(), '[]'); +console.log('Harmony clipboard history: deduplication, pinning, bounds, persistence, deletion and corrupt data passed'); diff --git a/openless-all/app/scripts/harmony-editor-compat.test.mjs b/openless-all/app/scripts/harmony-editor-compat.test.mjs new file mode 100644 index 000000000..a296e1208 --- /dev/null +++ b/openless-all/app/scripts/harmony-editor-compat.test.mjs @@ -0,0 +1,620 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import ts from 'typescript'; + +const source = readFileSync(new URL('../harmony/entry/src/main/ets/inputmethod/InputSession.ets', import.meta.url), 'utf8'); +const stripImports = (text) => text.replace(/\r\n/g, '\n').replace(/^import .*;\n/gm, ''); +assert.equal(stripImports(source), stripImports(source.replace(/\r?\n/g, '\r\n'))); +const compiled = ts.transpileModule(stripImports(source), { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, +}).outputText; + +const clipboardExports = {}; +vm.runInNewContext(ts.transpileModule(readFileSync(new URL('../harmony/entry/src/main/ets/inputmethod/ClipboardHistory.ets', import.meta.url), 'utf8'), { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, +}).outputText, { exports: clipboardExports }); + +function host(getEditorAttribute, clientOverrides = {}, queryCloud = async () => [], candidatePages = [], autoClipboard = false) { + const schemas = [], inserted = []; + const cloudTimers = [], operations = []; + let pending = '', acknowledgements = 0; + let timer, recordings = 0; + let voiceStartup = Promise.resolve(), voiceResult = Promise.resolve({ rawText: '语音测试', polishedText: '语音结果' }), voiceStops = 0; + let clipboardText = '本地剪贴板测试', clipboardListener, clipboardFailure; + let clipboardReply; + const board = { + on: (_type, callback) => { clipboardListener = callback; }, + off: (_type, callback) => { if (clipboardListener === callback) clipboardListener = undefined; }, + getData: async () => { + if (clipboardFailure) throw clipboardFailure; + if (clipboardReply) return clipboardReply; + const text = clipboardText; + return { getPrimaryText: () => text }; + }, + }; + class Storage { + constructor(initial) { this.values = new Map(Object.entries(initial)); } + setOrCreate(key, value) { this.values.set(key, value); } + get(key) { return this.values.get(key); } + } + class Voice { + async start() { recordings++; await voiceStartup; } + async stop() { voiceStops++; return voiceResult; } + async cancel() {} + } + const exports = {}; + vm.runInNewContext(compiled, { + exports, console, LocalStorage: Storage, VoiceClient: Voice, ClipboardHistory: clipboardExports.ClipboardHistory, + pasteboard: { + getSystemPasteboard: () => board, + }, + preferences: { getPreferences: async () => ({ get: async (key) => autoClipboard && key === 'clipboard_history' ? '[]' : false, put: async () => {}, flush: async () => {} }) }, + cloudCandidates: queryCloud, + refreshPublicDictionary: async () => ({ pending: false, revision: 'test', checked: 1 }), + setTimeout: (callback, delay) => { + if (delay === 800) return setTimeout(callback, 20); + if (delay === 150 || delay === 60) return setTimeout(callback, 1); + if (delay === 180) { cloudTimers.push(callback); return -1; } + timer = callback; return -1; + }, clearTimeout: (handle) => { if (handle !== -1) clearTimeout(handle); }, + initializeRime: async () => {}, testRime: async () => {}, + inputMethodEngine: { + ENTER_KEY_TYPE_UNSPECIFIED: 0, ENTER_KEY_TYPE_SEND: 1, ENTER_KEY_TYPE_SEARCH: 2, + ENTER_KEY_TYPE_DONE: 3, ENTER_KEY_TYPE_NEXT: 4, ENTER_KEY_TYPE_GO: 5, + ENTER_KEY_TYPE_NEWLINE: 6, + PATTERN_PASSWORD: 10, PATTERN_PASSWORD_NUMBER: 11, + PATTERN_PASSWORD_SCREEN_LOCK: 12, PATTERN_NEW_PASSWORD: 13, + }, + rimeRequest: async (operation, session, argument) => { + operations.push([operation, argument]); + if (candidatePages.length) { + if (operation === 'select_absolute') pending = candidatePages.flat()[Number(argument.split('@')[0])]; + if (operation === 'ack') { pending = ''; acknowledgements++; } + const offset = operation === 'candidates' ? Number(argument.split('@')[0]) : 0; + const rows = operation === 'candidates' ? candidatePages.flat().slice(offset, offset + 32) : candidatePages[0]; + return { session: 'test', revision: 1, input: pending ? '' : 'ni', preedit: 'ni', + candidates: rows, commit: pending, page: 0, candidateOffset: offset, + candidateHasNext: offset + rows.length < candidatePages.flat().length, hasNext: candidatePages.length > 1 }; + } + if (operation === 'create') { schemas.push(argument); pending = ''; } + if (operation === 'select') pending = '测试'; + if (operation === 'external') pending = argument.slice(argument.indexOf('@') + 1); + if (operation === 'ack') { acknowledgements++; pending = ''; } + if (operation === 'clear') pending = ''; + return { session: 'test', revision: 1, input: '', preedit: '', candidates: ['测试'], commit: pending, page: 0, hasNext: false }; + }, + }); + const client = { getEditorAttribute, insertText: async (text) => { inserted.push(text); return true; }, ...clientOverrides }; + const session = exports.inputSession, state = exports.imeStorage; + session.attach({ filesDir: '/test', resourceManager: { getBooleanByName: () => autoClipboard } }, {}, client); + const pump = async () => { + for (let i = 0; i < 20; i++) await Promise.resolve(); + }; + const drain = async () => { + await pump(); + await session.queue; + }; + return { session, state, schemas, inserted, drain, pump, timeout: () => timer(), recordings: () => recordings, + voiceStartWith: (promise) => { voiceStartup = promise; }, voiceStopWith: (promise) => { voiceResult = promise; }, voiceStops: () => voiceStops, + cloudTimers, operations, + copy: (text) => { clipboardText = text; clipboardListener?.(); }, + watching: () => !!clipboardListener, + clipboardError: (error) => { clipboardFailure = error; }, + clipboardDelay: (reply) => { clipboardReply = reply; }, + acknowledgements: () => acknowledgements }; +} + +const normal = host(async () => ({ inputPattern: 0, enterKeyType: 2, bundleName: 'com.huawei.hmos.settings' })); +await normal.drain(); +assert.deepEqual(normal.schemas, ['learning']); +assert.equal(normal.state.get('rimeReady'), true); +assert.equal(normal.state.get('editorVerified'), true); +assert.equal(normal.state.get('enterLabel'), '搜索'); +assert.equal(normal.watching(), false, 'the default build must not subscribe to clipboard updates'); +normal.session.refreshClipboard(); +assert.equal(normal.state.get('clipboardAutoRead'), false); +normal.session.selectCandidate(0); +await normal.drain(); +assert.equal(normal.acknowledgements(), 1); +assert.equal(normal.state.get('pendingCommit'), false); +assert.equal(normal.state.get('commitNeedsConfirmation'), false); + +for (const [attribute, expectedRecords] of [ + [{ inputPattern: 0, enterKeyType: 0, bundleName: 'native.test' }, 1], + [{ inputPattern: 10, enterKeyType: 0, bundleName: 'native.test' }, 0], + [undefined, 0], +]) { + const pasted = host(async () => { if (!attribute) throw Error('unknown field'); return attribute; }); + await pasted.drain(); + let recorded = 0; + pasted.session.clipboard = { add: () => { recorded++; }, list: () => [], serialize: () => '[]' }; + pasted.session.pasteClipboard(); + await pasted.drain(); + assert.equal(recorded, expectedRecords, 'password and unverified fields must not populate clipboard history'); + assert.deepEqual(pasted.inserted, ['本地剪贴板测试'], 'explicit system paste remains available in private fields'); +} + +const autoClipboard = host(async () => ({ inputPattern: 0, enterKeyType: 0, bundleName: 'native.test' }), {}, async () => [], [], true); +await autoClipboard.drain(); +assert.equal(autoClipboard.watching(), true); +assert.equal(autoClipboard.state.get('clipboardItems')[0].text, '本地剪贴板测试'); +assert.deepEqual(autoClipboard.inserted, [], 'reading the clipboard must never type into the target editor'); +autoClipboard.copy('第二次复制'); +await autoClipboard.drain(); +assert.equal(autoClipboard.state.get('clipboardItems').length, 2); +autoClipboard.copy('第二次复制'); +await autoClipboard.drain(); +assert.equal(autoClipboard.state.get('clipboardItems').length, 2, 'update notifications deduplicate clipboard history'); +autoClipboard.clipboardError({ code: 201 }); +autoClipboard.session.refreshClipboard(); +await autoClipboard.drain(); +assert.match(autoClipboard.state.get('clipboardStatus'), /授权/); +assert.equal(autoClipboard.state.get('clipboardItems').length, 2, 'permission denial preserves existing history'); +autoClipboard.clipboardError(undefined); +let finishClearedClipboard; +autoClipboard.clipboardDelay(new Promise((resolve) => { finishClearedClipboard = resolve; })); +autoClipboard.session.refreshClipboard(); +autoClipboard.session.clearClipboardHistory(); +finishClearedClipboard({ getPrimaryText: () => '清空前的延迟读取' }); +await autoClipboard.drain(); +assert.equal(autoClipboard.state.get('clipboardItems').length, 0, 'a delayed clipboard read must not undo clearing history'); +autoClipboard.clipboardDelay(undefined); +autoClipboard.copy('清空后新的复制'); +await autoClipboard.drain(); +assert.equal(autoClipboard.state.get('clipboardItems').length, 1, 'new clipboard notifications still work after clearing'); +let finishClipboard; +autoClipboard.clipboardDelay(new Promise((resolve) => { finishClipboard = resolve; })); +autoClipboard.session.refreshClipboard(); +autoClipboard.session.detach(); +finishClipboard({ getPrimaryText: () => '过期读取不得入库' }); +await autoClipboard.drain(); +assert.equal(autoClipboard.watching(), false, 'detaching unregisters only this input session listener'); +assert.equal(autoClipboard.state.get('clipboardItems').length, 1, 'stale clipboard reads cannot update another editor'); +for (const inputPattern of [10, undefined]) { + const privateClipboard = host(async () => { if (inputPattern === undefined) throw Error('unknown field'); + return { inputPattern, enterKeyType: 0, bundleName: 'native.test' }; }, {}, async () => [], [], true); + await privateClipboard.drain(); + privateClipboard.session.refreshClipboard(); + privateClipboard.copy('私密输入不记录'); + await privateClipboard.drain(); + assert.equal(privateClipboard.watching(), false); + assert.equal(privateClipboard.state.get('clipboardItems').length, 0); +} + +const scrolling = host(async () => ({ inputPattern: 0, enterKeyType: 0, bundleName: 'native.test' }), {}, async () => [], + [['甲', '乙'], ['丙', '丁'], ['戊']]); +await scrolling.drain(); +scrolling.session.loadMoreCandidates(); +scrolling.session.loadMoreCandidates(); +await scrolling.drain(); +assert.deepEqual(Array.from(scrolling.state.get('candidates')), ['甲', '乙', '丙', '丁', '戊']); +assert.equal(scrolling.operations.filter(([op]) => op === 'candidates').length, 1); +scrolling.session.loadMoreCandidates(); +await scrolling.drain(); +assert.deepEqual(Array.from(scrolling.state.get('candidates')), ['甲', '乙', '丙', '丁', '戊']); +assert.equal(scrolling.state.get('hasNext'), false); +scrolling.session.loadMoreCandidates(); +await scrolling.drain(); +assert.equal(scrolling.operations.filter(([op]) => op === 'candidates').length, 1); +scrolling.session.selectCandidate(3, '丁'); +await scrolling.drain(); +assert(scrolling.operations.some(([op, arg]) => op === 'select_absolute' && arg === '3@1')); +assert.deepEqual(scrolling.inserted, ['丁']); + +const batchPages = Array.from({ length: 20 }, (_, page) => ['词' + page * 2, '词' + (page * 2 + 1)]); +const batch = host(async () => ({ inputPattern: 0, enterKeyType: 0, bundleName: 'native.test' }), {}, async () => [], batchPages); +await batch.drain(); +batch.session.loadMoreCandidates(); +await batch.drain(); +assert.equal(batch.state.get('candidates').length, 34, 'one native range adds 32 candidates to the initial pool'); +assert.equal(batch.state.get('hasNext'), true); +batch.session.loadMoreCandidates(); +await batch.drain(); +assert.equal(batch.state.get('candidates').length, 40); +assert.equal(batch.state.get('hasNext'), false); + +const oldCandidate = host(async () => ({ inputPattern: 0, enterKeyType: 0, bundleName: 'native.test' }), {}, async () => [], + [['甲', '乙'], ['丙', '丁']]); +await oldCandidate.drain(); +oldCandidate.session.loadMoreCandidates(); +await oldCandidate.drain(); +oldCandidate.session.selectCandidate(0, '甲'); +await oldCandidate.drain(); +assert(oldCandidate.operations.some(([op, arg]) => op === 'select_absolute' && arg === '0@1')); +assert.deepEqual(oldCandidate.inserted, ['甲']); + +const scrollCloud = host(async () => ({ inputPattern: 0, enterKeyType: 0, bundleName: 'native.test' }), {}, async () => [], + [['甲', '乙'], ['丙', '丁']]); +await scrollCloud.drain(); +scrollCloud.session.cloudChoices = ['云']; +scrollCloud.session.loadMoreCandidates(); +await scrollCloud.drain(); +assert.deepEqual(Array.from(scrollCloud.state.get('candidates')), ['甲', '云', '乙', '丙', '丁']); +scrollCloud.session.selectCandidate(4, '丁'); +await scrollCloud.drain(); +assert.deepEqual(scrollCloud.inserted, ['丁'], 'cloud insertion must not shift appended absolute indices'); + +const staleScroll = host(async () => ({ inputPattern: 0, enterKeyType: 0, bundleName: 'native.test' }), {}, async () => [], + [['甲', '乙'], ['丙', '丁']]); +await staleScroll.drain(); +staleScroll.session.loadMoreCandidates(); +staleScroll.session.publish({ revision: 99, input: 'hao', preedit: 'hao', candidates: ['好'], page: 0, hasNext: false, commit: '' }); +await staleScroll.drain(); +assert.deepEqual(Array.from(staleScroll.state.get('candidates')), ['好']); +assert.equal(staleScroll.state.get('candidatesLoading'), false); +assert.equal(staleScroll.operations.filter(([op]) => op === 'candidates').length, 0, + 'a queued append must not page a new composition'); + +let asyncWrites = 0, syncWrites = 0, asyncDeletes = 0; +const bridge = host(async () => ({ inputPattern: 0, enterKeyType: 0, bundleName: 'com.droi.tong' }), { + insertText: async () => { asyncWrites++; return true; }, + insertTextSync: () => { syncWrites++; }, // Public sync API returns void on success. + deleteForward: async () => { asyncDeletes++; return true; }, +}); +await bridge.drain(); +bridge.session.selectCandidate(0); +await bridge.drain(); +assert.equal(syncWrites, 0, 'never block the UI through synchronous IPC'); +assert.equal(asyncWrites, 1); +assert.equal(bridge.acknowledgements(), 1); +assert.equal(bridge.state.get('pendingCommit'), false); +bridge.session.backspace(); +await bridge.drain(); +assert.equal(asyncDeletes, 1); + +let accidentalRetries = 0, asynchronousAttempts = 0; +const failedWrite = host(async () => ({ inputPattern: 0, enterKeyType: 0, bundleName: 'com.huawei.hmos.settings' }), { + insertText: async () => { asynchronousAttempts++; return false; }, + insertTextSync: () => { accidentalRetries++; }, +}); +await failedWrite.drain(); +failedWrite.session.selectCandidate(0); +await failedWrite.drain(); +assert.equal(failedWrite.state.get('pendingCommit'), true); +assert.equal(failedWrite.acknowledgements(), 0); +assert.equal(accidentalRetries, 0, 'an uncertain write must never automatically replay through another transport'); +failedWrite.session.acknowledgePendingCommit(); +await failedWrite.drain(); +assert.equal(failedWrite.state.get('pendingCommit'), false); +assert.equal(failedWrite.acknowledgements(), 1); +assert.equal(asynchronousAttempts, 1, 'manual confirmation of an applied write must not insert it again'); +failedWrite.session.selectCandidate(0); +await failedWrite.drain(); +failedWrite.session.cancelPendingCommit(); +await failedWrite.drain(); +assert.equal(failedWrite.state.get('pendingCommit'), false); +assert.equal(failedWrite.acknowledgements(), 1, 'cancel does not claim another failed write succeeded'); + +const syncFailure = host(async () => ({ inputPattern: 0, enterKeyType: 0, bundleName: 'native.test' }), { + insertText: async () => { throw { code: 12800003 }; }, + insertTextSync: () => { throw { code: 12800003 }; }, +}); +await syncFailure.drain(); +syncFailure.session.selectCandidate(0); +await syncFailure.drain(); +assert.equal(syncFailure.state.get('pendingCommit'), true); +assert.equal(syncFailure.acknowledgements(), 0); + +let anonymousSyncWrites = 0; +const anonymousBridge = host(async () => ({ inputPattern: 0, enterKeyType: 0, bundleName: '' }), { + insertText: async () => true, + insertTextSync: () => { anonymousSyncWrites++; }, +}); +await anonymousBridge.drain(); +anonymousBridge.session.selectCandidate(0); +await anonymousBridge.drain(); +assert.equal(anonymousSyncWrites, 0); +assert.equal(anonymousBridge.state.get('pendingCommit'), false); +assert.equal(anonymousBridge.acknowledgements(), 1); + +let observedAttempts = 0; +const observed = host(async () => ({ inputPattern: 0, enterKeyType: 0, bundleName: 'native.test' }), { + insertText: () => { observedAttempts++; return new Promise(() => {}); }, +}); +await observed.drain(); +observed.session.observeText('原文字'); +observed.session.observeSelection(1, 2); +observed.session.selectCandidate(0); +await observed.pump(); +observed.session.observeText('原错误字'); +await observed.pump(); +assert.equal(observed.acknowledgements(), 0, 'unrelated text must not confirm a commit'); +observed.session.observeSelection(3, 3); +observed.session.observeText('原测试字'); +await observed.drain(); +assert.equal(observed.acknowledgements(), 1, 'exact selected-range replacement confirms an applied edit'); +assert.equal(observedAttempts, 1, 'observation must not replay the insertion'); +assert.equal(observed.state.get('pendingCommit'), false); + +const noReceipt = host(async () => ({ inputPattern: 0, enterKeyType: 0, bundleName: 'native.test' }), { + insertText: () => new Promise(() => {}), +}); +await noReceipt.drain(); +noReceipt.session.selectCandidate(0); +await noReceipt.drain(); +assert.equal(noReceipt.state.get('writeInFlight'), false, 'unresponsive IPC has a bounded UI wait'); +assert.equal(noReceipt.state.get('pendingCommit'), true); +assert.equal(noReceipt.state.get('commitNeedsConfirmation'), true, 'only an unconfirmed completed attempt opens recovery'); +noReceipt.session.observeText('测试'); +noReceipt.session.observeSelection(2, 2); +await noReceipt.pump(); +assert.equal(noReceipt.acknowledgements(), 0, 'without a baseline, text alone is insufficient proof'); +noReceipt.session.acknowledgePendingCommit(); +await noReceipt.drain(); +noReceipt.session.selectCandidate(0); +await noReceipt.pump(); +noReceipt.session.observeText('测试测试'); +noReceipt.session.observeSelection(4, 4); +await noReceipt.drain(); +assert.equal(noReceipt.acknowledgements(), 2, 'manual recovery preserves the active-field baseline for the next word'); + +let dispatchedDeletes = 0, dispatchedWrites = 0; +const dispatchedReplies = []; +const continuous = host(async () => ({ inputPattern: 0, enterKeyType: 0, bundleName: '' }), { + insertText: () => { dispatchedWrites++; return new Promise((resolve) => dispatchedReplies.push(resolve)); }, + deleteForward: async () => { dispatchedDeletes++; return new Promise(() => {}); }, +}); +await continuous.drain(); +continuous.session.selectCandidate(0); +await continuous.drain(); +assert.equal(continuous.state.get('pendingCommit'), false, 'missing bridge replies must not lock the first commit'); +assert.equal(continuous.state.get('commitNeedsConfirmation'), false); +for (let i = 0; i < 3; i++) continuous.session.selectCandidate(0); +await continuous.drain(); +assert.equal(dispatchedWrites, 4, 'one and only one dispatch per selected word'); +assert.equal(continuous.state.get('pendingCommit'), false); +for (let i = 0; i < 5; i++) continuous.session.backspace(); +await continuous.drain(); +assert.equal(dispatchedDeletes, 5, 'missing delete replies must not serialize repeat keys behind timeouts'); +dispatchedReplies[0](false); +await continuous.pump(); +assert.equal(dispatchedWrites, 4, 'a late false reply must never resend committed text'); +assert.equal(continuous.state.get('pendingCommit'), false, 'late bridge replies must not lock the next composition'); +continuous.session.attach({ filesDir: '/test', resourceManager: {} }, {}, { + getEditorAttribute: async () => ({ inputPattern: 0, enterKeyType: 0, bundleName: '' }), + insertText: () => new Promise(() => {}), +}); +await continuous.drain(); +continuous.session.selectCandidate(0); +await continuous.drain(); +assert.equal(continuous.state.get('pendingCommit'), false, 'new compatibility fields also allow continuous composition'); +dispatchedReplies[1](false); +await continuous.pump(); +assert.equal(continuous.state.get('status'), 'Rime 拼音', 'old editor failures must not affect the new field'); + +let actionCalls = 0, postActionWrites = 0; +const actions = host(async () => ({ inputPattern: 0, enterKeyType: 2, bundleName: '' }), { + sendKeyFunction: () => { actionCalls++; return new Promise(() => {}); }, + insertText: () => { postActionWrites++; return new Promise(() => {}); }, +}); +await actions.drain(); +actions.session.enter(); +actions.session.selectCandidate(0); +await actions.drain(); +assert.equal(actionCalls, 1, 'editor action must be dispatched once'); +assert.equal(postActionWrites, 1, 'a missing action reply must not block further input'); + +let releaseOldWrite, hangingAttempts = 0; +const hanging = host(async () => ({ inputPattern: 0, enterKeyType: 0, bundleName: 'native.test' }), { + insertText: () => { + hangingAttempts++; + return hangingAttempts === 1 ? new Promise((resolve) => { releaseOldWrite = resolve; }) : Promise.resolve(true); + }, +}); +await hanging.drain(); +hanging.session.selectCandidate(0); +await hanging.pump(); +assert.equal(hanging.state.get('pendingCommit'), true); +assert.equal(hanging.state.get('writeInFlight'), true); +assert.equal(hanging.state.get('commitNeedsConfirmation'), false, 'normal pending IPC must not flash the recovery controls'); +// Recovery must work while the original insertion promise is still unresolved. +hanging.session.acknowledgePendingCommit(); +await hanging.drain(); +assert.equal(hanging.state.get('pendingCommit'), false); +assert.equal(hanging.state.get('commitNeedsConfirmation'), false); +hanging.session.selectCandidate(0); +await hanging.drain(); +assert.equal(hangingAttempts, 2); +assert.equal(hanging.acknowledgements(), 2); +releaseOldWrite(false); +await hanging.pump(); +assert.equal(hanging.state.get('status'), 'Rime 拼音', 'late failure must not replace recovered state'); +assert.equal(hanging.acknowledgements(), 2); + +let lateFieldWrite; +const rebound = host(async () => ({ inputPattern: 0, enterKeyType: 0, bundleName: 'native.test' }), { + insertText: () => new Promise((resolve) => { lateFieldWrite = resolve; }), +}); +await rebound.drain(); +rebound.session.selectCandidate(0); +await rebound.pump(); +rebound.session.attach({ filesDir: '/test', resourceManager: {} }, {}, { + getEditorAttribute: async () => ({ inputPattern: 0, enterKeyType: 2, bundleName: 'native.other' }), + insertText: async () => true, +}); +await rebound.drain(); +assert.equal(rebound.state.get('rimeReady'), true, 'new field initialization must not wait for old IPC'); +assert.equal(rebound.state.get('enterLabel'), '搜索'); +assert.equal(rebound.state.get('pendingCommit'), false); +lateFieldWrite(true); +await rebound.pump(); +assert.equal(rebound.acknowledgements(), 0, 'late old-field success must not acknowledge the new session'); + +const rejected = host(async () => { throw { code: 43 }; }); +await rejected.drain(); +assert.deepEqual(rejected.schemas, ['private']); +assert.equal(rejected.state.get('rimeReady'), true); +assert.equal(rejected.state.get('editorVerified'), false); +assert.equal(rejected.state.get('enterLabel'), '换行'); +await rejected.session.startVoice(); +assert.equal(rejected.recordings(), 0); +rejected.session.toggleLanguage(); +rejected.session.insert('a'); +await rejected.drain(); +assert.deepEqual(rejected.inserted, ['a'], 'attribute failure must not block ordinary input'); + +let lateResolve; +const stalled = host(() => new Promise((resolve) => { lateResolve = resolve; })); +await stalled.drain(); +assert.equal(stalled.state.get('rimeReady'), false); +stalled.timeout(); +await stalled.drain(); +assert.deepEqual(stalled.schemas, ['private']); +lateResolve({ inputPattern: 0, enterKeyType: 1 }); +await stalled.drain(); +assert.deepEqual(stalled.schemas, ['private'], 'late attributes must not upgrade unknown fields to learning'); + +const password = host(async () => ({ inputPattern: 10, enterKeyType: 0 })); +await password.drain(); +assert.equal(password.state.get('password'), true); +assert.equal(password.state.get('chinese'), false); +assert.deepEqual(password.schemas, []); +password.session.observeText('private'); +password.session.observeSelection(7, 7); +assert.equal(password.session.observedText, undefined); +assert.equal(password.session.selectionBegin, -1); +await password.session.startVoice(); +assert.equal(password.recordings(), 0); + +const detached = host(() => new Promise(() => {})); +detached.session.detach(); +detached.timeout(); +await detached.drain(); +assert.deepEqual(detached.schemas, []); +assert.equal(detached.state.get('connected'), false); +console.log('Harmony editor compatibility: transports, commit acknowledgements, cancellation, attributes and privacy passed'); + +let resolveCloud, cloudQueries = 0; +const cloud = host(async () => ({ inputPattern: 0, enterKeyType: 0, bundleName: 'native.test' }), {}, () => { + cloudQueries++; return new Promise((resolve) => { resolveCloud = resolve; }); +}); +await cloud.drain(); +const composition = { session: 'test', revision: 1, input: 'nihao', preedit: 'ni hao', + candidates: ['你好', '你号'], commit: '', page: 0, hasNext: true }; +cloud.session.publish(composition); +assert.equal(cloud.cloudTimers.length, 0, 'cloud is off by default'); +cloud.state.setOrCreate('cloudEnabled', true); +cloud.session.publish(composition); +cloud.cloudTimers.shift()(); +assert.equal(cloudQueries, 1); +resolveCloud(['你好', '你好世界']); +await cloud.pump(); +assert.deepEqual(Array.from(cloud.state.get('candidates')), ['你好', '你好世界', '你号']); +cloud.session.selectCandidate(1, '你号'); +await cloud.drain(); +assert(cloud.operations.some(([op, arg]) => op === 'select' && arg === '1@1'), 'cloud insertion must not shift the native selection index'); +cloud.session.publish(composition); +cloud.cloudTimers.shift()(); +resolveCloud(['你好', '你好世界']); +await cloud.pump(); +cloud.session.selectCandidate(1); +await cloud.drain(); +assert.deepEqual(cloud.inserted, ['测试', '你好世界']); +assert(cloud.operations.some(([op, arg]) => op === 'external' && arg === '1@你好世界')); +cloud.session.publish(composition); +cloud.cloudTimers.shift()(); +cloud.session.detach(); +resolveCloud(['迟到的候选']); +await cloud.pump(); +assert.equal(cloud.state.get('candidates').length, 0, 'late cloud replies cannot populate a different field'); +password.state.setOrCreate('cloudEnabled', true); +password.session.publish(composition); +assert.equal(password.cloudTimers.length, 0, 'password input never schedules cloud queries'); + +let finishQuery, queryCount = 0; +const serialCloud = host(async () => ({ inputPattern: 0, enterKeyType: 0, bundleName: 'native.test' }), {}, () => { + queryCount++; return new Promise((resolve) => { finishQuery = resolve; }); +}); +await serialCloud.drain(); +serialCloud.state.setOrCreate('cloudEnabled', true); +serialCloud.session.publish(composition); +serialCloud.cloudTimers.shift()(); +serialCloud.session.publish({ ...composition, revision: 2, input: 'nihaoma', candidates: ['你好吗'] }); +serialCloud.cloudTimers.shift()(); +assert.equal(queryCount, 1, 'only one cloud IPC may occupy the worker pool'); +finishQuery(['过期回复']); +await serialCloud.pump(); +assert.deepEqual(Array.from(serialCloud.state.get('candidates')), ['你好吗']); +serialCloud.cloudTimers.shift()(); +assert.equal(queryCount, 2, 'after a slow reply, query the latest composition only'); +serialCloud.session.toggleCloudCandidates(); +finishQuery(['你好么']); +await serialCloud.pump(); +assert.deepEqual(Array.from(serialCloud.state.get('candidates')), ['你好吗'], 'switching cloud off discards in-flight results'); +rejected.state.setOrCreate('cloudEnabled', true); +rejected.session.publish(composition); +assert.equal(rejected.cloudTimers.length, 0, 'unverified editors never query cloud'); +console.log('Harmony cloud candidates: opt-in, privacy, serial queries, stale replies and native/external selection passed'); + +normal.session.insertSymbol(','); +assert.equal(normal.state.get('symbolFeedback'), '正在输入「,」'); +await normal.drain(); +assert.equal(normal.state.get('symbolFeedback'), '已输入「,」'); +anonymousBridge.session.insertSymbol('!'); +await anonymousBridge.drain(); +assert.equal(anonymousBridge.state.get('symbolFeedback'), '已发送「!」', 'bridge dispatch is not a confirmed editor write'); +failedWrite.session.insertSymbol('。'); +await failedWrite.drain(); +assert.equal(failedWrite.state.get('symbolFeedback'), '未能确认,请检查输入框'); +normal.session.detach(); +assert.equal(normal.state.get('symbolFeedback'), '', 'editor feedback must not leak across bindings'); + +const voiceEditor = () => host(async () => ({ inputPattern: 0, enterKeyType: 2, bundleName: 'com.huawei.hmos.settings' })); +const held = voiceEditor(); +await held.drain(); +await held.session.startVoice(true); +assert.equal(held.state.get('voiceHolding'), true); +assert.equal(held.state.get('voicePhase'), 'recording'); +held.session.releaseVoiceHold(); +held.session.releaseVoiceHold(); +held.session.cancelVoiceHold(); // Gesture teardown after release must not cancel recognition. +await held.drain(); +assert.equal(held.voiceStops(), 1); +assert.deepEqual(held.inserted, ['语音结果'], 'release inserts once without a confirmation step'); +assert.equal(held.state.get('voicePhase'), 'idle'); + +const earlyRelease = voiceEditor(); +await earlyRelease.drain(); +let finishStartup; +earlyRelease.voiceStartWith(new Promise((resolve) => { finishStartup = resolve; })); +const starting = earlyRelease.session.startVoice(true); +await earlyRelease.pump(); +earlyRelease.session.releaseVoiceHold(); +assert.equal(earlyRelease.state.get('voiceHolding'), false); +finishStartup(); +await starting; +await earlyRelease.drain(); +assert.equal(earlyRelease.voiceStops(), 1, 'release while the microphone prepares is retained'); +assert.deepEqual(earlyRelease.inserted, ['语音结果']); + +const cancelledHold = voiceEditor(); +await cancelledHold.drain(); +await cancelledHold.session.startVoice(true); +cancelledHold.session.cancelVoiceHold(); +cancelledHold.session.releaseVoiceHold(); +await cancelledHold.drain(); +assert.deepEqual(cancelledHold.inserted, []); +assert.equal(cancelledHold.voiceStops(), 0); + +const lateVoice = voiceEditor(); +await lateVoice.drain(); +let finishRecognition; +lateVoice.voiceStopWith(new Promise((resolve) => { finishRecognition = resolve; })); +await lateVoice.session.startVoice(true); +lateVoice.session.releaseVoiceHold(); +await lateVoice.pump(); +lateVoice.session.detach(); +finishRecognition({ rawText: '过期结果', polishedText: '' }); +await lateVoice.drain(); +assert.deepEqual(lateVoice.inserted, [], 'late recognition cannot write after editor detaches'); + +const emptyVoice = voiceEditor(); +await emptyVoice.drain(); +emptyVoice.voiceStopWith(Promise.resolve({ rawText: '', polishedText: '' })); +await emptyVoice.session.startVoice(true); +emptyVoice.session.releaseVoiceHold(); +await emptyVoice.drain(); +assert.deepEqual(emptyVoice.inserted, []); +assert.equal(emptyVoice.state.get('voicePhase'), 'idle'); +console.log('Harmony hold-to-talk: release, early release, cancellation, late results and empty recognition passed'); diff --git a/openless-all/app/scripts/harmony-entry-startup.test.mjs b/openless-all/app/scripts/harmony-entry-startup.test.mjs new file mode 100644 index 000000000..ecf699d56 --- /dev/null +++ b/openless-all/app/scripts/harmony-entry-startup.test.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import ts from 'typescript'; + +const source = readFileSync( + new URL('../harmony/entry/src/main/ets/entryability/EntryAbility.ets', import.meta.url), + 'utf8', +) + .replace(/\r\n/g, '\n') + .replace(/^import .*;\n/gm, ''); +let clipboardAccess = false; +const requests = [], + pages = []; +class UIAbility { + context = { resourceManager: { getBooleanByName: () => clipboardAccess } }; +} +const exports = {}; +vm.runInNewContext( + ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, + }).outputText, + { + exports, + UIAbility, + console, + abilityAccessCtrl: { + createAtManager: () => ({ + requestPermissionsFromUser: async (_context, permissions) => { + requests.push(Array.from(permissions)); + return { authResults: [0] }; + }, + }), + }, + }, +); +const stage = { + loadContent: (page, ready) => { + pages.push(page); + ready(); + }, +}; +for (const want of [undefined, {}, { parameters: {} }]) { + const ability = new exports.default(); + ability.onCreate(want); + ability.onForeground(); + ability.onWindowStageCreate(stage); + ability.onNewWant(); + ability.onForeground(); +} +assert.deepEqual(pages, ['pages/Index', 'pages/Index', 'pages/Index']); +assert.equal(requests.length, 0, 'normal launches must not request clipboard access'); +const ability = new exports.default(); +ability.onCreate({ parameters: { requestClipboardPermission: true } }); +ability.onWindowStageCreate(stage); +assert.equal( + requests.length, + 0, + 'the default build must ignore clipboard authorization deep links', +); +clipboardAccess = true; +ability.onNewWant({ parameters: { requestClipboardPermission: true } }); +ability.onForeground(); +ability.onWindowStageCreate(stage); +assert.deepEqual( + requests, + [['ohos.permission.READ_PASTEBOARD']], + 'authorized builds request only once per explicit action', +); +console.log( + 'Harmony entry startup: absent launch arguments, page loading and clipboard capability gating passed', +); diff --git a/openless-all/app/scripts/harmony-panel-layout.test.mjs b/openless-all/app/scripts/harmony-panel-layout.test.mjs new file mode 100644 index 000000000..202bb935c --- /dev/null +++ b/openless-all/app/scripts/harmony-panel-layout.test.mjs @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import ts from 'typescript'; + +const source = readFileSync(new URL('../harmony/entry/src/main/ets/inputmethod/PanelLayout.ets', import.meta.url), 'utf8'); +const stripImports = (text) => text.replace(/\r\n/g, '\n').replace(/^import .*;\n/gm, ''); +assert.equal(stripImports(source), stripImports(source.replace(/\r?\n/g, '\r\n'))); +const compiled = ts.transpileModule(stripImports(source), { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, +}).outputText; + +async function host(saved = {}) { + const state = new Map(); + const values = new Map(Object.entries(saved)); + const screen = { width: 1600, height: 2560, densityPixels: 2.25 }; + const panel = { + flag: 0, width: 0, height: 0, x: 0, y: 0, failFloating: false, resizes: 0, + changeFlag(flag) { if (flag === 1 && this.failFloating) throw Error('unsupported'); this.flag = flag; }, + async resize(width, height) { + this.resizes++; + assert.ok(width > 0 && width <= screen.width); + assert.ok(height > 0 && height <= screen.height * 0.7); + this.width = width; this.height = height; + }, + async moveTo(x, y) { + assert.equal(this.flag, 1); + assert.ok(x >= 0 && x + this.width <= screen.width); + assert.ok(y >= 0 && y + this.height <= screen.height); + this.x = x; this.y = y; + }, + }; + const exports = {}; + vm.runInNewContext(compiled, { + exports, console, + inputMethodEngine: { PanelFlag: { FLG_FIXED: 0, FLG_FLOATING: 1 } }, + display: { getDefaultDisplaySync: () => screen }, + imeStorage: { setOrCreate: (key, value) => state.set(key, value) }, + preferences: { getPreferences: async () => ({ + get: async (key, fallback) => values.has(key) ? values.get(key) : fallback, + put: async (key, value) => values.set(key, value), flush: async () => {}, + }) }, + }); + const layout = exports.panelLayout; + await layout.attach(panel, {}); + return { layout, panel, screen, state, values }; +} + +const h = await host(); +function adaptiveGeometry(h) { + const size = h.state.get('keyHeight'); + const keyWidth = h.state.get('keyWidth'); + const split = h.state.get('keyboardLayout') === 'split'; + const gap = h.state.get('keyboardLayout') === 'standard' ? 7 : 5; + const width = h.state.get('keyboardWidth'); + assert.equal(width, (split ? 12 : 10) * keyWidth + (split ? 10 : 9) * gap); + const expectedWidth = h.state.get('keyboardLayout') === 'onehand' ? + Math.min(420, h.panel.width / h.screen.densityPixels * 0.72) : h.panel.width / h.screen.densityPixels; + assert.ok(Math.abs(width + 12 - expectedWidth) < 0.01, 'keys must fill the selected layout width'); + if (h.state.get('keyboardLayout') === 'floating') assert.ok(size <= keyWidth + 0.01); + assert.ok(size >= 12); + const rows = h.state.get('showNumberRow') ? 5 : 4; + const fitted = rows * size + (rows - 1) * 7 + 66 + (h.state.get('keyboardLayout') === 'floating' ? 26 : 0); + assert.ok(Math.abs(h.panel.height / h.screen.densityPixels - fitted) < 0.5, + 'panel height must follow the fitted rows rather than leaving an empty band'); +} +adaptiveGeometry(h); +assert.equal(h.state.get('keyboardLayout'), 'standard'); +assert.equal(h.state.get('showNumberRow'), true); +const standardHeight = h.panel.height; +const initialResizes = h.panel.resizes; +await Promise.all([h.layout.refresh(), h.layout.refresh(), h.layout.refresh()]); +assert.equal(h.panel.resizes, initialResizes, 'native resize notifications must not cause another resize'); +h.layout.select('split'); +await h.layout.queue; +assert.equal(h.state.get('keyboardLayout'), 'split'); +assert.equal(h.values.get('mode'), 'split'); +assert.equal(h.panel.height, standardHeight); +adaptiveGeometry(h); +h.layout.select('onehand'); +await h.layout.queue; +assert.equal(h.state.get('keyboardLayout'), 'onehand'); +assert.equal(h.panel.height, standardHeight); +assert.equal(h.panel.flag, 0); +assert.ok(h.state.get('keyboardWidth') < h.panel.width / h.screen.densityPixels - 12); +adaptiveGeometry(h); +const restoredOnehand = await host(Object.fromEntries(h.values)); +assert.equal(restoredOnehand.state.get('keyboardLayout'), 'onehand'); +adaptiveGeometry(restoredOnehand); +h.layout.select('floating'); +await h.layout.queue; +assert.equal(h.panel.flag, 1); +assert.ok(h.panel.width < h.screen.width); +assert.equal(h.state.get('showNumberRow'), false); +adaptiveGeometry(h); +const originalWidth = h.panel.width; +h.layout.resize(0.1); +await h.layout.queue; +assert.ok(h.panel.width > originalWidth); +h.layout.beginMove(); +h.layout.drag(-10000, 10000); +h.layout.endMove(); +await h.layout.queue; +assert.equal(h.panel.x, 0); +assert.equal(h.panel.y + h.panel.height, h.screen.height); + +// Restore the actual mode, size, and position in a new extension instance. +const restored = await host(Object.fromEntries(h.values)); +assert.equal(restored.panel.flag, 1); +assert.equal(restored.panel.width, h.panel.width); +assert.equal(restored.panel.x, h.panel.x); +assert.equal(restored.panel.y, h.panel.y); +adaptiveGeometry(restored); + +// Landscape must fit keys without retaining an oversized portrait number row. +Object.assign(h.screen, { width: 2560, height: 720 }); +h.layout.select('standard'); +await h.layout.queue; +adaptiveGeometry(h); +assert.equal(h.panel.flag, 0); +assert.equal(h.state.get('showNumberRow'), false); +assert.ok(h.state.get('keyHeight') * 4 + 18 + 66 <= h.panel.height / h.screen.densityPixels); + +const landscapeHeight = h.panel.height; +h.layout.select('split'); +await h.layout.queue; +adaptiveGeometry(h); +assert.equal(h.panel.height, landscapeHeight); +h.layout.select('standard'); +await h.layout.queue; + +// A native mode failure keeps the previous working mode and stored preference. +h.panel.failFloating = true; +h.layout.select('floating'); +await h.layout.queue; +assert.equal(h.state.get('keyboardLayout'), 'standard'); +assert.equal(h.values.get('mode'), 'standard'); +assert.match(h.state.get('status'), /切换失败/); + +const narrow = await host(); +Object.assign(narrow.screen, { width: 720, height: 1600 }); +await narrow.layout.refresh(); +const narrowHeight = narrow.panel.height; +narrow.layout.select('split'); +await narrow.layout.queue; +adaptiveGeometry(narrow); +assert.equal(narrow.panel.height, narrowHeight); + +const invalid = await host({ mode: 'unknown', scale: Infinity, x: -20, y: 99 }); +assert.equal(invalid.state.get('keyboardLayout'), 'standard'); +console.log('Harmony panel layout: modes, persistence, resize, drag bounds, landscape and native failure passed'); diff --git a/openless-all/app/scripts/harmony-rime-startup.test.mjs b/openless-all/app/scripts/harmony-rime-startup.test.mjs new file mode 100644 index 000000000..ff6e0f1ec --- /dev/null +++ b/openless-all/app/scripts/harmony-rime-startup.test.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import ts from 'typescript'; + +const source = readFileSync(new URL('../harmony/entry/src/main/ets/inputmethod/RimeClient.ets', import.meta.url), 'utf8'); +const stripImports = text => text.replace(/\r\n/g, '\n').replace(/^import .*;\n/gm, ''); +assert.equal(stripImports(source), stripImports(source.replace(/\r?\n/g, '\r\n'))); +const compiled = ts.transpileModule(stripImports(source), { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, +}).outputText; + +function host({ selected = '', rejectSnapshot = false } = {}) { + const files = new Map(), requests = []; + const initializationPaths = [], dictionaryCommands = []; + let handle, calls = 0, fail = false; + const expected = new TextEncoder().encode('dictionary-中文-contents'); + // Resource APIs may return a view into a larger buffer. + const backing = new Uint8Array(expected.length + 8).fill(255); + backing.set(expected, 4); + const view = backing.subarray(4, 4 + expected.length); + const fs = { + OpenMode: { CREATE: 1, WRITE_ONLY: 2, TRUNC: 4 }, + accessSync: path => files.has(path), mkdirSync: path => files.set(path, []), + async open(path) { handle = path; files.set(path, []); return { fd: 1 }; }, + async write(_fd, buffer) { + calls++; + if (fail) { fail = false; return 0; } + const bytes = new Uint8Array(buffer), count = Math.min(7, bytes.length); + files.get(handle).push(...bytes.subarray(0, count)); + return count; + }, + async close() {}, + async rename(from, to) { files.set(to, files.get(from)); files.delete(from); }, + }; + const exports = {}; + vm.runInNewContext(compiled, { + exports, console, fs, + util: { TextDecoder: { create: () => ({ decodeToString: bytes => new TextDecoder().decode(bytes) }) } }, + nativeHost: { + initBackend: () => '{"ok":true}', + async invokeAsync(command) { + dictionaryCommands.push(command); + return JSON.stringify({ ok: true, value: { data_path: command === 'ime_dictionary_select' ? selected : '/profile/rime-shared-1.17.0-ice-da1fbe60-v1' } }); + }, + async rimeAsync(operation, path) { + requests.push(operation); initializationPaths.push(path); + return JSON.stringify({ ok: !(rejectSnapshot && path === selected), error: 'invalid public dictionary fixture' }); + }, + }, + }); + const resources = { + async getRawFileContent(name) { + return name.endsWith('manifest.json') ? new TextEncoder().encode('["test.dict.yaml"]') : view; + }, + }; + return { exports, files, requests, resources, expected, initializationPaths, dictionaryCommands, writes: () => calls, failNext: () => { fail = true; } }; +} + +const h = host(); +await Promise.all([h.exports.initializeRime('/profile', h.resources), h.exports.initializeRime('/profile', h.resources)]); +assert.deepEqual(h.requests, ['initialize'], 'prewarm and editor attachment share one deployment'); +const path = '/profile/rime-shared-1.17.0-ice-da1fbe60-v1/test.dict.yaml'; +assert.deepEqual(h.files.get(path), Array.from(h.expected), 'partial writes and resource view bounds preserve every byte'); +const writes = h.writes(); +await h.exports.initializeRime('/profile', h.resources); +assert.equal(h.writes(), writes, 'ready dictionaries must not be recopied'); + +const retry = host(); +retry.failNext(); +await assert.rejects(retry.exports.initializeRime('/profile', retry.resources)); +assert.equal(retry.files.has(path), false, 'incomplete data must never become the cached resource'); +assert.deepEqual(retry.requests, []); +await retry.exports.initializeRime('/profile', retry.resources); +assert.deepEqual(retry.files.get(path), Array.from(retry.expected)); +assert.deepEqual(retry.requests, ['initialize']); +const base = '/profile/rime-shared-1.17.0-ice-da1fbe60-v1'; +const selected = base + '/public-updates-v1/' + 'a'.repeat(40); +const updated = host({ selected }); +await updated.exports.initializeRime('/profile', updated.resources, 'rime-user', '/cache'); +assert.deepEqual(updated.initializationPaths, [selected]); +const rollback = host({ selected, rejectSnapshot: true }); +await rollback.exports.initializeRime('/profile', rollback.resources, 'rime-user', '/cache'); +assert.deepEqual(rollback.initializationPaths, [selected, base]); +assert.deepEqual(rollback.dictionaryCommands, ['ime_dictionary_select', 'ime_dictionary_reject']); +const invalidPath = host({ selected: '/profile/rime-user' }); +await invalidPath.exports.initializeRime('/profile', invalidPath.resources, 'rime-user', '/cache'); +assert.deepEqual(invalidPath.initializationPaths, [base], 'public data selection must not redirect to the personal profile'); +console.log('Harmony Rime startup: shared initialization, asynchronous partial writes, atomic resources and failure retry passed'); diff --git a/openless-all/app/scripts/harmony-symbol-gesture.test.mjs b/openless-all/app/scripts/harmony-symbol-gesture.test.mjs new file mode 100644 index 000000000..8a944cc8b --- /dev/null +++ b/openless-all/app/scripts/harmony-symbol-gesture.test.mjs @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import ts from 'typescript'; + +const source = readFileSync(new URL('../harmony/entry/src/main/ets/inputmethod/SymbolGesture.ets', import.meta.url), 'utf8'); +const exports = {}; +vm.runInNewContext(ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, +}).outputText, { exports }); +const { SymbolGesture, letterSymbol, digitSymbol, symbolRows, localizedSymbol, liveSymbolHint } = exports; +const gesture = new SymbolGesture(); +const field = 'editor1:zh:standard'; +gesture.begin('a', field); +assert.equal(gesture.tap('a', field), true); +assert.equal(gesture.tap('a', field), false, 'native click and tap callbacks must not double-insert'); +gesture.begin('a', field); +gesture.arm('a', '~', field, 'hold'); +assert.equal(gesture.finish('a', field, 'hold'), '~'); +assert.equal(gesture.finish('a', field, 'hold'), '', 'release must commit at most once'); +assert.equal(gesture.tap('a', field), false, 'a completed long press must not emit its letter'); +gesture.begin('a', field); +gesture.arm('a', '~', field, 'swipe'); +assert.equal(gesture.cancel('a', 'hold'), false, 'losing long-press recognizer must not cancel the winning swipe'); +assert.equal(gesture.move('a', 2, -20, 13), true); +assert.equal(gesture.finish('a', field, 'swipe'), '~'); +gesture.begin('a', field); +gesture.arm('a', '~', field, 'swipe'); +assert.equal(gesture.move('a', 40, -20, 13), false, 'horizontal drift must not insert a symbol'); +assert.equal(gesture.finish('a', field, 'swipe'), ''); +gesture.begin('a', field); +gesture.arm('a', '~', field, 'swipe'); +assert.equal(gesture.move('a', 0, -18, 25), false, 'a small movement must not arm the default swipe'); +assert.equal(gesture.move('a', 18, -28, 25), false, 'diagonal movement must not arm the swipe'); +assert.equal(gesture.move('a', 5, -28, 25), true); +assert.equal(gesture.finish('a', field, 'swipe'), '~'); +gesture.begin('a', field); +gesture.arm('a', '~', field, 'swipe'); +gesture.move('a', 0, -25, 13); +assert.equal(gesture.move('a', 0, -3, 13), false, 'moving back cancels an armed swipe'); +assert.equal(gesture.finish('a', field, 'swipe'), ''); +gesture.begin('a', field); +gesture.arm('a', '~', field, 'hold'); +assert.equal(gesture.finish('a', 'editor2:zh:standard', 'hold'), '', 'held keys cannot cross editors'); +gesture.begin('a', field); +gesture.arm('a', '~', field, 'hold'); +assert.equal(gesture.finish('a', 'editor1:en:standard', 'hold'), '', 'held keys cannot cross languages'); +gesture.begin('a', field); +gesture.arm('a', '~', field, 'hold'); +gesture.cancel('a'); +assert.equal(gesture.finish('a', field, 'hold'), ''); +assert.equal(gesture.tap('a', field), false); +gesture.begin('a', field); +assert.equal(gesture.tap('a', field), true, 'the next physical press is independent'); +gesture.begin('left', field); gesture.begin('right', field); +gesture.arm('left', ',', field, 'hold'); gesture.arm('right', '。', field, 'hold'); +assert.equal(gesture.finish('left', field, 'hold'), ','); +assert.equal(gesture.finish('right', field, 'hold'), '。'); +assert.equal(letterSymbol(1, 4, true, true), '%'); +assert.equal(letterSymbol(1, 5, true, true), '%', 'duplicated split G has the same symbol'); +assert.equal(letterSymbol(2, 3, true, true), '!'); +assert.equal(letterSymbol(2, 4, true, true), '!', 'duplicated split V has the same symbol'); +assert.equal(letterSymbol(2, 0, false, false), ','); +assert.equal(letterSymbol(2, 0, false, true), ','); +assert.equal(digitSymbol('6', true), '……', 'multi-character punctuation must not be truncated'); +assert.equal(digitSymbol('6', false), '^'); +assert.equal(symbolRows(0, true)[0].join(''), ',。?!:;、…—·'); +assert.equal(symbolRows(0, false)[0].join(''), ',.?!:;\'"-_'); +assert.equal(symbolRows(1, true)[0][0], '¥'); +assert.equal(symbolRows(1, false)[0][0], '$'); +assert.equal(symbolRows(1, true)[1].slice(0, 6).join(''), '【】{}《》'); +assert.equal(symbolRows(1, false)[1].slice(0, 6).join(''), '[]{}<>'); +assert.equal(symbolRows(1, true)[2][0], '……'); +assert.equal(symbolRows(1, false)[2][0], '...'); +for (const [en, zh] of [[',', ','], ['.', '。'], ['?', '?'], ['!', '!'], ['(', '('], [')', ')'], ['$', '¥']]) { + assert.equal(localizedSymbol(zh, false), en, 'old Chinese builder argument must follow the live English mode'); + assert.equal(localizedSymbol(en, true), zh, 'old English builder argument must follow the live Chinese mode'); + assert.equal(localizedSymbol(en, false), en); + assert.equal(localizedSymbol(zh, true), zh); +} +assert.equal(localizedSymbol("'", true), "'", 'Rime syllable delimiter remains ASCII'); +assert.equal(liveSymbolHint('digit:6', '……', false), '^'); +assert.equal(liveSymbolHint('digit:6', '^', true), '……'); +gesture.begin('z', field); +assert.equal(gesture.arm('z', ',', field, 'swipe', false), false, 'disabled swipe cannot arm a symbol'); +assert.equal(gesture.finish('z', field, 'swipe', false), ''); +gesture.begin('z', field); +gesture.arm('z', ',', field, 'swipe', true); +gesture.move('z', 0, -40, 25); +assert.equal(gesture.finish('z', field, 'swipe', false), '', 'turning off swipe before release prevents a late commit'); +gesture.begin('z', field, 20, 50); +gesture.moveTouch('z', 20, 20); +assert.equal(gesture.arm('z', ',', field, 'hold', false), false, 'a slow swipe must not fall through to long press'); +gesture.begin('z', field, 20, 50); +gesture.arm('z', ',', field, 'hold', false); +assert.equal(gesture.moveTouch('z', 20, 20), true); +assert.equal(gesture.finish('z', field, 'hold', false), '', 'moving a held key cancels its symbol'); +gesture.begin('z', field); +gesture.arm('z', ',', field, 'hold', false); +assert.equal(gesture.finish('z', field, 'hold', false), ',', 'stationary long press remains independent of the swipe switch'); +console.log('Harmony symbol gestures: one-shot release, arbitration, cancellation, editor/language changes and split mapping passed'); diff --git a/openless-all/app/scripts/harmony-voice-configuration.test.mjs b/openless-all/app/scripts/harmony-voice-configuration.test.mjs new file mode 100644 index 000000000..3e28e4a2e --- /dev/null +++ b/openless-all/app/scripts/harmony-voice-configuration.test.mjs @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import ts from 'typescript'; + +const source = readFileSync( + new URL('../harmony/entry/src/main/ets/VoiceConfiguration.ets', import.meta.url), + 'utf8', +); +function compile(source) { + return ts.transpileModule(source.replace(/\r\n/g, '\n').replace(/^import .*;\n/gm, ''), { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, + }).outputText; +} +const compiled = compile(source); +assert.equal( + compile(source.replace(/\r?\n/g, '\r\n')), + compiled, + 'Windows CRLF source uses the same isolated test host', +); +assert(!compiled.includes('require('), 'device-only imports are removed from the test host'); +const records = new Map(); +let snapshot = { + version: 1, + preferences: { workingLanguages: ['汉字'.repeat(700)] }, + channels: {}, +}; +let imported; +let exportsCount = 0; +let writes = 0; +let mutateOnRead; +const asset = { + Tag: { + ALIAS: 'alias', + SECRET: 'secret', + DATA_LABEL_NORMAL_1: 'label', + ACCESSIBILITY: 'access', + CONFLICT_RESOLUTION: 'conflict', + RETURN_TYPE: 'return', + }, + ReturnType: { ALL: 1, ATTRIBUTES: 2 }, + Accessibility: { DEVICE_UNLOCKED: 1 }, + ConflictResolution: { OVERWRITE: 1 }, + async add(attributes) { + const secret = attributes.get('secret'); + assert(secret.length > 0 && secret.length <= 1024, 'respect native Asset Store size limits'); + records.set(new TextDecoder().decode(attributes.get('alias')), new Map(attributes)); + writes++; + }, + async query(query) { + const alias = query.get('alias'); + if (alias) { + const name = new TextDecoder().decode(alias); + if (mutateOnRead && !name.endsWith('.manifest')) { + const mutate = mutateOnRead; + mutateOnRead = undefined; + await mutate(); + } + if (!records.has(name)) throw Object.assign(new Error('not found'), { code: 24000002 }); + return [new Map(records.get(name))]; + } + return [...records.values()].filter( + (record) => + new TextDecoder().decode(record.get('label')) === + new TextDecoder().decode(query.get('label')), + ); + }, + async remove(query) { + records.delete(new TextDecoder().decode(query.get('alias'))); + }, +}; +function client() { + const exports = {}; + vm.runInNewContext(compiled, { + exports, + console, + asset, + Uint8Array, + Date, + util: { + TextEncoder: class { + encodeInto(text) { + return new TextEncoder().encode(text); + } + }, + TextDecoder: { + create: () => ({ + decodeWithStream: (bytes) => new TextDecoder('utf-8', { fatal: true }).decode(bytes), + }), + }, + }, + nativeHost: { + async invokeAsync(command, args) { + if (command === '__export_voice_configuration') { + exportsCount++; + return JSON.stringify({ ok: true, value: snapshot }); + } + assert.equal(command, '__import_voice_configuration'); + imported = JSON.parse(args); + return JSON.stringify({ ok: true, value: true }); + }, + }, + }); + return exports; +} +const main = client(), + ime = client(); +await assert.rejects(ime.restoreVoiceConfiguration(), /请先打开/); +await main.publishVoiceConfiguration(); +await ime.restoreVoiceConfiguration(); +assert.deepEqual(imported, snapshot, 'UTF-8 survives chunk boundaries across independent runtimes'); +const firstWrites = writes; +await main.publishVoiceConfiguration(); +assert.equal(writes, firstWrites, 'unchanged settings avoid redundant vault writes'); +snapshot = { ...snapshot, preferences: { workingLanguages: ['中文'], pipelineMode: 'multimodal' } }; +mutateOnRead = () => main.publishVoiceConfiguration(); +await ime.restoreVoiceConfiguration(); +assert.deepEqual( + imported, + snapshot, + 'concurrent replacement retries instead of accepting mixed generations', +); +assert.equal(records.size, 2, 'obsolete chunks are cleaned up'); +const manifest = [...records.keys()].find((key) => key.endsWith('.manifest')); +records + .get(manifest) + .set('secret', new TextEncoder().encode('{"generation":"1-1","chunks":999,"bytes":1}')); +await assert.rejects(ime.restoreVoiceConfiguration(), /同步失败/); +snapshot = { preferences: { text: 'x'.repeat(65536) } }; +await assert.rejects(main.publishVoiceConfiguration(), /大小限制/); +assert(exportsCount >= 4); +console.log( + 'Harmony voice configuration: independent sandboxes, UTF-8 chunks, atomic publish, concurrent replacement and bounds passed', +); diff --git a/openless-all/app/scripts/run-android-tauri-build.mjs b/openless-all/app/scripts/run-android-tauri-build.mjs index d3c918dde..f2a1b8a9f 100644 --- a/openless-all/app/scripts/run-android-tauri-build.mjs +++ b/openless-all/app/scripts/run-android-tauri-build.mjs @@ -8,7 +8,8 @@ */ import { spawnSync } from 'node:child_process'; import process from 'node:process'; -import { parseAndroidAbis, ANDROID_ABI_MATRIX } from './android-abi-matrix.mjs'; +import { parseAndroidAbis, ANDROID_ABI_MATRIX, entryForAbi } from './android-abi-matrix.mjs'; +import { fileURLToPath } from 'node:url'; function resolveTargets() { const raw = process.env.OPENLESS_ANDROID_TARGETS ?? ''; @@ -27,6 +28,14 @@ function main() { process.exit(1); } const targets = resolveTargets(); + const rimeBuilder = fileURLToPath(new URL('./build-mobile-rime.py', import.meta.url)); + for (const target of targets) { + const rime = spawnSync(process.env.PYTHON ?? 'python3', [rimeBuilder, 'android', '--stage', '--abi', entryForAbi(target).gradleAbi], { + stdio: 'inherit', env: process.env, + }); + if (rime.error) throw rime.error; + if (rime.status !== 0) process.exit(rime.status ?? 1); + } const args = ['tauri', 'android', 'build', '--apk', '--split-per-abi', '--target', ...targets]; if (mode === '--debug') { args.push('--debug'); diff --git a/openless-all/app/scripts/sign-harmony-hap.py b/openless-all/app/scripts/sign-harmony-hap.py new file mode 100644 index 000000000..e7e3d8994 --- /dev/null +++ b/openless-all/app/scripts/sign-harmony-hap.py @@ -0,0 +1,21 @@ +"""Sign with local developer material without storing passwords in the project.""" +from pathlib import Path +import subprocess +import sys + +toolchain, signing, output = map(Path, sys.argv[1:]) +password = (signing / 'keystore-password').read_text().strip() +jar = toolchain / 'sdk/default/openharmony/toolchains/lib/hap-sign-tool.jar' +unsigned = output / 'entry-default-unsigned.hap' +signed = output / 'entry-default-signed.hap' +command = ['java', '-jar', str(jar), 'sign-app', '-mode', 'localSign', + '-keyAlias', 'harmony-debug', '-appCertFile', str(signing / 'debug.cer'), + '-profileFile', str(signing / 'debug.p7b'), '-inFile', str(unsigned), + '-signAlg', 'SHA256withECDSA', '-keystoreFile', str(signing / 'debug.p12'), + '-keystorePwd', password, '-keyPwd', password, '-outFile', str(signed), + '-compatibleVersion', '12', '-signCode', '1'] +result = subprocess.run(command, capture_output=True, text=True) +print((result.stdout + result.stderr).replace(password, '[redacted]')) +if result.returncode: + sys.exit(result.returncode) +print('Signed HAP:', signed) diff --git a/openless-all/app/scripts/windows-ui-config.test.mjs b/openless-all/app/scripts/windows-ui-config.test.mjs index d680179dd..9628487ca 100644 --- a/openless-all/app/scripts/windows-ui-config.test.mjs +++ b/openless-all/app/scripts/windows-ui-config.test.mjs @@ -167,7 +167,7 @@ assertMatch( const tokensCss = await readFile(new URL('../src/styles/tokens.css', import.meta.url), 'utf-8'); -if (!/os === 'win' \|\| os === 'android' \? 0 : 14/.test(windowChromeTsx)) { +if (!/os === 'win' \|\| os === 'android'(?: \|\| os === 'harmony')? \? 0 : 14/.test(windowChromeTsx)) { throw new Error( 'windows main shell should rely on native decorations instead of a frameless chrome shell', ); diff --git a/openless-all/app/src-tauri/src/android/native_bridge.rs b/openless-all/app/src-tauri/src/android/native_bridge.rs index c881f66e0..f1510392c 100644 --- a/openless-all/app/src-tauri/src/android/native_bridge.rs +++ b/openless-all/app/src-tauri/src/android/native_bridge.rs @@ -1078,6 +1078,69 @@ mod jni_exports { f(&mut env, &context) } + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativePublicDictionary( + env: *mut JNIEnv, + _class: JClass, + base: jstring, + action: jstring, + ) -> jstring { + let Ok(mut env) = JniEnv::from_raw(env) else { + return std::ptr::null_mut(); + }; + let result = (|| -> Result { + let base = env + .get_string(&JString::from_raw(base)) + .map(String::from) + .map_err(|_| "invalid dictionary path")?; + let action = env + .get_string(&JString::from_raw(action)) + .map(String::from) + .map_err(|_| "invalid dictionary action")?; + let base = std::path::PathBuf::from(base); + if base.file_name().and_then(|s| s.to_str()) + != Some(openless_core::ime_dictionary::BUNDLED_DIRECTORY) + { + return Err("unexpected dictionary directory".into()); + } + let status = match action.as_str() { + "select" => openless_core::ime_dictionary::select(&base), + "reject" => openless_core::ime_dictionary::reject(&base), + "update" => tauri::async_runtime::block_on(openless_core::ime_dictionary::update( + &base, false, + ))?, + _ => return Err("unsupported dictionary action".into()), + }; + serde_json::to_value(status).map_err(|_| "encode dictionary status".into()) + })(); + crate::android::jni::android::export_jstring( + &mut env, + &match result { + Ok(value) => value.to_string(), + Err(error) => serde_json::json!({"error":error}).to_string(), + }, + ) + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeCloudPinyin( + env: *mut JNIEnv, + _class: JClass, + pinyin: jstring, + ) -> jstring { + let Ok(mut env) = JniEnv::from_raw(env) else { + return std::ptr::null_mut(); + }; + let input = env.get_string(&JString::from_raw(pinyin)).map(String::from); + let result = input + .map(|query| tauri::async_runtime::block_on(openless_core::ime_cloud::query(&query))) + .unwrap_or_default(); + crate::android::jni::android::export_jstring( + &mut env, + &serde_json::json!(result).to_string(), + ) + } + #[no_mangle] pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeImeCommand( env: *mut JNIEnv, diff --git a/openless-all/app/src/components/GithubLoginModal.tsx b/openless-all/app/src/components/GithubLoginModal.tsx index 6f61db216..3ef9328c2 100644 --- a/openless-all/app/src/components/GithubLoginModal.tsx +++ b/openless-all/app/src/components/GithubLoginModal.tsx @@ -1,3 +1,4 @@ +import { isHarmonyHost, invokeHarmony } from '../lib/harmonyBridge'; // GitHub login modal — the style marketplace and extension marketplace share one // login UI. GitHub OAuth Device Flow: start on open → show the user code and wait // for browser authorization → poll until authorized. All phases share one minHeight @@ -177,7 +178,8 @@ export function GithubLoginModal({ const copyCode = async () => { if (phase.kind !== 'pending') return; try { - await navigator.clipboard.writeText(phase.userCode); + if (isHarmonyHost) await invokeHarmony('copy_text', { text: phase.userCode }); + else await navigator.clipboard.writeText(phase.userCode); setCopied(true); window.setTimeout(() => setCopied(false), 1500); } catch { diff --git a/openless-all/app/src/components/HarmonyDictation.tsx b/openless-all/app/src/components/HarmonyDictation.tsx new file mode 100644 index 000000000..6b7da33d9 --- /dev/null +++ b/openless-all/app/src/components/HarmonyDictation.tsx @@ -0,0 +1,156 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { invokeHarmony } from '../lib/harmonyBridge'; +import { copyTextToClipboard } from '../lib/ipc'; +import { Btn, Card } from '../pages/_atoms'; + +interface VoiceState { + phase: 'idle' | 'starting' | 'recording' | 'processing' | 'cancelling'; + level: number; + error: string; +} +interface VoiceResult { + rawText: string; + polishedText: string; +} + +export function HarmonyDictation({ onComplete }: { onComplete: () => void }) { + const { t } = useTranslation(); + const [state, setState] = useState({ phase: 'idle', level: 0, error: '' }); + const [result, setResult] = useState(''); + const [copied, setCopied] = useState(false); + const generation = useRef(0); + const active = useRef(false); + const busy = useRef(false); + useEffect(() => { + const update = (event: Event) => { + const next = (event as CustomEvent).detail; + if ( + next && + ['idle', 'starting', 'recording', 'processing', 'cancelling'].includes(next.phase) + ) { + active.current = next.phase !== 'idle'; + setState(next); + } + }; + window.addEventListener('openless:harmony-dictation', update); + return () => { + generation.current++; + window.removeEventListener('openless:harmony-dictation', update); + if (active.current) void invokeHarmony('cancel_dictation').catch(() => {}); + }; + }, []); + + const record = async () => { + if (busy.current) return; + busy.current = true; + const epoch = ++generation.current; + const stopping = state.phase === 'recording'; + active.current = true; + setState({ phase: stopping ? 'processing' : 'starting', level: 0, error: '' }); + if (!stopping) { + setResult(''); + setCopied(false); + } + try { + if (stopping) { + const reply = await invokeHarmony('stop_dictation'); + if (epoch !== generation.current) return; + active.current = false; + setResult(reply.polishedText || reply.rawText); + setState({ phase: 'idle', level: 0, error: '' }); + onComplete(); + } else { + await invokeHarmony('start_dictation'); + if (epoch === generation.current) setState({ phase: 'recording', level: 0, error: '' }); + } + } catch (error) { + if (epoch === generation.current) { + active.current = false; + setState({ phase: 'idle', level: 0, error: String(error) }); + } + } finally { + if (epoch === generation.current) busy.current = false; + } + }; + const cancel = async () => { + const epoch = ++generation.current; + busy.current = true; + setState({ phase: 'cancelling', level: 0, error: '' }); + try { + await invokeHarmony('cancel_dictation'); + } catch (error) { + if (epoch === generation.current) setState({ phase: 'idle', level: 0, error: String(error) }); + return; + } finally { + if (epoch === generation.current) { + busy.current = false; + active.current = false; + } + } + if (epoch === generation.current) setState({ phase: 'idle', level: 0, error: '' }); + }; + const copy = async () => { + try { + await copyTextToClipboard(result); + setCopied(true); + } catch (error) { + setState((old) => ({ ...old, error: String(error) })); + } + }; + const waiting = ['starting', 'processing', 'cancelling'].includes(state.phase); + return ( + +
+ {t('overview.inAppDictation.title')} + + {t(`overview.inAppDictation.${waiting ? 'processing' : state.phase}`)} + + void record()} disabled={waiting}> + {t(`overview.inAppDictation.${state.phase === 'recording' ? 'stop' : 'start'}`)} + + {state.phase !== 'idle' && ( + void cancel()} + > + {t('common.cancel')} + + )} +
+ {state.phase === 'recording' && ( + + )} + {state.error && ( +
+ {state.error} +
+ )} + {result && ( +
+
+ {result} +
+ void copy()}> + {t(copied ? 'common.copied' : 'common.copy')} + +
+ )} +
+ ); +} diff --git a/openless-all/app/src/components/WindowChrome.tsx b/openless-all/app/src/components/WindowChrome.tsx index 8aed20f55..266ef05d9 100644 --- a/openless-all/app/src/components/WindowChrome.tsx +++ b/openless-all/app/src/components/WindowChrome.tsx @@ -1,8 +1,9 @@ import { type CSSProperties, type ReactNode } from 'react'; -export type OS = 'mac' | 'win' | 'android'; +export type OS = 'mac' | 'win' | 'android' | 'harmony'; export function detectOS(): OS { + if (import.meta.env?.VITE_OPENLESS_PLATFORM === 'harmony') return 'harmony'; if (typeof navigator === 'undefined') return 'mac'; const uaDataPlatform = (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ?? @@ -28,11 +29,12 @@ interface WindowChromeProps { export function WindowChrome({ os = 'mac', children, height = 800 }: WindowChromeProps) { // Windows: with decorations:true the shell draws no radius/border/shadow/titlebar, // avoiding overlap with the native window. - const shellRadius = os === 'mac' ? 0 : os === 'win' || os === 'android' ? 0 : 14; + const shellRadius = + os === 'mac' ? 0 : os === 'win' || os === 'android' || os === 'harmony' ? 0 : 14; const consoleRadius = os === 'mac' ? 20 : os === 'win' ? WIN_CONSOLE_RADIUS : 0; const titlebarHeight = os === 'mac' ? MAC_TITLEBAR_HEIGHT : 0; - const useSolidSurface = os === 'android'; + const useSolidSurface = os === 'android' || os === 'harmony'; return (
( + command: string, + args?: Record, +): Promise { + const bridge = window.OpenLessHarmonyApi; + if (!bridge) throw new Error('鸿蒙原生后端桥尚未就绪'); + const wire = + bridge.request && bridge.takeResult + ? await new Promise((resolve, reject) => { + const id = bridge.request!(command, JSON.stringify(args ?? {})); + const deadline = Date.now() + 120_000; + const poll = () => { + try { + const result = bridge.takeResult!(id); + if (result) resolve(result); + else if (Date.now() >= deadline) reject(new Error(`鸿蒙命令超时:${command}`)); + else window.setTimeout(poll, 50); + } catch (error) { + reject(error); + } + }; + poll(); + }) + : bridge.invoke(command, JSON.stringify(args ?? {})); + const result = JSON.parse(wire) as { + ok: boolean; + value?: T; + error?: string; + }; + if (!result.ok) throw new Error(result.error ?? '鸿蒙后端调用失败'); + return result.value as T; +} diff --git a/openless-all/app/src/lib/ipc/asr-credentials.ts b/openless-all/app/src/lib/ipc/asr-credentials.ts index ff8bd02b6..584f85fbf 100644 --- a/openless-all/app/src/lib/ipc/asr-credentials.ts +++ b/openless-all/app/src/lib/ipc/asr-credentials.ts @@ -1,3 +1,4 @@ +import { isHarmonyHost } from '../harmonyBridge'; import type { CredentialsStatus } from '../types'; import { invokeOrMock, isTauri } from './shared'; import { mockCredentialsStatus, mockCredentialValues } from './mock-data'; @@ -92,13 +93,18 @@ export async function listProviderModels( channelId?: string, providerType?: string, ): Promise { - if (!isTauri) { + if (!isTauri && !isHarmonyHost) { const descriptor = (await listProviderDescriptors(kind)).find( (item) => item.providerType === providerType, ); if (descriptor?.staticModels.length) return { models: descriptor.staticModels }; } - if (!isTauri && providerType === 'orcarouter' && (kind === 'llm' || kind === 'asr')) { + if ( + !isTauri && + !isHarmonyHost && + providerType === 'orcarouter' && + (kind === 'llm' || kind === 'asr') + ) { const endpointAccount = kind === 'llm' ? 'ark.endpoint' : 'asr.endpoint'; const endpoint = mockCredentialValues.get(`${channelId ?? ''}:${endpointAccount}`); if (endpoint && new URL(endpoint).hostname.toLowerCase() === 'api.orcarouter.ai') { diff --git a/openless-all/app/src/lib/ipc/hotkeys.ts b/openless-all/app/src/lib/ipc/hotkeys.ts index 9ba50c668..8df0f2b6f 100644 --- a/openless-all/app/src/lib/ipc/hotkeys.ts +++ b/openless-all/app/src/lib/ipc/hotkeys.ts @@ -41,7 +41,7 @@ export function getHotkeyCapability(): Promise { export function getWindowsImeStatus(): Promise { return platformCapabilities().then((caps) => { - if (caps.platform === 'android') { + if (caps.platform !== 'desktop') { return androidWindowsImeStatus; } return invokeOrMock('get_windows_ime_status', undefined, () => mockWindowsImeStatus); diff --git a/openless-all/app/src/lib/ipc/shared.ts b/openless-all/app/src/lib/ipc/shared.ts index bfcfb7e27..9565f20b7 100644 --- a/openless-all/app/src/lib/ipc/shared.ts +++ b/openless-all/app/src/lib/ipc/shared.ts @@ -5,6 +5,7 @@ import type { WindowsImeStatus, } from '../types'; import { getPlatformCapabilities as loadPlatformCapabilities } from '../platform'; +import { invokeHarmony, isHarmonyHost } from '../harmonyBridge'; declare global { interface Window { @@ -45,6 +46,11 @@ export function validateStartupSnapshot(snapshot: StartupSnapshot): StartupSnaps let backendReadyPromise: Promise | null = null; export function requireBackendReady(): Promise { + if (isHarmonyHost) { + backendReadyPromise ??= + invokeHarmony('get_startup_snapshot').then(validateStartupSnapshot); + return backendReadyPromise; + } if (!isTauriNow()) { return Promise.resolve({ contractVersion: BACKEND_CONTRACT_VERSION, @@ -73,6 +79,10 @@ export async function invokeOrMock( args: Record | undefined, mock: () => T, ): Promise { + if (isHarmonyHost) { + await requireBackendReady(); + return invokeHarmony(cmd, args); + } if (!isTauriNow()) { return mock(); } diff --git a/openless-all/app/src/lib/ipc/utils.ts b/openless-all/app/src/lib/ipc/utils.ts index c26fdf427..805bf15ba 100644 --- a/openless-all/app/src/lib/ipc/utils.ts +++ b/openless-all/app/src/lib/ipc/utils.ts @@ -1,8 +1,13 @@ import { isTauri, invokeOrMock } from './shared'; +import { isHarmonyHost, invokeHarmony } from '../harmonyBridge'; export { isTauri }; export async function openExternal(url: string): Promise { + if (isHarmonyHost) { + await invokeHarmony('open_external_url', { url }); + return; + } if (!isTauri) { window.open(url, '_blank', 'noopener,noreferrer'); return; @@ -32,6 +37,10 @@ export async function openExternal(url: string): Promise { * Android writes the log to public Downloads through MediaStore. */ export async function exportErrorLog(suggestedFileName: string): Promise { + if (isHarmonyHost) + return invokeHarmony('export_harmony_error_log', { + fileName: suggestedFileName, + }); if (!isTauri) { return `~/Downloads/${suggestedFileName}`; } @@ -44,12 +53,10 @@ export async function exportErrorLog(suggestedFileName: string): Promise('export_error_log', { targetPath: target }, () => undefined); return target; @@ -78,6 +85,7 @@ export async function logClientError(message: string): Promise { export async function debugReadCursorContext( delayMs: number, ): Promise { + if (isHarmonyHost) throw new Error('鸿蒙跨应用文字读取尚未接入'); const { invoke } = await import('@tauri-apps/api/core'); return invoke('debug_read_cursor_context', { delayMs }); } diff --git a/openless-all/app/src/lib/platform.ts b/openless-all/app/src/lib/platform.ts index 398c16e98..79ca8dc0b 100644 --- a/openless-all/app/src/lib/platform.ts +++ b/openless-all/app/src/lib/platform.ts @@ -2,6 +2,7 @@ // Prefers Tauri `get_platform_capabilities`; falls back to UA / OS heuristics. import { detectOS } from '../components/WindowChrome'; +import { invokeHarmony, isHarmonyHost } from './harmonyBridge'; import type { PlatformCapabilities, PlatformKind } from './types'; export type { PlatformCapabilities, PlatformKind }; @@ -46,6 +47,7 @@ export function isAndroid(): boolean { } export function isMobile(): boolean { + if (import.meta.env?.VITE_OPENLESS_PLATFORM === 'harmony') return true; if (cachedCapabilities) { return cachedCapabilities.platform === 'mobile' || cachedCapabilities.platform === 'android'; } @@ -58,6 +60,9 @@ export function isDesktop(): boolean { } export function inferPlatformCapabilities(): PlatformCapabilities { + if (import.meta.env?.VITE_OPENLESS_PLATFORM === 'harmony') { + return { ...MOBILE_UNAVAILABLE, platform: 'harmony', supportsInAppDictation: true }; + } if (isAndroid()) { return { platform: 'android', @@ -96,6 +101,10 @@ export function inferPlatformCapabilities(): PlatformCapabilities { export async function getPlatformCapabilities(): Promise { if (cachedCapabilities) return cachedCapabilities; + if (isHarmonyHost) { + cachedCapabilities = await invokeHarmony('get_platform_capabilities'); + return cachedCapabilities; + } const isTauri = globalThis.window !== undefined && '__TAURI_INTERNALS__' in globalThis.window; diff --git a/openless-all/app/src/lib/stylePackZip.ts b/openless-all/app/src/lib/stylePackZip.ts index a91069fc4..290a38828 100644 --- a/openless-all/app/src/lib/stylePackZip.ts +++ b/openless-all/app/src/lib/stylePackZip.ts @@ -1,3 +1,4 @@ +import { isHarmonyHost, invokeHarmony } from './harmonyBridge'; const INVALID_FILE_NAME_CHARS = /[<>:"/\\|?*\u0000-\u001f]+/g; function normalizeFileNamePart(value: string): string { @@ -26,6 +27,7 @@ export async function pickStylePackZipTargetPath( defaultFileName: string, nativeApp: boolean, ): Promise { + if (isHarmonyHost) return invokeHarmony('pick_style_pack_zip_target', { fileName: defaultFileName }); if (!nativeApp) return `~/Downloads/${defaultFileName}`; try { diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 66a4dd2d0..5a3965620 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -802,7 +802,7 @@ export type PermissionStatus = 'granted' | 'denied' | 'notDetermined' | 'restricted' | 'notApplicable' | 'noDevice'; /** Runtime platform kind returned by `get_platform_capabilities`. */ -export type PlatformKind = 'desktop' | 'android' | 'mobile'; +export type PlatformKind = 'desktop' | 'android' | 'harmony' | 'mobile'; /** Feature flags for desktop vs Android APK UI gating. Mirrors src-tauri PlatformCapabilities. */ export interface PlatformCapabilities { diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 038dcd04b..e11f87462 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -26,6 +26,8 @@ import type { ActivityDay, CredentialsStatus, DictationSession, PolishMode } fro import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { Btn, Card, PageHeader, Pill } from './_atoms'; import { ASR_LABELS } from './settings/shared'; +import { isHarmonyHost } from '../lib/harmonyBridge'; +import { HarmonyDictation } from '../components/HarmonyDictation'; function useModeLabels(): Record { const { t } = useTranslation(); @@ -278,6 +280,8 @@ export function Overview({ onOpenHistory, onOpenSettings }: OverviewProps) { } /> + {isHarmonyHost && } + {showProvidersSection && (
('pick_style_pack_zip'); + } else if (isTauri) { const { open } = await import('@tauri-apps/plugin-dialog'); const picked = await open({ filters: [{ name: 'Style Pack ZIP', extensions: ['zip'] }], diff --git a/openless-all/app/src/pages/settings/ChannelList.test.ts b/openless-all/app/src/pages/settings/ChannelList.test.ts index dcd244151..d49a4f5e2 100644 --- a/openless-all/app/src/pages/settings/ChannelList.test.ts +++ b/openless-all/app/src/pages/settings/ChannelList.test.ts @@ -39,6 +39,7 @@ const expectedByPlatform: Record = { mac: ['apple-speech'], win: ['foundry-local-whisper', 'sherpa-onnx-local'], android: [], + harmony: [], }; // Production receives this catalog from Core. The test supplies only the diff --git a/openless-all/app/src/pages/settings/MicrophoneSelect.tsx b/openless-all/app/src/pages/settings/MicrophoneSelect.tsx index f82c15174..5bd42d8ae 100644 --- a/openless-all/app/src/pages/settings/MicrophoneSelect.tsx +++ b/openless-all/app/src/pages/settings/MicrophoneSelect.tsx @@ -8,6 +8,7 @@ import { isTauri, startMicrophoneLevelMonitor, stopMicrophoneLevelMonitor } from import type { MicrophoneDevice } from '../../lib/types'; import { SelectLite, type SelectOption } from '../../components/ui/SelectLite'; import { useMobileLayout } from '../../lib/useMobileLayout'; +import { isHarmonyHost } from '../../lib/harmonyBridge'; interface MicrophoneSelectProps { devices: MicrophoneDevice[]; @@ -50,7 +51,18 @@ export function MicrophoneSelect({ async function start() { await enqueueMonitorTask(async () => { try { - if (isTauri) { + if (isHarmonyHost) { + const onLevel = (event: Event) => + setLevel(Math.max(0, Math.min(1, (event as CustomEvent).detail))); + window.addEventListener('openless:harmony-level', onLevel); + unlisten = () => window.removeEventListener('openless:harmony-level', onLevel); + if (!cancelled) await startMicrophoneLevelMonitor(selectedName); + if (cancelled) { + unlisten(); + unlisten = undefined; + await stopMicrophoneLevelMonitor(); + } + } else if (isTauri) { const { listen } = await import('@tauri-apps/api/event'); if (cancelled) return; const stopListening = await listen<{ level: number }>('microphone:level', (event) => { diff --git a/openless-all/app/src/pages/settings/navigation.test.ts b/openless-all/app/src/pages/settings/navigation.test.ts index 5cbff7bdd..e8a5af0ec 100644 --- a/openless-all/app/src/pages/settings/navigation.test.ts +++ b/openless-all/app/src/pages/settings/navigation.test.ts @@ -207,6 +207,8 @@ assert.deepEqual( ); console.log('settings service view tests passed'); +assert.equal(visibleSettingsSections(false, 'harmony').some((item) => item.id === 'inputMethod'), false, 'Harmony wiring does not add an Android input-method navigation entry'); +assert.deepEqual(visibleAdvancedPages('harmony', 'harmony').map((item) => item.id), ['testingFeatures', 'vocabularyLearning', 'customSyncServer', 'debug'], 'Harmony retains shared utility settings without desktop coding-agent controls'); for (const os of ['mac', 'win', 'android'] as const) { assert.equal( visibleAdvancedPages('desktop', os).some((item) => diff --git a/openless-all/app/src/pages/settings/navigation.ts b/openless-all/app/src/pages/settings/navigation.ts index f9568618a..2e0794501 100644 --- a/openless-all/app/src/pages/settings/navigation.ts +++ b/openless-all/app/src/pages/settings/navigation.ts @@ -39,8 +39,13 @@ export function visibleAdvancedPages( if (page.id === 'lessComputer') return platform === 'desktop' && (os === 'mac' || os === 'win'); if (page.id === 'claudeConsole') return platform === 'desktop' && os === 'mac'; if (page.id === 'vocabularyLearning') - return platform === 'android' || (platform === 'desktop' && (os === 'win' || os === 'mac')); - if (page.id === 'debug') return platform === 'desktop' || platform === 'android'; + return ( + platform === 'android' || + platform === 'harmony' || + (platform === 'desktop' && (os === 'win' || os === 'mac')) + ); + if (page.id === 'debug') + return platform === 'desktop' || platform === 'android' || platform === 'harmony'; return true; }); } diff --git a/openless-all/app/src/state/HotkeySettingsContext.tsx b/openless-all/app/src/state/HotkeySettingsContext.tsx index 995cf2555..61381cf55 100644 --- a/openless-all/app/src/state/HotkeySettingsContext.tsx +++ b/openless-all/app/src/state/HotkeySettingsContext.tsx @@ -1,3 +1,4 @@ +import { isHarmonyHost } from '../lib/harmonyBridge'; import { createContext, useCallback, @@ -84,6 +85,11 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { void refresh(); }, [refresh]); useEffect(() => { + if (isHarmonyHost) { + const refresh = () => { void reloadPreferences().catch((error) => setError(errorMessage(error))); }; + window.addEventListener('openless:harmony-backend-change', refresh); + return () => window.removeEventListener('openless:harmony-backend-change', refresh); + } if (!isTauri) return; let cancelled = false; let unlisten: (() => void) | undefined; diff --git a/openless-all/app/vite.config.ts b/openless-all/app/vite.config.ts index c026eede5..a031f0fa8 100644 --- a/openless-all/app/vite.config.ts +++ b/openless-all/app/vite.config.ts @@ -7,6 +7,7 @@ import tailwindcss from '@tailwindcss/vite'; const appRoot = fileURLToPath(new URL('.', import.meta.url)); const host = process.env.TAURI_DEV_HOST; +const isHarmony = process.env.VITE_OPENLESS_PLATFORM === 'harmony'; const isMobileDev = process.env.TAURI_ENV_PLATFORM === 'android' || process.env.TAURI_ENV_PLATFORM === 'ios'; @@ -43,6 +44,13 @@ export default defineConfig(async () => ({ }, envPrefix: ['VITE_', 'TAURI_'], build: { + ...(isHarmony + ? { + modulePreload: false, + cssCodeSplit: false, + rollupOptions: { output: { format: 'iife' as const, inlineDynamicImports: true } }, + } + : {}), target: process.env.TAURI_PLATFORM === 'windows' ? 'chrome105' : 'safari13', minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, sourcemap: !!process.env.TAURI_DEBUG,