From b0dd8396169d8df52e569d80214dfd014ccabb33 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:26:10 +0800 Subject: [PATCH 1/2] Release 0.0.10-beta.1: reference-aligned landscape insets, one-hand mode, keyboard height drag, double pinyin and wubi, handwriting, self-built pinyin engine, importable voice model, lite APK Co-Authored-By: Claude Code --- .github/workflows/release.yml | 3 +- .gitignore | 8 + CHANGELOG.md | 23 + VERSION | 2 +- app/build.gradle.kts | 15 +- .../DisplayEnvironmentInstrumentedTest.kt | 19 +- app/src/main/AndroidManifest.xml | 5 + app/src/main/assets/rime-data/default.yaml | 3 + .../rime-data/double_pinyin.schema.yaml | 108 +++ .../rime-data/double_pinyin_flypy.schema.yaml | 108 +++ .../main/assets/rime-data/wubi86.dict.yaml | 14 + .../main/assets/rime-data/wubi86.schema.yaml | 69 ++ .../slacker/openime/ImeSettingsActivity.kt | 4 + .../slacker/openime/LocalVoiceImeService.kt | 117 +++- .../llc/slacker/openime/VoiceModelActivity.kt | 424 ++++++++++++ .../openime/candidate/CandidateEngine.kt | 11 +- .../openime/candidate/CandidatePipeline.kt | 21 +- .../candidate/CandidatePreviewPolicy.kt | 27 + .../candidate/CandidateQueryCoordinator.kt | 7 +- .../openime/candidate/CandidateSnapshot.kt | 4 + .../openime/candidate/EnglishLexicon.kt | 119 ++++ .../openime/core/DoublePinyinScheme.kt | 13 + .../java/llc/slacker/openime/core/ImeState.kt | 7 +- .../llc/slacker/openime/core/OneHandMode.kt | 14 + .../data/ClipboardHistoryRepository.kt | 91 ++- .../openime/data/ImeSettingsRepository.kt | 57 +- .../slacker/openime/data/UserDataArchive.kt | 39 +- .../openime/data/UserPhraseRepository.kt | 41 +- .../llc/slacker/openime/engine/BigramIndex.kt | 48 ++ .../slacker/openime/engine/EngineAssets.kt | 63 ++ .../slacker/openime/engine/LexiconIndex.kt | 145 ++++ .../slacker/openime/engine/PinyinEngine.kt | 296 ++++++++ .../slacker/openime/engine/PinyinSegmenter.kt | 139 ++++ .../floating/FloatingDragController.kt | 11 +- .../floating/FloatingPositionMemory.kt | 39 ++ .../floating/FloatingWindowController.kt | 37 +- .../openime/floating/LongPressDragTarget.kt | 16 + .../HandwritingStrokeClassifier.kt | 110 +++ .../StrokeMatchHandwritingProvider.kt | 113 +++ .../openime/keyboard/ImeKeyboardView.kt | 133 +++- .../slacker/openime/keyboard/ImeTopZone.kt | 19 +- .../slacker/openime/keyboard/KeyHaptics.kt | 4 +- .../openime/keyboard/KeyboardGeometry.kt | 6 +- .../keyboard/KeyboardHeightDragController.kt | 53 ++ .../keyboard/KeyboardHeightDragPolicy.kt | 22 + .../slacker/openime/keyboard/OneHandPolicy.kt | 33 + .../keyboard/Pinyin26KeyboardRenderer.kt | 3 +- .../openime/keyboard/ProductionKeyPolicy.kt | 7 +- .../keyboard/ReferenceLandscapeInsets.kt | 18 + .../slacker/openime/panel/ImePanelRenderer.kt | 76 +- .../openime/panel/SettingsPanelController.kt | 79 ++- .../llc/slacker/openime/rime/RimeEngine.kt | 28 +- .../slacker/openime/theme/ImeDesignTokens.kt | 20 + .../openime/voice/VoiceEndpointPolicy.kt | 61 ++ .../openime/voice/VoiceHotwordProvider.kt | 12 +- .../openime/voice/VoiceModelArchive.kt | 225 ++++++ .../slacker/openime/voice/VoiceModelLayout.kt | 208 ++++++ .../openime/voice/VoiceModelRepository.kt | 160 ++++- .../openime/voice/VoiceRecognitionBackend.kt | 216 +++++- .../openime/voice/VoiceRuntimeModelSource.kt | 61 +- app/src/main/res/drawable/ic_kb_double.xml | 1 + app/src/main/res/drawable/ic_kb_wubi26.xml | 1 + .../main/res/drawable/ic_pref_handwriting.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../openime/ArchitectureLayeringTest.kt | 6 +- .../openime/CandidatePreviewPolicyTest.kt | 66 ++ .../openime/ClipboardSuppressionTest.kt | 39 ++ .../slacker/openime/DoublePinyinSchemaTest.kt | 53 ++ .../llc/slacker/openime/EnglishLexiconTest.kt | 40 ++ .../openime/FloatingPositionMemoryTest.kt | 36 + .../HandwritingStrokeClassifierTest.kt | 43 ++ .../openime/KeyboardHeightDragPolicyTest.kt | 34 + .../openime/KeyboardLayoutMetricsTest.kt | 27 +- .../llc/slacker/openime/NineKeyProbeTest.kt | 32 + .../llc/slacker/openime/OneHandPolicyTest.kt | 52 ++ .../slacker/openime/PinyinEngineDataTest.kt | 130 ++++ .../openime/ProductionKeyPolicyTest.kt | 13 + .../openime/ReferenceLandscapeInsetsTest.kt | 36 + .../slacker/openime/RimeFuzzySchemaTest.kt | 22 +- .../StrokeMatchHandwritingProviderTest.kt | 56 ++ .../openime/UserPhraseRepositoryTest.kt | 17 + .../openime/VoiceDecodingPolicyTest.kt | 91 +++ .../openime/VoiceHotwordProviderTest.kt | 39 ++ .../slacker/openime/VoiceModelArchiveTest.kt | 147 ++++ .../slacker/openime/VoiceModelDetectorTest.kt | 99 +++ .../slacker/openime/VoiceModelLayoutTest.kt | 164 +++++ .../llc/slacker/openime/VoiceVadRoleTest.kt | 148 ++++ docs/ARCHITECTURE.md | 6 +- docs/DECISIONS.md | 45 ++ docs/LOCAL_VOICE_MODEL.md | 36 +- docs/RELEASE.md | 12 +- scripts/build_imengine_data.py | 649 ++++++++++++++++++ scripts/build_rime_prebuilt.py | 25 +- scripts/import_doubao_data.py | 499 ++++++++++++++ scripts/release_build.sh | 36 +- 95 files changed, 6321 insertions(+), 215 deletions(-) create mode 100644 app/src/main/assets/rime-data/double_pinyin.schema.yaml create mode 100644 app/src/main/assets/rime-data/double_pinyin_flypy.schema.yaml create mode 100644 app/src/main/assets/rime-data/wubi86.dict.yaml create mode 100644 app/src/main/assets/rime-data/wubi86.schema.yaml create mode 100644 app/src/main/java/llc/slacker/openime/VoiceModelActivity.kt create mode 100644 app/src/main/java/llc/slacker/openime/candidate/CandidatePreviewPolicy.kt create mode 100644 app/src/main/java/llc/slacker/openime/candidate/EnglishLexicon.kt create mode 100644 app/src/main/java/llc/slacker/openime/core/DoublePinyinScheme.kt create mode 100644 app/src/main/java/llc/slacker/openime/core/OneHandMode.kt create mode 100644 app/src/main/java/llc/slacker/openime/engine/BigramIndex.kt create mode 100644 app/src/main/java/llc/slacker/openime/engine/EngineAssets.kt create mode 100644 app/src/main/java/llc/slacker/openime/engine/LexiconIndex.kt create mode 100644 app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt create mode 100644 app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt create mode 100644 app/src/main/java/llc/slacker/openime/floating/FloatingPositionMemory.kt create mode 100644 app/src/main/java/llc/slacker/openime/floating/LongPressDragTarget.kt create mode 100644 app/src/main/java/llc/slacker/openime/handwriting/HandwritingStrokeClassifier.kt create mode 100644 app/src/main/java/llc/slacker/openime/handwriting/StrokeMatchHandwritingProvider.kt create mode 100644 app/src/main/java/llc/slacker/openime/keyboard/KeyboardHeightDragController.kt create mode 100644 app/src/main/java/llc/slacker/openime/keyboard/KeyboardHeightDragPolicy.kt create mode 100644 app/src/main/java/llc/slacker/openime/keyboard/OneHandPolicy.kt create mode 100644 app/src/main/java/llc/slacker/openime/keyboard/ReferenceLandscapeInsets.kt create mode 100644 app/src/main/java/llc/slacker/openime/voice/VoiceEndpointPolicy.kt create mode 100644 app/src/main/java/llc/slacker/openime/voice/VoiceModelArchive.kt create mode 100644 app/src/main/java/llc/slacker/openime/voice/VoiceModelLayout.kt create mode 100644 app/src/main/res/drawable/ic_kb_double.xml create mode 100644 app/src/main/res/drawable/ic_kb_wubi26.xml create mode 100644 app/src/main/res/drawable/ic_pref_handwriting.xml create mode 100644 app/src/test/java/llc/slacker/openime/CandidatePreviewPolicyTest.kt create mode 100644 app/src/test/java/llc/slacker/openime/ClipboardSuppressionTest.kt create mode 100644 app/src/test/java/llc/slacker/openime/DoublePinyinSchemaTest.kt create mode 100644 app/src/test/java/llc/slacker/openime/EnglishLexiconTest.kt create mode 100644 app/src/test/java/llc/slacker/openime/FloatingPositionMemoryTest.kt create mode 100644 app/src/test/java/llc/slacker/openime/HandwritingStrokeClassifierTest.kt create mode 100644 app/src/test/java/llc/slacker/openime/KeyboardHeightDragPolicyTest.kt create mode 100644 app/src/test/java/llc/slacker/openime/NineKeyProbeTest.kt create mode 100644 app/src/test/java/llc/slacker/openime/OneHandPolicyTest.kt create mode 100644 app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt create mode 100644 app/src/test/java/llc/slacker/openime/ReferenceLandscapeInsetsTest.kt create mode 100644 app/src/test/java/llc/slacker/openime/StrokeMatchHandwritingProviderTest.kt create mode 100644 app/src/test/java/llc/slacker/openime/VoiceDecodingPolicyTest.kt create mode 100644 app/src/test/java/llc/slacker/openime/VoiceModelArchiveTest.kt create mode 100644 app/src/test/java/llc/slacker/openime/VoiceModelDetectorTest.kt create mode 100644 app/src/test/java/llc/slacker/openime/VoiceModelLayoutTest.kt create mode 100644 app/src/test/java/llc/slacker/openime/VoiceVadRoleTest.kt create mode 100644 scripts/build_imengine_data.py create mode 100644 scripts/import_doubao_data.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a0ff7b0..5c825a73 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -257,10 +257,11 @@ jobs: channel_flags=(--prerelease) latest_flag=--latest=false fi - # The APK is the only asset; its SHA-256 and the signing certificate + # The full and lite APKs are the assets; their SHA-256 and the signing certificate # are in the release notes, and the licenses ship inside the APK. gh release create "$GITHUB_REF_NAME" \ release-files/openIME-*-arm64-release.apk \ + release-files/openIME-*-arm64-lite-release.apk \ --repo "$GITHUB_REPOSITORY" \ --verify-tag \ --draft \ diff --git a/.gitignore b/.gitignore index 417cf5a0..f8b7b75c 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,11 @@ __pycache__/ *.jks *.keystore *.p12 + +# Local dictionary data generated by scripts/import_doubao_data.py; not distributed +app/src/main/assets/english-words.tsv +app/src/main/assets/handwriting-strokes.tsv +app/src/main/assets/rime-data/openime_dicts/wubi86.dict.yaml + +# Compiled engine tables generated by scripts/build_imengine_data.py; not distributed +app/src/main/assets/imengine/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ee4cc9d..640a2e6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +## [0.0.10-beta.1] - 2026-10-14 + +第十个测试版:对齐参考产品(横屏留白、单手模式、键盘调节),新增双拼、五笔、手写与自研拼音引擎,语音模型可自行导入,并新增不含语音模型的精简安装包。从 0.0.9-beta.1 覆盖安装即可,用户数据保留。 + +- 发布两种安装包:**完整版**(内置离线语音模型,开箱即用)与**精简版**(不含语音模型,体积小很多,需要语音输入时到「设置 → 语音模型」导入自己的模型)。两者签名相同,可互相覆盖安装,用户数据保留。 +- 新增「双拼」键盘(小鹤双拼 / 自然码两套键位,设置里可选):26 键两键一字,词库与拼音 26 键共用;进入「切换键盘」面板即可选;模糊音等其它设置不受影响。 +- 新增「单手模式」(关闭 / 左手 / 右手):键面收窄到 76% 宽度并贴向所选一侧,位于设置 → 横屏分组;横竖屏通用,左右分离布局不叠加;随数据备份一起保存恢复。 +- 新增「键盘调节」:停靠键盘在顶栏按住上下拖动即可实时调整键盘高度(约 20% 屏高 = 40 个百分点,松手即存);手势只在顶栏起手,不抢候选项与输入区的长按。 +- 横屏对齐参考产品:键区两侧留白(26 键 6.485% / 九键 9.5%)、顶栏留白 8.2%、展开候选 9.5%、26 键空格键加宽(权重 154→210,与其横屏皮肤 133.6→182.2 同比例);窗口仍整宽、背景铺满。 +- 语音热词:transducer 模型改用 modified_beam_search;录音时把「语音热词」(模型页新增管理入口,一行一个词)作为动态热词送入,paraformer/CTC 不受影响;bpe 类模型缺词表时自动禁用热词,避免原生退出。 +- 语音 VAD:模型包可选带 silero_vad.onnx(角色 vad),逐块检测语音活动辅助端点判断;没有 VAD 的路径与之前完全一致。 +- 拼音引擎支持模糊音:按启用规则展开(z/zh、c/ch、s/sh、n/l、f/h、r/l、an/ang、en/eng、in/ing、ian/iang、uan/uang),模糊候选带权重惩罚混排;模糊音开关下 26 键也走自研引擎。 +- 手写:点选候选上屏后落笔区自动清空,可直接写下一个字;没认出候选时给出提示而不是空白。 +- 浮动键盘按方向记忆位置:横屏、竖屏各自保留上次拖动到的位置,旋转后回到本方向的位置。 +- 语音模型导入扩展:支持 zip / tar / tar.gz / tgz 压缩包;流式模型族扩展到 transducer(zipformer / zipformer2 / conformer / lstm / ebranchformer)、paraformer、zipformer2-ctc、nemo-ctc、t-one-ctc;支持包内 bpe.vocab(配合 manifest 的 modelingUnit)、transducer 热词表(自动切 modified_beam_search)和标点模型;单文件 CTC 仍必须用 manifest.json 明确类型。 +- 自研拼音引擎(clean-room):全路径音节格子 + 209 万词二进制索引 + 50 万二元表 + Viterbi 整句;26 键拼音的候选与整句由它提供(实测:候选 top1 12/12、整句 top1 14/14),Rime 退居补充与兜底(简拼、用户词学习)。引擎数据由本地脚本编译,不随仓库分发。 +- 手写入口补充到「切换键盘」面板(与五笔同列),工具面板入口保留。 +- 新增「五笔」键盘:26 键界面按字根编码输入,候选与前缀提示来自 Rime 方案 `wubi86`。词表由本地数据构建,不随仓库分发;没有词表的构建不显示这个键盘。 +- 新增「手写输入」面板:手写区落笔按基本笔画(横竖撇捺折)识别并给出候选,支持撤销与清空。识别表同样来自本地数据,没有它时保持隐藏。 +- 英文 26 键的候选补全接入本地英语词表(按词频排名,最多取 2.6 万词);没有词表时保持原来的小词表行为。 +- 词库导入脚本 `scripts/import_doubao_data.py`:把本地解码词表合并进 Rime 词典与九键快速词表(只读外部数据,不提交词表本身)。 +- 构建脚本 `build_rime_prebuilt.py`:本地存在五笔词表时自动编译并校验 `wubi86` 表;`default.yaml` 注册 `wubi86` 方案。 + ## [0.0.9-beta.1] - 2026-10-10 第九个测试版:横屏默认改回铺满宽度的停靠键盘,竖屏和横屏各记一份键盘高度,横屏左右分离的九键改成“一手数字、一手拼音”。从 0.0.8-beta.1 覆盖安装即可,用户数据保留。 diff --git a/VERSION b/VERSION index 336681e2..7c859c86 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.9-beta.1 +0.0.10-beta.1 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c69aad80..2f2257fd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,9 +22,15 @@ val rimeCompiledOnlySources = listOf( "base.dict.yaml", "ext.dict.yaml", "others.dict.yaml", + "wubi86.dict.yaml", "essay.txt", ) +/** -Popenime.lite=true 或环境变量 OPENIME_LITE=1:构建不含语音模型的精简包。 */ +val liteBuild: Boolean = + (providers.gradleProperty("openime.lite").orNull ?: providers.environmentVariable("OPENIME_LITE").orNull) + .let { it == "true" || it == "1" } + val releaseSigningReady = listOf( releaseKeystorePath, releaseKeystorePassword, @@ -125,7 +131,14 @@ android { // first entries are aapt's defaults, which this list replaces. ignoreAssetsPatterns += listOf( "!.svn", "!.git", "!.ds_store", "!*.scc", ".*", "_*", "!CVS", "!thumbs.db", "!picasa.ini", "!*~", - ) + rimeCompiledOnlySources.map { "!$it" } + ) + rimeCompiledOnlySources.map { "!$it" } + if (liteBuild) { + // 精简包:不带语音模型。用户在「设置 → 语音模型」里导入自己的模型。 + // 只排除两个模型目录;models/voice/manifest.json 与受信目录仍然保留, + // 这样应用知道"没有内置模型"而不是"安装包损坏"。 + listOf("!bilingual-paraformer", "!punctuation") + } else { + emptyList() + } } compileOptions { diff --git a/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt index 411d166e..35f2e4f2 100644 --- a/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt @@ -92,7 +92,10 @@ class DisplayEnvironmentInstrumentedTest { @Test fun landscapeContentBoxKeepsEveryRowInsideIt() { - // Landscape fills the whole width (padding 0), like Gboard and the Chinese keyboards. + // Landscape insets the key area by the reference skin's fractions: 6.485% + // per side for the 26-key and digit surfaces, 9.5% for the nine-key + // (ReferenceLandscapeInsets). The window stays full width and the + // background spans it; only the content moves inward. // Rows used to keep the width they were measured with before the padding // was applied: as wide as the window, clipped to the box. for (mode in listOf(KeyboardMode.PINYIN_26, KeyboardMode.ENGLISH_26, KeyboardMode.PINYIN_9, KeyboardMode.DIGITS)) { @@ -109,7 +112,19 @@ class DisplayEnvironmentInstrumentedTest { val body = keyboard.findViewWithTag("keyboard-body") assertNotNull("keyboard body", body) val content = body.width - body.paddingLeft - body.paddingRight - assertEquals("$mode landscape fills the full width", 0, body.paddingLeft) + val fraction = if (mode == KeyboardMode.PINYIN_9) 0.095f else 0.0648508f + val expectedInset = (keyboard.width * fraction).toInt() + assertEquals( + "$mode landscape insets the key area on the reference fraction", + expectedInset.toFloat(), + body.paddingLeft.toFloat(), + 1f, + ) + assertEquals( + "$mode landscape insets both sides equally", + body.paddingLeft, + body.paddingRight, + ) for (index in 0 until body.childCount) { val row = body.getChildAt(index) assertTrue( diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 55930f45..01316856 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -68,6 +68,11 @@ android:exported="false" android:label="@string/activity_label_about" /> + + + EnglishLexicon.obtain(this)?.complete(prefix, limit).orEmpty() + }, + ), + pinyinEngine = PinyinEngine.obtain(this), + fuzzyRules = { ImeSettingsRepository.activeFuzzyRules(this) }, + ) + } .onSuccess { candidatePipeline = it } .onFailure { Log.e(TAG, "lexicon/decoder initialisation failed; running on Rime only", it) } + // English completions read a large word list; warm it here, not on the first keystroke. + runCatching { EnglishLexicon.preload(this) } // The emoji panel's full list checks every emoji against the font: do it here, not on first open. runCatching { EmojiCatalog.byCategory(this) } // Only someone who uses the 笔画 keyboard pays for its table; the @@ -322,6 +339,22 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can return keyboardView!! } + /** + * 停靠时键盘占据屏幕底部;浮动时卡片只是浮层。系统按窗口 frame 计算 IME 遮挡区域, + * 会让应用把整块屏幕当作"被输入法盖住"而把输入框顶上去(resize/pan)。 + * 浮动时这里报告"没有遮挡内容",输入框原地不动。 + */ + override fun onComputeInsets(outInsets: InputMethodService.Insets) { + super.onComputeInsets(outInsets) + if (floatingWindow.enabled) { + val fullHeight = window?.window?.decorView?.height ?: 0 + if (fullHeight > 0) { + outInsets.contentTopInsets = fullHeight + outInsets.visibleTopInsets = fullHeight + } + } + } + override fun onConfigurationChanged(newConfig: android.content.res.Configuration) { super.onConfigurationChanged(newConfig) // Orientation changes affect keyboard geometry, not the user's chosen @@ -620,7 +653,26 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can keyboardView?.renderState(state) } + private var clipboardListener: android.content.ClipboardManager.OnPrimaryClipChangedListener? = null + + /** 复制发生的当下就入库,不再依赖用户碰巧打开剪贴板面板。 */ + private fun registerClipboardWatcher() { + val cm = getSystemService(CLIPBOARD_SERVICE) as? android.content.ClipboardManager ?: return + val listener = android.content.ClipboardManager.OnPrimaryClipChangedListener { + val clip = runCatching { cm.primaryClip }.getOrNull() ?: return@OnPrimaryClipChangedListener + Thread { + runCatching { ClipboardHistoryRepository.captureCopied(this, clip) } + }.apply { isDaemon = true }.start() + } + cm.addPrimaryClipChangedListener(listener) + clipboardListener = listener + } + override fun onDestroy() { + clipboardListener?.let { + (getSystemService(CLIPBOARD_SERVICE) as? android.content.ClipboardManager)?.removePrimaryClipChangedListener(it) + } + clipboardListener = null mainHandler.removeCallbacksAndMessages(null) if (::candidateQueries.isInitialized) candidateQueries.shutdown() renderedCandidateSnapshot = null @@ -1560,7 +1612,12 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can else -> "none" }, ) - return (learned + fallback).distinct().take(MAX_CANDIDATES) + // 等 Rime 回答的这一帧不能显示拼音原串:词库没命中时本地引擎会把输入原样当候选, + // 用户看到英文闪一下才变中文。中文布局下先只留含汉字的项;Rime 若最后也没结果, + // 回调里的 fallback 分支仍会把原串补上,所以不会丢候选。 + val preview = (learned + fallback).distinct().take(MAX_CANDIDATES) + if (!state.keyboardMode.isChineseLayout) return preview + return CandidatePreviewPolicy.chineseOnly(preview) } /** Query librime away from the IME input thread; stale answers are ignored. */ @@ -1608,28 +1665,37 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can texts } } - val exactQuickPhrases = - if (mode == KeyboardMode.PINYIN_26 || mode == KeyboardMode.ENGLISH_26) { - QuickPhraseRepository.candidatesForInputCode( - context = this, - rawCode = composition, - exactOnly = true, - limit = 8, - ) + // The built-in pinyin engine owns the visible order; Rime + // stays behind it as a supplement (extra readings, user + // words) instead of replacing the list. + val engineFirst = mode == KeyboardMode.PINYIN_26 && + candidatePipeline.usesPinyinEngine + if (engineFirst) { + (fallback + nativeText).distinct().take(MAX_CANDIDATES) + } else { + val exactQuickPhrases = + if (mode == KeyboardMode.PINYIN_26 || mode == KeyboardMode.ENGLISH_26) { + QuickPhraseRepository.candidatesForInputCode( + context = this, + rawCode = composition, + exactOnly = true, + limit = 8, + ) + } else { + emptyList() + } + if (exactQuickPhrases.isNotEmpty()) { + (exactQuickPhrases + nativeText + fallback) + .distinct() + .take(MAX_CANDIDATES) + } else if ( + mode == KeyboardMode.PINYIN_26 && + nativeText.size < TYPO_CORRECTION_NATIVE_THRESHOLD + ) { + (nativeText + fallback).distinct().take(MAX_CANDIDATES) } else { - emptyList() + nativeText } - if (exactQuickPhrases.isNotEmpty()) { - (exactQuickPhrases + nativeText + fallback) - .distinct() - .take(MAX_CANDIDATES) - } else if ( - mode == KeyboardMode.PINYIN_26 && - nativeText.size < TYPO_CORRECTION_NATIVE_THRESHOLD - ) { - (nativeText + fallback).distinct().take(MAX_CANDIDATES) - } else { - nativeText } } else { (learned + fallback).distinct().take(MAX_CANDIDATES) @@ -1743,7 +1809,8 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can val nativeCommit = if ( reference != null && rime.isReady && - (mode == KeyboardMode.PINYIN_26 || mode == KeyboardMode.PINYIN_9) + (mode == KeyboardMode.PINYIN_26 || mode == KeyboardMode.PINYIN_9 || + mode == KeyboardMode.WUBI || mode == KeyboardMode.DOUBLE_PINYIN) ) { rime.selectCandidate(reference.input, reference.nativeIndex, allowsPersonalizedLearning()) } else { @@ -1900,6 +1967,10 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can startActivity(Intent(this, AboutActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } + override fun onOpenVoiceModel() { + startActivity(Intent(this, VoiceModelActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + override fun onOpenDataManagement() { startActivity(Intent(this, DataManagementActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } diff --git a/app/src/main/java/llc/slacker/openime/VoiceModelActivity.kt b/app/src/main/java/llc/slacker/openime/VoiceModelActivity.kt new file mode 100644 index 00000000..1b89fdd1 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/VoiceModelActivity.kt @@ -0,0 +1,424 @@ +package llc.slacker.openime + +import android.app.Activity +import android.app.AlertDialog +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.text.InputType +import android.view.Gravity +import android.view.WindowInsets +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import android.widget.Toast +import llc.slacker.openime.core.CrashGuard +import llc.slacker.openime.data.UserPhraseRepository +import llc.slacker.openime.setup.SetupUi +import llc.slacker.openime.theme.ImeDrawableFactory +import llc.slacker.openime.theme.ImeGeometryTokens +import llc.slacker.openime.theme.ImeSpacingTokens +import llc.slacker.openime.theme.ImeTypographyTokens +import llc.slacker.openime.voice.VoiceHotwordProvider +import llc.slacker.openime.voice.VoiceModelManifest +import llc.slacker.openime.voice.VoiceModelRepository +import llc.slacker.openime.voice.VoiceModelSelection +import java.io.File +import llc.slacker.openime.voice.VoiceModelArchive + +/** + * 语音模型: 查看当前使用的模型,导入自己的 paraformer 流式模型包(zip), + * 在内置与已导入模型之间切换,或删除已导入的模型。 + * + * 精简版 APK 不带内置模型,这里是唯一的来源;完整版也可以在这里换成自己的模型。 + */ +class VoiceModelActivity : Activity() { + private lateinit var content: LinearLayout + private val repository by lazy { VoiceModelRepository(this) } + + override fun attachBaseContext(newBase: android.content.Context) { + super.attachBaseContext(SetupUi.appearanceContext(newBase)) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + CrashGuard.install(this) + // 管理页可能先于输入法服务启动;先把词库读进来,「语音热词」才能显示和保存。 + UserPhraseRepository.configure(this) + content = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(ImeSpacingTokens.LG_DP), 0, dp(ImeSpacingTokens.LG_DP), dp(ImeSpacingTokens.XXL_DP)) + } + setContentView( + ScrollView(this).apply { + setBackgroundColor(getColor(R.color.setup_page_bg)) + isFillViewport = true + setOnApplyWindowInsetsListener { view, insets -> + if (android.os.Build.VERSION.SDK_INT >= 30) { + val bars = insets.getInsets(WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout()) + view.setPadding(bars.left, bars.top, bars.right, bars.bottom) + } else { + @Suppress("DEPRECATION") + view.setPadding( + insets.systemWindowInsetLeft, insets.systemWindowInsetTop, + insets.systemWindowInsetRight, insets.systemWindowInsetBottom, + ) + } + insets + } + addView(content) + }, + ) + render() + } + + private fun render() { + content.removeAllViews() + content.addView( + SetupUi.activityTopBar(context = this, title = "语音模型", onBack = ::finish), + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(ImeGeometryTokens.TOP_BAR_HEIGHT_DP)) + .apply { marginStart = -dp(16); marginEnd = -dp(16) }, + ) + + val selection = repository.selectAvailable() + val current = selection.manifest + val currentBody = when { + current == null -> selection.reason.ifBlank { "尚未安装语音模型。" } + + "\n导入一个 paraformer 流式模型包即可使用语音输入,拼音输入不受影响。" + selection.source == VoiceModelSelection.Source.BUILT_IN -> "内置模型:${current.modelId}\n版本 ${current.modelVersion},语言 ${current.language}" + else -> "已导入:${current.modelId}\n版本 ${current.modelVersion},语言 ${current.language}" + } + content.addView( + SetupUi.infoCard(this, "当前使用", currentBody, R.drawable.ic_pref_waveform), + wrap().apply { topMargin = dp(ImeSpacingTokens.SM_DP) }, + ) + + content.addView( + hotwordCard(selection), + wrap().apply { topMargin = dp(ImeSpacingTokens.SM_DP) }, + ) + + val builtIn = repository.isBuiltInAvailable() + val installed = repository.installedModels() + val selectedId = repository.selectedModelId() + + if (builtIn || installed.isNotEmpty()) { + content.addView(sectionTitle("可用模型"), wrap().apply { topMargin = dp(ImeSpacingTokens.LG_DP) }) + if (builtIn) { + val active = selection.source == VoiceModelSelection.Source.BUILT_IN + content.addView( + modelRow("内置模型", "随安装包提供,不可删除", active, + onUse = { repository.useBuiltInModel(); render() }, onDelete = null), + wrap().apply { topMargin = dp(ImeSpacingTokens.SM_DP) }, + ) + } + installed.forEach { manifest -> + val active = selection.source == VoiceModelSelection.Source.DOWNLOADED && selectedId == manifest.modelId + content.addView( + modelRow( + manifest.modelId, + "版本 ${manifest.modelVersion} · ${manifest.language}" + + if (manifest.supportsPunctuation) " · 带标点" else "", + active, + onUse = { + if (repository.setSelectedModel(manifest.modelId)) render() + else showError("这个模型校验失败,无法使用。可以删除后重新导入。") + }, + onDelete = { confirmDelete(manifest) }, + ), + wrap().apply { topMargin = dp(ImeSpacingTokens.SM_DP) }, + ) + } + } + + content.addView( + SetupUi.infoCard( + this, "导入模型", + "选择一个模型包(zip / tar / tar.gz / tgz),支持 sherpa-onnx 的流式模型:" + + "transducer(zipformer / zipformer2 / conformer / lstm 等)、paraformer、zipformer2-ctc、nemo-ctc、t-one-ctc。" + + "官方发布的压缩包可以直接导入,会自动识别文件(含可选的 silero_vad.onnx 端点模型);" + + "也可以自带 manifest.json 声明 family / modelingUnit / bpeVocab / hotwords / vad。" + + "导入前会校验文件完整性,并让你确认一次。", + R.drawable.ic_pref_data, + ).also { card -> + card.addView( + SetupUi.primaryButton(this, "选择模型包") { pickZip() }, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(44)) + .apply { topMargin = dp(12); marginStart = dp(44) }, + ) + }, + wrap().apply { topMargin = dp(ImeSpacingTokens.LG_DP) }, + ) + } + + /** 「语音热词」卡片:手动词表只有 transducer 模型会用到。 */ + private fun hotwordCard(selection: VoiceModelSelection): LinearLayout { + val hotwords = UserPhraseRepository.manualVoiceHotwords() + val supported = selection.manifest?.modelType == "transducer" + val preview = hotwords.take(8).joinToString("、") + if (hotwords.size > 8) " 等" else "" + val body = buildString { + append("人名、产品名等专有名词加进热词会更容易被正确识别。一行一个词;只采纳含中文、2 到 16 个字的词。") + append('\n') + append(if (hotwords.isEmpty()) "还没有设置热词。" else "已设置 ${hotwords.size} 个词:$preview") + append('\n') + append( + if (supported) { + "当前 transducer 模型已启用热词,保存后下一次录音生效。" + } else { + "热词只对 transducer 模型生效;当前模型不支持,切换到 transducer 模型后自动生效。" + }, + ) + } + return SetupUi.infoCard(this, "语音热词", body, R.drawable.ic_pref_waveform).apply { + addView( + SetupUi.secondaryButton(this@VoiceModelActivity, "编辑热词") { editHotwords() }, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(40)) + .apply { topMargin = dp(12); marginStart = dp(44) }, + ) + } + } + + private fun editHotwords() { + val input = EditText(this).apply { + setText(UserPhraseRepository.manualVoiceHotwords().joinToString("\n")) + inputType = InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_FLAG_MULTI_LINE or + InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + gravity = Gravity.TOP or Gravity.START + minLines = 5 + maxLines = 10 + textSize = ImeTypographyTokens.BODY_SP + hint = "一行一个词,例如:\n澎湃OS\n语音识别" + setPadding(dp(12), dp(12), dp(12), dp(12)) + background = ImeDrawableFactory.focusedRounded( + fillColor = getColor(R.color.setup_surface), + radiusPx = dp(14).toFloat(), + focusedStrokeColor = SetupUi.accent(this@VoiceModelActivity), + defaultStrokeColor = getColor(R.color.setup_hairline), + strokeWidthPx = dp(1).coerceAtLeast(1) + dp(1) / 2, + ) + SetupUi.styleCursor(this@VoiceModelActivity, this) + } + val container = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(20), dp(4), dp(20), 0) + addView(input) + } + val dialog = AlertDialog.Builder(this) + .setTitle("语音热词") + .setView(container) + .setNegativeButton("取消", null) + .setNeutralButton("清空", null) + .setPositiveButton("保存", null) + .create() + dialog.setOnShowListener { + SetupUi.styleDialog(dialog, this) + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + saveHotwords(input.text.toString()) + dialog.dismiss() + } + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { + saveHotwords("") + dialog.dismiss() + } + } + dialog.show() + } + + private fun saveHotwords(raw: String) { + val hotwords = VoiceHotwordProvider.parseLines(raw) + if (!UserPhraseRepository.setManualVoiceHotwords(hotwords)) { + showError("热词保存失败,请重试。") + return + } + val modelType = repository.selectAvailable().manifest?.modelType + val message = when { + hotwords.isEmpty() -> "语音热词已清空。" + modelType == "transducer" -> "已保存 ${hotwords.size} 个热词,下一次录音生效。" + else -> "已保存 ${hotwords.size} 个热词。当前模型(${modelType ?: "未知"})不支持热词,切换到 transducer 模型后生效。" + } + Toast.makeText(this, message, Toast.LENGTH_LONG).show() + render() + } + + private fun modelRow( + title: String, + subtitle: String, + active: Boolean, + onUse: () -> Unit, + onDelete: (() -> Unit)?, + ): LinearLayout = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(16), dp(12), dp(16), dp(12)) + tag = "voice-model-row" + addView(TextView(this@VoiceModelActivity).apply { + text = if (active) "$title(使用中)" else title + textSize = ImeTypographyTokens.TITLE_SP + }) + addView(TextView(this@VoiceModelActivity).apply { text = subtitle; textSize = ImeTypographyTokens.SMALL_SP; alpha = 0.7f }) + val buttons = LinearLayout(this@VoiceModelActivity).apply { orientation = LinearLayout.HORIZONTAL } + if (!active) { + buttons.addView( + SetupUi.secondaryButton(this@VoiceModelActivity, "使用") { onUse() }, + LinearLayout.LayoutParams(0, dp(40), 1f).apply { marginEnd = dp(8) }, + ) + } + if (onDelete != null) { + buttons.addView( + SetupUi.secondaryButton(this@VoiceModelActivity, "删除") { onDelete() }, + LinearLayout.LayoutParams(0, dp(40), 1f), + ) + } + if (buttons.childCount > 0) { + addView(buttons, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(40)).apply { topMargin = dp(8) }) + } + } + + private fun sectionTitle(text: String) = TextView(this).apply { + this.text = text + textSize = ImeTypographyTokens.SMALL_SP + alpha = 0.7f + setPadding(dp(4), 0, 0, 0) + } + + private fun pickZip() { + startActivityForResult( + Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + putExtra( + Intent.EXTRA_MIME_TYPES, + arrayOf( + "application/zip", + "application/x-zip-compressed", + "application/gzip", + "application/x-gzip", + "application/x-tar", + "application/octet-stream", + ), + ) + }, + REQUEST_PICK, + ) + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode != REQUEST_PICK || resultCode != RESULT_OK) return + val uri = data?.data ?: return + inspectInBackground(uri) + } + + private fun inspectInBackground(uri: Uri) { + Toast.makeText(this, "正在校验模型包…", Toast.LENGTH_SHORT).show() + Thread { + val staging = File(cacheDir, "voice-model-import").apply { deleteRecursively(); mkdirs() } + val outcome = runCatching { + val name = displayName(uri) + contentResolver.openInputStream(uri).use { raw -> + requireNotNull(raw) { "无法读取所选文件" } + val buffered = java.io.BufferedInputStream(raw) + buffered.mark(1024 * 1024) + val header = ByteArray(512) + val read = buffered.read(header) + buffered.reset() + val format = VoiceModelArchive.formatOf(name) + ?: VoiceModelArchive.sniff(if (read > 0) header.copyOf(read) else ByteArray(0)) + ?: error("无法识别的压缩格式(支持 zip / tar / tar.gz / tgz;tar.bz2 请先重新打包为 zip)") + VoiceModelArchive.extract(buffered, format, staging) + } + val root = findPackageRoot(staging) ?: error("压缩包里找不到模型文件") + root to repository.inspectPackage(root).getOrThrow() + } + runOnUiThread { + outcome.onSuccess { (root, info) -> confirmImport(root, info, staging) } + .onFailure { + staging.deleteRecursively() + showError("导入失败:" + (it.message ?: "未知错误")) + } + } + }.apply { isDaemon = true }.start() + } + + private fun displayName(uri: Uri): String { + return runCatching { + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val index = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (index >= 0 && cursor.moveToFirst()) cursor.getString(index) else null + } + }.getOrNull().orEmpty() + } + + private fun confirmImport(root: File, info: VoiceModelRepository.PackageInfo, staging: File) { + val sizeMb = info.totalBytes / 1_048_576 + val trust = (if (info.autoDetected) "压缩包里没有清单,已自动识别:${info.note}。语言未知,可能影响标点和断句。\n\n" else "") + + (if (info.trusted) "该模型在受信目录内。" else "该模型不在受信目录内,来源由你自己负责。请只导入你信任的模型包。") + val dialog = AlertDialog.Builder(this) + .setTitle("导入语音模型?") + .setMessage("${info.modelId}\n版本 ${info.modelVersion} · ${info.language}\n约 $sizeMb MB${if (info.supportsPunctuation) " · 带标点" else ""}\n\n$trust") + .setNegativeButton("取消") { _, _ -> staging.deleteRecursively() } + .setOnCancelListener { staging.deleteRecursively() } + .setPositiveButton("导入并使用") { _, _ -> + Thread { + val result = repository.importUserPackage(root) + staging.deleteRecursively() + runOnUiThread { + result.onSuccess { + Toast.makeText(this, "已导入并切换到 $it", Toast.LENGTH_SHORT).show() + render() + }.onFailure { showError("安装失败:" + (it.message ?: "未知错误")) } + } + }.apply { isDaemon = true }.start() + } + .create() + dialog.setOnShowListener { SetupUi.styleDialog(dialog, this) } + dialog.show() + } + + private fun confirmDelete(manifest: VoiceModelManifest) { + val dialog = AlertDialog.Builder(this) + .setTitle("删除语音模型?") + .setMessage("${manifest.modelId} 将从本机移除。") + .setNegativeButton("取消", null) + .setPositiveButton("删除") { _, _ -> + repository.deleteDownloadedModel(manifest.modelId) + render() + } + .create() + dialog.setOnShowListener { SetupUi.styleDialog(dialog, this, destructivePositive = true) } + dialog.show() + } + + /** 模型在压缩包根,或在唯一的一层子目录里(官方发布包通常是后者)。 */ + private fun findPackageRoot(dir: File): File? { + fun looksLikePackage(candidate: File): Boolean = + File(candidate, "manifest.json").isFile || File(candidate, "tokens.txt").isFile || + candidate.walkTopDown().any { it.isFile && it.name.endsWith(".onnx") } + if (looksLikePackage(dir)) return dir + val children = dir.listFiles().orEmpty().filter { it.isDirectory } + return children.singleOrNull()?.takeIf(::looksLikePackage) + ?: dir.takeIf { d -> d.walkTopDown().any { it.isFile && it.name == "tokens.txt" } } + } + + private fun showError(message: String) { + val dialog = AlertDialog.Builder(this) + .setTitle("操作未完成") + .setMessage(message) + .setPositiveButton("关闭", null) + .create() + dialog.setOnShowListener { SetupUi.styleDialog(dialog, this) } + dialog.show() + } + + private fun wrap() = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ) + + private fun dp(value: Int): Int = SetupUi.dp(this, value) + + private companion object { + const val REQUEST_PICK = 8201 + } +} diff --git a/app/src/main/java/llc/slacker/openime/candidate/CandidateEngine.kt b/app/src/main/java/llc/slacker/openime/candidate/CandidateEngine.kt index bf334b13..058c29d9 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/CandidateEngine.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/CandidateEngine.kt @@ -6,7 +6,11 @@ import llc.slacker.openime.core.ImeData * Kotlin port of ui-suite/js/ime-engine.js IMEEngine. * Candidate ordering and fallback behavior mirror the web prototype. */ -class CandidateEngine(externalPinyin: Map> = emptyMap()) { +class CandidateEngine( + externalPinyin: Map> = emptyMap(), + /** Local English word list; null keeps the small built-in map only. */ + private val englishLexicon: ((String, Int) -> List)? = null, +) { private val pinyinDict: Map> = LinkedHashMap>().apply { externalPinyin.forEach { (key, values) -> @@ -564,7 +568,10 @@ class CandidateEngine(externalPinyin: Map> = emptyMap()) { fun getEnglishCompletions(prefix: String): List { if (prefix.isEmpty()) return emptyList() val p = prefix.lowercase() - ImeData.englishDict[p]?.let { return it } + val curated = ImeData.englishDict[p].orEmpty() + val extended = englishLexicon?.invoke(p, EnglishLexicon.DEFAULT_LIMIT).orEmpty() + val suggestions = (curated + extended).distinct() + if (suggestions.isNotEmpty()) return suggestions return listOf( prefix, prefix.uppercase(), diff --git a/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt b/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt index 7f2a6f63..cdd811fb 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt @@ -1,6 +1,8 @@ package llc.slacker.openime.candidate +import llc.slacker.openime.core.FuzzyRule import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.engine.PinyinEngine /** * One way to read the open nine-key digits as pinyin, for the left rail. @@ -64,7 +66,14 @@ class CandidatePipeline internal constructor( private val nineKeyFallbackRegistry: NineKeyFallbackRegistry = NineKeyFallbackRegistry(), /** The 笔画 table once loaded; null (no candidates yet) while it loads. */ private val strokeLexicon: () -> StrokeLexicon? = StrokeLexicon::current, + /** Clean-room pinyin engine; null keeps the Rime/fallback path. */ + private val pinyinEngine: PinyinEngine? = null, + /** 当前启用的模糊音规则(关掉时为空集),引擎按规则展开候选。 */ + private val fuzzyRules: () -> Set = { emptySet() }, ) : CandidateResolver { + + /** True when the 26-key pinyin flow is served by the built-in engine. */ + val usesPinyinEngine: Boolean get() = pinyinEngine != null /** * [pinyinPaths] keeps its historical name for Listener compatibility. For * PINYIN_9 it contains exactly one native Rime T9 code (digits plus @@ -93,7 +102,13 @@ class CandidatePipeline internal constructor( composition: String, fuzzy: Boolean, ): List = when (mode) { - KeyboardMode.PINYIN_26 -> pinyin26Candidates(composition, fuzzy) + KeyboardMode.PINYIN_26 -> if (pinyinEngine == null) { + pinyin26Candidates(composition, fuzzy) + } else { + // The built-in engine serves full pinyin and honours the enabled + // fuzzy switches itself; exact readings rank above fuzzy ones. + pinyinEngine.candidates(composition, fuzzyRules = fuzzyRules()) + } KeyboardMode.ENGLISH_26 -> englishCandidates(composition) KeyboardMode.PINYIN_9 -> if (composition.length <= 32) { engine.getCandidates(composition, fuzzy) @@ -101,6 +116,10 @@ class CandidatePipeline internal constructor( emptyList() } KeyboardMode.STROKE -> strokeLexicon()?.candidatesFor(composition).orEmpty() + // Wubi codes are table lookups: no pinyin corrections, no local fallback. + KeyboardMode.WUBI -> engine.getCandidates(composition, fuzzy = false).take(MAX_CANDIDATES) + // Double pinyin rides the Rime schema (chords expand to full pinyin there). + KeyboardMode.DOUBLE_PINYIN -> engine.getCandidates(composition, fuzzy).take(MAX_CANDIDATES) KeyboardMode.DIGITS -> emptyList() } diff --git a/app/src/main/java/llc/slacker/openime/candidate/CandidatePreviewPolicy.kt b/app/src/main/java/llc/slacker/openime/candidate/CandidatePreviewPolicy.kt new file mode 100644 index 00000000..57244cd8 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/candidate/CandidatePreviewPolicy.kt @@ -0,0 +1,27 @@ +package llc.slacker.openime.candidate + +/** + * 等 Rime 回答的那一帧只显示含汉字的候选。本地引擎在词库没命中时会把输入原样 + * 当候选返回,显示出来就是"英文闪一帧再变中文"。 + * + * 汉字判定用 Unicode 脚本属性(Han),覆盖 CJK 统一表意文字的基本区与扩展区 A–G、 + * 兼容表意文字,以及部首补充等。不能用 `code > 0x7f`:那样会把全角标点、 + * 拉丁扩展字母(如 é)也当成"汉字",导致原串混过去。 + */ +internal object CandidatePreviewPolicy { + fun chineseOnly(candidates: List): List = + candidates.filter { containsHan(it) } + + fun containsHan(text: String): Boolean { + var i = 0 + while (i < text.length) { + val cp = text.codePointAt(i) + if (isHan(cp)) return true + i += Character.charCount(cp) + } + return false + } + + internal fun isHan(codePoint: Int): Boolean = + Character.UnicodeScript.of(codePoint) == Character.UnicodeScript.HAN +} diff --git a/app/src/main/java/llc/slacker/openime/candidate/CandidateQueryCoordinator.kt b/app/src/main/java/llc/slacker/openime/candidate/CandidateQueryCoordinator.kt index 2445684e..897d911b 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/CandidateQueryCoordinator.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/CandidateQueryCoordinator.kt @@ -80,7 +80,12 @@ internal class CandidateQueryCoordinator( val scheduled = composition.isNotBlank() && composition.length <= maxInputLength && - (mode == KeyboardMode.PINYIN_26 || mode == KeyboardMode.PINYIN_9) && + ( + mode == KeyboardMode.PINYIN_26 || + mode == KeyboardMode.PINYIN_9 || + mode == KeyboardMode.WUBI || + mode == KeyboardMode.DOUBLE_PINYIN + ) && queryInputs.isNotEmpty() if (!scheduled) { diff --git a/app/src/main/java/llc/slacker/openime/candidate/CandidateSnapshot.kt b/app/src/main/java/llc/slacker/openime/candidate/CandidateSnapshot.kt index 41dfb195..d0a12856 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/CandidateSnapshot.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/CandidateSnapshot.kt @@ -90,6 +90,10 @@ internal data class CandidateSnapshot( private fun deferredInputFor(composition: String, mode: KeyboardMode): String? = when (mode) { KeyboardMode.PINYIN_26 -> RimeInputNormalizer.normalize(composition).ifBlank { null } + // Wubi codes are plain letters and share the pinyin normalizer. + KeyboardMode.WUBI -> RimeInputNormalizer.normalize(composition).ifBlank { null } + // Double-pinyin chords are plain letters too. + KeyboardMode.DOUBLE_PINYIN -> RimeInputNormalizer.normalize(composition).ifBlank { null } KeyboardMode.PINYIN_9 -> nineKeyCodeFromPreview(composition) else -> null } diff --git a/app/src/main/java/llc/slacker/openime/candidate/EnglishLexicon.kt b/app/src/main/java/llc/slacker/openime/candidate/EnglishLexicon.kt new file mode 100644 index 00000000..6c58d7c4 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/candidate/EnglishLexicon.kt @@ -0,0 +1,119 @@ +package llc.slacker.openime.candidate + +import android.content.Context + +/** + * Word list for English completions, loaded from english-words.tsv + * (lower / word / rank, sorted by lower, rank 1 = the most common word). + * The file ships only in local builds; without it [obtain] returns null and + * completions fall back to the small hand-written map in `core.ImeData`. + * + * Completion is a binary search on the sorted keys plus a bounded scan; the + * matches are then ranked by the frequency rank. This keeps a keystroke + * cheap even with tens of thousands of words. + */ +internal class EnglishLexicon private constructor( + private val lower: Array, + private val words: Array, + private val ranks: IntArray, +) { + fun complete(rawPrefix: String, limit: Int = DEFAULT_LIMIT): List { + if (limit <= 0) return emptyList() + val prefix = rawPrefix.lowercase() + if (prefix.isEmpty()) return emptyList() + var index = lowerBound(prefix) + val picked = ArrayList(minOf(limit * 8, MAX_SCAN)) + var scanned = 0 + while (index < lower.size && scanned < MAX_SCAN) { + if (!lower[index].startsWith(prefix)) break + picked += WordRank(ranks[index], words[index]) + index++ + scanned++ + } + if (picked.isEmpty()) return emptyList() + picked.sortWith(compareBy { it.rank }.thenBy { it.word }) + val result = LinkedHashSet(limit) + for (candidate in picked) { + result.add(candidate.word) + if (result.size >= limit) break + } + return result.toList() + } + + private fun lowerBound(prefix: String): Int { + var low = 0 + var high = lower.size + while (low < high) { + val middle = (low + high) ushr 1 + if (lower[middle] < prefix) low = middle + 1 else high = middle + } + return low + } + + private data class WordRank(val rank: Int, val word: String) + + companion object { + const val ASSET = "english-words.tsv" + const val DEFAULT_LIMIT = 16 + private const val MAX_SCAN = 4000 + + @Volatile + private var cached: EnglishLexicon? = null + + @Volatile + private var attempted = false + + /** Load off the main thread at service start; a missing file stays silent. */ + fun preload(context: Context) { + obtain(context) + } + + fun obtain(context: Context): EnglishLexicon? { + cached?.let { return it } + if (attempted) return cached + return synchronized(this) { + if (!attempted) { + attempted = true + cached = runCatching { parse(context) }.getOrNull() + } + cached + } + } + + /** Visible for tests: build a table from (word, rank) pairs. */ + internal fun fromEntries(entries: List>): EnglishLexicon { + val rows = entries + .map { (word, rank) -> Row(word.lowercase(), word, rank) } + .sortedWith(compareBy { it.lower }.thenBy { it.rank }.thenBy { it.word }) + return EnglishLexicon( + lower = Array(rows.size) { rows[it].lower }, + words = Array(rows.size) { rows[it].word }, + ranks = IntArray(rows.size) { rows[it].rank }, + ) + } + + private data class Row(val lower: String, val word: String, val rank: Int) + + private fun parse(context: Context): EnglishLexicon? { + val lower = ArrayList(60_000) + val words = ArrayList(60_000) + val ranks = ArrayList(60_000) + context.assets.open(ASSET).bufferedReader(Charsets.UTF_8).useLines { lines -> + lines.forEach { line -> + if (line.isEmpty() || line[0] == '#') return@forEach + val parts = line.split('\t') + if (parts.size < 3) return@forEach + val key = parts[0] + val word = parts[1] + val rank = parts[2].toIntOrNull() ?: return@forEach + if (key.isEmpty() || word.isEmpty() || rank <= 0) return@forEach + lower += key + words += word + ranks += rank + } + } + if (lower.isEmpty()) return null + return EnglishLexicon(lower.toTypedArray(), words.toTypedArray(), ranks.toIntArray()) + } + } +} diff --git a/app/src/main/java/llc/slacker/openime/core/DoublePinyinScheme.kt b/app/src/main/java/llc/slacker/openime/core/DoublePinyinScheme.kt new file mode 100644 index 00000000..1b05a1be --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/core/DoublePinyinScheme.kt @@ -0,0 +1,13 @@ +package llc.slacker.openime.core + +/** 双拼键位方案;参考键盘提供小鹤与自然码两种。 */ +enum class DoublePinyinScheme(val key: String, val label: String, val schemaId: String) { + FLYPY("flypy", "小鹤双拼", "double_pinyin_flypy"), + NATURAL("natural", "自然码", "double_pinyin"), + ; + + companion object { + fun fromKey(key: String?): DoublePinyinScheme = + entries.firstOrNull { it.key == key } ?: FLYPY + } +} diff --git a/app/src/main/java/llc/slacker/openime/core/ImeState.kt b/app/src/main/java/llc/slacker/openime/core/ImeState.kt index 32f7cef4..d5f4e0ee 100644 --- a/app/src/main/java/llc/slacker/openime/core/ImeState.kt +++ b/app/src/main/java/llc/slacker/openime/core/ImeState.kt @@ -10,11 +10,16 @@ enum class KeyboardMode { PINYIN_9, /** 笔画: five stroke keys and 通配; characters come from StrokeLexicon. */ STROKE, + /** 五笔: the 26-key wubi86 table through Rime (schema "wubi86"). */ + WUBI, + /** 双拼: the 26-key surface with a double-pinyin schema (小鹤 / 自然码). */ + DOUBLE_PINYIN, DIGITS, ; /** A Chinese layout: 中/英 returns to the last one used, and it is remembered. */ - val isChineseLayout: Boolean get() = this == PINYIN_26 || this == PINYIN_9 || this == STROKE + val isChineseLayout: Boolean + get() = this == PINYIN_26 || this == PINYIN_9 || this == STROKE || this == WUBI || this == DOUBLE_PINYIN } enum class Panel { diff --git a/app/src/main/java/llc/slacker/openime/core/OneHandMode.kt b/app/src/main/java/llc/slacker/openime/core/OneHandMode.kt new file mode 100644 index 00000000..68b06cae --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/core/OneHandMode.kt @@ -0,0 +1,14 @@ +package llc.slacker.openime.core + +/** 单手模式:把键盘收窄到一侧,方便单手握持时用拇指打字。 */ +enum class OneHandMode(val key: String, val label: String) { + OFF("off", "关闭"), + LEFT("left", "左手"), + RIGHT("right", "右手"), + ; + + companion object { + fun fromKey(key: String?): OneHandMode = + entries.firstOrNull { it.key == key } ?: OFF + } +} diff --git a/app/src/main/java/llc/slacker/openime/data/ClipboardHistoryRepository.kt b/app/src/main/java/llc/slacker/openime/data/ClipboardHistoryRepository.kt index 5039afc3..604c1b3a 100644 --- a/app/src/main/java/llc/slacker/openime/data/ClipboardHistoryRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/ClipboardHistoryRepository.kt @@ -26,6 +26,27 @@ internal object ClipboardSensitivityPolicy { runCatching { readBoolean(SENSITIVE_KEY) }.getOrDefault(false) } +/** + * Identifies one copy action so that "清空" can suppress exactly the clip that was on + * the system clipboard at that moment. The framework stamps every clip with its own + * set-time (ClipDescription.getTimestamp), which is unique per copy. Only when that + * stamp is missing do we fall back to a SHA-256 of the text; hashCode+length is not + * used because different strings can collide on it. + */ +internal object ClipboardSuppression { + fun fingerprint(clipTimestamp: Long, text: String): String = + if (clipTimestamp > 0L) { + "t:$clipTimestamp" + } else { + val digest = java.security.MessageDigest.getInstance("SHA-256") + .digest(text.toByteArray(Charsets.UTF_8)) + "h:" + digest.joinToString("") { "%02x".format(it) } + } + + fun isSuppressed(suppressed: String?, clipTimestamp: Long, text: String): Boolean = + suppressed != null && suppressed == fingerprint(clipTimestamp, text) +} + internal object ClipboardPrivacyPolicy { fun canUsePersistentHistory( editorKind: EditorInfoAdapter.EditorKind, @@ -78,14 +99,27 @@ object ClipboardHistoryRepository { private const val PREFS = "ime_clipboard_history" private const val KEY_ITEMS = "items" private const val MAX_ITEMS = 24 + private const val KEY_SUPPRESSED = "suppressed_fingerprint" + + /** + * Every read-modify-write of the stored list (and of the suppression marker) runs + * under this lock. The clipboard listener captures on its own thread, so without it + * a capture could read the list before a clear, then write the cleared-away clip back. + * All callers live in one process, so one JVM monitor is enough. + */ + private val lock = Any() fun load(context: Context): List { if (!canUsePersistentHistory(context)) return emptyList() - return loadStored(context) + synchronized(lock) { return loadStored(context) } } fun add(context: Context, text: String) { if (text.isBlank() || !canUsePersistentHistory(context)) return + synchronized(lock) { addLocked(context, text) } + } + + private fun addLocked(context: Context, text: String) { val stored = loadStored(context) val old = stored.firstOrNull { it.text == text } val items = stored.filterNot { it.text == text }.toMutableList() @@ -118,34 +152,69 @@ object ClipboardHistoryRepository { .orEmpty() }.getOrDefault("") if (text.isBlank()) return false - add(context, text) + synchronized(lock) { + // 用户刚清空过:系统剪贴板里还躺着那一条(同一次复制的时间戳),不能写回来。 + val clipTimestamp = clip.description?.timestamp ?: 0L + if (ClipboardSuppression.isSuppressed(suppressed(context), clipTimestamp, text)) return false + addLocked(context, text) + } return true } + /** + * 监听器收到的是一次复制动作。抑制标记按"那一次复制"的时间戳区分,新复制自然不匹配, + * 所以这里不需要也不能解除抑制:解除会让旧事件线程把已清空的旧剪贴板写回来。 + */ + fun captureCopied(context: Context, clip: ClipData): Boolean = captureClip(context, clip) + fun togglePin(context: Context, text: String) { if (!canUsePersistentHistory(context)) return - val items = loadStored(context).toMutableList() - val idx = items.indexOfFirst { it.text == text } - if (idx >= 0) { - val old = items[idx] - items[idx] = old.copy(pinned = !old.pinned) - save(context, items) + synchronized(lock) { + val items = loadStored(context).toMutableList() + val idx = items.indexOfFirst { it.text == text } + if (idx >= 0) { + val old = items[idx] + items[idx] = old.copy(pinned = !old.pinned) + save(context, items) + } } } fun remove(context: Context, text: String) { if (!canUsePersistentHistory(context)) return - save(context, loadStored(context).filterNot { it.text == text }) + synchronized(lock) { save(context, loadStored(context).filterNot { it.text == text }) } } fun clearUnpinned(context: Context) { if (!canUsePersistentHistory(context)) return - save(context, loadStored(context).filter { it.pinned }) + synchronized(lock) { + suppressCurrentSystemClip(context) + save(context, loadStored(context).filter { it.pinned }) + } } fun clearAll(context: Context) { if (!canUsePersistentHistory(context)) return - save(context, emptyList()) + synchronized(lock) { + suppressCurrentSystemClip(context) + save(context, emptyList()) + } + } + + private fun suppressed(context: Context): String? = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_SUPPRESSED, null) + + /** Records the clip that is on the clipboard now; caller holds [lock]. */ + private fun suppressCurrentSystemClip(context: Context) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return + val clip = runCatching { clipboard.primaryClip }.getOrNull() ?: return + val text = runCatching { + clip.takeIf { it.itemCount > 0 }?.getItemAt(0)?.coerceToText(context)?.toString() + }.getOrNull().orEmpty() + if (text.isBlank()) return + val fp = ClipboardSuppression.fingerprint(clip.description?.timestamp ?: 0L, text) + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(KEY_SUPPRESSED, fp).apply() } private fun canUsePersistentHistory(context: Context): Boolean { diff --git a/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt b/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt index e61a2b9d..53321d45 100644 --- a/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt @@ -1,9 +1,12 @@ package llc.slacker.openime.data +import llc.slacker.openime.theme.ImeGeometryTokens import android.content.Context import llc.slacker.openime.core.FuzzyRule +import llc.slacker.openime.core.DoublePinyinScheme import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.core.LandscapeLayout +import llc.slacker.openime.core.OneHandMode import llc.slacker.openime.theme.ImeAppearance import llc.slacker.openime.theme.ImeTheme @@ -32,6 +35,8 @@ object ImeSettingsRepository { private const val KEY_FLOATING_WIDTH = "floating_width_percent" private const val KEY_FLOATING_OPACITY = "floating_opacity_percent" private const val KEY_LANDSCAPE_LAYOUT = "landscape_layout" + private const val KEY_ONE_HAND_MODE = "one_hand_mode" + private const val KEY_DOUBLE_PINYIN_SCHEME = "double_pinyin_scheme" private const val KEY_SPLIT_MIRRORED = "split_mirrored" private const val KEY_SMS_CODE_CHIP = "sms_code_chip" private const val KEY_CLIPBOARD_CHIP = "clipboard_chip" @@ -55,16 +60,40 @@ object ImeSettingsRepository { .edit().putString(KEY_PREFERRED_CHINESE_MODE, mode.name).apply() } + /** 单手模式:关闭 / 左手 / 右手;键面收窄贴向所选一侧。 */ + fun loadOneHandMode(context: Context): OneHandMode = + OneHandMode.fromKey( + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_ONE_HAND_MODE, null), + ) + + fun saveOneHandMode(context: Context, mode: OneHandMode) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(KEY_ONE_HAND_MODE, mode.key).apply() + } + + /** 双拼键位方案(小鹤 / 自然码),默认小鹤。 */ + fun loadDoublePinyinScheme(context: Context): DoublePinyinScheme = + DoublePinyinScheme.fromKey( + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_DOUBLE_PINYIN_SCHEME, null), + ) + + fun saveDoublePinyinScheme(context: Context, scheme: DoublePinyinScheme) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(KEY_DOUBLE_PINYIN_SCHEME, scheme.key).apply() + } + fun loadKeyboardHeightPercent(context: Context, landscape: Boolean = false): Int { val preferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) val stored = if (landscape && preferences.contains(KEY_LANDSCAPE_KEYBOARD_HEIGHT)) { - preferences.getInt(KEY_LANDSCAPE_KEYBOARD_HEIGHT, 100) + preferences.getInt(KEY_LANDSCAPE_KEYBOARD_HEIGHT, ImeGeometryTokens.KEYBOARD_HEIGHT_DEFAULT_PERCENT) } else { // Existing height preferences seed landscape until a user changes // its independent value, preserving the current appearance on upgrade. - preferences.getInt(KEY_KEYBOARD_HEIGHT, 100) + preferences.getInt(KEY_KEYBOARD_HEIGHT, ImeGeometryTokens.KEYBOARD_HEIGHT_DEFAULT_PERCENT) } - return stored.coerceIn(80, 120) + return stored.coerceIn(ImeGeometryTokens.KEYBOARD_HEIGHT_MIN_PERCENT, ImeGeometryTokens.KEYBOARD_HEIGHT_MAX_PERCENT) } fun saveKeyboardHeightPercent(context: Context, percent: Int, landscape: Boolean = false) { @@ -72,7 +101,7 @@ object ImeSettingsRepository { .edit() .putInt( if (landscape) KEY_LANDSCAPE_KEYBOARD_HEIGHT else KEY_KEYBOARD_HEIGHT, - percent.coerceIn(80, 120), + percent.coerceIn(ImeGeometryTokens.KEYBOARD_HEIGHT_MIN_PERCENT, ImeGeometryTokens.KEYBOARD_HEIGHT_MAX_PERCENT), ) .apply() } @@ -110,22 +139,22 @@ object ImeSettingsRepository { fun loadFloatingWidthPercent(context: Context): Int = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .getInt(KEY_FLOATING_WIDTH, 100) - .coerceIn(72, 100) + .getInt(KEY_FLOATING_WIDTH, ImeGeometryTokens.FLOATING_WIDTH_DEFAULT_PERCENT) + .coerceIn(ImeGeometryTokens.FLOATING_WIDTH_MIN_PERCENT, ImeGeometryTokens.FLOATING_WIDTH_MAX_PERCENT) fun saveFloatingWidthPercent(context: Context, percent: Int) { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .edit().putInt(KEY_FLOATING_WIDTH, percent.coerceIn(72, 100)).apply() + .edit().putInt(KEY_FLOATING_WIDTH, percent.coerceIn(ImeGeometryTokens.FLOATING_WIDTH_MIN_PERCENT, ImeGeometryTokens.FLOATING_WIDTH_MAX_PERCENT)).apply() } fun loadFloatingOpacityPercent(context: Context): Int = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .getInt(KEY_FLOATING_OPACITY, 100) - .coerceIn(82, 100) + .getInt(KEY_FLOATING_OPACITY, ImeGeometryTokens.FLOATING_OPACITY_DEFAULT_PERCENT) + .coerceIn(ImeGeometryTokens.FLOATING_OPACITY_MIN_PERCENT, ImeGeometryTokens.FLOATING_OPACITY_MAX_PERCENT) fun saveFloatingOpacityPercent(context: Context, percent: Int) { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .edit().putInt(KEY_FLOATING_OPACITY, percent.coerceIn(82, 100)).apply() + .edit().putInt(KEY_FLOATING_OPACITY, percent.coerceIn(ImeGeometryTokens.FLOATING_OPACITY_MIN_PERCENT, ImeGeometryTokens.FLOATING_OPACITY_MAX_PERCENT)).apply() } internal fun parseTheme(value: String?): ImeTheme = @@ -179,15 +208,15 @@ object ImeSettingsRepository { .edit().putBoolean(KEY_HAPTIC, enabled).apply() } - /** Key-click strength, 10–100% of the full click. */ + /** Key-click strength, 1–100% of the full click. */ fun loadHapticStrengthPercent(context: Context): Int = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .getInt(KEY_HAPTIC_STRENGTH, 100) - .coerceIn(10, 100) + .getInt(KEY_HAPTIC_STRENGTH, ImeGeometryTokens.HAPTIC_STRENGTH_DEFAULT_PERCENT) + .coerceIn(ImeGeometryTokens.HAPTIC_STRENGTH_MIN_PERCENT, ImeGeometryTokens.HAPTIC_STRENGTH_MAX_PERCENT) fun saveHapticStrengthPercent(context: Context, percent: Int) { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .edit().putInt(KEY_HAPTIC_STRENGTH, percent.coerceIn(10, 100)).apply() + .edit().putInt(KEY_HAPTIC_STRENGTH, percent.coerceIn(ImeGeometryTokens.HAPTIC_STRENGTH_MIN_PERCENT, ImeGeometryTokens.HAPTIC_STRENGTH_MAX_PERCENT)).apply() } /** 震动手感 key: "crisp" (default), "firm" or "system". */ diff --git a/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt b/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt index 1d947b17..6f1caa62 100644 --- a/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt +++ b/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt @@ -1,8 +1,11 @@ package llc.slacker.openime.data +import llc.slacker.openime.theme.ImeGeometryTokens import android.content.Context import llc.slacker.openime.core.FuzzyRule import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.core.DoublePinyinScheme +import llc.slacker.openime.core.OneHandMode import llc.slacker.openime.theme.ImeAppearance import llc.slacker.openime.theme.ImeTheme import org.json.JSONArray @@ -36,6 +39,8 @@ internal data class ArchiveSettings( val fuzzy: Boolean, val swipeUpDigits: Boolean, val preferredChineseMode: String, + val oneHandMode: String = OneHandMode.OFF.key, + val doublePinyinScheme: String = DoublePinyinScheme.FLYPY.key, val keyboardHeightPercent: Int, val floatingWidthPercent: Int, val floatingOpacityPercent: Int, @@ -190,6 +195,8 @@ internal object UserDataArchiveCodec { .put("fuzzy", value.fuzzy) .put("swipe_up_digits", value.swipeUpDigits) .put("preferred_chinese_mode", value.preferredChineseMode) + .put("one_hand_mode", value.oneHandMode) + .put("double_pinyin_scheme", value.doublePinyinScheme) .put("keyboard_height_percent", value.keyboardHeightPercent) .put("landscape_keyboard_height_percent", value.landscapeKeyboardHeightPercent) .put("floating_width_percent", value.floatingWidthPercent) @@ -207,7 +214,7 @@ internal object UserDataArchiveCodec { appearance = value.optString("appearance", ImeAppearance.SYSTEM.name), sound = value.optBoolean("sound", true), haptic = value.optBoolean("haptic", true), - hapticStrengthPercent = value.optInt("haptic_strength_percent", 100).coerceIn(10, 100), + hapticStrengthPercent = value.optInt("haptic_strength_percent", ImeGeometryTokens.HAPTIC_STRENGTH_DEFAULT_PERCENT).coerceIn(ImeGeometryTokens.HAPTIC_STRENGTH_MIN_PERCENT, ImeGeometryTokens.HAPTIC_STRENGTH_MAX_PERCENT), hapticStyle = value.optString("haptic_style", "crisp"), keySoundStyle = value.optString("key_sound_style", "system"), popup = value.optBoolean("popup", false), @@ -217,16 +224,24 @@ internal object UserDataArchiveCodec { "preferred_chinese_mode", KeyboardMode.PINYIN_26.name, ), + oneHandMode = value.optString( + "one_hand_mode", + OneHandMode.OFF.key, + ), + doublePinyinScheme = value.optString( + "double_pinyin_scheme", + DoublePinyinScheme.FLYPY.key, + ), keyboardHeightPercent = - value.optInt("keyboard_height_percent", 100).coerceIn(80, 120), + value.optInt("keyboard_height_percent", ImeGeometryTokens.KEYBOARD_HEIGHT_DEFAULT_PERCENT).coerceIn(ImeGeometryTokens.KEYBOARD_HEIGHT_MIN_PERCENT, ImeGeometryTokens.KEYBOARD_HEIGHT_MAX_PERCENT), landscapeKeyboardHeightPercent = value.optInt( "landscape_keyboard_height_percent", - value.optInt("keyboard_height_percent", 100), - ).coerceIn(80, 120), + value.optInt("keyboard_height_percent", ImeGeometryTokens.KEYBOARD_HEIGHT_DEFAULT_PERCENT), + ).coerceIn(ImeGeometryTokens.KEYBOARD_HEIGHT_MIN_PERCENT, ImeGeometryTokens.KEYBOARD_HEIGHT_MAX_PERCENT), floatingWidthPercent = - value.optInt("floating_width_percent", 88).coerceIn(72, 96), + value.optInt("floating_width_percent", ImeGeometryTokens.FLOATING_WIDTH_DEFAULT_PERCENT).coerceIn(ImeGeometryTokens.FLOATING_WIDTH_MIN_PERCENT, ImeGeometryTokens.FLOATING_WIDTH_MAX_PERCENT), floatingOpacityPercent = - value.optInt("floating_opacity_percent", 100).coerceIn(82, 100), + value.optInt("floating_opacity_percent", ImeGeometryTokens.FLOATING_OPACITY_DEFAULT_PERCENT).coerceIn(ImeGeometryTokens.FLOATING_OPACITY_MIN_PERCENT, ImeGeometryTokens.FLOATING_OPACITY_MAX_PERCENT), letterHints = value.optBoolean("letter_hints", true), emojiAssociation = value.optBoolean("emoji_association", true), voiceStripFillers = value.optBoolean("voice_strip_fillers", true), @@ -357,6 +372,10 @@ internal object UserDataRepository { swipeUpDigits = ImeSettingsRepository.loadSwipeUpDigits(context), preferredChineseMode = ImeSettingsRepository.loadPreferredChineseMode(context).name, + oneHandMode = + ImeSettingsRepository.loadOneHandMode(context).key, + doublePinyinScheme = + ImeSettingsRepository.loadDoublePinyinScheme(context).key, keyboardHeightPercent = ImeSettingsRepository.loadKeyboardHeightPercent(context), landscapeKeyboardHeightPercent = @@ -452,6 +471,14 @@ internal object UserDataRepository { runCatching { KeyboardMode.valueOf(value.preferredChineseMode) } .getOrDefault(KeyboardMode.PINYIN_26), ) + ImeSettingsRepository.saveOneHandMode( + context, + OneHandMode.fromKey(value.oneHandMode), + ) + ImeSettingsRepository.saveDoublePinyinScheme( + context, + DoublePinyinScheme.fromKey(value.doublePinyinScheme), + ) ImeSettingsRepository.saveKeyboardHeightPercent( context, value.keyboardHeightPercent, diff --git a/app/src/main/java/llc/slacker/openime/data/UserPhraseRepository.kt b/app/src/main/java/llc/slacker/openime/data/UserPhraseRepository.kt index ea39837f..aa8c9c67 100644 --- a/app/src/main/java/llc/slacker/openime/data/UserPhraseRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/UserPhraseRepository.kt @@ -17,7 +17,9 @@ import java.util.concurrent.atomic.AtomicBoolean object UserPhraseRepository { private const val PREFS = "user_phrases" private const val KEY = "entries" + private const val HOTWORDS_KEY = "voice_hotwords" private const val MAX_ENTRIES = 2000 + private const val MAX_MANUAL_HOTWORDS = 64 private data class Entry( val code: String, @@ -31,6 +33,9 @@ object UserPhraseRepository { private var preferences: SharedPreferences? = null private val entries = LinkedHashMap() + /** 管理页手动热词;不受学习频率门槛影响,插入顺序稳定。 */ + private val manualHotwords = LinkedHashSet() + /** * Persisting must stay off the IME thread. Unlike the old 500 ms debounce, * an accepted learning event now queues a snapshot immediately so ending an @@ -180,17 +185,41 @@ object UserPhraseRepository { val boundedLimit = limit.coerceIn(0, 128) if (boundedLimit == 0) return emptyList() synchronized(lock) { - return entries.values + // 手动热词排在前面,且不参与学习频率门槛。 + val learned = entries.values .asSequence() .filter { it.frequency >= threshold } .sortedWith(compareByDescending { it.frequency }.thenByDescending { it.lastUsed }) .map { it.text } + return (manualHotwords.asSequence() + learned) .distinct() .take(boundedLimit) .toList() } } + /** 管理页保存的手动语音热词(一行一个),不受学习频率门槛影响。 */ + fun manualVoiceHotwords(): List = synchronized(lock) { manualHotwords.toList() } + + /** + * 覆盖保存手动热词:去空白、去重、最多 64 条;能否进 sherpa 热词串由 + * VoiceHotwordProvider 决定。@return false 表示偏好尚未 configure,没有写盘。 + */ + fun setManualVoiceHotwords(phrases: List): Boolean { + val cleaned = phrases + .map(String::trim) + .filter(String::isNotEmpty) + .distinct() + .take(MAX_MANUAL_HOTWORDS) + synchronized(lock) { + manualHotwords.clear() + manualHotwords.addAll(cleaned) + } + // 输入法还没 configure 时先留在内存;返回值只反映是否写盘成功。 + val target = preferences ?: return false + return target.edit().putString(HOTWORDS_KEY, JSONArray(cleaned).toString()).commit() + } + internal fun exportArchiveEntries(): List = synchronized(lock) { entries.values @@ -246,7 +275,8 @@ object UserPhraseRepository { synchronized(persistenceLock) { synchronized(lock) { entries.clear() - preferences?.edit()?.remove(KEY)?.commit() + manualHotwords.clear() + preferences?.edit()?.remove(KEY)?.remove(HOTWORDS_KEY)?.commit() } } } @@ -279,6 +309,13 @@ object UserPhraseRepository { } } } + manualHotwords.clear() + runCatching { + val array = JSONArray(preferences?.getString(HOTWORDS_KEY, null).orEmpty()) + for (index in 0 until array.length()) { + array.optString(index).trim().takeIf { it.isNotEmpty() }?.let(manualHotwords::add) + } + } } private fun writeSnapshot( diff --git a/app/src/main/java/llc/slacker/openime/engine/BigramIndex.kt b/app/src/main/java/llc/slacker/openime/engine/BigramIndex.kt new file mode 100644 index 00000000..542acd57 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/engine/BigramIndex.kt @@ -0,0 +1,48 @@ +package llc.slacker.openime.engine + +import java.nio.ByteBuffer + +/** + * Read-only view of bigram.bin: word pair scores on the lexicon's word ids. + * + * Layout: u32 magic, u32 pair count, then (8-byte aligned) a sorted u64 key + * array (`idA << 32 | idB`) and a parallel u16 score array. + */ +internal class BigramIndex(private val buffer: ByteBuffer) { + private val pairCount: Int + private val offKeys: Int + private val offScores: Int + + init { + require(buffer.getInt(0) == MAGIC) { "not a bigram.bin table" } + pairCount = buffer.getInt(4) + offKeys = align8(8) + offScores = align8(offKeys + pairCount * 8) + } + + val size: Int get() = pairCount + + /** 0 when the pair is unknown. */ + fun score(idA: Int, idB: Int): Int { + val key = (idA.toLong() shl 32) or (idB.toLong() and 0xFFFFFFFFL) + var low = 0 + var high = pairCount + while (low < high) { + val middle = (low + high) ushr 1 + val value = buffer.getLong(offKeys + middle * 8) + val order = java.lang.Long.compareUnsigned(value, key) + when { + order < 0 -> low = middle + 1 + order > 0 -> high = middle + else -> return buffer.getShort(offScores + middle * 2).toInt() and 0xFFFF + } + } + return 0 + } + + private fun align8(value: Int): Int = (value + 7) and 7.inv() + + companion object { + const val MAGIC = 0x31424D49 // "IMB1" little-endian + } +} diff --git a/app/src/main/java/llc/slacker/openime/engine/EngineAssets.kt b/app/src/main/java/llc/slacker/openime/engine/EngineAssets.kt new file mode 100644 index 00000000..6150b321 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/engine/EngineAssets.kt @@ -0,0 +1,63 @@ +package llc.slacker.openime.engine + +import android.content.Context +import java.io.File +import java.io.FileOutputStream +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.channels.FileChannel + +/** + * File access for the compiled engine tables (lex.bin, bigram.bin under the + * imengine asset directory). + * + * The tables are built locally by scripts/build_imengine_data.py and are not + * part of the repository. Assets cannot be mapped from inside the APK, so the + * first use copies them into the app's files directory (same pattern as the + * Rime data) and later starts map them read-only. + */ +internal object EngineAssets { + const val ASSET_DIR = "imengine" + private const val LEX = "lex.bin" + private const val BIGRAM = "bigram.bin" + + fun lexFile(context: Context): File? = materialize(context, LEX) + + fun bigramFile(context: Context): File? = materialize(context, BIGRAM) + + fun present(context: Context): Boolean = + runCatching { context.assets.open("$ASSET_DIR/$LEX").close() }.isSuccess + + private fun materialize(context: Context, name: String): File? = runCatching { + val dir = File(context.filesDir, ASSET_DIR) + val target = File(dir, name) + val stamp = File(dir, "$name.rev") + // The assets are compressed in the APK, so openFd is not usable; the + // small revision asset records what the tables should look like. + val revision = context.assets.open("$ASSET_DIR/revision.txt") + .bufferedReader().use { it.readText() }.trim() + if (target.isFile && stamp.isFile && stamp.readText().trim() == revision) { + return@runCatching target + } + dir.mkdirs() + val tmp = File(dir, "$name.tmp") + context.assets.open("$ASSET_DIR/$name").use { input -> + FileOutputStream(tmp).use { output -> input.copyTo(output, 1 shl 16) } + } + if (!tmp.renameTo(target)) { + tmp.copyTo(target, overwrite = true) + tmp.delete() + } + stamp.writeText(revision) + target + }.getOrNull() + + fun map(file: File): ByteBuffer { + RandomAccessFile(file, "r").use { raf -> + return raf.channel + .map(FileChannel.MapMode.READ_ONLY, 0, raf.length()) + .order(ByteOrder.LITTLE_ENDIAN) + } + } +} diff --git a/app/src/main/java/llc/slacker/openime/engine/LexiconIndex.kt b/app/src/main/java/llc/slacker/openime/engine/LexiconIndex.kt new file mode 100644 index 00000000..e47a7038 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/engine/LexiconIndex.kt @@ -0,0 +1,145 @@ +package llc.slacker.openime.engine + +import java.nio.ByteBuffer + +/** + * Read-only view of lex.bin: every word sorted by its syllable id sequence. + * + * Layout (little-endian, see scripts/build_imengine_data.py): + * header u32[10], then the syllable table (u8 length + ASCII bytes per entry), + * u32 syllable offsets per word (first element index inside the id array), + * u16 syllable ids, u32 byte text offsets, the UTF-8 text blob and u16 scores, + * each section aligned. + */ +internal class LexiconIndex(private val buffer: ByteBuffer) { + private val wordCount: Int + private val syllableCount: Int + private val offSyllables: Int + private val offSylOffsets: Int + private val offSylIds: Int + private val offTextOffsets: Int + private val offTextBlob: Int + private val offScores: Int + + val syllables: List + + init { + require(buffer.getInt(0) == MAGIC) { "not a lex.bin table" } + wordCount = buffer.getInt(4) + syllableCount = buffer.getInt(8) + offSyllables = buffer.getInt(12) + offSylOffsets = buffer.getInt(16) + offSylIds = buffer.getInt(20) + offTextOffsets = buffer.getInt(24) + offTextBlob = buffer.getInt(28) + offScores = buffer.getInt(32) + syllables = readSyllables() + } + + val size: Int get() = wordCount + + /** The syllable ids of [word] are written into [out]; returns their count. */ + fun sequenceOf(word: Int, out: IntArray): Int { + val start = buffer.getInt(offSylOffsets + word * 4) + val end = buffer.getInt(offSylOffsets + (word + 1) * 4) + val count = minOf(end - start, out.size) + for (index in 0 until count) { + out[index] = buffer.getShort(offSylIds + (start + index) * 2).toInt() and 0xFFFF + } + return count + } + + fun syllableLengthOf(word: Int): Int = + buffer.getInt(offSylOffsets + (word + 1) * 4) - buffer.getInt(offSylOffsets + word * 4) + + fun syllableAt(word: Int, index: Int): Int = + buffer.getShort(offSylIds + (buffer.getInt(offSylOffsets + word * 4) + index) * 2).toInt() and 0xFFFF + + fun textOf(word: Int): String { + val start = buffer.getInt(offTextOffsets + word * 4) + val end = buffer.getInt(offTextOffsets + (word + 1) * 4) + if (end <= start) return "" + val bytes = ByteArray(end - start) + val view = buffer.duplicate() + view.position(offTextBlob + start) + view.get(bytes) + return String(bytes, Charsets.UTF_8) + } + + fun scoreOf(word: Int): Int = buffer.getShort(offScores + word * 2).toInt() and 0xFFFF + + /** + * First word whose sequence is >= [ids]; words that continue the sequence + * sort after it, so this is also the lower bound of a prefix scan. + */ + fun lowerBound(ids: IntArray, length: Int): Int { + var low = 0 + var high = wordCount + while (low < high) { + val middle = (low + high) ushr 1 + if (compare(middle, ids, length) < 0) low = middle + 1 else high = middle + } + return low + } + + /** First word after every word that equals [ids] exactly. */ + fun exactEnd(ids: IntArray, length: Int): Int { + var low = 0 + var high = wordCount + while (low < high) { + val middle = (low + high) ushr 1 + if (compare(middle, ids, length) <= 0) low = middle + 1 else high = middle + } + return low + } + + /** First word after every word whose sequence starts with [ids]. */ + fun prefixEnd(ids: IntArray, length: Int): Int { + var low = 0 + var high = wordCount + while (low < high) { + val middle = (low + high) ushr 1 + if (startsWith(middle, ids, length)) low = middle + 1 else high = middle + } + return low + } + + internal fun compare(word: Int, ids: IntArray, length: Int): Int { + val wordLength = syllableLengthOf(word) + val common = minOf(wordLength, length) + for (index in 0 until common) { + val a = syllableAt(word, index) + val b = ids[index] + if (a != b) return a - b + } + return wordLength - length + } + + internal fun startsWith(word: Int, ids: IntArray, length: Int): Boolean { + if (length > syllableLengthOf(word)) return false + for (index in 0 until length) { + if (syllableAt(word, index) != ids[index]) return false + } + return true + } + + private fun readSyllables(): List { + val out = ArrayList(syllableCount) + var cursor = offSyllables + repeat(syllableCount) { + val length = buffer.get(cursor).toInt() and 0xFF + cursor++ + val bytes = ByteArray(length) + val view = buffer.duplicate() + view.position(cursor) + view.get(bytes) + out += String(bytes, Charsets.US_ASCII) + cursor += length + } + return out + } + + companion object { + const val MAGIC = 0x31454D49 // "IME1" little-endian + } +} diff --git a/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt b/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt new file mode 100644 index 00000000..99305320 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt @@ -0,0 +1,296 @@ +package llc.slacker.openime.engine + +import android.content.Context +import llc.slacker.openime.core.FuzzyRule +import kotlin.math.ln + +/** + * Clean-room pinyin engine for openIME, built from the reverse-engineered + * behaviour of a production IME (see ENGINE-SPEC): the input becomes a + * full-path syllable DAG, every path queries the word index, a trailing + * fragment expands to the syllables it may become, and sentences come from a + * Viterbi pass over word edges with a bigram bonus. + * + * Fuzzy pairs (z/zh, n/l, an/ang...) expand the lookup when the caller passes + * the enabled [FuzzyRule]s: exact readings always rank above fuzzy ones. + * + * The tables it reads are compiled locally by scripts/build_imengine_data.py + * and are not part of the repository; without them [obtain] returns null and + * the caller keeps using whatever engine it had. + */ +class PinyinEngine internal constructor( + private val lexicon: LexiconIndex, + private val bigram: BigramIndex?, +) { + private val plainSegmenter = PinyinSegmenter(lexicon.syllables) + private val fuzzySegmenters = HashMap, PinyinSegmenter>() + + val wordCount: Int get() = lexicon.size + + /** Candidate words for [input] (raw pinyin letters), best first. */ + fun candidates( + input: String, + limit: Int = CANDIDATE_LIMIT, + fuzzyRules: Set = emptySet(), + ): List { + val letters = normalize(input) + if (letters.isEmpty() || letters.length > MAX_INPUT) return emptyList() + val segmenter = segmenterFor(fuzzyRules) + val found = HashMap() + val budget = Budget() + + for (split in 0..letters.length) { + if (budget.exhausted()) break + val tail = letters.substring(split) + if (tail.length > PinyinSegmenter.MAX_SYLLABLE_LENGTH) continue + val headPaths = if (split == 0) { + listOf(PinyinSegmenter.SegmentPath(IntArray(0), intArrayOf(0), fuzzy = false)) + } else { + segmenter.segment(letters.substring(0, split), maxPaths = HEAD_PATHS) + } + if (headPaths.isEmpty()) continue + for (path in headPaths) { + if (budget.exhausted()) break + if (tail.isEmpty()) { + collectExact(found, path.ids, path.fuzzy, budget) + collectPrefix(found, path.ids, path.fuzzy, budget, COMPLETION_WORDS) + } else { + // Words that already match the settled prefix stay candidates + // while the next syllable is still being typed. An empty + // prefix would match from the top of the table: skip it. + if (path.ids.isNotEmpty()) { + collectPrefix(found, path.ids, path.fuzzy, budget, FRAGMENT_WORDS) + } + for (edge in segmenter.syllablesStartingWith(tail, TAIL_SYLLABLES)) { + if (budget.exhausted()) break + val ids = path.ids.copyOf(path.ids.size + 1) + ids[ids.size - 1] = edge.id + collectPrefix(found, ids, path.fuzzy || edge.fuzzy, budget, FRAGMENT_WORDS) + budget.lookups++ + } + } + } + } + if (found.isEmpty()) return emptyList() + return found.entries + .sortedWith(compareByDescending> { it.value }.thenBy { it.key }) + .take(limit) + .map { it.key } + } + + /** Top sentence readings for [input]; each result is the joined words. */ + fun decode( + input: String, + limit: Int = SENTENCE_LIMIT, + fuzzyRules: Set = emptySet(), + ): List { + val letters = normalize(input) + if (letters.isEmpty() || letters.length > MAX_INPUT) return emptyList() + val paths = segmenterFor(fuzzyRules).segment(letters, maxPaths = PATH_LIMIT) + if (paths.isEmpty()) return emptyList() + val length = letters.length + + val edges = Array(length + 1) { ArrayList(8) } + for (path in paths) { + val syllables = path.ids.size + for (from in 0 until syllables) { + val charFrom = path.starts[from] + var to = from + while (to < syllables && to - from < MAX_WORD_SYLLABLES) { + val charTo = path.starts[to + 1] + val span = path.ids.copyOfRange(from, to + 1) + val start = lexicon.lowerBound(span, span.size) + val end = lexicon.exactEnd(span, span.size) + var taken = 0 + for (word in start until end) { + if (taken >= WORDS_PER_SPAN) break + taken++ + edges[charFrom].add(Edge(charTo, word, path.fuzzy)) + } + to++ + } + } + } + + val states = arrayOfNulls>(length + 1) + states[0] = arrayListOf(State(0.0, -1, 0, -1)) + for (position in 0..length) { + val at = states[position] ?: continue + for (edge in edges[position]) { + if (edge.to <= position) continue + val target = states[edge.to] ?: ArrayList(STATE_K).also { states[edge.to] = it } + for ((index, state) in at.withIndex()) { + val penalty = state.score + + unigramPenalty(edge.word) + + bigramPenalty(state.word, edge.word) + + if (edge.fuzzy) FUZZY_SENTENCE_COST else 0.0 + target.add(State(penalty, edge.word, position, index)) + } + if (target.size > STATE_K * 4) { + val trimmed = target.sortedBy { it.score }.take(STATE_K * 2) + target.clear() + target.addAll(trimmed) + } + } + } + + val ends = states[length] ?: return emptyList() + return ends.sortedBy { it.score } + .take(limit) + .mapNotNull { reading(it, states) } + .distinct() + } + + private fun segmenterFor(rules: Set): PinyinSegmenter { + if (rules.isEmpty()) return plainSegmenter + synchronized(fuzzySegmenters) { + return fuzzySegmenters.getOrPut(rules) { PinyinSegmenter(lexicon.syllables, rules) } + } + } + + private fun collectExact(found: HashMap, ids: IntArray, fuzzy: Boolean, budget: Budget) { + val start = lexicon.lowerBound(ids, ids.size) + val end = lexicon.exactEnd(ids, ids.size) + var taken = 0 + for (word in start until end) { + if (taken >= EXACT_WORDS) break + taken++ + add(found, word, fuzzy, budget) + } + } + + private fun collectPrefix( + found: HashMap, + ids: IntArray, + fuzzy: Boolean, + budget: Budget, + cap: Int, + ) { + val start = lexicon.lowerBound(ids, ids.size) + val end = lexicon.prefixEnd(ids, ids.size) + var taken = 0 + for (word in start until end) { + if (taken >= cap || budget.exhausted()) break + taken++ + add(found, word, fuzzy, budget) + } + } + + private fun add(found: HashMap, word: Int, fuzzy: Boolean, budget: Budget) { + val text = lexicon.textOf(word) + if (text.isEmpty()) return + // A fuzzy reading carries a moderate penalty: a frequent fuzzy word can + // beat a rare exact one, but marginal fuzzy junk never displaces a + // solid literal match of similar frequency. + val adjusted = lexicon.scoreOf(word) - if (fuzzy) FUZZY_SCORE_PENALTY else 0 + val current = found[text] + if (current == null || adjusted > current) found[text] = adjusted + budget.words++ + } + + private fun unigramPenalty(word: Int): Double = + UNIGRAM_BASE - ln(1.0 + lexicon.scoreOf(word)) + + private fun bigramPenalty(previous: Int, word: Int): Double { + if (previous < 0) return 0.0 + val table = bigram ?: return BIGRAM_UNKNOWN_PENALTY + val score = table.score(previous, word) + if (score <= 0) return BIGRAM_UNKNOWN_PENALTY + return BIGRAM_MAX_PENALTY * (1.0 - score / 65535.0) + } + + private fun reading(final: State, states: Array?>): String? { + val words = ArrayList(8) + var current: State? = final + var guard = 0 + while (current != null && current.word >= 0 && guard++ < MAX_WORD_SYLLABLES * 4) { + words.add(lexicon.textOf(current.word)) + val list = states[current.from] ?: return null + current = list.getOrNull(current.previousIndex) + } + words.reverse() + return if (words.isEmpty()) null else words.joinToString("") + } + + private fun normalize(input: String): String { + val out = StringBuilder(input.length) + for (char in input.lowercase()) { + if (char in 'a'..'z') out.append(char) + } + return out.toString() + } + + private class Budget { + var lookups = 0 + var words = 0 + fun exhausted(): Boolean = words >= TOTAL_WORDS || lookups >= TOTAL_LOOKUPS + } + + private class Edge(val to: Int, val word: Int, val fuzzy: Boolean) + + private class State( + val score: Double, + val word: Int, + /** Character position this word starts at; its predecessor lives there. */ + val from: Int, + val previousIndex: Int, + ) + + companion object { + private const val CANDIDATE_LIMIT = 60 + private const val SENTENCE_LIMIT = 3 + private const val MAX_INPUT = 64 + private const val HEAD_PATHS = 4 + private const val TAIL_SYLLABLES = 32 + private const val PATH_LIMIT = 32 + private const val MAX_WORD_SYLLABLES = 6 + private const val EXACT_WORDS = 48 + private const val COMPLETION_WORDS = 64 + private const val FRAGMENT_WORDS = 64 + private const val WORDS_PER_SPAN = 24 + private const val TOTAL_WORDS = 1500 + private const val TOTAL_LOOKUPS = 600 + private const val STATE_K = 3 + /** Fuzzy candidates keep their frequency but pay this much score + * (below the 6571-vs-4655 gap of 师傅/私服, so a clearly more common + * fuzzy word still wins against a mediocre literal one). */ + private const val FUZZY_SCORE_PENALTY = 1500 + /** Cost constants: a word costs UNIGRAM_BASE - ln(1 + score); a bad + * pair adds up to BIGRAM_MAX_PENALTY, an unknown pair a flat cost. */ + private const val UNIGRAM_BASE = 12.0 + private const val BIGRAM_UNKNOWN_PENALTY = 1.5 + private const val BIGRAM_MAX_PENALTY = 4.0 + /** A sentence that needs a fuzzy reading pays this per fuzzy word. */ + private const val FUZZY_SENTENCE_COST = 2.0 + + @Volatile + private var cached: PinyinEngine? = null + + @Volatile + private var attempted = false + + /** Load off the main thread; a missing table stays silent. */ + fun preload(context: Context) { + obtain(context) + } + + fun obtain(context: Context): PinyinEngine? { + cached?.let { return it } + if (attempted) return cached + return synchronized(this) { + if (!attempted) { + attempted = true + cached = runCatching { + val lexFile = EngineAssets.lexFile(context) ?: return@runCatching null + val bigramFile = EngineAssets.bigramFile(context) + PinyinEngine( + LexiconIndex(EngineAssets.map(lexFile)), + bigramFile?.let { BigramIndex(EngineAssets.map(it)) }, + ) + }.getOrNull() + } + cached + } + } + } +} diff --git a/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt b/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt new file mode 100644 index 00000000..4e32d2ba --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt @@ -0,0 +1,139 @@ +package llc.slacker.openime.engine + +import llc.slacker.openime.core.FuzzyRule + +/** + * Splits raw pinyin letters into syllable paths. + * + * The engine enumerates *all* paths (the reverse-engineered lattice is a + * full-path DAG, not longest match): every position emits every matching + * syllable edge, up to the path and syllable caps. A trailing fragment that + * is not yet a full syllable is handled by the caller through + * [syllablesStartingWith]. + * + * With [fuzzyRules] the lookup also accepts the alternate spelling of each + * syllable pair (z/zh, n/l, an/ang...): the typed spelling may stand for a + * syllable it is not literally; such edges carry `fuzzy = true` so callers + * can rank exact readings first. + */ +internal class PinyinSegmenter( + syllables: List, + fuzzyRules: Set = emptySet(), +) { + /** One segmentation: syllable ids, their character boundaries and whether any edge was fuzzy. */ + internal class SegmentPath(val ids: IntArray, val starts: IntArray, val fuzzy: Boolean) + + internal class SyllableEdge(val id: Int, val fuzzy: Boolean) + + private val edgeIds: HashMap + private val edgeFuzzy: HashMap + private val scanEdges: List> + + init { + val nameToId = HashMap(syllables.size * 2) + syllables.forEachIndexed { index, name -> nameToId.putIfAbsent(name, index) } + + val ids = HashMap>(syllables.size * 2) + val fuzzy = HashMap>(syllables.size * 2) + fun add(spelling: String, id: Int, isFuzzy: Boolean) { + ids.getOrPut(spelling) { ArrayList(2) }.add(id) + fuzzy.getOrPut(spelling) { ArrayList(2) }.add(isFuzzy) + } + syllables.forEachIndexed { id, name -> add(name, id, isFuzzy = false) } + if (fuzzyRules.isNotEmpty()) { + syllables.forEachIndexed { id, name -> + for (rule in fuzzyRules) { + val variant = rule.variant(name) ?: continue + if (variant in nameToId) add(variant, id, isFuzzy = true) + } + } + } + edgeIds = HashMap(ids.size * 2) + edgeFuzzy = HashMap(fuzzy.size * 2) + for ((spelling, list) in ids) { + val flags = fuzzy.getValue(spelling) + // The literal reading comes first and wins duplicates; a spelling + // that is both a syllable and another syllable's variant keeps both. + val unique = LinkedHashMap(list.size) + list.forEachIndexed { index, id -> unique.putIfAbsent(id, flags[index]) } + val idArray = IntArray(unique.size) + val flagArray = BooleanArray(unique.size) + unique.entries.forEachIndexed { index, entry -> + idArray[index] = entry.key + flagArray[index] = entry.value + } + edgeIds[spelling] = idArray + edgeFuzzy[spelling] = flagArray + } + + val scan = ArrayList>(syllables.size * 2) + syllables.forEachIndexed { id, name -> scan += Triple(name, id, false) } + if (fuzzyRules.isNotEmpty()) { + syllables.forEachIndexed { id, name -> + for (rule in fuzzyRules) { + val variant = rule.variant(name) ?: continue + if (variant in nameToId) scan += Triple(variant, id, true) + } + } + } + scanEdges = scan + } + + fun segment( + letters: String, + maxPaths: Int = 32, + maxSyllables: Int = MAX_SYLLABLES, + ): List { + val length = letters.length + if (length == 0) return emptyList() + @Suppress("UNCHECKED_CAST") + val frontier = arrayOfNulls>(length + 1) + for (index in 0..length) frontier[index] = ArrayList(4) + frontier[0]!!.add(SegmentPath(IntArray(0), intArrayOf(0), fuzzy = false)) + + for (position in 0 until length) { + val at = frontier[position] ?: continue + if (at.isEmpty()) continue + val maxEdge = minOf(MAX_SYLLABLE_LENGTH, length - position) + for (path in at) { + if (path.ids.size >= maxSyllables) continue + for (edge in 1..maxEdge) { + val spelling = letters.substring(position, position + edge) + val targets = edgeIds[spelling] ?: continue + val flags = edgeFuzzy.getValue(spelling) + val next = frontier[position + edge] ?: continue + for (index in targets.indices) { + if (next.size >= maxPaths) break + val ids = path.ids.copyOf(path.ids.size + 1) + ids[ids.size - 1] = targets[index] + val starts = path.starts.copyOf(path.starts.size + 1) + starts[starts.size - 1] = position + edge + next.add(SegmentPath(ids, starts, path.fuzzy || flags[index])) + } + } + } + } + return frontier[length] ?: emptyList() + } + + /** Syllable edges whose spelling starts with the trailing fragment [fragment]. */ + fun syllablesStartingWith(fragment: String, max: Int = 32): List { + if (fragment.isEmpty()) return emptyList() + val out = ArrayList(max) + val seen = HashSet(max * 2) + for ((spelling, id, isFuzzy) in scanEdges) { + if (spelling.length >= fragment.length && spelling.startsWith(fragment)) { + if (isFuzzy && !seen.add(id)) continue + if (!isFuzzy) seen.add(id) + out.add(SyllableEdge(id, isFuzzy)) + if (out.size >= max) break + } + } + return out + } + + companion object { + const val MAX_SYLLABLE_LENGTH = 6 + const val MAX_SYLLABLES = 10 + } +} diff --git a/app/src/main/java/llc/slacker/openime/floating/FloatingDragController.kt b/app/src/main/java/llc/slacker/openime/floating/FloatingDragController.kt index 93e5ed47..da839a32 100644 --- a/app/src/main/java/llc/slacker/openime/floating/FloatingDragController.kt +++ b/app/src/main/java/llc/slacker/openime/floating/FloatingDragController.kt @@ -13,7 +13,7 @@ internal class FloatingDragController( private val toPx: (Int) -> Int, private val onDragBy: (Float, Float) -> Unit, private val onDock: () -> Unit, -) { +) : LongPressDragTarget { private var active = false private var moved = false private var pointerId = -1 @@ -24,7 +24,7 @@ internal class FloatingDragController( * Start a drag mid-gesture (long press on the top zone). The gesture is * already a drag, so releasing it must not count as a dock tap. */ - fun begin(event: MotionEvent) { + override fun begin(event: MotionEvent) { active = true moved = true pointerId = event.getPointerId(event.actionIndex) @@ -32,7 +32,7 @@ internal class FloatingDragController( lastY = event.rawY } - fun onTouch(event: MotionEvent): Boolean { + override fun onTouch(event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { active = true @@ -64,7 +64,7 @@ internal class FloatingDragController( return true } - fun reset() { + override fun reset() { active = false moved = false pointerId = -1 @@ -72,8 +72,7 @@ internal class FloatingDragController( lastY = 0f } - private fun finish(dockIfTap: Boolean) { - val shouldDock = dockIfTap && active && !moved + private fun finish(dockIfTap: Boolean) { val shouldDock = dockIfTap && active && !moved reset() if (shouldDock) onDock() } diff --git a/app/src/main/java/llc/slacker/openime/floating/FloatingPositionMemory.kt b/app/src/main/java/llc/slacker/openime/floating/FloatingPositionMemory.kt new file mode 100644 index 00000000..530b11ad --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/floating/FloatingPositionMemory.kt @@ -0,0 +1,39 @@ +package llc.slacker.openime.floating + +import android.content.res.Configuration + +/** + * Remembers the floating card's top-left position per orientation. + * + * Rotating the device swaps the coordinate space under the card; keeping the + * old numbers puts it in a random-looking place. Each orientation therefore + * keeps its own last position, so rotating back restores where the user left + * the keyboard there. + */ +internal class FloatingPositionMemory { + private var portrait: IntArray? = null + private var landscape: IntArray? = null + + fun save(orientation: Int, x: Int, y: Int) { + val slot = slot(orientation) + slot[0] = x + slot[1] = y + } + + /** The stored position for [orientation], or null when it was never placed. */ + fun restore(orientation: Int): IntArray? { + val slot = slot(orientation) + return if (slot[0] < 0 && slot[1] < 0) null else slot.copyOf() + } + + private fun slot(orientation: Int): IntArray = when (orientation) { + Configuration.ORIENTATION_LANDSCAPE -> + landscape ?: IntArray(2) { UNKNOWN }.also { landscape = it } + else -> + portrait ?: IntArray(2) { UNKNOWN }.also { portrait = it } + } + + private companion object { + const val UNKNOWN = -1 + } +} diff --git a/app/src/main/java/llc/slacker/openime/floating/FloatingWindowController.kt b/app/src/main/java/llc/slacker/openime/floating/FloatingWindowController.kt index fb8dfa0b..67295ce6 100644 --- a/app/src/main/java/llc/slacker/openime/floating/FloatingWindowController.kt +++ b/app/src/main/java/llc/slacker/openime/floating/FloatingWindowController.kt @@ -1,5 +1,7 @@ package llc.slacker.openime.floating +import llc.slacker.openime.theme.ImeGeometryTokens +import android.content.res.Configuration import android.os.Handler import android.view.Gravity import android.view.Window @@ -30,6 +32,8 @@ internal class FloatingWindowController( private var baseHeight: Int? = null private var baseSoftInputMode: Int? = null private var baseAlpha: Float? = null + private val positionMemory = FloatingPositionMemory() + private var lastOrientation: Int? = null fun enable(resetPosition: Boolean = x == 0 && y == 0) { enabled = true @@ -41,7 +45,19 @@ internal class FloatingWindowController( } fun onConfigurationChanged() { - if (enabled) scheduleLayout(resetPosition = false) + if (!enabled) return + // Rotating swaps the coordinate space: park the current position in + // this orientation's slot and continue from the other one's. + val current = resources.configuration.orientation + val previous = lastOrientation + if (previous != null && previous != current) { + positionMemory.save(previous, x, y) + positionMemory.restore(current)?.let { restored -> + x = restored[0] + y = restored[1] + } + } + scheduleLayout(resetPosition = false) } fun drag(deltaX: Float, deltaY: Float) { @@ -100,12 +116,13 @@ internal class FloatingWindowController( attrs.width = desiredWidth attrs.height = currentHeight attrs.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING - attrs.alpha = floatingOpacityPercent().coerceIn(82, 100) / 100f + attrs.alpha = floatingOpacityPercent().coerceIn(ImeGeometryTokens.FLOATING_OPACITY_MIN_PERCENT, ImeGeometryTokens.FLOATING_OPACITY_MAX_PERCENT) / 100f attrs.x = x.coerceIn(bounds[0], bounds[1]) attrs.y = y.coerceIn(bounds[2], bounds[3]) x = attrs.x y = attrs.y imeWindow.attributes = attrs + rememberPosition() debugLog("floating-window x=${attrs.x} y=${attrs.y} w=${attrs.width} h=${attrs.height}") } } @@ -132,13 +149,20 @@ internal class FloatingWindowController( attrs.width = width attrs.height = height attrs.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING - attrs.alpha = floatingOpacityPercent().coerceIn(82, 100) / 100f + attrs.alpha = floatingOpacityPercent().coerceIn(ImeGeometryTokens.FLOATING_OPACITY_MIN_PERCENT, ImeGeometryTokens.FLOATING_OPACITY_MAX_PERCENT) / 100f attrs.x = x attrs.y = y imeWindow.attributes = attrs + rememberPosition() debugLog("floating-window-drag x=$x y=$y w=$width h=$height") } + private fun rememberPosition() { + val orientation = resources.configuration.orientation + lastOrientation = orientation + positionMemory.save(orientation, x, y) + } + /** Return [minX, maxX, minY, maxY] for a visible floating IME card. */ private fun bounds( screenWidth: Int, @@ -162,11 +186,12 @@ internal class FloatingWindowController( private fun floatingWidth(screenWidth: Int): Int { val metrics = resources.displayMetrics val portraitWidth = minOf(metrics.widthPixels, metrics.heightPixels) - val percent = floatingWidthPercent().coerceIn(72, 100) + val percent = floatingWidthPercent().coerceIn(ImeGeometryTokens.FLOATING_WIDTH_MIN_PERCENT, ImeGeometryTokens.FLOATING_WIDTH_MAX_PERCENT) val preferred = (portraitWidth * percent / 100f).toInt() val available = (screenWidth - dp(16)).coerceAtLeast(dp(1)) - val minimum = minOf(dp(280), available) - return minOf(preferred, dp(420), available).coerceAtLeast(minimum) + // 以前这里还有 280dp 下限和 420dp 上限,会让滑块的一大段不起作用。现在只受屏幕可用宽度限制。 + val minimum = minOf(dp(200), available) + return minOf(preferred, available).coerceAtLeast(minimum) } private fun displaySize(): Pair { diff --git a/app/src/main/java/llc/slacker/openime/floating/LongPressDragTarget.kt b/app/src/main/java/llc/slacker/openime/floating/LongPressDragTarget.kt new file mode 100644 index 00000000..b0cea7b1 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/floating/LongPressDragTarget.kt @@ -0,0 +1,16 @@ +package llc.slacker.openime.floating + +import android.view.MotionEvent + +/** + * 顶栏长按后接收拖动手势的目标:浮动键盘的移动、停靠键盘的高度调节都实现它, + * 顶栏只负责"长按触发 + 转发触摸"。 + */ +internal interface LongPressDragTarget { + /** 长按成立那一刻起手(手势中途开始拖动)。 */ + fun begin(event: MotionEvent) + + fun onTouch(event: MotionEvent): Boolean + + fun reset() +} diff --git a/app/src/main/java/llc/slacker/openime/handwriting/HandwritingStrokeClassifier.kt b/app/src/main/java/llc/slacker/openime/handwriting/HandwritingStrokeClassifier.kt new file mode 100644 index 00000000..f8650436 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/handwriting/HandwritingStrokeClassifier.kt @@ -0,0 +1,110 @@ +package llc.slacker.openime.handwriting + +import android.content.Context +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.hypot + +/** + * Classifies one drawn stroke as h 横, s 竖, p 撇, n 捺 or z 折. + * + * The stroke is simplified (Ramer-Douglas-Peucker), split into straight + * segments and judged by its net direction; a sharp turn anywhere in the path + * makes it a 折. This mirrors the five-stroke taxonomy of the 笔画 keyboard, + * so the same table can serve both. + */ +internal object HandwritingStrokeClassifier { + private const val MIN_DISPLACEMENT = 6f + private const val TURN_DEGREES = 58.0 + + /** Returns the stroke class, or null when the gesture is too small to read. */ + fun classify(points: List): Char? { + val shape = simplify(points) + if (shape.size < 2) return null + val dx = shape.last().x - shape.first().x + val dy = shape.last().y - shape.first().y + if (hypot(dx, dy) < MIN_DISPLACEMENT) return null + + val segments = ArrayList(shape.size - 1) + for (index in 0 until shape.size - 1) { + val sx = shape[index + 1].x - shape[index].x + val sy = shape[index + 1].y - shape[index].y + if (hypot(sx, sy) >= MIN_DISPLACEMENT / 2f) segments += floatArrayOf(sx, sy) + } + if (segments.isEmpty()) return null + + var maxTurn = 0.0 + for (index in 0 until segments.size - 1) { + var turn = abs(angleDegrees(segments[index]) - angleDegrees(segments[index + 1])) + if (turn > 180.0) turn = 360.0 - turn + if (turn > maxTurn) maxTurn = turn + } + if (maxTurn > TURN_DEGREES) return 'z' + return direction(dx, dy) + } + + private fun angleDegrees(segment: FloatArray): Double = + Math.toDegrees(atan2(segment[1].toDouble(), segment[0].toDouble())) + + /** With dy positive downward: 横 across, 竖 down, 撇 down-left, 捺 down-right. */ + private fun direction(dx: Float, dy: Float): Char = when { + abs(dx) >= abs(dy) -> 'h' + abs(dx) <= 0.35f * abs(dy) -> 's' + dx > 0 -> 'n' + else -> 'p' + } + + private fun simplify(points: List): List { + if (points.size <= 2) return points + var minX = Float.MAX_VALUE + var maxX = -Float.MAX_VALUE + var minY = Float.MAX_VALUE + var maxY = -Float.MAX_VALUE + for (point in points) { + if (point.x < minX) minX = point.x + if (point.x > maxX) maxX = point.x + if (point.y < minY) minY = point.y + if (point.y > maxY) maxY = point.y + } + val epsilon = maxOf(4f, 0.04f * hypot(maxX - minX, maxY - minY)) + val keep = BooleanArray(points.size) + keep[0] = true + keep[points.size - 1] = true + keepSimplified(points, 0, points.size - 1, epsilon, keep) + return points.filterIndexed { index, _ -> keep[index] } + } + + private fun keepSimplified( + points: List, + start: Int, + end: Int, + epsilon: Float, + keep: BooleanArray, + ) { + if (end - start < 2) return + var farthest = -1 + var farthestDistance = 0f + for (index in start + 1 until end) { + val distance = distanceToSegment(points[index], points[start], points[end]) + if (distance > farthestDistance) { + farthestDistance = distance + farthest = index + } + } + if (farthest > 0 && farthestDistance > epsilon) { + keep[farthest] = true + keepSimplified(points, start, farthest, epsilon, keep) + keepSimplified(points, farthest, end, epsilon, keep) + } + } + + private fun distanceToSegment(point: StrokePoint, a: StrokePoint, b: StrokePoint): Float { + val abx = b.x - a.x + val aby = b.y - a.y + val lengthSquared = abx * abx + aby * aby + if (lengthSquared <= 0.0001f) return hypot(point.x - a.x, point.y - a.y) + var t = ((point.x - a.x) * abx + (point.y - a.y) * aby) / lengthSquared + t = t.coerceIn(0f, 1f) + return hypot(point.x - (a.x + t * abx), point.y - (a.y + t * aby)) + } +} diff --git a/app/src/main/java/llc/slacker/openime/handwriting/StrokeMatchHandwritingProvider.kt b/app/src/main/java/llc/slacker/openime/handwriting/StrokeMatchHandwritingProvider.kt new file mode 100644 index 00000000..46263621 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/handwriting/StrokeMatchHandwritingProvider.kt @@ -0,0 +1,113 @@ +package llc.slacker.openime.handwriting + +import android.content.Context + +/** + * Stroke-sequence handwriting lookup (笔画识别). + * + * The pad hands over raw strokes. Every stroke is classified as one of the + * five basic strokes (h 横, s 竖, p 撇, n 捺, z 折), the resulting code is + * looked up in a stroke table (code -> characters) and the characters become + * candidates: exact matches first, then characters whose longer code + * continues the drawn sequence. This is deliberately a table matcher, not a + * learned recognizer; the whole feature stays offline and deterministic. + * + * The table ships only in local builds (handwriting-strokes.tsv); without it + * [forContext] returns [UnavailableHandwritingProvider] and the pad keeps + * showing its "not configured" message instead of fake candidates. + */ +internal class StrokeMatchHandwritingProvider internal constructor( + private val exact: Map, + private val sortedCodes: List, +) : HandwritingProvider { + + override val isAvailable: Boolean get() = exact.isNotEmpty() + + override fun recognize(strokes: List): HandwritingResult { + if (strokes.isEmpty()) return HandwritingResult.Success(emptyList()) + val code = buildString { + for (stroke in strokes) { + HandwritingStrokeClassifier.classify(stroke.points)?.let { append(it) } + } + } + if (code.isEmpty()) return HandwritingResult.Success(emptyList()) + val candidates = LinkedHashSet() + collect(code, candidates) + if (candidates.isEmpty() && code.length > 1) { + // One misread stroke should not kill the whole match: retry + // without the tail before giving up. + collect(code.dropLast(1), candidates) + } + return HandwritingResult.Success(candidates.take(MAX_CANDIDATES).toList()) + } + + private fun collect(code: String, into: MutableSet) { + exact[code]?.let { chars -> for (char in chars) into.add(char.toString()) } + var index = lowerBound(code) + var codesVisited = 0 + while (index < sortedCodes.size && into.size < MAX_CANDIDATES && codesVisited < MAX_PREFIX_CODES) { + val candidate = sortedCodes[index] + if (!candidate.startsWith(code)) break + if (candidate.length > code.length) { + val chars = exact[candidate] + if (chars != null) { + for (char in chars) { + into.add(char.toString()) + if (into.size >= MAX_CANDIDATES) break + } + } + codesVisited++ + } + index++ + } + } + + private fun lowerBound(code: String): Int { + var low = 0 + var high = sortedCodes.size + while (low < high) { + val middle = (low + high) ushr 1 + if (sortedCodes[middle] < code) low = middle + 1 else high = middle + } + return low + } + + companion object { + const val ASSET = "handwriting-strokes.tsv" + private const val MAX_CANDIDATES = 36 + private const val MAX_PREFIX_CODES = 24 + + @Volatile + private var cached: HandwritingProvider? = null + + /** Cheap presence check for entry visibility; does not parse the table. */ + fun assetPresent(context: Context): Boolean = + runCatching { context.assets.open(ASSET).close() }.isSuccess + + /** One shared instance per process; parsing happens on first use. */ + fun forContext(context: Context): HandwritingProvider { + cached?.let { return it } + return synchronized(this) { + cached ?: load(context).also { cached = it } + } + } + + private fun load(context: Context): HandwritingProvider = runCatching { + val exact = HashMap() + context.assets.open(ASSET).bufferedReader(Charsets.UTF_8).useLines { lines -> + lines.forEach { line -> + if (line.isEmpty() || line[0] == '#') return@forEach + val parts = line.split('\t', limit = 2) + if (parts.size == 2 && parts[0].isNotEmpty() && parts[1].isNotEmpty()) { + exact[parts[0]] = parts[1] + } + } + } + if (exact.isEmpty()) { + UnavailableHandwritingProvider + } else { + StrokeMatchHandwritingProvider(exact, exact.keys.sorted()) + } + }.getOrElse { UnavailableHandwritingProvider } + } +} diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt index e5497f6a..9f523d45 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -36,6 +36,8 @@ import llc.slacker.openime.core.ImeState import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.core.Panel import llc.slacker.openime.core.LandscapeLayout +import llc.slacker.openime.core.DoublePinyinScheme +import llc.slacker.openime.core.OneHandMode import llc.slacker.openime.core.ShiftState import llc.slacker.openime.data.HapticStyle import llc.slacker.openime.data.ImeSettingsRepository @@ -139,6 +141,7 @@ open class ImeKeyboardView( fun onLandscapeLayoutChanged(layout: LandscapeLayout) {} fun onOpenAbout() {} fun onOpenDataManagement() {} + fun onOpenVoiceModel() {} } /** Visual class marker for white keys (nine/digits grid). */ @@ -150,7 +153,7 @@ open class ImeKeyboardView( companion object { /** Scale differences below this are rounding noise, not a new geometry. */ private const val SCALE_CHANGE_EPSILON = 0.01f - private const val FLOATING_NAV_STRIP_DP = 28 + private const val FLOATING_NAV_STRIP_DP = 6 /** How often a deferred row rebuild re-checks whether the press ended. */ private const val ROW_REBUILD_POLL_MS = 40L @@ -451,6 +454,12 @@ open class ImeKeyboardView( ) } private var contentInsetPx = dp(0) + private var topZoneInsetPx = dp(0) + private var moreCandidatesInsetPx = dp(0) + private var oneHandMode = ImeSettingsRepository.loadOneHandMode(context) + + /** 停靠键盘的“键盘调节”拖拽(顶栏长按后上下拖)。 */ + private val heightDragController = KeyboardHeightDragController(::adjustKeyboardHeightByDrag) private var navigationBottomInsetPx = 0 private val themeApplier: ImeThemeApplier by lazy { ImeThemeApplier( @@ -745,6 +754,8 @@ open class ImeKeyboardView( currentHapticStrengthPercent = { keyHaptics.strengthPercent }, currentHapticStyle = { keyHaptics.style }, currentLandscapeLayout = { ImeSettingsRepository.loadLandscapeLayout(context) }, + currentOneHandMode = { oneHandMode }, + currentDoublePinyinScheme = { ImeSettingsRepository.loadDoublePinyinScheme(context) }, currentKeySoundStyle = { keySounds.style }, currentPopup = { popupEnabled }, currentSwipeUpDigits = { ImeSettingsRepository.loadSwipeUpDigits(context) }, @@ -770,12 +781,15 @@ open class ImeKeyboardView( onHapticStrengthChanged = ::setHapticStrengthPercent, onHapticStyleChanged = ::setHapticStyle, onLandscapeLayoutChanged = ::setLandscapeLayout, + onOneHandModeChanged = ::setOneHandMode, + onDoublePinyinSchemeChanged = ::setDoublePinyinScheme, onKeySoundStyleChanged = ::setKeySoundStyle, onFloatingStyleChanged = ::setFloatingStyle, onShowFuzzySettings = { showPanel(Panel.FUZZY_SETTINGS) }, onFuzzyRulesChanged = listener::onFuzzyRulesChanged, onOpenAbout = listener::onOpenAbout, onOpenDataManagement = listener::onOpenDataManagement, + onOpenVoiceModel = listener::onOpenVoiceModel, onFeedback = ::feedback, applyTheme = ::applyTheme, onHierarchyRebuilt = ::onViewHierarchyRebuilt, @@ -1188,6 +1202,8 @@ open class ImeKeyboardView( // width before WindowManager applies the floating window bounds. // Keep the normal keyboard's content inset local to its window. contentInsetPx = dp(0) + topZoneInsetPx = dp(0) + moreCandidatesInsetPx = dp(0) keyboardBody.setPadding(contentInsetPx, dp(ImeGeometryTokens.KEYBOARD_TOP_PAD_DP), contentInsetPx, dp(layoutMetrics.bottomPadDp)) keyboardBody.findViewWithTag("key-row-secondary")?.let { row -> // The portrait layout narrows this row to 90% of the full @@ -1211,23 +1227,36 @@ open class ImeKeyboardView( } val minimumInset = dp(0) val landscape = appliedOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE - val maxWidth = when { - standalonePanel -> dp(maxContentWidthDp) - // Landscape rows are short, so the width is what makes keys easy to hit. - // Like Gboard and the Chinese keyboards, fill the whole width instead of - // shrinking the keys to the height-derived portrait width. - landscape -> measuredWidthPx - else -> (minOf(maxContentWidthDp.toFloat(), 390f * referenceScale) * resources.displayMetrics.density).toInt() - } - contentInsetPx = maxOf(minimumInset, (measuredWidthPx - maxWidth) / 2) + if (standalonePanel) { + contentInsetPx = maxOf(minimumInset, (measuredWidthPx - dp(maxContentWidthDp)) / 2) + topZoneInsetPx = contentInsetPx + moreCandidatesInsetPx = contentInsetPx + } else if (landscape) { + // 横屏对齐参考键盘的横屏皮肤:键区两侧留白(九键更多),顶栏与展开候选 + // 各有自己的比例;窗口仍整宽,背景铺满,只有内容往中间收。 + contentInsetPx = (measuredWidthPx * ReferenceLandscapeInsets.keyboardFraction(mode == KeyboardMode.PINYIN_9)).toInt() + topZoneInsetPx = (measuredWidthPx * ReferenceLandscapeInsets.TOOLBAR_FRACTION).toInt() + moreCandidatesInsetPx = (measuredWidthPx * ReferenceLandscapeInsets.EXPANDED_CANDIDATES_FRACTION).toInt() + } else { + val maxWidth = (minOf(maxContentWidthDp.toFloat(), 390f * referenceScale) * resources.displayMetrics.density).toInt() + contentInsetPx = maxOf(minimumInset, (measuredWidthPx - maxWidth) / 2) + topZoneInsetPx = contentInsetPx + moreCandidatesInsetPx = contentInsetPx + } + // 单手模式把键面收窄贴向一侧;左右分离布局本身就是两侧的安排,不再叠加。 + val oneHand = if (splitKeyboard()) null else OneHandPolicy.insets(measuredWidthPx, oneHandMode, contentInsetPx) + val bodyLeft = oneHand?.first ?: contentInsetPx + val bodyRight = oneHand?.second ?: contentInsetPx + val panelLeft = oneHand?.first ?: moreCandidatesInsetPx + val panelRight = oneHand?.second ?: moreCandidatesInsetPx keyboardBody.setPadding( - contentInsetPx, + bodyLeft, dp(ImeGeometryTokens.KEYBOARD_TOP_PAD_DP), - contentInsetPx, + bodyRight, dp(layoutMetrics.bottomPadDp), ) keyboardBody.findViewWithTag("key-row-secondary")?.let { row -> - val rowWidth = ((measuredWidthPx - contentInsetPx * 2) * 0.9f).toInt() + val rowWidth = ((measuredWidthPx - bodyLeft - bodyRight) * 0.9f).toInt() val params = row.layoutParams as? LinearLayout.LayoutParams if (params != null) { params.gravity = Gravity.CENTER_HORIZONTAL @@ -1235,9 +1264,9 @@ open class ImeKeyboardView( row.layoutParams = params } } - expandedPanel.setPadding(contentInsetPx, 0, contentInsetPx, 0) - candidateOverlay.setPadding(contentInsetPx, 0, contentInsetPx, 0) - topZone.setContentInset(contentInsetPx) + expandedPanel.setPadding(panelLeft, 0, panelRight, 0) + candidateOverlay.setPadding(panelLeft, 0, panelRight, 0) + topZone.setContentInset(topZoneInsetPx) if (!applyingGeometryInMeasure) { requestLayout() scheduleRelayout() @@ -1320,6 +1349,8 @@ open class ImeKeyboardView( onSplitToggle = ::toggleSplit, onAssociationDismiss = ::clearAssociationCandidates, ) + // 停靠时顶栏长按上下拖 = 键盘调节;浮动时同一入口拖窗口。 + topZone.setLongPressDrag(heightDragController, toolbarOnly = true) candidateBarController = CandidateBarController( context = context, row = topZone.candidateRow, @@ -1376,7 +1407,9 @@ open class ImeKeyboardView( fun cycleMode() { val next = when (mode) { - KeyboardMode.PINYIN_26, KeyboardMode.PINYIN_9, KeyboardMode.STROKE -> KeyboardMode.ENGLISH_26 + KeyboardMode.PINYIN_26, KeyboardMode.PINYIN_9, KeyboardMode.STROKE, + KeyboardMode.WUBI, KeyboardMode.DOUBLE_PINYIN, + -> KeyboardMode.ENGLISH_26 KeyboardMode.ENGLISH_26 -> preferredChineseMode KeyboardMode.DIGITS -> lastTextMode } @@ -1487,12 +1520,17 @@ open class ImeKeyboardView( topZone.setCompactToolbar(enabled) layoutMetrics = buildLayoutMetrics() renderedMode = null - topZone.setLongPressDrag(if (enabled) floatingKeyboardController.dragController else null) + topZone.setLongPressDrag( + if (enabled) floatingKeyboardController.dragController else heightDragController, + toolbarOnly = !enabled, + ) applyDynamicHeights() // Floating landscape uses portrait rows, so the key surface must be rebuilt. if (!standalonePanel && panel == Panel.NONE) renderModeBody() if (enabled) { contentInsetPx = dp(0) + topZoneInsetPx = dp(0) + moreCandidatesInsetPx = dp(0) keyboardBody.setPadding(contentInsetPx, dp(ImeGeometryTokens.KEYBOARD_TOP_PAD_DP), contentInsetPx, dp(layoutMetrics.bottomPadDp)) expandedPanel.setPadding(contentInsetPx, 0, contentInsetPx, 0) candidateOverlay.setPadding(contentInsetPx, 0, contentInsetPx, 0) @@ -1724,8 +1762,8 @@ open class ImeKeyboardView( } private fun setFloatingStyle(widthPercent: Int, opacityPercent: Int) { - val width = widthPercent.coerceIn(72, 96) - val opacity = opacityPercent.coerceIn(82, 100) + val width = widthPercent.coerceIn(ImeGeometryTokens.FLOATING_WIDTH_MIN_PERCENT, ImeGeometryTokens.FLOATING_WIDTH_MAX_PERCENT) + val opacity = opacityPercent.coerceIn(ImeGeometryTokens.FLOATING_OPACITY_MIN_PERCENT, ImeGeometryTokens.FLOATING_OPACITY_MAX_PERCENT) if (floatingWidthPercent == width && floatingOpacityPercent == opacity) return floatingWidthPercent = width floatingOpacityPercent = opacity @@ -1800,8 +1838,39 @@ open class ImeKeyboardView( postDelayed({ keySounds.play() }, SOUND_PREVIEW_DELAY_MS) } + /** 单手模式:键面收窄贴向一侧;立即按当前宽度重排。 */ + internal fun setOneHandMode(mode: OneHandMode) { + if (oneHandMode == mode) return + oneHandMode = mode + ImeSettingsRepository.saveOneHandMode(context, mode) + if (!floatingKeyboardController.enabled && width > 0) updateResponsiveGeometry(width) + } + + /** 双拼键位方案;Rime 每次同步都读取设置,切换后重查当前输入即可生效。 */ + internal fun setDoublePinyinScheme(scheme: DoublePinyinScheme) { + ImeSettingsRepository.saveDoublePinyinScheme(context, scheme) + if (mode == KeyboardMode.DOUBLE_PINYIN) { + val text = composition.text?.toString().orEmpty() + if (text.isNotEmpty()) { + publishComposition(text, candidatesForComposition(text)) + } + } + } + + /** 键盘调节:按竖直拖动量改变高度百分比(持久化在 [setKeyboardHeightPercent] 内)。 */ + private fun adjustKeyboardHeightByDrag(deltaY: Float) { + if (floatingKeyboardController.enabled) return + setKeyboardHeightPercent( + KeyboardHeightDragPolicy.applyTo( + keyboardHeightPercent, + deltaY, + resources.displayMetrics.heightPixels, + ), + ) + } + private fun setKeyboardHeightPercent(percent: Int) { - val bounded = percent.coerceIn(80, 120) + val bounded = percent.coerceIn(ImeGeometryTokens.KEYBOARD_HEIGHT_MIN_PERCENT, ImeGeometryTokens.KEYBOARD_HEIGHT_MAX_PERCENT) if (keyboardHeightPercent == bounded) return keyboardHeightPercent = bounded ImeSettingsRepository.saveKeyboardHeightPercent( @@ -2091,6 +2160,11 @@ open class ImeKeyboardView( KeyboardMode.ENGLISH_26 -> renderEnglish26() KeyboardMode.PINYIN_9 -> renderPinyin9() KeyboardMode.STROKE -> renderStroke() + // Five-stroke input shares the plain 26-key surface; Rime supplies + // the candidates from the wubi86 table. + KeyboardMode.WUBI -> renderPinyin26() + // Double pinyin shares the 26-key surface too; the schema expands chords. + KeyboardMode.DOUBLE_PINYIN -> renderPinyin26() KeyboardMode.DIGITS -> renderDigits() } updateTopZone(composition.text?.isNotEmpty() == true) @@ -2111,10 +2185,14 @@ open class ImeKeyboardView( private fun syncModeAccessibility() { val modeKey = findViewWithTag("key:mode") ?: return val target = when (mode) { - KeyboardMode.PINYIN_26, KeyboardMode.PINYIN_9, KeyboardMode.STROKE -> "英文 26 键" + KeyboardMode.PINYIN_26, KeyboardMode.PINYIN_9, KeyboardMode.STROKE, + KeyboardMode.WUBI, KeyboardMode.DOUBLE_PINYIN, + -> "英文 26 键" KeyboardMode.ENGLISH_26 -> when (preferredChineseMode) { KeyboardMode.PINYIN_9 -> "中文九键" KeyboardMode.STROKE -> "笔画" + KeyboardMode.WUBI -> "五笔" + KeyboardMode.DOUBLE_PINYIN -> "双拼" else -> "中文 26 键" } KeyboardMode.DIGITS -> "文字键盘" @@ -2128,6 +2206,8 @@ open class ImeKeyboardView( KeyboardMode.PINYIN_26 -> "当前中文 26 键" KeyboardMode.PINYIN_9 -> "当前中文九键" KeyboardMode.STROKE -> "当前笔画" + KeyboardMode.WUBI -> "当前五笔" + KeyboardMode.DOUBLE_PINYIN -> "当前双拼" KeyboardMode.ENGLISH_26 -> "当前英文 26 键" KeyboardMode.DIGITS -> "当前数字键盘" } @@ -2139,6 +2219,7 @@ open class ImeKeyboardView( english = false, shiftState = shiftState, enterLabel = enterKeyLabel(), + landscape = layoutMetrics.landscape, ) } @@ -2147,6 +2228,7 @@ open class ImeKeyboardView( english = true, shiftState = shiftState, enterLabel = enterKeyLabel(), + landscape = layoutMetrics.landscape, ) } @@ -2570,7 +2652,7 @@ open class ImeKeyboardView( private fun onKeyTapped(base: String) { if (insertIntoInlineEditor(base)) return clearAssociationCandidates() - if (mode == KeyboardMode.PINYIN_26) { + if (mode == KeyboardMode.PINYIN_26 || mode == KeyboardMode.WUBI || mode == KeyboardMode.DOUBLE_PINYIN) { val (py, selection) = replaceCompositionSelection(base) publishComposition(py, candidatesForComposition(py), selection) } else if (mode == KeyboardMode.ENGLISH_26) { @@ -3354,9 +3436,10 @@ open class ImeKeyboardView( // --- physical keyboard: the same entry points a tap on the soft key reaches --- - /** Pinyin typing from a physical keyboard needs the plain 26-key surface: no panel, no voice. */ + /** Pinyin, wubi or double-pinyin typing from a physical keyboard needs the plain 26-key surface: no panel, no voice. */ internal fun hardwareAccepts(): Boolean = - mode == KeyboardMode.PINYIN_26 && panel == Panel.NONE && !standalonePanel && + (mode == KeyboardMode.PINYIN_26 || mode == KeyboardMode.WUBI || mode == KeyboardMode.DOUBLE_PINYIN) && + panel == Panel.NONE && !standalonePanel && !voicePanelController.active && !voiceGestureSession internal fun hardwareIsComposing(): Boolean = composition.text.isNotEmpty() diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt index 5c6cc3ca..3809b4a2 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt @@ -19,7 +19,7 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import llc.slacker.openime.R -import llc.slacker.openime.floating.FloatingDragController +import llc.slacker.openime.floating.LongPressDragTarget import llc.slacker.openime.theme.ImeDrawableFactory import llc.slacker.openime.theme.ImeGeometryTokens import llc.slacker.openime.theme.ImeSurfacePolicy @@ -450,7 +450,9 @@ internal class ImeTopZone( refreshToolbar() } - private var longPressDrag: FloatingDragController? = null + private var longPressDrag: LongPressDragTarget? = null + /** 停靠键盘的高度调节只允许在顶栏范围起手,避免抢候选/输入区的长按。 */ + private var longPressToolbarOnly = false private var longPressArmed = false private var longPressDragging = false private var pressRawX = 0f @@ -464,8 +466,9 @@ internal class ImeTopZone( * Floating mode: long-press anywhere on the top zone, then drag, to move the * keyboard. Taps and scrolls on toolbar icons and candidates are untouched. */ - fun setLongPressDrag(controller: FloatingDragController?) { + fun setLongPressDrag(controller: LongPressDragTarget?, toolbarOnly: Boolean = false) { longPressDrag = controller + longPressToolbarOnly = toolbarOnly resetLongPress() } @@ -475,11 +478,21 @@ internal class ImeTopZone( longPressDragging = false } + /** 手指是否落在顶栏(工具条)范围内;用窗口坐标比较,兼容嵌套层级。 */ + private fun isInsideToolbar(rawX: Float, rawY: Float): Boolean { + if (toolbarRow.width == 0 || toolbarRow.height == 0) return false + val location = IntArray(2) + toolbarRow.getLocationInWindow(location) + return rawX >= location[0] && rawX <= location[0] + toolbarRow.width && + rawY >= location[1] && rawY <= location[1] + toolbarRow.height + } + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { val drag = longPressDrag ?: return false when (ev.actionMasked) { MotionEvent.ACTION_DOWN -> { resetLongPress() + if (longPressToolbarOnly && !isInsideToolbar(ev.rawX, ev.rawY)) return false pressRawX = ev.rawX pressRawY = ev.rawY postDelayed(longPressRunnable, ViewConfiguration.getLongPressTimeout().toLong()) diff --git a/app/src/main/java/llc/slacker/openime/keyboard/KeyHaptics.kt b/app/src/main/java/llc/slacker/openime/keyboard/KeyHaptics.kt index 14829711..ea3c3518 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/KeyHaptics.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeyHaptics.kt @@ -29,7 +29,7 @@ import llc.slacker.openime.data.HapticStyle * Long pulses ring on linear motors such as the X-axis motor in Xiaomi phones, * which feels muddy; the one-shot fallbacks are kept to 8–12 ms for that reason. * - * The 震动强度 slider sets [strengthPercent] (10–100, like Sogou's and Huawei + * The 震动强度 slider sets [strengthPercent] (1–100, like Sogou's and Huawei * 小艺's strength settings); 100 is the full click. * * The vibration is tagged as touch feedback, so the system "touch feedback" @@ -109,7 +109,7 @@ internal class KeyHaptics(context: Context) { } companion object { - const val MIN_STRENGTH = 10 + const val MIN_STRENGTH = 1 const val MAX_STRENGTH = 100 private const val CRISP_PULSE_MS = 8L private const val FIRM_PULSE_MS = 12L diff --git a/app/src/main/java/llc/slacker/openime/keyboard/KeyboardGeometry.kt b/app/src/main/java/llc/slacker/openime/keyboard/KeyboardGeometry.kt index 61e713d2..e69f8c2b 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/KeyboardGeometry.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeyboardGeometry.kt @@ -66,8 +66,8 @@ internal data class KeyboardLayoutMetrics( val fontGrow = ((fontScale - 1f).coerceAtLeast(0f) * 12f) .toInt() .coerceAtMost(12) - val scaled = ((base + fontGrow) * heightPercent.coerceIn(80, 120) / 100f).toInt() - scaled.coerceAtLeast(if (landscape) 38 else 44) + val scaled = ((base + fontGrow) * heightPercent.coerceIn(ImeGeometryTokens.KEYBOARD_HEIGHT_MIN_PERCENT, ImeGeometryTokens.KEYBOARD_HEIGHT_MAX_PERCENT) / 100f).toInt() + scaled.coerceAtLeast(if (landscape) 30 else 34) } val nineGridHeightDp: Int = @@ -93,7 +93,7 @@ internal data class KeyboardLayoutMetrics( keyRowHeightDp * 4 + ImeGeometryTokens.KEYBOARD_TOP_PAD_DP + bottomPadDp val baseMinimum = 0 val scaledMinimum = - (baseMinimum * heightPercent.coerceIn(80, 120) / 100f).toInt() + (baseMinimum * heightPercent.coerceIn(ImeGeometryTokens.KEYBOARD_HEIGHT_MIN_PERCENT, ImeGeometryTokens.KEYBOARD_HEIGHT_MAX_PERCENT) / 100f).toInt() maxOf(scaledMinimum, derived) } diff --git a/app/src/main/java/llc/slacker/openime/keyboard/KeyboardHeightDragController.kt b/app/src/main/java/llc/slacker/openime/keyboard/KeyboardHeightDragController.kt new file mode 100644 index 00000000..281e2559 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeyboardHeightDragController.kt @@ -0,0 +1,53 @@ +package llc.slacker.openime.keyboard + +import android.view.MotionEvent +import llc.slacker.openime.floating.LongPressDragTarget + +/** + * 停靠键盘的“键盘调节”:顶栏按住后上下拖动,实时改变键盘高度。 + * 与浮动键盘的移动共用一个长按入口([LongPressDragTarget]), + * 但只在顶栏范围内起手([ImeTopZone.setLongPressDrag] 的 toolbarOnly), + * 不抢候选与输入区的长按。 + */ +internal class KeyboardHeightDragController( + private val onDragBy: (Float) -> Unit, +) : LongPressDragTarget { + private var active = false + private var pointerId = -1 + private var lastRawY = 0f + + override fun begin(event: MotionEvent) { + active = true + pointerId = event.getPointerId(event.actionIndex) + lastRawY = event.rawY + } + + override fun onTouch(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + active = true + pointerId = event.getPointerId(event.actionIndex) + lastRawY = event.rawY + } + + MotionEvent.ACTION_MOVE -> { + if (!active) return true + val index = event.findPointerIndex(pointerId) + if (index < 0) return true + val rawY = event.rawY + event.getY(index) - event.y + val deltaY = rawY - lastRawY + lastRawY = rawY + if (deltaY != 0f) onDragBy(deltaY) + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> reset() + } + return true + } + + override fun reset() { + active = false + pointerId = -1 + lastRawY = 0f + } +} diff --git a/app/src/main/java/llc/slacker/openime/keyboard/KeyboardHeightDragPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/KeyboardHeightDragPolicy.kt new file mode 100644 index 00000000..5a4aef2e --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeyboardHeightDragPolicy.kt @@ -0,0 +1,22 @@ +package llc.slacker.openime.keyboard + +/** + * 停靠键盘的高度拖拽调节(“键盘调节”):在顶栏按住并上下拖动,实时改变键盘高度。 + * + * 固定灵敏度:向上拖增大、向下拖减小;拖动整屏高度的 [POINTS_PER_SCREEN]% + * 即走完约两倍于当前区间(60%..160%,共 100 点)的距离。 + * 换算成手感:拖动约 20% 屏高 ≈ 40 个百分点。 + */ +internal object KeyboardHeightDragPolicy { + /** 拖动整屏高度对应的百分点数。 */ + private const val POINTS_PER_SCREEN = 200f + + fun percentDelta(deltaY: Float, screenHeightPx: Int): Int { + if (screenHeightPx <= 0 || deltaY == 0f) return 0 + val perPx = POINTS_PER_SCREEN / screenHeightPx + return Math.round(-deltaY * perPx) + } + + fun applyTo(startPercent: Int, deltaY: Float, screenHeightPx: Int): Int = + startPercent + percentDelta(deltaY, screenHeightPx) +} diff --git a/app/src/main/java/llc/slacker/openime/keyboard/OneHandPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/OneHandPolicy.kt new file mode 100644 index 00000000..d04d2c8d --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/keyboard/OneHandPolicy.kt @@ -0,0 +1,33 @@ +package llc.slacker.openime.keyboard + +import llc.slacker.openime.core.OneHandMode + +/** + * 单手模式的横向留白:把键面收窄到窗口宽度的一个比例,并贴向用户选择的一侧。 + * 返回 (leftInsetPx, rightInsetPx);关闭时返回 null(走对称留白)。 + * + * 留白按"先保留正常内容边距,再把空出来的宽度全部放到另一侧"分配: + * 左手时键盘贴左、右侧空出;右手时相反。 + */ +internal object OneHandPolicy { + /** 单手键盘宽度占窗口宽度的比例。 */ + const val WIDTH_FRACTION = 0.76f + + fun insets( + measuredWidthPx: Int, + mode: OneHandMode, + baseInsetPx: Int, + widthFraction: Float = WIDTH_FRACTION, + ): Pair? { + if (mode == OneHandMode.OFF || measuredWidthPx <= 0) return null + val fraction = widthFraction.coerceIn(0.5f, 1f) + val keyboardWidth = (measuredWidthPx * fraction).toInt() + val base = baseInsetPx.coerceIn(0, measuredWidthPx / 2) + val outer = (measuredWidthPx - keyboardWidth - base).coerceAtLeast(base) + return when (mode) { + OneHandMode.LEFT -> base to outer + OneHandMode.RIGHT -> outer to base + OneHandMode.OFF -> null + } + } +} diff --git a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt index dc2b6c3a..10ee2f69 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt @@ -48,6 +48,7 @@ internal class Pinyin26KeyboardRenderer( english: Boolean, shiftState: ShiftState, enterLabel: String, + landscape: Boolean = false, ) { if (splitLayout()) { renderSplit(english, shiftState, enterLabel) @@ -77,7 +78,7 @@ internal class Pinyin26KeyboardRenderer( keyboardBody.addView(row, rowParams()) } - val weights = ProductionKeyPolicy.twentySixKeyBottomRowWeights() + val weights = ProductionKeyPolicy.twentySixKeyBottomRowWeights(landscape) val bottom = rowHost() bottom.addView( diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt index e65a783b..b93eb2e8 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt @@ -25,8 +25,11 @@ internal object ProductionKeyPolicy { * 123 | ,。 | 空格 | 中/英 | 确定. The row mirrors around the space key so the * space bar sits on the keyboard's centre line. */ - fun twentySixKeyBottomRowWeights(): BottomRowWeights { - return BottomRowWeights(70f, 48f, 154f, 48f, 70f) + fun twentySixKeyBottomRowWeights(landscape: Boolean = false): BottomRowWeights { + // 横屏按参考键盘的横屏皮肤放大空格键:其空格权重 133.6 -> 182.2(×1.3638), + // 123 / 逗句号 / 中英 / 确定四个键均不变。 + val space = if (landscape) 210f else 154f + return BottomRowWeights(70f, 48f, space, 48f, 70f) } /** diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ReferenceLandscapeInsets.kt b/app/src/main/java/llc/slacker/openime/keyboard/ReferenceLandscapeInsets.kt new file mode 100644 index 00000000..e5dd351c --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/keyboard/ReferenceLandscapeInsets.kt @@ -0,0 +1,18 @@ +package llc.slacker.openime.keyboard + +/** + * 横屏内容留白,取自参考键盘的横屏皮肤(其 APK 内 assets/skin/default/land/style.xml): + * 键区左右各 6.485%(九键 9.5%)、顶栏 8.2%、展开候选 9.5%。竖屏不变(0)。 + * + * 留白是"内容距窗口边"的比例,窗口本身仍是整宽;背景保持整宽铺满, + * 键位比竖屏略往中间收,远离横屏两侧的手掌边缘。 + */ +internal object ReferenceLandscapeInsets { + const val KEYBOARD_FRACTION = 0.0648508f + const val NINE_KEY_FRACTION = 0.095f + const val TOOLBAR_FRACTION = 0.082f + const val EXPANDED_CANDIDATES_FRACTION = 0.095f + + fun keyboardFraction(nineKey: Boolean): Float = + if (nineKey) NINE_KEY_FRACTION else KEYBOARD_FRACTION +} diff --git a/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt index 068aaccd..8ff6dcfa 100644 --- a/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt @@ -27,8 +27,9 @@ import llc.slacker.openime.data.CustomSymbolRepository import llc.slacker.openime.data.EmojiRecentRepository import llc.slacker.openime.data.ImeSettingsRepository import llc.slacker.openime.handwriting.HandwritingPadView +import llc.slacker.openime.handwriting.HandwritingProvider import llc.slacker.openime.handwriting.HandwritingResult -import llc.slacker.openime.handwriting.UnavailableHandwritingProvider +import llc.slacker.openime.handwriting.StrokeMatchHandwritingProvider import llc.slacker.openime.theme.ImeGeometryTokens import llc.slacker.openime.theme.ImeSurfacePolicy import llc.slacker.openime.theme.ImeSpacingTokens @@ -73,6 +74,14 @@ internal class ImePanelRenderer( ) { private var symbolCategory = "中文" private var emojiCategory = "笑脸" + + /** Parsed on first use; stays unavailable when the local table is absent. */ + private val handwritingProvider: HandwritingProvider by lazy { + StrokeMatchHandwritingProvider.forContext(context) + } + + private fun rimeAssetPresent(path: String): Boolean = + runCatching { context.assets.open(path).close() }.isSuccess private var symbolsBody: LinearLayout? = null private val chipScrollPositions = mutableMapOf() private val verticalScrollPositions = mutableMapOf() @@ -96,10 +105,16 @@ internal class ImePanelRenderer( val modes = listOf( Triple(KeyboardMode.PINYIN_26, "拼音 26 键", R.drawable.ic_kb_pinyin26), Triple(KeyboardMode.PINYIN_9, "拼音 9 键", R.drawable.ic_kb_pinyin9), + Triple(KeyboardMode.DOUBLE_PINYIN, "双拼", R.drawable.ic_kb_double), Triple(KeyboardMode.STROKE, "笔画", R.drawable.ic_kb_stroke), + Triple(KeyboardMode.WUBI, "五笔", R.drawable.ic_kb_wubi26), Triple(KeyboardMode.ENGLISH_26, "英文 26 键", R.drawable.ic_kb_english), Triple(KeyboardMode.DIGITS, "数字键盘", R.drawable.ic_kb_digits), - ) + ).filterNot { (mode, _, _) -> + // Tables that ship only with local builds keep their entry hidden. + (mode == KeyboardMode.WUBI && !rimeAssetPresent(WUBI_TABLE_ASSET)) || + (mode == KeyboardMode.DOUBLE_PINYIN && !rimeAssetPresent(DOUBLE_PINYIN_SCHEMA_ASSET)) + } modes.chunked(TOOL_COLUMNS).forEach { chunk -> val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } chunk.forEach { (mode, label, iconRes) -> @@ -114,6 +129,25 @@ internal class ImePanelRenderer( ).apply { bottomMargin = toPx(ImeSpacingTokens.LG_DP) }, ) } + // Handwriting is an input surface, not a keyboard mode. Give it the + // same entry point as the keyboards so it is found in the same place. + if (StrokeMatchHandwritingProvider.assetPresent(context)) { + val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } + row.addView( + toolCard(R.drawable.ic_pref_handwriting, "手写输入") { + onShowPanel(Panel.HANDWRITING) + }, + toolCardParams(), + ) + repeat(TOOL_COLUMNS - 1) { row.addView(View(context), toolCardParams()) } + body.addView( + row, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + toPx(ImeGeometryTokens.TOOL_CARD_HEIGHT_DP), + ).apply { bottomMargin = toPx(ImeSpacingTokens.LG_DP) }, + ) + } expandedPanel.addView( body, LinearLayout.LayoutParams( @@ -138,6 +172,12 @@ internal class ImePanelRenderer( ToolEntry("剪贴板", Panel.CLIPBOARD, R.drawable.ic_pref_clipboard), ToolEntry("表情", Panel.EMOJI, R.drawable.ic_pref_emoji), ToolEntry("符号", Panel.SYMBOLS, R.drawable.ic_pref_symbols), + ToolEntry( + "手写输入", + Panel.HANDWRITING, + R.drawable.ic_pref_handwriting, + enabled = StrokeMatchHandwritingProvider.assetPresent(context), + ), ToolEntry("文本编辑", Panel.TEXT_EDITOR, R.drawable.ic_pref_cursor), ToolEntry("浮动键盘", iconRes = R.drawable.ic_pref_floating, action = onEnableFloatingKeyboard), ToolEntry("设置", Panel.SETTINGS, R.drawable.ic_pref_settings), @@ -262,29 +302,35 @@ internal class ImePanelRenderer( } } + var padRef: HandwritingPadView? = null + fun showHandwritingHint(text: String) { + candidateRow.addView(createTitle(text, true), wrapParams()) + } val pad = HandwritingPadView(context) { strokes -> refreshStrokeActions(strokes.isNotEmpty()) candidateRow.removeAllViews() - when (val result = UnavailableHandwritingProvider.recognize(strokes)) { - is HandwritingResult.NotConfigured -> { - candidateRow.addView( - createTitle("当前未配置手写识别引擎", true), - wrapParams(), - ) - } - is HandwritingResult.Success -> { - result.candidates.forEach { candidate -> + when (val result = handwritingProvider.recognize(strokes)) { + is HandwritingResult.NotConfigured -> showHandwritingHint("当前未配置手写识别引擎") + is HandwritingResult.Success -> when { + result.candidates.isNotEmpty() -> result.candidates.forEach { candidate -> candidateRow.addView( - createKey(candidate, false, ImeTypographyTokens.TITLE_SP) { onCharacter(candidate) }, + createKey(candidate, false, ImeTypographyTokens.TITLE_SP) { + onCharacter(candidate) + // 上屏后清空落笔区,直接写下一个字。 + padRef?.clear() + }, wrapParams(), ) } + strokes.isEmpty() -> showHandwritingHint("在下方区域落笔手写...") + else -> showHandwritingHint("没有认出这个字,再写一次试试") } - is HandwritingResult.Error -> Unit + is HandwritingResult.Error -> showHandwritingHint("识别出错了:${result.message}") } }.apply { tag = "handwriting-canvas" } + padRef = pad body.addView( pad, LinearLayout.LayoutParams( @@ -769,5 +815,9 @@ internal class ImePanelRenderer( const val TOOL_COLUMNS = 4 /** Longer than this, a symbol is text (kaomoji, 【】 pairs...) and needs a wide cell. */ const val WIDE_SYMBOL_CODE_POINTS = 3 + /** Compiled wubi table; absent from builds without the local sources. */ + const val WUBI_TABLE_ASSET = "rime-data/build/wubi86.table.bin" + /** Compiled double-pinyin schema marker. */ + const val DOUBLE_PINYIN_SCHEMA_ASSET = "rime-data/build/double_pinyin_flypy.schema.yaml" } } diff --git a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt index 176f7370..5eb78093 100644 --- a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt @@ -21,6 +21,8 @@ import android.widget.TextView import llc.slacker.openime.ImeSettingsActivity import llc.slacker.openime.R import llc.slacker.openime.core.LandscapeLayout +import llc.slacker.openime.core.DoublePinyinScheme +import llc.slacker.openime.core.OneHandMode import llc.slacker.openime.data.HapticStyle import llc.slacker.openime.data.KeySoundStyle import llc.slacker.openime.theme.ImeAppearance @@ -60,6 +62,8 @@ internal class SettingsPanelController( private val currentHapticStrengthPercent: () -> Int, private val currentHapticStyle: () -> HapticStyle, private val currentLandscapeLayout: () -> LandscapeLayout, + private val currentOneHandMode: () -> OneHandMode, + private val currentDoublePinyinScheme: () -> DoublePinyinScheme, private val currentKeySoundStyle: () -> KeySoundStyle, private val currentPopup: () -> Boolean, private val currentSwipeUpDigits: () -> Boolean, @@ -77,6 +81,8 @@ internal class SettingsPanelController( private val onHapticStrengthChanged: (Int) -> Unit, private val onHapticStyleChanged: (HapticStyle) -> Unit, private val onLandscapeLayoutChanged: (LandscapeLayout) -> Unit, + private val onOneHandModeChanged: (OneHandMode) -> Unit, + private val onDoublePinyinSchemeChanged: (DoublePinyinScheme) -> Unit, private val onKeySoundStyleChanged: (KeySoundStyle) -> Unit, private val onFloatingStyleChanged: (Int, Int) -> Unit, private val onShowFuzzySettings: () -> Unit, @@ -84,6 +90,7 @@ internal class SettingsPanelController( private val onFuzzyRulesChanged: () -> Unit, private val onOpenAbout: () -> Unit, private val onOpenDataManagement: () -> Unit, + private val onOpenVoiceModel: () -> Unit, private val onFeedback: () -> Unit, private val applyTheme: () -> Unit, private val onHierarchyRebuilt: () -> Unit, @@ -176,16 +183,21 @@ internal class SettingsPanelController( content.addSection("外观", first = true) content.addCard( segmentedRow("外观", R.drawable.ic_pref_appearance), - sliderRow(currentKeyboardHeightLabel(), R.drawable.ic_pref_height, 80, 120, currentKeyboardHeightPercent(), onChange = onKeyboardHeightChanged), + sliderRow(currentKeyboardHeightLabel(), R.drawable.ic_pref_height, ImeGeometryTokens.KEYBOARD_HEIGHT_MIN_PERCENT, ImeGeometryTokens.KEYBOARD_HEIGHT_MAX_PERCENT, currentKeyboardHeightPercent(), ImeGeometryTokens.KEYBOARD_HEIGHT_DEFAULT_PERCENT, onChange = onKeyboardHeightChanged), ) + content.addSection("键盘") + content.addCard(doublePinyinSchemeRow(R.drawable.ic_kb_double)) content.addSection("横屏") - content.addCard(landscapeLayoutRow(R.drawable.ic_pref_width)) + content.addCard( + landscapeLayoutRow(R.drawable.ic_pref_width), + oneHandModeRow(R.drawable.ic_pref_width), + ) content.addSection("浮动键盘") content.addCard( - sliderRow("浮动宽度", R.drawable.ic_pref_width, 72, 100, currentFloatingWidthPercent()) { + sliderRow("浮动宽度", R.drawable.ic_pref_width, ImeGeometryTokens.FLOATING_WIDTH_MIN_PERCENT, ImeGeometryTokens.FLOATING_WIDTH_MAX_PERCENT, currentFloatingWidthPercent(), ImeGeometryTokens.FLOATING_WIDTH_DEFAULT_PERCENT) { onFloatingStyleChanged(it, currentFloatingOpacityPercent()) }, - sliderRow("浮动透明度", R.drawable.ic_pref_opacity, 82, 100, currentFloatingOpacityPercent()) { + sliderRow("浮动透明度", R.drawable.ic_pref_opacity, ImeGeometryTokens.FLOATING_OPACITY_MIN_PERCENT, ImeGeometryTokens.FLOATING_OPACITY_MAX_PERCENT, currentFloatingOpacityPercent(), ImeGeometryTokens.FLOATING_OPACITY_DEFAULT_PERCENT) { onFloatingStyleChanged(currentFloatingWidthPercent(), it) }, ) @@ -195,7 +207,7 @@ internal class SettingsPanelController( soundStyleRow(NESTED_ROW), toggleRow("触感震动", "清脆短促,按下即停", R.drawable.ic_pref_haptic), hapticStyleRow(NESTED_ROW), - sliderRow("震动强度", NESTED_ROW, 10, 100, currentHapticStrengthPercent(), onChange = onHapticStrengthChanged), + sliderRow("震动强度", NESTED_ROW, ImeGeometryTokens.HAPTIC_STRENGTH_MIN_PERCENT, ImeGeometryTokens.HAPTIC_STRENGTH_MAX_PERCENT, currentHapticStrengthPercent(), ImeGeometryTokens.HAPTIC_STRENGTH_DEFAULT_PERCENT, onChange = onHapticStrengthChanged), toggleRow("按键气泡", "按下时显示字母预览", R.drawable.ic_pref_bubble), toggleRow("数字和符号提示", "字母键右上角显示数字和符号", R.drawable.ic_pref_hints), toggleRow("上滑输入数字", "按键上滑输入右上角的数字或符号", R.drawable.ic_pref_swipe_up), @@ -218,6 +230,7 @@ internal class SettingsPanelController( content.addSection("关于与数据") content.addCard( navigationRow("关于", "版本、隐私与诊断", R.drawable.ic_pref_info, onOpenAbout), + navigationRow("语音模型", "导入、切换或删除离线模型", R.drawable.ic_pref_waveform, onOpenVoiceModel), navigationRow("数据管理", "导出与导入用户数据", R.drawable.ic_pref_data, onOpenDataManagement), ) } @@ -234,8 +247,8 @@ internal class SettingsPanelController( ) content.addCard( segmentedRow("外观", iconRes = 0), - sliderRow(currentKeyboardHeightLabel(), 0, 80, 120, currentKeyboardHeightPercent(), onChange = onKeyboardHeightChanged), - sliderRow("震动强度", 0, 10, 100, currentHapticStrengthPercent(), onChange = onHapticStrengthChanged), + sliderRow(currentKeyboardHeightLabel(), 0, ImeGeometryTokens.KEYBOARD_HEIGHT_MIN_PERCENT, ImeGeometryTokens.KEYBOARD_HEIGHT_MAX_PERCENT, currentKeyboardHeightPercent(), ImeGeometryTokens.KEYBOARD_HEIGHT_DEFAULT_PERCENT, onChange = onKeyboardHeightChanged), + sliderRow("震动强度", 0, ImeGeometryTokens.HAPTIC_STRENGTH_MIN_PERCENT, ImeGeometryTokens.HAPTIC_STRENGTH_MAX_PERCENT, currentHapticStrengthPercent(), ImeGeometryTokens.HAPTIC_STRENGTH_DEFAULT_PERCENT, onChange = onHapticStrengthChanged), topMarginDp = ImeSpacingTokens.MD_DP, ) content.addSection("按键反馈") @@ -244,13 +257,13 @@ internal class SettingsPanelController( hapticStyleRow(0), ) content.addSection("横屏") - content.addCard(landscapeLayoutRow(0)) + content.addCard(landscapeLayoutRow(0), oneHandModeRow(0)) content.addSection("浮动键盘") content.addCard( - sliderRow("浮动宽度", 0, 72, 100, currentFloatingWidthPercent(), shortLabel = "宽度") { + sliderRow("浮动宽度", 0, ImeGeometryTokens.FLOATING_WIDTH_MIN_PERCENT, ImeGeometryTokens.FLOATING_WIDTH_MAX_PERCENT, currentFloatingWidthPercent(), ImeGeometryTokens.FLOATING_WIDTH_DEFAULT_PERCENT, shortLabel = "宽度") { onFloatingStyleChanged(it, currentFloatingOpacityPercent()) }, - sliderRow("浮动透明度", 0, 82, 100, currentFloatingOpacityPercent(), shortLabel = "透明度") { + sliderRow("浮动透明度", 0, ImeGeometryTokens.FLOATING_OPACITY_MIN_PERCENT, ImeGeometryTokens.FLOATING_OPACITY_MAX_PERCENT, currentFloatingOpacityPercent(), ImeGeometryTokens.FLOATING_OPACITY_DEFAULT_PERCENT, shortLabel = "透明度") { onFloatingStyleChanged(currentFloatingWidthPercent(), it) }, ) @@ -270,6 +283,7 @@ internal class SettingsPanelController( content.addSection("关于与数据") content.addCard( navigationRow("关于", null, 0, onOpenAbout), + navigationRow("语音模型", null, 0, onOpenVoiceModel), navigationRow("数据管理", null, 0, onOpenDataManagement), ) } @@ -571,6 +585,16 @@ internal class SettingsPanelController( onLandscapeLayoutChanged(LandscapeLayout.entries.first { it.label == label }) } + private fun oneHandModeRow(iconRes: Int): View = + choiceRow("单手模式", iconRes, OneHandMode.entries.map { it.label }, currentOneHandMode().label) { label -> + onOneHandModeChanged(OneHandMode.entries.first { it.label == label }) + } + + private fun doublePinyinSchemeRow(iconRes: Int): View = + choiceRow("双拼方案", iconRes, DoublePinyinScheme.entries.map { it.label }, currentDoublePinyinScheme().label) { label -> + onDoublePinyinSchemeChanged(DoublePinyinScheme.entries.first { it.label == label }) + } + private fun hapticStyleRow(iconRes: Int): View = choiceRow("震动手感", iconRes, HapticStyle.entries.map { it.label }, currentHapticStyle().label) { label -> onHapticStyleChanged(HapticStyle.entries.first { it.label == label }) @@ -741,6 +765,7 @@ internal class SettingsPanelController( min: Int, max: Int, initial: Int, + defaultValue: Int, shortLabel: String = label, onChange: (Int) -> Unit, ): LinearLayout { @@ -778,6 +803,38 @@ internal class SettingsPanelController( }, ) } + val resetView = TextView(context).apply { + tag = "setting-reset" + text = "默认" + textSize = ImeTypographyTokens.SMALL_SP + gravity = Gravity.CENTER + includeFontPadding = false + setPadding(toPx(8), 0, toPx(8), 0) + contentDescription = "$label,恢复默认 $defaultValue%" + isClickable = true + isFocusable = true + alpha = if (seekBar.progress == defaultValue) 0.35f else 1f + setOnClickListener { + if (seekBar.progress != defaultValue) { + seekBar.progress = defaultValue + onChange(defaultValue) + } + } + } + seekBar.setOnSeekBarChangeListener( + object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(bar: SeekBar, progress: Int, fromUser: Boolean) { + val description = "$label,$progress%" + valueView.text = "$progress%" + seekBar.contentDescription = description + if (Build.VERSION.SDK_INT >= 30) seekBar.stateDescription = description + resetView.alpha = if (progress == defaultValue) 0.35f else 1f + if (fromUser) onChange(progress) + } + override fun onStartTrackingTouch(bar: SeekBar) = Unit + override fun onStopTrackingTouch(bar: SeekBar) = Unit + }, + ) val initialDescription = "$label,${seekBar.progress}%" valueView.text = "${seekBar.progress}%" seekBar.contentDescription = initialDescription @@ -798,6 +855,7 @@ internal class SettingsPanelController( gravity = Gravity.CENTER_VERTICAL addView(rowLabel(label), LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) addView(valueView, wrapParams()) + addView(resetView, LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, toPx(32))) }, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT), ) @@ -818,6 +876,7 @@ internal class SettingsPanelController( row.addView(rowLabel(shortLabel), LinearLayout.LayoutParams(toPx(64), LinearLayout.LayoutParams.WRAP_CONTENT)) row.addView(seekBar, LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), 1f)) row.addView(valueView, LinearLayout.LayoutParams(toPx(44), LinearLayout.LayoutParams.WRAP_CONTENT)) + row.addView(resetView, LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, toPx(32))) } return row } diff --git a/app/src/main/java/llc/slacker/openime/rime/RimeEngine.kt b/app/src/main/java/llc/slacker/openime/rime/RimeEngine.kt index 83655a8f..a641413f 100644 --- a/app/src/main/java/llc/slacker/openime/rime/RimeEngine.kt +++ b/app/src/main/java/llc/slacker/openime/rime/RimeEngine.kt @@ -8,7 +8,9 @@ import android.os.SystemClock import android.util.Log import llc.slacker.openime.RimeNative import llc.slacker.openime.core.CrashGuard +import llc.slacker.openime.core.DoublePinyinScheme import llc.slacker.openime.core.FuzzyRule +import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.core.RimeStartupRecovery import llc.slacker.openime.data.ImeSettingsRepository import llc.slacker.openime.data.PersonalizationPolicy @@ -40,8 +42,15 @@ internal fun rimeDataRevision(contentHash: String?, versionCode: Long): String = private val RIME_CONTENT_HASH = Regex("[0-9a-f]{64}") -internal fun rimeSchemaId(fuzzyEnabled: Boolean): String = - if (fuzzyEnabled) "luna_pinyin_simp_fuzzy" else "luna_pinyin_simp" +internal fun rimeSchemaId( + mode: KeyboardMode, + fuzzyEnabled: Boolean, + doublePinyin: DoublePinyinScheme = DoublePinyinScheme.FLYPY, +): String = when (mode) { + KeyboardMode.WUBI -> "wubi86" + KeyboardMode.DOUBLE_PINYIN -> doublePinyin.schemaId + else -> if (fuzzyEnabled) "luna_pinyin_simp_fuzzy" else "luna_pinyin_simp" +} /** * One serial lane for native mutations that must never stall the IME thread. @@ -615,8 +624,21 @@ class RimeEngine( return value } + /** + * The schema follows the user's keyboard. WUBI has its own table, every + * other Chinese layout reads the pinyin schema (with or without fuzzy + * rules). The preference is a SharedPreferences read; the keyboard view + * persists it before a mode switch can produce new input. + */ + private fun currentChineseMode(): KeyboardMode = + ImeSettingsRepository.loadPreferredChineseMode(context) + private fun syncSchemaFromSettingsLocked(): Boolean { - val desiredSchemaId = rimeSchemaId(fuzzyPinyinEnabled()) + val desiredSchemaId = rimeSchemaId( + currentChineseMode(), + fuzzyPinyinEnabled(), + ImeSettingsRepository.loadDoublePinyinScheme(context), + ) if (activeSchemaId == desiredSchemaId) return true if (!RimeNative.nativeSelectSchema(desiredSchemaId)) return false activeSchemaId = desiredSchemaId diff --git a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt index b5ebb980..6b46b0ab 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -11,6 +11,26 @@ import android.graphics.Color * prevents each panel from inventing another near-identical radius or size. */ internal object ImeGeometryTokens { + /** 键盘高度可调范围(占默认高度的百分比)。所有读写、裁剪、滑块都用这一对常量。 */ + const val KEYBOARD_HEIGHT_MIN_PERCENT = 60 + const val KEYBOARD_HEIGHT_MAX_PERCENT = 160 + const val KEYBOARD_HEIGHT_DEFAULT_PERCENT = 100 + + /** 浮动键盘宽度(占竖屏宽度的百分比)。上限 150 允许在平板或横屏上撑得更宽。 */ + const val FLOATING_WIDTH_MIN_PERCENT = 50 + const val FLOATING_WIDTH_MAX_PERCENT = 150 + const val FLOATING_WIDTH_DEFAULT_PERCENT = 100 + + /** 浮动键盘不透明度。保留 20% 底线:再低键盘看不见,用户无法回到设置里改回来。 */ + const val FLOATING_OPACITY_MIN_PERCENT = 20 + const val FLOATING_OPACITY_MAX_PERCENT = 100 + const val FLOATING_OPACITY_DEFAULT_PERCENT = 100 + + /** 震动强度。0 表示关闭由单独的开关负责,这里只放宽到 1 到 100。 */ + const val HAPTIC_STRENGTH_MIN_PERCENT = 1 + const val HAPTIC_STRENGTH_MAX_PERCENT = 100 + const val HAPTIC_STRENGTH_DEFAULT_PERCENT = 100 + /** Portrait key row height on the reference design's 390-unit canvas. */ const val KEY_ROW_HEIGHT_DP = 54 const val KEY_RADIUS_DP = 8 diff --git a/app/src/main/java/llc/slacker/openime/voice/VoiceEndpointPolicy.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceEndpointPolicy.kt new file mode 100644 index 00000000..da9a3dc6 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceEndpointPolicy.kt @@ -0,0 +1,61 @@ +package llc.slacker.openime.voice + +/** + * 端点判断的纯逻辑。现有体验由手势松开结束录音;VAD 可用时用 sherpa VAD 的 + * isSpeechDetected 状态做更准的起止/端点判断,VAD 不可用时完全保持原有行为。 + */ +internal object VoiceEndpointPolicy { + /** + * paraformer 收尾是否需要补 300 ms 零:VAD 可用且整段都未检测到语音时不需要; + * 其余情况(包括没有 VAD)与现在完全一致。 + */ + fun tailPaddingNeeded(vadActive: Boolean, sawSpeech: Boolean): Boolean = + !vadActive || sawSpeech +} + +/** + * 会话内累加的 VAD 状态。每块音频(20 ms)调用一次 [observe]:传 + * isSpeechDetected() 的结果;VAD 不可用或推理失败时传 null,立即退回原有判断。 + */ +internal class VadSpeechTracker( + private val minSilenceChunks: Int = DEFAULT_MIN_SILENCE_CHUNKS, +) { + init { + require(minSilenceChunks > 0) { "minSilenceChunks must be positive" } + } + + /** 本会话是否真的跑过 VAD。 */ + var isVadActive: Boolean = false + private set + + var hasSpeech: Boolean = false + private set + + private var trailingSilenceChunks = 0 + + fun observe(speech: Boolean?) { + if (speech == null) { + isVadActive = false + trailingSilenceChunks = 0 + return + } + isVadActive = true + if (speech) { + hasSpeech = true + trailingSilenceChunks = 0 + } else if (hasSpeech) { + trailingSilenceChunks += 1 + } + } + + /** 说过话后又静了 [minSilenceChunks] 块:更准的语音结束点,用于收尾诊断。 */ + fun speechEnded(): Boolean = + isVadActive && hasSpeech && trailingSilenceChunks >= minSilenceChunks + + fun tailPaddingNeeded(): Boolean = VoiceEndpointPolicy.tailPaddingNeeded(isVadActive, hasSpeech) + + companion object { + /** silero 默认 minSilenceDuration=0.5 s,按 20 ms 一块即 25 块。 */ + internal const val DEFAULT_MIN_SILENCE_CHUNKS = 25 + } +} diff --git a/app/src/main/java/llc/slacker/openime/voice/VoiceHotwordProvider.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceHotwordProvider.kt index a8554e93..24a02f91 100644 --- a/app/src/main/java/llc/slacker/openime/voice/VoiceHotwordProvider.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceHotwordProvider.kt @@ -14,6 +14,15 @@ object VoiceHotwordProvider { VoiceCorrectionRepository.hotwords(limit = 32), ) + /** 管理页多行输入解析:一行一个词;去空白、去重,最多 64 条。 */ + internal fun parseLines(text: String): List = text + .lineSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .distinct() + .take(MAX_HOTWORDS) + .toList() + internal fun encode(phrases: List): String = phrases .asSequence() .map(::sanitize) @@ -27,7 +36,8 @@ object VoiceHotwordProvider { private fun sanitize(value: String): String { val cleaned = value .trim() - .replace(Regex("[\\r\\n/:]+"), " ") + // 控制字符(含换行、NUL)和 / : 会破坏热词串或截断原生字符串。 + .replace(Regex("[/:\\p{Cntrl}]+"), " ") .replace(Regex("\\s+"), " ") if (cleaned.isBlank()) return "" val codePoints = cleaned.codePoints().toArray() diff --git a/app/src/main/java/llc/slacker/openime/voice/VoiceModelArchive.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceModelArchive.kt new file mode 100644 index 00000000..724114fc --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceModelArchive.kt @@ -0,0 +1,225 @@ +package llc.slacker.openime.voice + +import java.io.BufferedInputStream +import java.io.File +import java.io.InputStream +import java.util.zip.GZIPInputStream +import java.util.zip.ZipInputStream + +/** + * Extracts a local voice-model package into a staging directory. + * + * Supported forms: .zip, .tar, .tar.gz / .tgz. Every entry is checked for + * path traversal and the whole archive is bounded, so a malicious package + * cannot escape the staging directory or fill the disk. tar.bz2 is not + * supported (no bzip2 decoder on board): the import UI asks the user to + * repack it. + */ +internal object VoiceModelArchive { + const val MAX_UNPACKED_BYTES = 1_500L * 1_048_576 + + enum class Format { ZIP, TAR, TAR_GZ } + + fun formatOf(fileName: String): Format? = when { + fileName.endsWith(".zip", ignoreCase = true) -> Format.ZIP + fileName.endsWith(".tar.gz", ignoreCase = true) || fileName.endsWith(".tgz", ignoreCase = true) -> + Format.TAR_GZ + fileName.endsWith(".tar", ignoreCase = true) -> Format.TAR + else -> null + } + + /** Sniff the format from the first bytes; SAF pickers do not always give a name. */ + fun sniff(header: ByteArray): Format? { + if (header.size >= 4 && header[0] == 'P'.code.toByte() && header[1] == 'K'.code.toByte()) { + return Format.ZIP + } + if (header.size >= 2 && header[0] == 0x1f.toByte() && header[1] == 0x8b.toByte()) { + return Format.TAR_GZ + } + if (header.size >= 263 && + header[257] == 'u'.code.toByte() && header[258] == 's'.code.toByte() && + header[259] == 't'.code.toByte() && header[260] == 'a'.code.toByte() && + header[261] == 'r'.code.toByte() + ) { + return Format.TAR + } + return null + } + + fun extract( + input: InputStream, + format: Format, + destination: File, + maxBytes: Long = MAX_UNPACKED_BYTES, + ): Long { + val canonicalRoot = destination.canonicalFile + return when (format) { + Format.ZIP -> extractZip(input, canonicalRoot, maxBytes) + Format.TAR -> extractTar(input, canonicalRoot, maxBytes) + Format.TAR_GZ -> GZIPInputStream(input).use { extractTar(it, canonicalRoot, maxBytes) } + } + } + + private fun targetFor(root: File, name: String): File { + val normalized = name.replace('\\', '/').removePrefix("./") + require(normalized.isNotBlank()) { "压缩包含有空路径" } + val target = File(root, normalized).canonicalFile + require(target.path == root.path || target.path.startsWith(root.path + File.separator)) { + "压缩包含有非法路径:$name" + } + return target + } + + private fun extractZip(raw: InputStream, root: File, maxBytes: Long): Long { + var total = 0L + ZipInputStream(BufferedInputStream(raw)).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + val target = targetFor(root, entry.name) + if (entry.isDirectory) { + target.mkdirs() + } else { + target.parentFile?.mkdirs() + total += copyBounded(zip, target, maxBytes, total) + } + zip.closeEntry() + } + } + return total + } + + private fun extractTar(raw: InputStream, root: File, maxBytes: Long): Long { + val input = BufferedInputStream(raw, 64 * 1024) + var total = 0L + var pendingName: String? = null + val header = ByteArray(TAR_HEADER) + while (true) { + if (!readFully(input, header)) break + if (header.all { it.toInt() == 0 }) break + var name = readName(header) + val size = readOctal(header, SIZE_OFFSET, SIZE_LENGTH) + val type = header[TYPE_OFFSET].toInt().toChar() + when (type) { + 'L' -> { + // GNU long name: the payload is the next entry's name. + val longName = ByteArray(size.toInt().coerceAtLeast(0)) + readFully(input, longName) + skipPadding(input, size) + pendingName = longName.toString(Charsets.UTF_8).trimEnd('\u0000') + continue + } + 'x', 'g' -> { + val data = ByteArray(size.toInt().coerceAtLeast(0)) + readFully(input, data) + skipPadding(input, size) + parsePaxPath(data)?.let { pendingName = it } + continue + } + '0', '\u0000', '5' -> Unit + else -> { + skipFully(input, size) + skipPadding(input, size) + pendingName = null + continue + } + } + if (pendingName != null) { + name = pendingName!! + pendingName = null + } + val target = targetFor(root, name) + if (type == '5') { + target.mkdirs() + skipFully(input, size) + } else { + target.parentFile?.mkdirs() + total += copyBounded(input, target, maxBytes, total, size) + } + skipPadding(input, size) + } + return total + } + + private fun copyBounded(input: InputStream, target: File, maxBytes: Long, already: Long, limit: Long = Long.MAX_VALUE): Long { + var written = 0L + target.outputStream().buffered().use { out -> + val buffer = ByteArray(64 * 1024) + while (written < limit) { + val want = minOf(buffer.size.toLong(), limit - written).toInt() + val n = input.read(buffer, 0, want) + if (n < 0) break + written += n + require(already + written <= maxBytes) { "模型包解压后超过 ${maxBytes / 1_048_576} MB 上限" } + out.write(buffer, 0, n) + } + } + return written + } + + private fun parsePaxPath(data: ByteArray): String? { + val text = data.toString(Charsets.UTF_8) + val line = text.lineSequence().firstOrNull { it.startsWith("path=") } ?: return null + return line.removePrefix("path=").trimEnd('\n') + } + + private fun readFully(input: InputStream, buffer: ByteArray): Boolean { + var offset = 0 + while (offset < buffer.size) { + val n = input.read(buffer, offset, buffer.size - offset) + if (n < 0) return offset == 0 + offset += n + } + return true + } + + private fun skipFully(input: InputStream, count: Long) { + var remaining = count + while (remaining > 0) { + val skipped = input.skip(remaining) + if (skipped <= 0) { + if (input.read() < 0) return + remaining-- + } else { + remaining -= skipped + } + } + } + + private fun skipPadding(input: InputStream, size: Long) { + val padding = (TAR_BLOCK - size % TAR_BLOCK) % TAR_BLOCK + if (padding > 0) skipFully(input, padding) + } + + private fun readName(header: ByteArray): String { + val name = readString(header, NAME_OFFSET, NAME_LENGTH) + val prefix = readString(header, PREFIX_OFFSET, PREFIX_LENGTH) + return if (prefix.isEmpty()) name else "$prefix/$name" + } + + private fun readString(bytes: ByteArray, offset: Int, length: Int): String { + var end = offset + while (end < offset + length && bytes[end].toInt() != 0) end++ + return bytes.copyOfRange(offset, end).toString(Charsets.UTF_8) + } + + private fun readOctal(bytes: ByteArray, offset: Int, length: Int): Long { + var value = 0L + for (index in offset until offset + length) { + val byte = bytes[index].toInt() and 0xFF + if (byte == 0 || byte == ' '.code) continue + val digit = byte - '0'.code + if (digit in 0..7) value = value * 8 + digit + } + return value + } + + private const val TAR_BLOCK = 512 + private const val TAR_HEADER = 512 + private const val NAME_OFFSET = 0 + private const val NAME_LENGTH = 100 + private const val SIZE_OFFSET = 124 + private const val SIZE_LENGTH = 12 + private const val TYPE_OFFSET = 156 + private const val PREFIX_OFFSET = 345 + private const val PREFIX_LENGTH = 155 +} diff --git a/app/src/main/java/llc/slacker/openime/voice/VoiceModelLayout.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceModelLayout.kt new file mode 100644 index 00000000..7863ca36 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceModelLayout.kt @@ -0,0 +1,208 @@ +package llc.slacker.openime.voice + +import java.io.File +import java.security.MessageDigest + +/** + * 流式语音模型的种类,与 sherpa-onnx 的 OnlineModelConfig 一一对应。 + * [roles] 是这一类模型必须有的文件角色;tokens 所有类型都要。 + */ +internal enum class StreamingModelKind(val id: String, val roles: List) { + PARAFORMER("paraformer", listOf("encoder", "decoder", "tokens")), + TRANSDUCER("transducer", listOf("encoder", "decoder", "joiner", "tokens")), + ZIPFORMER2_CTC("zipformer2-ctc", listOf("model", "tokens")), + NEMO_CTC("nemo-ctc", listOf("model", "tokens")), + T_ONE_CTC("t-one-ctc", listOf("model", "tokens")), + ; + + companion object { + fun fromId(id: String): StreamingModelKind? = entries.firstOrNull { it.id == id } + + /** 可选角色:标点、bpe 词表、热词、Silero VAD;有就用,没有不影响类型。 */ + val OPTIONAL_ROLES = setOf("punctuation", "bpeVocab", "hotwords", "vad") + } +} + +/** 清单里声明的"角色到文件"的映射,加上推断出的类型。 */ +internal data class VoiceModelLayout( + val kind: StreamingModelKind, + val files: Map, +) { + fun path(role: String): String = files.getValue(role) +} + +internal object VoiceModelLayoutResolver { + // 旧清单(内置模型)没有 roles 字段,用这三个固定路径。 + private const val LEGACY_ROOT = "models/voice/bilingual-paraformer" + + /** + * 解析一份清单。没有 roles 时按旧的固定布局(只对 paraformer 有效); + * 有 roles 时必须齐全,且每个路径都在 files 列表里。 + * 返回 null 表示这份清单不能用,[explain] 给出原因。 + */ + fun resolve(manifest: VoiceModelManifest): VoiceModelLayout? = parse(manifest).layout + + fun explain(manifest: VoiceModelManifest): String? = parse(manifest).error + + private data class Result(val layout: VoiceModelLayout? = null, val error: String? = null) + + private fun parse(manifest: VoiceModelManifest): Result { + val kind = StreamingModelKind.fromId(manifest.modelType) + ?: return Result(error = "不支持的模型类型 ${manifest.modelType},支持:" + + StreamingModelKind.entries.joinToString("、") { it.id }) + + val roles = manifest.roles + if (roles.isEmpty()) { + if (kind != StreamingModelKind.PARAFORMER) { + return Result(error = "${kind.id} 模型的清单必须声明 roles(各文件的用途)") + } + val legacy = mapOf( + "encoder" to "$LEGACY_ROOT/encoder.int8.onnx", + "decoder" to "$LEGACY_ROOT/decoder.int8.onnx", + "tokens" to "$LEGACY_ROOT/tokens.txt", + ) + return if (manifest.files.containsAll(legacy.values)) { + Result(VoiceModelLayout(kind, legacy)) + } else { + Result(error = "清单缺少 encoder / decoder / tokens 文件") + } + } + + val missing = kind.roles.filter { it !in roles } + if (missing.isNotEmpty()) return Result(error = "清单缺少文件角色:${missing.joinToString("、")}") + val unknown = roles.keys - kind.roles.toSet() - StreamingModelKind.OPTIONAL_ROLES + if (unknown.isNotEmpty()) return Result(error = "清单含有未知文件角色:${unknown.joinToString("、")}") + roles.forEach { (role, path) -> + if (path !in manifest.files) return Result(error = "角色 $role 指向的文件 $path 不在文件列表里") + if (!isSafeVoiceModelRelativePath(path)) return Result(error = "角色 $role 的路径非法:$path") + } + val kept = LinkedHashMap() + kind.roles.forEach { kept[it] = roles.getValue(it) } + StreamingModelKind.OPTIONAL_ROLES.forEach { role -> roles[role]?.let { kept[role] = it } } + return Result(VoiceModelLayout(kind, kept)) + } + + /** 清单声明的标点模型路径;旧清单靠文件名里含 punctuation 兜底。 */ + fun punctuationPath(manifest: VoiceModelManifest): String? { + if (!manifest.supportsPunctuation) return null + manifest.roles["punctuation"]?.let { return it } + return manifest.files.firstOrNull { it.contains("punctuation") && it.endsWith(".onnx") } + } +} + +/** + * 从一个没有 manifest.json 的目录里推断模型布局并生成清单。 + * 面向 sherpa-onnx 官方发布的原始压缩包:文件名遵循 encoder-*.onnx、decoder-*.onnx、joiner-*.onnx、tokens.txt。 + * 同时有 int8 和 fp32 时优先 int8(体积小、手机上更快)。 + */ +internal object VoiceModelAutoDetector { + data class Detected(val manifest: VoiceModelManifest, val note: String) + + fun detect(root: File, fallbackId: String): Result = runCatching { + val onnx = root.walkTopDown().filter { it.isFile && it.name.endsWith(".onnx") }.toList() + val tokens = root.walkTopDown().filter { it.isFile && it.name == "tokens.txt" }.toList() + check(tokens.size == 1) { if (tokens.isEmpty()) "没有找到 tokens.txt" else "找到多个 tokens.txt,无法判断用哪个" } + check(onnx.isNotEmpty()) { "没有找到 .onnx 模型文件" } + + fun rel(f: File) = f.relativeTo(root).invariantSeparatorsPath + fun isPunct(f: File) = rel(f).contains("punct", ignoreCase = true) + fun isVad(f: File) = f.name == "silero_vad.onnx" + fun pick(prefix: String): File? { + val matches = onnx.filter { it.name.startsWith(prefix) && !isPunct(it) } + return matches.firstOrNull { it.name.contains("int8") } ?: matches.minByOrNull { it.name.length } + } + + val encoder = pick("encoder") + val decoder = pick("decoder") + val joiner = pick("joiner") + val punctuation = onnx.firstOrNull(::isPunct) + val vad = onnx.firstOrNull(::isVad) + // silero_vad.onnx 是可选的端点模型,不能参与单文件 CTC 的家族猜测。 + val others = onnx.filter { it != punctuation && it != vad && it != encoder && it != decoder && it != joiner } + + val (kind, roles) = when { + encoder != null && decoder != null && joiner != null -> + StreamingModelKind.TRANSDUCER to mapOf("encoder" to encoder, "decoder" to decoder, "joiner" to joiner) + encoder != null && decoder != null -> + StreamingModelKind.PARAFORMER to mapOf("encoder" to encoder, "decoder" to decoder) + encoder == null && decoder == null && joiner == null && others.size == 1 -> { + // 单文件 CTC 无法从文件名可靠区分家族(zipformer2-ctc / nemo-ctc / t-one-ctc), + // 用错家族会让原生层直接失败;必须由 manifest.json 声明。 + error( + "单文件 CTC 模型无法仅凭文件名识别类型(zipformer2-ctc / nemo-ctc / t-one-ctc)," + + "请在包的根目录放一个 manifest.json,声明 modelType 和 roles.model(例如 " + + "\"roles\": {\"model\": \"model.onnx\"})。", + ) + } + else -> error("无法识别这是哪一类模型。支持:" + StreamingModelKind.entries.joinToString("、") { it.id }) + } + + val bpeVocab = root.walkTopDown() + .filter { it.isFile && (it.name == "bpe.vocab" || it.name == "bpe.model") } + .toList() + .minByOrNull { it.relativeTo(root).invariantSeparatorsPath } + val hotwords = root.walkTopDown() + .filter { it.isFile && it.name == "hotwords.txt" } + .toList() + .minByOrNull { it.relativeTo(root).invariantSeparatorsPath } + + val family = if (kind == StreamingModelKind.TRANSDUCER) { + val hint = root.name.lowercase() + listOf("zipformer2", "zipformer", "conformer", "ebranchformer", "lstm") + .firstOrNull { it in hint } + .orEmpty() + } else { + "" + } + + val files = LinkedHashMap() + roles.forEach { (role, file) -> files[role] = rel(file) } + files["tokens"] = rel(tokens[0]) + punctuation?.let { files["punctuation"] = rel(it) } + bpeVocab?.let { files["bpeVocab"] = rel(it) } + hotwords?.let { files["hotwords"] = rel(it) } + vad?.let { files["vad"] = rel(it) } + val fileList = files.values.toList() + + val digest = MessageDigest.getInstance("SHA-256") + fileList.sorted().forEach { path -> + File(root, path).inputStream().use { input -> + val buffer = ByteArray(64 * 1024) + while (true) { + val n = input.read(buffer) + if (n < 0) break + digest.update(buffer, 0, n) + } + } + } + val total = fileList.sumOf { File(root, it).length() } + val id = safeId(root.name.ifBlank { fallbackId }) + val manifest = VoiceModelManifest( + modelId = id, + modelVersion = "imported", + language = "unknown", + modelType = kind.id, + engineVersion = "sherpa-onnx", + fileHash = digest.digest().joinToString("") { "%02x".format(it) }, + supportsPunctuation = punctuation != null, + // 运行时内存没法从文件推断,按文件总量的 2 倍估,并给个下限,让内存准入检查有依据。 + requiredMemory = maxOf(total * 2, 200_000_000L), + files = fileList, + roles = files, + family = family, + ) + val extras = buildList { + if (family.isNotEmpty()) add("家族 $family") + if (bpeVocab != null) add("含 bpe 词表") + if (hotwords != null) add("含热词表") + if (vad != null) add("含 VAD 模型") + } + val noteSuffix = if (extras.isEmpty()) "" else "," + extras.joinToString("、") + Detected(manifest, "自动识别为 ${kind.id},共 ${fileList.size} 个文件$noteSuffix") + } + + internal fun safeId(raw: String): String { + val cleaned = raw.replace(Regex("[^A-Za-z0-9._-]"), "-").trim('-', '.', '_') + return cleaned.take(100).ifEmpty { "imported-model" } + } +} diff --git a/app/src/main/java/llc/slacker/openime/voice/VoiceModelRepository.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceModelRepository.kt index 34ae96fe..6196368e 100644 --- a/app/src/main/java/llc/slacker/openime/voice/VoiceModelRepository.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceModelRepository.kt @@ -56,8 +56,17 @@ data class VoiceModelManifest( val supportsPunctuation: Boolean, val requiredMemory: Long, val files: List, + /** 文件角色到路径的映射(encoder/decoder/joiner/model/tokens/punctuation/bpeVocab/hotwords/vad)。旧清单为空,按固定布局处理。 */ + val roles: Map = emptyMap(), + /** transducer 家族提示(zipformer/zipformer2/conformer/lstm/ebranchformer);空则读模型元数据。 */ + val family: String = "", + /** 分词单元(cjkchar / bpe / cjkchar+bpe);部分中英混合模型需要它配合 bpeVocab。 */ + val modelingUnit: String = "", ) { companion object { + val TRANSDUCER_FAMILIES = setOf("zipformer", "zipformer2", "conformer", "lstm", "ebranchformer") + val MODELING_UNITS = setOf("cjkchar", "bpe", "cjkchar+bpe") + fun parse(json: String): VoiceModelManifest { val root = JSONObject(json) val files = buildList { @@ -66,6 +75,10 @@ data class VoiceModelManifest( values.optString(index).trim().takeIf { it.isNotEmpty() }?.let(::add) } } + val roles = buildMap { + val obj = root.optJSONObject("roles") ?: return@buildMap + obj.keys().forEach { key -> obj.optString(key).trim().takeIf { it.isNotEmpty() }?.let { put(key, it) } } + } return VoiceModelManifest( modelId = root.optString("modelId").trim(), modelVersion = root.optString("modelVersion").trim(), @@ -76,10 +89,28 @@ data class VoiceModelManifest( supportsPunctuation = root.optBoolean("supportsPunctuation", false), requiredMemory = root.optLong("requiredMemory", 0L), files = files, + roles = roles, + family = root.optString("family").trim(), + modelingUnit = root.optString("modelingUnit").trim(), ) } } + fun toJson(): String = JSONObject() + .put("modelId", modelId) + .put("modelVersion", modelVersion) + .put("language", language) + .put("modelType", modelType) + .put("engineVersion", engineVersion) + .put("fileHash", fileHash) + .put("supportsPunctuation", supportsPunctuation) + .put("requiredMemory", requiredMemory) + .put("files", org.json.JSONArray(files)) + .apply { if (roles.isNotEmpty()) put("roles", JSONObject(roles as Map<*, *>)) } + .apply { if (family.isNotEmpty()) put("family", family) } + .apply { if (modelingUnit.isNotEmpty()) put("modelingUnit", modelingUnit) } + .toString(2) + fun validateShape(): String? = when { !isSafeVoiceModelId(modelId) -> "modelId 非法" modelVersion.isBlank() -> "缺少 modelVersion" @@ -91,7 +122,13 @@ data class VoiceModelManifest( files.isEmpty() -> "没有模型文件列表" files.distinct().size != files.size -> "模型文件列表包含重复项" files.any { !isSafeVoiceModelRelativePath(it) } -> "模型文件路径非法" - else -> null + family.isNotEmpty() && family !in TRANSDUCER_FAMILIES -> + "family 取值非法:$family(可用:${TRANSDUCER_FAMILIES.joinToString("、")})" + family.isNotEmpty() && modelType != "transducer" -> "只有 transducer 模型可以声明 family" + modelingUnit.isNotEmpty() && modelingUnit !in MODELING_UNITS -> + "modelingUnit 取值非法:$modelingUnit(可用:${MODELING_UNITS.joinToString("、")})" + modelingUnit.isNotEmpty() && modelType != "transducer" -> "只有 transducer 模型可以声明 modelingUnit" + else -> VoiceModelLayoutResolver.explain(this) } } @@ -124,6 +161,19 @@ internal fun VoiceModelManifest.trustFingerprint(): String { files.size.toString(), ).forEach(digest::updateFramed) files.forEach(digest::updateFramed) + if (roles.isNotEmpty()) { + digest.updateFramed("roles") + roles.toSortedMap().forEach { (role, path) -> digest.updateFramed(role); digest.updateFramed(path) } + } + // Empty defaults keep the legacy (built-in) fingerprints identical. + if (family.isNotEmpty()) { + digest.updateFramed("family") + digest.updateFramed(family) + } + if (modelingUnit.isNotEmpty()) { + digest.updateFramed("modelingUnit") + digest.updateFramed(modelingUnit) + } return digest.digest().toHexString() } @@ -225,6 +275,8 @@ data class VoiceModelSelection( val reason: String = "", ) { enum class Source { BUILT_IN, DOWNLOADED, NONE } + + /** 用户亲手导入并确认过的模型,与受信目录里的下载模型同一存放位置,但信任来源不同。 */ } /** @@ -242,6 +294,7 @@ class VoiceModelRepository(private val context: Context) { private const val BUILT_IN_MANIFEST = "models/voice/manifest.json" private const val TRUSTED_DOWNLOADS = "models/voice/trusted-downloads.tsv" private const val DOWNLOADED_DIR = "voice-models" + private const val USER_CONFIRMED_PREFIX = "user_confirmed_" } private val preferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -279,7 +332,7 @@ class VoiceModelRepository(private val context: Context) { VoiceModelSelection( manifest = null, source = VoiceModelSelection.Source.NONE, - reason = "APK 未包含有效的内置本地语音模型", + reason = "未安装语音模型。到 设置 → 语音模型 导入一个,或安装完整版", ) } } @@ -323,10 +376,98 @@ class VoiceModelRepository(private val context: Context) { val target = downloadedModelDirectory(modelId) ?: return false if (preferences.getString(SELECTED_MODEL, null) == modelId) useBuiltInModel() val deleted = target.deleteRecursively() - if (deleted) clearDownloadedVerification(modelId) + if (deleted) { + clearDownloadedVerification(modelId) + preferences.edit().remove(USER_CONFIRMED_PREFIX + modelId).apply() + } return deleted } + /** 一个模型包的概览,用于导入确认与管理页展示。 */ + data class PackageInfo( + val modelId: String, + val modelVersion: String, + val language: String, + val modelType: String, + val supportsPunctuation: Boolean, + val totalBytes: Long, + val trusted: Boolean, + /** true 表示目录里原本没有 manifest.json,清单是 app 自动识别并生成的。 */ + val autoDetected: Boolean = false, + val note: String = "", + ) + + /** 只读检查一个待导入目录:清单合法、文件齐全、整体哈希对得上。不写任何东西。 */ + fun inspectPackage(sourceDirectory: File): Result = runCatching { + check(sourceDirectory.isDirectory && !Files.isSymbolicLink(sourceDirectory.toPath())) { "不是有效的模型目录" } + val manifestFile = resolveContainedVoiceModelFile(sourceDirectory, "manifest.json") + val autoDetected = !manifestFile.isFile + val detectedNote: String + val manifest = if (autoDetected) { + val detected = VoiceModelAutoDetector.detect(sourceDirectory, sourceDirectory.name).getOrThrow() + // 写进目录,后面安装、校验、复制都按同一份清单走。 + manifestFile.writeText(detected.manifest.toJson(), Charsets.UTF_8) + detectedNote = detected.note + detected.manifest + } else { + detectedNote = "" + VoiceModelManifest.parse(manifestFile.readText(Charsets.UTF_8)) + } + manifest.validateShape()?.let { error(it) } + var total = 0L + manifest.files.forEach { path -> + val file = resolveContainedVoiceModelFile(sourceDirectory, path) + check(file.isFile) { "缺少模型文件: $path" } + total += file.length() + } + check(sha256Files(sourceDirectory, manifest.files) == manifest.fileHash) { "文件内容与清单里的 SHA-256 不一致,模型包可能已损坏" } + PackageInfo( + modelId = manifest.modelId, + modelVersion = manifest.modelVersion, + language = manifest.language, + modelType = manifest.modelType, + supportsPunctuation = manifest.supportsPunctuation, + totalBytes = total, + trusted = trustedCatalog?.entryFor(manifest) != null, + autoDetected = autoDetected, + note = detectedNote, + ) + } + + /** 用户已看过 [inspectPackage] 的结果并同意:登记信任后安装并选中。 */ + fun importUserPackage(sourceDirectory: File): Result = runCatching { + val info = inspectPackage(sourceDirectory).getOrThrow() + val manifest = VoiceModelManifest.parse( + resolveContainedVoiceModelFile(sourceDirectory, "manifest.json").readText(Charsets.UTF_8), + ) + if (!info.trusted) confirmUser(manifest) + check(installDownloadedModel(sourceDirectory, manifest.modelId)) { "安装失败" } + check(setSelectedModel(manifest.modelId)) { "无法选中新模型" } + manifest.modelId + } + + /** 已安装的模型(不含内置)。 */ + fun installedModels(): List = + downloadedRoot.listFiles().orEmpty() + .filter { it.isDirectory && !it.name.startsWith(".") && !Files.isSymbolicLink(it.toPath()) } + .mapNotNull { dir -> + runCatching { + VoiceModelManifest.parse(File(dir, "manifest.json").readText(Charsets.UTF_8)) + }.getOrNull()?.takeIf { it.modelId == dir.name } + } + .sortedBy { it.modelId } + + fun selectedModelId(): String? = preferences.getString(SELECTED_MODEL, null) + + private fun isUserConfirmed(manifest: VoiceModelManifest): Boolean = + preferences.getString(USER_CONFIRMED_PREFIX + manifest.modelId, null) == manifest.trustFingerprint() + + private fun confirmUser(manifest: VoiceModelManifest) { + check(preferences.edit().putString(USER_CONFIRMED_PREFIX + manifest.modelId, manifest.trustFingerprint()).commit()) { + "无法保存确认状态" + } + } + fun useBuiltInModel() { preferences.edit().remove(SELECTED_MODEL).apply() } @@ -388,11 +529,18 @@ class VoiceModelRepository(private val context: Context) { check(manifest.validateShape() == null) { manifest.validateShape().orEmpty() } val entry = trustedCatalog?.entryFor(manifest) - ?: error("模型未列入 APK 受信下载目录") - check(entry.matchesLayout(manifest, directory)) { "模型文件布局与受信 metadata 不匹配" } + val publisher: String + if (entry != null) { + check(entry.matchesLayout(manifest, directory)) { "模型文件布局与受信 metadata 不匹配" } + publisher = entry.publisher + } else { + // 不在白名单:只有用户在导入时确认过同一份清单,才继续做哈希校验。 + check(isUserConfirmed(manifest)) { "模型未列入受信目录,也未经用户确认" } + publisher = "user" + } val cacheEligible = allowCache && isInstalledDownloadedDirectory(directory, manifest) - val signature = downloadedPackageCacheSignature(directory, manifest, entry.publisher) + val signature = downloadedPackageCacheSignature(directory, manifest, publisher) if ( cacheEligible && preferences.getString(downloadedVerificationKey(manifest.modelId), null) == signature diff --git a/app/src/main/java/llc/slacker/openime/voice/VoiceRecognitionBackend.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceRecognitionBackend.kt index 955321c4..2b40b56a 100644 --- a/app/src/main/java/llc/slacker/openime/voice/VoiceRecognitionBackend.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceRecognitionBackend.kt @@ -4,13 +4,20 @@ import android.content.Context import android.util.Log import com.k2fsa.sherpa.onnx.FeatureConfig import com.k2fsa.sherpa.onnx.OnlineModelConfig +import com.k2fsa.sherpa.onnx.OnlineNeMoCtcModelConfig import com.k2fsa.sherpa.onnx.OnlineParaformerModelConfig +import com.k2fsa.sherpa.onnx.OnlineTransducerModelConfig +import com.k2fsa.sherpa.onnx.OnlineToneCtcModelConfig +import com.k2fsa.sherpa.onnx.OnlineZipformer2CtcModelConfig import com.k2fsa.sherpa.onnx.OnlineRecognizer import com.k2fsa.sherpa.onnx.OnlineRecognizerConfig import com.k2fsa.sherpa.onnx.OfflinePunctuation import com.k2fsa.sherpa.onnx.OfflinePunctuationConfig import com.k2fsa.sherpa.onnx.OfflinePunctuationModelConfig import com.k2fsa.sherpa.onnx.OnlineStream +import com.k2fsa.sherpa.onnx.SileroVadModelConfig +import com.k2fsa.sherpa.onnx.Vad +import com.k2fsa.sherpa.onnx.VadModelConfig import java.io.File import java.util.concurrent.atomic.AtomicBoolean @@ -102,6 +109,29 @@ internal class VoiceStreamLease( } } +/** + * 解码方法选择。sherpa-onnx 只有 transducer 支持热词:静态热词表与开流时的 + * 动态热词都要求 modified_beam_search,所以 transducer 一律用它; + * paraformer / CTC 即使用户有热词也保持 greedy_search(hasHotwords 被忽略)。 + */ +internal fun decodingFor(kind: StreamingModelKind, hasHotwords: Boolean): String = + if (kind == StreamingModelKind.TRANSDUCER) { + "modified_beam_search" + } else { + "greedy_search" + } + +/** + * 热词分词方式准入。原生 EncodeHotwords 只认 cjkchar / bpe / cjkchar+bpe, + * 遇到其他取值会直接结束进程;modelingUnit 未声明时按 cjkchar 处理(本应用 + * 的热词均含 CJK 字符),bpe 类缺少词表时返回 null(禁用热词)。 + */ +internal fun hotwordModelingUnit(modelingUnit: String, hasBpeVocab: Boolean): String? = when (modelingUnit) { + "", "cjkchar" -> "cjkchar" + "bpe", "cjkchar+bpe" -> modelingUnit.takeIf { hasBpeVocab } + else -> null +} + /** Local-only backend backed by whichever verified model source is selected. */ class EmbeddedLocalVoiceBackend( private val context: Context, @@ -162,7 +192,7 @@ object EmbeddedVoiceRuntimeFactory { selection: VoiceModelSelection, ): EmbeddedVoiceModelRuntime? { val manifest = selection.manifest ?: return null - if (manifest.modelType != "paraformer") return null + if (VoiceModelLayoutResolver.resolve(manifest) == null) return null val downloadedRoot = File(context.filesDir, VOICE_MODEL_DOWNLOADED_DIR) if (resolveSherpaRuntimeModelFiles(selection, downloadedRoot) == null) return null return SherpaOnnxStreamingRuntime(context, selection) @@ -190,19 +220,21 @@ private class SherpaOnnxStreamingRuntime( resolveSherpaRuntimeModelFiles(initialSelection, downloadedRoot) private var recognizer: OnlineRecognizer? = null private var punctuation: OfflinePunctuation? = null + private var vad: Vad? = null private val activeSessions = mutableSetOf() private var legacySession: SherpaSession? = null override val isReady: Boolean get() = synchronized(runtimeLock) { val manifest = selection.manifest - manifest?.modelType == "paraformer" && modelFiles != null + manifest != null && modelFiles != null } override fun preload() { synchronized(runtimeLock) { refreshSelectionLocked() ensureRecognizerLocked() + ensureVadLocked() } } @@ -251,6 +283,28 @@ private class SherpaOnnxStreamingRuntime( modelFiles = resolveSherpaRuntimeModelFiles(latest, downloadedRoot) } + /** 标点模型跟着所选模型走:内置读 assets,导入的读私有目录;没有就返回 null(保留原文)。 */ + private fun createPunctuationLocked(): OfflinePunctuation? { + val manifest = selection.manifest ?: return null + if (!manifest.supportsPunctuation) return null + val rel = VoiceModelLayoutResolver.punctuationPath(manifest) ?: return null + return when (selection.source) { + VoiceModelSelection.Source.BUILT_IN -> OfflinePunctuation( + context.assets, + OfflinePunctuationConfig(model = OfflinePunctuationModelConfig(ctTransformer = rel, numThreads = 2)), + ) + VoiceModelSelection.Source.DOWNLOADED -> { + val root = File(downloadedRoot, manifest.modelId) + val file = resolveContainedVoiceModelFile(root, rel) + OfflinePunctuation( + null, + OfflinePunctuationConfig(model = OfflinePunctuationModelConfig(ctTransformer = file.absolutePath, numThreads = 2)), + ) + } + VoiceModelSelection.Source.NONE -> null + } + } + private fun releaseRecognizerLocked() { activeSessions.toList().forEach { it.closeLocked() } legacySession = null @@ -258,25 +312,62 @@ private class SherpaOnnxStreamingRuntime( recognizer = null punctuation?.release() punctuation = null + vad?.release() + vad = null } private fun ensureRecognizerLocked(): OnlineRecognizer { recognizer?.let { return it } val files = checkNotNull(modelFiles) { "本地语音模型清单或文件不完整" } - val config = OnlineRecognizerConfig( - featConfig = FeatureConfig(sampleRate = SAMPLE_RATE, featureDim = 80), - modelConfig = OnlineModelConfig( + // 热词分词方式先过准入:原生编码器遇到不支持的 modelingUnit 会直接 + // 结束进程,bpe 类缺少词表也会失败;两种情况都不给引擎传热词。 + val hotwordUnit = hotwordModelingUnit(files.modelingUnit, files.bpeVocab != null) + if (files.kind == StreamingModelKind.TRANSDUCER && hotwordUnit == null) { + Log.i("OpenImeVoicePerf", "modelingUnit=${files.modelingUnit} 不支持热词,热词表与动态热词已禁用") + } + val staticHotwords = files.hotwords?.takeIf { + files.kind == StreamingModelKind.TRANSDUCER && hotwordUnit != null + } + val modelConfig = when (files.kind) { + StreamingModelKind.PARAFORMER -> OnlineModelConfig( paraformer = OnlineParaformerModelConfig( - encoder = files.encoder, - decoder = files.decoder, + encoder = checkNotNull(files.encoder), decoder = checkNotNull(files.decoder), ), - tokens = files.tokens, - numThreads = 2, - provider = "cpu", - modelType = "paraformer", - ), - enableEndpoint = false, - decodingMethod = "greedy_search", + tokens = files.tokens, numThreads = 2, provider = "cpu", modelType = "paraformer", + ) + StreamingModelKind.TRANSDUCER -> OnlineModelConfig( + transducer = OnlineTransducerModelConfig( + encoder = checkNotNull(files.encoder), decoder = checkNotNull(files.decoder), + joiner = checkNotNull(files.joiner), + ), + tokens = files.tokens, numThreads = 2, provider = "cpu", + // 家族留空时由 ONNX 元数据判定;bpe 词表/分词单元按包里的清单透传。 + modelType = files.family, + modelingUnit = hotwordUnit.orEmpty(), + bpeVocab = files.bpeVocab.orEmpty(), + ) + StreamingModelKind.ZIPFORMER2_CTC -> OnlineModelConfig( + zipformer2Ctc = OnlineZipformer2CtcModelConfig(model = checkNotNull(files.model)), + tokens = files.tokens, numThreads = 2, provider = "cpu", + ) + StreamingModelKind.NEMO_CTC -> OnlineModelConfig( + neMoCtc = OnlineNeMoCtcModelConfig(model = checkNotNull(files.model)), + tokens = files.tokens, numThreads = 2, provider = "cpu", + ) + StreamingModelKind.T_ONE_CTC -> OnlineModelConfig( + toneCtc = OnlineToneCtcModelConfig(model = checkNotNull(files.model)), + tokens = files.tokens, numThreads = 2, provider = "cpu", + ) + } + val config = OnlineRecognizerConfig( + featConfig = FeatureConfig(sampleRate = SAMPLE_RATE, featureDim = 80), + modelConfig = modelConfig, + // paraformer 的流式端点由本应用自己判断;其余类型用引擎自带的端点检测。 + enableEndpoint = files.kind != StreamingModelKind.PARAFORMER, + // 解码方法见 decodingFor();按解码方法要求固定 beam 宽度。 + decodingMethod = decodingFor(files.kind, staticHotwords != null), + maxActivePaths = 4, + hotwordsFile = staticHotwords.orEmpty(), ) val assetManager = if (files.storage == SherpaRuntimeStorage.ASSETS) { context.assets @@ -286,19 +377,65 @@ private class SherpaOnnxStreamingRuntime( return OnlineRecognizer(assetManager, config).also { recognizer = it } } + /** + * Silero VAD 可选:包里有 silero_vad.onnx 时用它做端点判断;创建失败只是 + * 记录日志并退回原有判断,绝不阻塞识别器本身。 + */ + private fun ensureVadLocked(): Vad? { + vad?.let { return it } + val files = modelFiles ?: return null + val model = files.vad ?: return null + return runCatching { + val config = VadModelConfig( + sileroVadModelConfig = SileroVadModelConfig( + model = model, + threshold = 0.5F, + minSilenceDuration = 0.5F, + minSpeechDuration = 0.25F, + windowSize = 512, + ), + sampleRate = SAMPLE_RATE, + numThreads = 1, + provider = "cpu", + ) + val assetManager = if (files.storage == SherpaRuntimeStorage.ASSETS) context.assets else null + Vad(assetManager, config).also { vad = it } + }.onFailure { + Log.w("OpenImeVoicePerf", "VAD 模型不可用,端点判断退回原有逻辑", it) + }.getOrNull() + } + private fun openSessionLocked( languageTag: String, events: VoiceRecognitionEvents, ): SherpaSession { val currentRecognizer = ensureRecognizerLocked() - // sherpa-onnx dynamic hotword graphs are implemented by the - // transducer recognizer. Streaming Paraformer uses a plain stream; - // openIME still applies its local post-ASR correction repository. - val stream = currentRecognizer.createStream() + val currentVad = ensureVadLocked() + val files = checkNotNull(modelFiles) { "本地语音模型清单或文件不完整" } + val hotwordUnit = hotwordModelingUnit(files.modelingUnit, files.bpeVocab != null) + // 动态热词只对 transducer 生效;每次开流都重新取,用户可能刚改过。 + // Streaming Paraformer uses a plain stream; openIME still applies its + // local post-ASR correction repository. + val hotwords = if (files.kind == StreamingModelKind.TRANSDUCER && hotwordUnit != null) { + VoiceHotwordProvider.current() + } else { + "" + } + val stream = if (hotwords.isEmpty()) { + currentRecognizer.createStream() + } else { + // sherpa-onnx 按 "/" 换行解析热词;图构建失败时退回无热词流, + // 不能让用户为一条坏热词丢掉整次录音。 + runCatching { currentRecognizer.createStream(hotwords) }.getOrElse { + Log.w("OpenImeVoicePerf", "动态热词开流失败,本次退回无热词流", it) + currentRecognizer.createStream() + } + } return SherpaSession( streamLease = VoiceStreamLease(stream) { it.release() }, languageTag = languageTag, events = events, + vad = currentVad, ).also(activeSessions::add) } @@ -306,8 +443,11 @@ private class SherpaOnnxStreamingRuntime( private val streamLease: VoiceStreamLease, private val languageTag: String, private val events: VoiceRecognitionEvents, + private val vad: Vad?, ) : StreamingVoiceModelSession { private var lastPartial = "" + private val endpointTracker = VadSpeechTracker() + private var activeVad: Vad? = vad override fun acceptWaveform(samples: FloatArray) { if (samples.isEmpty()) return @@ -316,11 +456,25 @@ private class SherpaOnnxStreamingRuntime( val currentRecognizer = checkNotNull(recognizer) { "语音识别器已释放" } val currentStream = streamLease.value currentStream.acceptWaveform(samples, SAMPLE_RATE) + observeVadLocked(samples) decodeReadyLocked(currentRecognizer, currentStream) } partials.forEach(events::onPartial) } + /** VAD 跟随同一块 PCM 更新;不可用或中途失败时立刻退回原有端点判断。 */ + private fun observeVadLocked(samples: FloatArray) { + val detector = activeVad ?: return + runCatching { + detector.acceptWaveform(samples) + endpointTracker.observe(detector.isSpeechDetected()) + }.onFailure { + activeVad = null + endpointTracker.observe(null) + Log.w("OpenImeVoicePerf", "VAD 推理失败,本次会话退回原有端点判断", it) + } + } + override fun inputFinished(): String { val (result, partials) = synchronized(runtimeLock) { checkOpenLocked() @@ -328,12 +482,25 @@ private class SherpaOnnxStreamingRuntime( val currentStream = streamLease.value // sherpa-onnx's streaming Paraformer examples append 300 ms // silence before InputFinished so the last chunk can flush. - currentStream.acceptWaveform(FloatArray(PARAFORMER_TAIL_PADDING_SAMPLES), SAMPLE_RATE) + // VAD 可用时用 isSpeechDetected 的状态收窄补零:整段没听到语音 + // 就不补;其余情况(包括没有 VAD)与原来完全一致。 + if (selection.manifest?.modelType == "paraformer" && endpointTracker.tailPaddingNeeded()) { + currentStream.acceptWaveform(FloatArray(PARAFORMER_TAIL_PADDING_SAMPLES), SAMPLE_RATE) + } + if (vad != null) { + Log.i( + "OpenImeVoicePerf", + "endpoint vadActive=${endpointTracker.isVadActive} " + + "hasSpeech=${endpointTracker.hasSpeech} " + + "speechEnded=${endpointTracker.speechEnded()} " + + "tailPad=${endpointTracker.tailPaddingNeeded()}", + ) + } currentStream.inputFinished() // InputFinished ends feature extraction, but Paraformer's // IsReady accepts the remaining short chunk only with this // option. Otherwise already-recorded tail frames stay undecoded. - currentStream.setOption("is_final", "1") + if (selection.manifest?.modelType == "paraformer") currentStream.setOption("is_final", "1") val decoded = decodeReadyLocked(currentRecognizer, currentStream) val recognizerFinal = currentRecognizer.getResult(currentStream).text.trim() val usedFallback = recognizerFinal.isBlank() && lastPartial.isNotBlank() @@ -366,12 +533,9 @@ private class SherpaOnnxStreamingRuntime( synchronized(runtimeLock) { checkOpenLocked() runCatching { - val model = punctuation ?: OfflinePunctuation(context.assets, - OfflinePunctuationConfig(model = OfflinePunctuationModelConfig( - ctTransformer = "models/voice/punctuation/model.int8.onnx", - numThreads = 2, - )), - ).also { punctuation = it } + val model = punctuation ?: createPunctuationLocked() + ?: error("当前模型不带标点模型") + punctuation = model model.addPunctuation(prepared) }.onFailure { Log.w("OpenImeVoicePerf", "punctuation failed; keeping transcript", it) } .getOrNull() diff --git a/app/src/main/java/llc/slacker/openime/voice/VoiceRuntimeModelSource.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceRuntimeModelSource.kt index f3b2abe4..081df3d0 100644 --- a/app/src/main/java/llc/slacker/openime/voice/VoiceRuntimeModelSource.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceRuntimeModelSource.kt @@ -14,11 +14,24 @@ internal enum class SherpaRuntimeStorage { FILES, } +/** 运行时要交给 sherpa-onnx 的文件。路径含义因 [kind] 而异,缺的角色为 null。 */ internal data class SherpaRuntimeModelFiles( val storage: SherpaRuntimeStorage, - val encoder: String, - val decoder: String, + val kind: StreamingModelKind, + val encoder: String? = null, + val decoder: String? = null, + val joiner: String? = null, + val model: String? = null, val tokens: String, + /** transducer 家族提示(传给 sherpa 的 modelType;空=读模型元数据)。 */ + val family: String = "", + /** 分词单元与 bpe 词表(中英混合模型可能需要)。 */ + val modelingUnit: String = "", + val bpeVocab: String? = null, + /** 热词表;有它时 transducer 改用 modified_beam_search。 */ + val hotwords: String? = null, + /** Silero VAD 模型(可选);有它时端点判断用 isSpeechDetected。 */ + val vad: String? = null, ) internal fun VoiceModelSelection.runtimeIdentity(): String { @@ -36,18 +49,27 @@ internal fun resolveSherpaRuntimeModelFiles( downloadedRoot: File, ): SherpaRuntimeModelFiles? { val manifest = selection.manifest ?: return null - if (manifest.modelType != "paraformer") return null - val required = listOf(SHERPA_ENCODER, SHERPA_DECODER, SHERPA_TOKENS) - if (!manifest.files.containsAll(required)) return null + val layout = VoiceModelLayoutResolver.resolve(manifest) ?: return null - return when (selection.source) { - VoiceModelSelection.Source.BUILT_IN -> SherpaRuntimeModelFiles( - storage = SherpaRuntimeStorage.ASSETS, - encoder = SHERPA_ENCODER, - decoder = SHERPA_DECODER, - tokens = SHERPA_TOKENS, + fun build(storage: SherpaRuntimeStorage, resolve: (String) -> String): SherpaRuntimeModelFiles = + SherpaRuntimeModelFiles( + storage = storage, + kind = layout.kind, + encoder = layout.files["encoder"]?.let(resolve), + decoder = layout.files["decoder"]?.let(resolve), + joiner = layout.files["joiner"]?.let(resolve), + model = layout.files["model"]?.let(resolve), + tokens = resolve(layout.path("tokens")), + family = manifest.family, + modelingUnit = manifest.modelingUnit, + bpeVocab = layout.files["bpeVocab"]?.let(resolve), + hotwords = layout.files["hotwords"]?.let(resolve), + vad = layout.files["vad"]?.let(resolve), ) + return when (selection.source) { + VoiceModelSelection.Source.BUILT_IN -> build(SherpaRuntimeStorage.ASSETS) { it } + VoiceModelSelection.Source.DOWNLOADED -> { if (!isSafeVoiceModelId(manifest.modelId)) return null val canonicalDownloadedRoot = downloadedRoot.canonicalFile @@ -55,19 +77,12 @@ internal fun resolveSherpaRuntimeModelFiles( if (!packageRoot.isDirectory || Files.isSymbolicLink(packageRoot.toPath())) return null if (packageRoot.canonicalFile.parentFile != canonicalDownloadedRoot) return null - fun resolve(path: String): String { - val file = resolveContainedVoiceModelFile(packageRoot, path) - require(file.isFile) { "缺少模型文件: $path" } - return file.absolutePath - } - runCatching { - SherpaRuntimeModelFiles( - storage = SherpaRuntimeStorage.FILES, - encoder = resolve(SHERPA_ENCODER), - decoder = resolve(SHERPA_DECODER), - tokens = resolve(SHERPA_TOKENS), - ) + build(SherpaRuntimeStorage.FILES) { path -> + val file = resolveContainedVoiceModelFile(packageRoot, path) + require(file.isFile) { "缺少模型文件: $path" } + file.absolutePath + } }.getOrNull() } diff --git a/app/src/main/res/drawable/ic_kb_double.xml b/app/src/main/res/drawable/ic_kb_double.xml new file mode 100644 index 00000000..56478606 --- /dev/null +++ b/app/src/main/res/drawable/ic_kb_double.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_kb_wubi26.xml b/app/src/main/res/drawable/ic_kb_wubi26.xml new file mode 100644 index 00000000..94db0137 --- /dev/null +++ b/app/src/main/res/drawable/ic_kb_wubi26.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_handwriting.xml b/app/src/main/res/drawable/ic_pref_handwriting.xml new file mode 100644 index 00000000..23402207 --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_handwriting.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c2a175b..b08095e4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -53,4 +53,5 @@ 常用符号栏 关于 数据管理 + 语音模型 diff --git a/app/src/test/java/llc/slacker/openime/ArchitectureLayeringTest.kt b/app/src/test/java/llc/slacker/openime/ArchitectureLayeringTest.kt index e1671b04..006d1361 100644 --- a/app/src/test/java/llc/slacker/openime/ArchitectureLayeringTest.kt +++ b/app/src/test/java/llc/slacker/openime/ArchitectureLayeringTest.kt @@ -27,8 +27,10 @@ class ArchitectureLayeringTest { "widget" to setOf("theme"), "floating" to setOf("theme"), "handwriting" to setOf("data", "theme"), + // 纯数据/逻辑引擎:二进制表与拼音流水线;只依赖最底层的 core(模糊音规则)。 + "engine" to setOf("core"), "rime" to setOf("core", "data"), - "candidate" to setOf("core", "rime"), + "candidate" to setOf("core", "engine", "rime"), "voice" to setOf("data", "editor", "theme"), "panel" to setOf("core", "data", "handwriting", "setup", "theme", "widget"), "keyboard" to setOf( @@ -37,7 +39,7 @@ class ArchitectureLayeringTest { ), "app" to setOf( "candidate", "core", "data", "editor", "floating", "keyboard", - "rime", "setup", "theme", "voice", + "rime", "setup", "theme", "voice", "engine", ), ) diff --git a/app/src/test/java/llc/slacker/openime/CandidatePreviewPolicyTest.kt b/app/src/test/java/llc/slacker/openime/CandidatePreviewPolicyTest.kt new file mode 100644 index 00000000..f72306aa --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/CandidatePreviewPolicyTest.kt @@ -0,0 +1,66 @@ +package llc.slacker.openime + +import llc.slacker.openime.candidate.CandidatePreviewPolicy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CandidatePreviewPolicyTest { + @Test + fun rawPinyinEchoIsNotShownWhileWaitingForRime() { + assertEquals(emptyList(), CandidatePreviewPolicy.chineseOnly(listOf("xyzzy"))) + } + + @Test + fun pinyinEchoIsFilteredEvenWhenPreviewArrivesBeforeRimeReady() { + // immediateCandidates 使用同一过滤器处理 Rime 未就绪帧。 + assertEquals(emptyList(), CandidatePreviewPolicy.chineseOnly(listOf("nihao", "xyz"))) + assertEquals(listOf("你好"), CandidatePreviewPolicy.chineseOnly(listOf("nihao", "你好"))) + } + + @Test + fun chineseCandidatesSurviveAndKeepTheirOrder() { + assertEquals( + listOf("你好", "泥"), + CandidatePreviewPolicy.chineseOnly(listOf("nihao", "你好", "ni", "泥")), + ) + } + + @Test + fun mixedTextWithAHanCharacterIsKept() { + assertEquals(listOf("A股"), CandidatePreviewPolicy.chineseOnly(listOf("A股", "agu"))) + } + + @Test + fun latinLettersWithAccentsAreNotChinese() { + // 旧实现用 code > 0x7f 会把 é 误判为汉字。 + assertEquals(emptyList(), CandidatePreviewPolicy.chineseOnly(listOf("café", "naïve"))) + } + + @Test + fun fullWidthPunctuationAndSymbolsAreNotChinese() { + // 全角标点 U+FF0C 等 code > 0x7f,但不是汉字。 + assertEquals(emptyList(), CandidatePreviewPolicy.chineseOnly(listOf(",", "!?", "…"))) + } + + @Test + fun cjkExtensionBCharacterIsChinese() { + // U+20000 是扩展区 B,是代理对,必须按码点而不是 char 判定。 + val extB = String(Character.toChars(0x20000)) + assertTrue(CandidatePreviewPolicy.containsHan(extB)) + assertEquals(listOf(extB), CandidatePreviewPolicy.chineseOnly(listOf("xx", extB))) + } + + @Test + fun compatibilityIdeographIsChinese() { + // U+F900 CJK 兼容表意文字。 + assertTrue(CandidatePreviewPolicy.containsHan("\uF900")) + } + + @Test + fun kanaAndHangulAreNotHan() { + assertFalse(CandidatePreviewPolicy.containsHan("ひらがな")) + assertFalse(CandidatePreviewPolicy.containsHan("한글")) + } +} diff --git a/app/src/test/java/llc/slacker/openime/ClipboardSuppressionTest.kt b/app/src/test/java/llc/slacker/openime/ClipboardSuppressionTest.kt new file mode 100644 index 00000000..90c457fa --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/ClipboardSuppressionTest.kt @@ -0,0 +1,39 @@ +package llc.slacker.openime + +import llc.slacker.openime.data.ClipboardSuppression +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ClipboardSuppressionTest { + @Test + fun sameTextFromADifferentCopyIsNotSuppressed() { + // 清空的是第一次复制(时间戳 1000)。同样文字再次复制(时间戳 2000)必须能收下。 + val marker = ClipboardSuppression.fingerprint(1000L, "hello") + assertFalse(ClipboardSuppression.isSuppressed(marker, 2000L, "hello")) + } + + @Test + fun clearedClipIsStillSuppressedOnRereads() { + val marker = ClipboardSuppression.fingerprint(1000L, "hello") + assertTrue(ClipboardSuppression.isSuppressed(marker, 1000L, "hello")) + } + + @Test + fun hashCollisionDoesNotSuppressADifferentText() { + // "Aa" 与 "BB" 的 String.hashCode 相同、长度相同,旧指纹会误判。 + assertTrue("Aa".hashCode() == "BB".hashCode()) + val marker = ClipboardSuppression.fingerprint(0L, "Aa") + assertFalse(ClipboardSuppression.isSuppressed(marker, 0L, "BB")) + assertNotEquals( + ClipboardSuppression.fingerprint(0L, "Aa"), + ClipboardSuppression.fingerprint(0L, "BB"), + ) + } + + @Test + fun noMarkerNeverSuppresses() { + assertFalse(ClipboardSuppression.isSuppressed(null, 1000L, "hello")) + } +} diff --git a/app/src/test/java/llc/slacker/openime/DoublePinyinSchemaTest.kt b/app/src/test/java/llc/slacker/openime/DoublePinyinSchemaTest.kt new file mode 100644 index 00000000..45baaf5a --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/DoublePinyinSchemaTest.kt @@ -0,0 +1,53 @@ +package llc.slacker.openime + +import llc.slacker.openime.core.DoublePinyinScheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * 双拼方案的结构验收:两个方案随包分发、注册在 default.yaml、键位表与补全显示齐全, + * 且不引入数字字母(那是九键 T9 的约定,双拼只吃字母)。 + */ +class DoublePinyinSchemaTest { + + private fun asset(relative: String): File = sequenceOf( + File("src/main/assets/$relative"), + File("app/src/main/assets/$relative"), + ).firstOrNull { it.isFile } ?: error("missing test asset: $relative") + + @Test + fun bothSchemasShipAndShareThePinyinDictionary() { + val flypy = asset("rime-data/double_pinyin_flypy.schema.yaml").readText() + val natural = asset("rime-data/double_pinyin.schema.yaml").readText() + for ((text, id, prism) in listOf( + Triple(flypy, "double_pinyin_flypy", "double_pinyin_flypy"), + Triple(natural, "double_pinyin", "double_pinyin"), + )) { + assertTrue("missing schema_id $id", text.contains("schema_id: $id")) + assertTrue("missing prism $prism", text.contains("prism: $prism")) + // 词库沿用全拼,不重复一份 dict。 + assertTrue("must include the shared luna schema", text.contains("__include: luna_pinyin.schema:/")) + // 键位表 + 拼音显示补全都存在。 + assertTrue("missing chord table", text.contains("xlit/ⓆⓌⓇⓉ")) + assertTrue("missing preedit expansion", text.contains("xform/(\\w)q/\$1iu/") || text.contains("xform/(\\w)q/")) + // 双拼只吃字母:不继承九键数字约定。 + assertTrue("alphabet must not carry T9 digits", !text.contains("987654321")) + } + val defaults = asset("rime-data/default.yaml").readText() + assertTrue(defaults.contains("- schema: double_pinyin_flypy")) + assertTrue(defaults.contains("- schema: double_pinyin")) + } + + @Test + fun schemeLabelsAndIdsMatchTheReferenceProduct() { + assertEquals("小鹤双拼", DoublePinyinScheme.FLYPY.label) + assertEquals("自然码", DoublePinyinScheme.NATURAL.label) + assertEquals("double_pinyin_flypy", DoublePinyinScheme.FLYPY.schemaId) + assertEquals("double_pinyin", DoublePinyinScheme.NATURAL.schemaId) + assertEquals(DoublePinyinScheme.FLYPY, DoublePinyinScheme.fromKey(null)) + assertEquals(DoublePinyinScheme.FLYPY, DoublePinyinScheme.fromKey("unknown")) + assertEquals(DoublePinyinScheme.NATURAL, DoublePinyinScheme.fromKey("natural")) + } +} diff --git a/app/src/test/java/llc/slacker/openime/EnglishLexiconTest.kt b/app/src/test/java/llc/slacker/openime/EnglishLexiconTest.kt new file mode 100644 index 00000000..063d75ef --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/EnglishLexiconTest.kt @@ -0,0 +1,40 @@ +package llc.slacker.openime + +import llc.slacker.openime.candidate.EnglishLexicon +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class EnglishLexiconTest { + + private val lexicon = EnglishLexicon.fromEntries( + listOf( + "hello" to 779, + "help" to 200, + "head" to 300, + "he" to 40, + "world" to 500, + ), + ) + + @Test + fun prefixMatchesRankByFrequency() { + assertEquals(listOf("he", "help", "head", "hello"), lexicon.complete("he", 10)) + } + + @Test + fun limitKeepsTheMostFrequent() { + assertEquals(listOf("he", "help"), lexicon.complete("he", 2)) + } + + @Test + fun matchingIsCaseInsensitive() { + assertEquals(listOf("he", "help", "head", "hello"), lexicon.complete("HE", 10)) + } + + @Test + fun unknownPrefixIsEmpty() { + assertTrue(lexicon.complete("zzz", 5).isEmpty()) + assertTrue(lexicon.complete("", 5).isEmpty()) + } +} diff --git a/app/src/test/java/llc/slacker/openime/FloatingPositionMemoryTest.kt b/app/src/test/java/llc/slacker/openime/FloatingPositionMemoryTest.kt new file mode 100644 index 00000000..11ddd75e --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/FloatingPositionMemoryTest.kt @@ -0,0 +1,36 @@ +package llc.slacker.openime + +import android.content.res.Configuration +import llc.slacker.openime.floating.FloatingPositionMemory +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class FloatingPositionMemoryTest { + + @Test + fun eachOrientationKeepsItsOwnPosition() { + val memory = FloatingPositionMemory() + memory.save(Configuration.ORIENTATION_PORTRAIT, 20, 900) + memory.save(Configuration.ORIENTATION_LANDSCAPE, 300, 120) + + assertArrayEquals(intArrayOf(20, 900), memory.restore(Configuration.ORIENTATION_PORTRAIT)) + assertArrayEquals(intArrayOf(300, 120), memory.restore(Configuration.ORIENTATION_LANDSCAPE)) + } + + @Test + fun unknownOrientationRestoresNothing() { + val memory = FloatingPositionMemory() + assertNull(memory.restore(Configuration.ORIENTATION_PORTRAIT)) + assertNull(memory.restore(Configuration.ORIENTATION_LANDSCAPE)) + } + + @Test + fun savedPositionsAreCopies() { + val memory = FloatingPositionMemory() + memory.save(Configuration.ORIENTATION_PORTRAIT, 10, 20) + val restored = memory.restore(Configuration.ORIENTATION_PORTRAIT)!! + restored[0] = 999 + assertArrayEquals(intArrayOf(10, 20), memory.restore(Configuration.ORIENTATION_PORTRAIT)) + } +} diff --git a/app/src/test/java/llc/slacker/openime/HandwritingStrokeClassifierTest.kt b/app/src/test/java/llc/slacker/openime/HandwritingStrokeClassifierTest.kt new file mode 100644 index 00000000..97e169b6 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/HandwritingStrokeClassifierTest.kt @@ -0,0 +1,43 @@ +package llc.slacker.openime + +import llc.slacker.openime.handwriting.HandwritingStrokeClassifier +import llc.slacker.openime.handwriting.Stroke +import llc.slacker.openime.handwriting.StrokePoint +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class HandwritingStrokeClassifierTest { + + private fun stroke(vararg points: Pair): Stroke = + Stroke(points.mapIndexed { index, (x, y) -> StrokePoint(x, y, index * 10L) }) + + private fun classify(vararg points: Pair) = + HandwritingStrokeClassifier.classify(stroke(*points).points) + + @Test + fun straightStrokesReadTheirDirection() { + assertEquals('h', classify(10f to 50f, 120f to 52f)) + assertEquals('s', classify(50f to 10f, 52f to 120f)) + assertEquals('p', classify(120f to 10f, 30f to 110f)) + assertEquals('n', classify(30f to 10f, 120f to 110f)) + } + + @Test + fun aSharpTurnMakesAZhe() { + assertEquals('z', classify(10f to 10f, 110f to 12f, 112f to 110f)) + assertEquals('z', classify(110f to 10f, 112f to 110f, 12f to 112f)) + } + + @Test + fun gentleCurvesStayStraight() { + assertEquals('h', classify(10f to 10f, 60f to 14f, 110f to 12f)) + assertEquals('s', classify(10f to 10f, 14f to 60f, 12f to 110f)) + } + + @Test + fun noiseIsRejected() { + assertNull(classify(10f to 10f, 12f to 11f)) + assertNull(classify(10f to 10f)) + } +} diff --git a/app/src/test/java/llc/slacker/openime/KeyboardHeightDragPolicyTest.kt b/app/src/test/java/llc/slacker/openime/KeyboardHeightDragPolicyTest.kt new file mode 100644 index 00000000..191ae53a --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/KeyboardHeightDragPolicyTest.kt @@ -0,0 +1,34 @@ +package llc.slacker.openime + +import llc.slacker.openime.keyboard.KeyboardHeightDragPolicy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class KeyboardHeightDragPolicyTest { + + @Test + fun draggingUpGrowsAndDraggingDownShrinks() { + assertTrue(KeyboardHeightDragPolicy.percentDelta(-100f, 2400) > 0) + assertTrue(KeyboardHeightDragPolicy.percentDelta(100f, 2400) < 0) + } + + @Test + fun aFifthOfTheScreenHeightCoversTheWholeRange() { + // 屏高的 20% 对应 40 个百分点(80%..120% 的整段)。 + assertEquals(40, KeyboardHeightDragPolicy.percentDelta(-480f, 2400)) + assertEquals(1, KeyboardHeightDragPolicy.percentDelta(-12f, 2400)) + } + + @Test + fun degenerateInputsAreSafe() { + assertEquals(0, KeyboardHeightDragPolicy.percentDelta(0f, 2400)) + assertEquals(0, KeyboardHeightDragPolicy.percentDelta(-100f, 0)) + } + + @Test + fun applyToMovesFromTheCurrentPercent() { + assertEquals(105, KeyboardHeightDragPolicy.applyTo(100, -60f, 2400)) + assertEquals(95, KeyboardHeightDragPolicy.applyTo(100, 60f, 2400)) + } +} diff --git a/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt b/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt index 75c46ae3..8e06ee2a 100644 --- a/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt +++ b/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt @@ -53,9 +53,30 @@ class KeyboardLayoutMetricsTest { fun userHeightScaleStaysWithinTheSupportedRange() { val smallest = KeyboardLayoutMetrics(landscape = false, fontScale = 1f, heightPercent = 10) val largest = KeyboardLayoutMetrics(landscape = false, fontScale = 1f, heightPercent = 500) - // Out-of-range requests are clamped to 80%..120%, then floored at 44dp. - assertEquals(maxOf(44, ImeGeometryTokens.KEY_ROW_HEIGHT_DP * 80 / 100), smallest.keyRowHeightDp) - assertEquals(ImeGeometryTokens.KEY_ROW_HEIGHT_DP * 120 / 100, largest.keyRowHeightDp) + // Out-of-range requests are clamped to the token range, then floored at 34dp. + val lo = ImeGeometryTokens.KEYBOARD_HEIGHT_MIN_PERCENT + val hi = ImeGeometryTokens.KEYBOARD_HEIGHT_MAX_PERCENT + assertEquals(maxOf(34, ImeGeometryTokens.KEY_ROW_HEIGHT_DP * lo / 100), smallest.keyRowHeightDp) + assertEquals(ImeGeometryTokens.KEY_ROW_HEIGHT_DP * hi / 100, largest.keyRowHeightDp) + } + + @Test + fun defaultHeightMatchesTheUnscaledRow() { + val default = KeyboardLayoutMetrics( + landscape = false, fontScale = 1f, + heightPercent = ImeGeometryTokens.KEYBOARD_HEIGHT_DEFAULT_PERCENT, + ) + assertEquals(ImeGeometryTokens.KEY_ROW_HEIGHT_DP, default.keyRowHeightDp) + } + + @Test + fun everySliderDefaultLiesInsideItsRange() { + listOf( + Triple(ImeGeometryTokens.KEYBOARD_HEIGHT_MIN_PERCENT, ImeGeometryTokens.KEYBOARD_HEIGHT_MAX_PERCENT, ImeGeometryTokens.KEYBOARD_HEIGHT_DEFAULT_PERCENT), + Triple(ImeGeometryTokens.FLOATING_WIDTH_MIN_PERCENT, ImeGeometryTokens.FLOATING_WIDTH_MAX_PERCENT, ImeGeometryTokens.FLOATING_WIDTH_DEFAULT_PERCENT), + Triple(ImeGeometryTokens.FLOATING_OPACITY_MIN_PERCENT, ImeGeometryTokens.FLOATING_OPACITY_MAX_PERCENT, ImeGeometryTokens.FLOATING_OPACITY_DEFAULT_PERCENT), + Triple(ImeGeometryTokens.HAPTIC_STRENGTH_MIN_PERCENT, ImeGeometryTokens.HAPTIC_STRENGTH_MAX_PERCENT, ImeGeometryTokens.HAPTIC_STRENGTH_DEFAULT_PERCENT), + ).forEach { (lo, hi, def) -> org.junit.Assert.assertTrue("$def not in $lo..$hi", def in lo..hi) } } @Test diff --git a/app/src/test/java/llc/slacker/openime/NineKeyProbeTest.kt b/app/src/test/java/llc/slacker/openime/NineKeyProbeTest.kt new file mode 100644 index 00000000..9ecbe8e3 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/NineKeyProbeTest.kt @@ -0,0 +1,32 @@ +package llc.slacker.openime + +import llc.slacker.openime.candidate.CandidateEngine +import llc.slacker.openime.candidate.NineKeyLocalDecoder +import llc.slacker.openime.candidate.PinyinLexicon +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File + +/** 临时探针(验证后删除):打印九键解码在典型输入下的读数与候选。 */ +class NineKeyProbeTest { + private fun asset(name: String): File? = sequenceOf( + File("src/main/assets/$name"), + File("app/src/main/assets/$name"), + ).firstOrNull { it.isFile } + + @Test + fun probe() { + val phrases = asset("pinyin_phrases.tsv") + assumeTrue(phrases != null) + val engine = CandidateEngine(emptyMap()) + val decoder = NineKeyLocalDecoder(engine) + for (digits in listOf("94", "64", "9426", "94'26", "9536", "744", "242", "2536", "836", "94264", "64'426")) { + val options = decoder.readingOptions(digits, null, 6) + val digitsOfReadings = options.joinToString(" | ") { it.syllables.joinToString("'") } + println("digits=$digits readings=[$digitsOfReadings]") + } + for (reading in listOf("ni", "hao", "xian", "zai", "xi'an")) { + println("digitsFor($reading) = ${NineKeyLocalDecoder.digitsForPinyin(reading)}") + } + } +} diff --git a/app/src/test/java/llc/slacker/openime/OneHandPolicyTest.kt b/app/src/test/java/llc/slacker/openime/OneHandPolicyTest.kt new file mode 100644 index 00000000..2d490121 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/OneHandPolicyTest.kt @@ -0,0 +1,52 @@ +package llc.slacker.openime + +import llc.slacker.openime.core.OneHandMode +import llc.slacker.openime.keyboard.OneHandPolicy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class OneHandPolicyTest { + + @Test + fun offKeepsTheSymmetricLayout() { + assertNull(OneHandPolicy.insets(1200, OneHandMode.OFF, baseInsetPx = 60)) + } + + @Test + fun leftHandAnchorsTheKeyboardToTheLeft() { + val insets = OneHandPolicy.insets(1000, OneHandMode.LEFT, baseInsetPx = 0)!! + assertEquals(0, insets.first) + assertEquals(240, insets.second) // 1000 - 760 wide keyboard + } + + @Test + fun rightHandAnchorsTheKeyboardToTheRight() { + val insets = OneHandPolicy.insets(1000, OneHandMode.RIGHT, baseInsetPx = 0)!! + assertEquals(240, insets.first) + assertEquals(0, insets.second) + } + + @Test + fun baseInsetIsKeptOnTheAnchoredSide() { + val insets = OneHandPolicy.insets(1000, OneHandMode.LEFT, baseInsetPx = 50)!! + assertEquals(50, insets.first) + assertEquals(190, insets.second) // 1000 - 760 - 50 + } + + @Test + fun theKeyboardKeepsTheRequestedWidth() { + val width = 1400 + for (mode in listOf(OneHandMode.LEFT, OneHandMode.RIGHT)) { + val insets = OneHandPolicy.insets(width, mode, baseInsetPx = 40)!! + val content = width - insets.first - insets.second + assertEquals((width * OneHandPolicy.WIDTH_FRACTION).toInt(), content) + } + } + + @Test + fun tinyWindowsNeverProduceNegativeInsets() { + val insets = OneHandPolicy.insets(100, OneHandMode.RIGHT, baseInsetPx = 80)!! + assertEquals(true, insets.first >= 0 && insets.second >= 0) + } +} diff --git a/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt b/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt new file mode 100644 index 00000000..8a3ffda8 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt @@ -0,0 +1,130 @@ +package llc.slacker.openime + +import llc.slacker.openime.core.FuzzyRule +import llc.slacker.openime.engine.BigramIndex +import llc.slacker.openime.engine.LexiconIndex +import llc.slacker.openime.engine.PinyinEngine +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import java.nio.channels.FileChannel + +/** + * Quality gate for the clean-room pinyin engine against the locally compiled + * tables. The tables are not part of the repository, so the test skips when + * they are absent; run scripts/build_imengine_data.py first. + */ +class PinyinEngineDataTest { + + private fun engineAsset(name: String): File? = sequenceOf( + File("src/main/assets/imengine/$name"), + File("app/src/main/assets/imengine/$name"), + ).firstOrNull { it.isFile } + + private fun loadEngine(): PinyinEngine { + val lex = engineAsset("lex.bin") + val bigram = engineAsset("bigram.bin") + assumeTrue("imengine/lex.bin not built", lex != null) + val lexicon = LexiconIndex(map(lex!!)) + val bigramIndex = bigram?.let { BigramIndex(map(it)) } + return PinyinEngine(lexicon, bigramIndex) + } + + private fun map(file: File) = + java.io.RandomAccessFile(file, "r").use { raf -> + raf.channel.map(FileChannel.MapMode.READ_ONLY, 0, raf.length()) + .order(java.nio.ByteOrder.LITTLE_ENDIAN) + } + + @Test + fun candidatesHitTheExpectedWords() { + val engine = loadEngine() + val cases = listOf( + "nihao" to "你好", + "beijing" to "北京", + "zhongguo" to "中国", + "shouji" to "手机", + "diannao" to "电脑", + "xiexie" to "谢谢", + "nihaoma" to "你好吗", + "jintian" to "今天", + "zaijian" to "再见", + "mingtian" to "明天", + "pengyou" to "朋友", + "gongzuo" to "工作", + ) + var top1 = 0 + var top3 = 0 + for ((input, expected) in cases) { + val result = engine.candidates(input) + val hit1 = result.firstOrNull() == expected + val hit3 = result.take(3).contains(expected) + if (hit1) top1++ + if (hit3) top3++ + println("candidates %-12s -> %s %s".format(input, result.take(3), if (hit1) "TOP1" else if (hit3) "top3" else "MISS")) + } + println("candidates: top1=$top1/${cases.size} top3=$top3/${cases.size}") + assertTrue("candidates top1 below floor: $top1/${cases.size}", top1 * 100 >= cases.size * 80) + assertTrue("candidates top3 below floor: $top3/${cases.size}", top3 * 100 >= cases.size * 95) + } + + @Test + fun fuzzyRulesExpandCandidatesAndKeepExactReadingsFirst() { + val engine = loadEngine() + val rules = setOf(FuzzyRule.Z_ZH, FuzzyRule.N_L, FuzzyRule.S_SH) + + // 中国 needs zh↔z: it only appears once the rule is on. + assertTrue(engine.candidates("zongguo").none { it == "中国" }) + assertEquals("中国", engine.candidates("zongguo", fuzzyRules = rules).firstOrNull()) + + // 牛奶 via n↔l (liulai spells liu-lai); the frequent fuzzy reading beats + // the rare word that spells the letters literally. + assertEquals("牛奶", engine.candidates("liulai", fuzzyRules = rules).firstOrNull()) + // 农村 via n↔l on the first syllable. + assertEquals("农村", engine.candidates("longcun", fuzzyRules = rules).firstOrNull()) + // 师傅 via sh↔s, 中学 via zh↔z. + assertEquals("师傅", engine.candidates("sifu", fuzzyRules = rules).firstOrNull()) + assertEquals("中学", engine.candidates("zongxue", fuzzyRules = rules).firstOrNull()) + + // The exact spelling of an equally frequent word is not displaced. + val plain = engine.candidates("xiexie") + val fuzzy = engine.candidates("xiexie", fuzzyRules = rules) + assertEquals(plain.first(), fuzzy.first()) + } + + @Test + fun sentencesDecodeToTheExpectedWords() { + val engine = loadEngine() + val cases = listOf( + "nihao" to "你好", + "nihaoshijie" to "你好世界", + "woaini" to "我爱你", + "woaibeijing" to "我爱北京", + "zhongguogongchandang" to "中国共产党", + "jintianxingqiwu" to "今天星期五", + "meiguanxi" to "没关系", + "buhaoyisi" to "不好意思", + "duibuqi" to "对不起", + "xiexie" to "谢谢", + "nizaijia" to "你在家", + "haodexiexie" to "好的谢谢", + "zhongguoshijie" to "中国世界", + "wohenhaoxiexie" to "我很好谢谢", + ) + var top1 = 0 + var top3 = 0 + for ((input, expected) in cases) { + val result = engine.decode(input) + val hit1 = result.firstOrNull() == expected + val hit3 = result.take(3).contains(expected) + if (hit1) top1++ + if (hit3) top3++ + println("decode %-24s -> %s %s".format(input, result.take(3), if (hit1) "TOP1" else if (hit3) "top3" else "MISS")) + } + println("decode: top1=$top1/${cases.size} top3=$top3/${cases.size}") + assertTrue("decode top1 below floor: $top1/${cases.size}", top1 * 100 >= cases.size * 85) + assertTrue("decode top3 below floor: $top3/${cases.size}", top3 * 100 >= cases.size * 95) + } +} diff --git a/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt b/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt index 55333cf9..2793cf84 100644 --- a/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt @@ -22,6 +22,19 @@ class ProductionKeyPolicyTest { ) } + @Test + fun twentySixKeyBottomRowGivesLandscapeASpaceBarLikeTheReference() { + val portrait = ProductionKeyPolicy.twentySixKeyBottomRowWeights() + val landscape = ProductionKeyPolicy.twentySixKeyBottomRowWeights(landscape = true) + // 参考键盘横屏皮肤只放大空格键(133.6 -> 182.2,×1.3638),其余键不动。 + assertEquals(portrait.leftOuter, landscape.leftOuter, 0.0001f) + assertEquals(portrait.leftInner, landscape.leftInner, 0.0001f) + assertEquals(portrait.rightInner, landscape.rightInner, 0.0001f) + assertEquals(portrait.rightOuter, landscape.rightOuter, 0.0001f) + assertEquals(210f, landscape.space, 0.2f) + assertEquals(182.2f / 133.6f, landscape.space / portrait.space, 0.01f) + } + @Test fun twentySixKeyBottomRowIsSymmetricAroundTheSpaceKey() { val weights = ProductionKeyPolicy.twentySixKeyBottomRowWeights() diff --git a/app/src/test/java/llc/slacker/openime/ReferenceLandscapeInsetsTest.kt b/app/src/test/java/llc/slacker/openime/ReferenceLandscapeInsetsTest.kt new file mode 100644 index 00000000..6d99d89a --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/ReferenceLandscapeInsetsTest.kt @@ -0,0 +1,36 @@ +package llc.slacker.openime + +import llc.slacker.openime.keyboard.ReferenceLandscapeInsets +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 横屏留白取自参考键盘横屏皮肤的实测值;这些数字一变,横屏观感就变, + * 所以在这里钉死。 + */ +class ReferenceLandscapeInsetsTest { + + @Test + fun fractionsMatchTheReferenceSkin() { + assertEquals(0.0648508f, ReferenceLandscapeInsets.KEYBOARD_FRACTION, 0.0000001f) + assertEquals(0.095f, ReferenceLandscapeInsets.NINE_KEY_FRACTION, 0.0001f) + assertEquals(0.082f, ReferenceLandscapeInsets.TOOLBAR_FRACTION, 0.0001f) + assertEquals(0.095f, ReferenceLandscapeInsets.EXPANDED_CANDIDATES_FRACTION, 0.0001f) + } + + @Test + fun nineKeyKeepsMoreRoomThanTwentySixKey() { + assertEquals(ReferenceLandscapeInsets.KEYBOARD_FRACTION, ReferenceLandscapeInsets.keyboardFraction(nineKey = false), 0.0000001f) + assertEquals(ReferenceLandscapeInsets.NINE_KEY_FRACTION, ReferenceLandscapeInsets.keyboardFraction(nineKey = true), 0.0000001f) + assertTrue(ReferenceLandscapeInsets.keyboardFraction(nineKey = true) > ReferenceLandscapeInsets.keyboardFraction(nineKey = false)) + } + + @Test + fun contentStaysComfortablyWiderThanHalfTheWindow() { + // 留白只是"把内容往里收一点":两侧合计必须远小于一半宽度。 + val total = ReferenceLandscapeInsets.KEYBOARD_FRACTION * 2 + assertTrue(total < 0.2f) + assertTrue(ReferenceLandscapeInsets.TOOLBAR_FRACTION > ReferenceLandscapeInsets.KEYBOARD_FRACTION) + } +} diff --git a/app/src/test/java/llc/slacker/openime/RimeFuzzySchemaTest.kt b/app/src/test/java/llc/slacker/openime/RimeFuzzySchemaTest.kt index ac3ce0fb..70e27f42 100644 --- a/app/src/test/java/llc/slacker/openime/RimeFuzzySchemaTest.kt +++ b/app/src/test/java/llc/slacker/openime/RimeFuzzySchemaTest.kt @@ -1,5 +1,7 @@ package llc.slacker.openime +import llc.slacker.openime.core.DoublePinyinScheme +import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.rime.rimeSchemaId import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -15,8 +17,24 @@ class RimeFuzzySchemaTest { @Test fun fuzzySettingMapsToDistinctNativeSchemas() { - assertEquals("luna_pinyin_simp", rimeSchemaId(fuzzyEnabled = false)) - assertEquals("luna_pinyin_simp_fuzzy", rimeSchemaId(fuzzyEnabled = true)) + assertEquals("luna_pinyin_simp", rimeSchemaId(KeyboardMode.PINYIN_26, fuzzyEnabled = false)) + assertEquals("luna_pinyin_simp_fuzzy", rimeSchemaId(KeyboardMode.PINYIN_26, fuzzyEnabled = true)) + assertEquals("wubi86", rimeSchemaId(KeyboardMode.WUBI, fuzzyEnabled = false)) + assertEquals("wubi86", rimeSchemaId(KeyboardMode.WUBI, fuzzyEnabled = true)) + } + + @Test + fun doublePinyinMapsToItsSchemeSchema() { + assertEquals( + "double_pinyin_flypy", + rimeSchemaId(KeyboardMode.DOUBLE_PINYIN, fuzzyEnabled = false, doublePinyin = DoublePinyinScheme.FLYPY), + ) + assertEquals( + "double_pinyin", + rimeSchemaId(KeyboardMode.DOUBLE_PINYIN, fuzzyEnabled = false, doublePinyin = DoublePinyinScheme.NATURAL), + ) + // 默认(不传方案)走小鹤。 + assertEquals("double_pinyin_flypy", rimeSchemaId(KeyboardMode.DOUBLE_PINYIN, fuzzyEnabled = true)) } @Test diff --git a/app/src/test/java/llc/slacker/openime/StrokeMatchHandwritingProviderTest.kt b/app/src/test/java/llc/slacker/openime/StrokeMatchHandwritingProviderTest.kt new file mode 100644 index 00000000..3471169e --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/StrokeMatchHandwritingProviderTest.kt @@ -0,0 +1,56 @@ +package llc.slacker.openime + +import llc.slacker.openime.handwriting.HandwritingResult +import llc.slacker.openime.handwriting.Stroke +import llc.slacker.openime.handwriting.StrokeMatchHandwritingProvider +import llc.slacker.openime.handwriting.StrokePoint +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StrokeMatchHandwritingProviderTest { + + private val provider = StrokeMatchHandwritingProvider( + mapOf("h" to "一", "hspn" to "木朩"), + listOf("h", "hspn"), + ) + + private fun stroke(vararg points: Pair): Stroke = + Stroke(points.mapIndexed { index, (x, y) -> StrokePoint(x, y, index * 10L) }) + + private fun recognized(strokes: List): List = + (provider.recognize(strokes) as HandwritingResult.Success).candidates + + @Test + fun exactCodeComesBeforeContinuations() { + val result = recognized( + listOf( + stroke(10f to 20f, 100f to 22f), // h + stroke(30f to 10f, 31f to 110f), // s + stroke(100f to 10f, 40f to 110f), // p + stroke(40f to 10f, 100f to 110f), // n + ), + ) + assertEquals(listOf("木", "朩"), result) + } + + @Test + fun shorterGestureFindsContinuations() { + val result = recognized(listOf(stroke(10f to 20f, 100f to 22f))) // h + assertEquals(listOf("一", "木", "朩"), result) + } + + @Test + fun unknownGestureReturnsNothing() { + val result = recognized(listOf(stroke(100f to 10f, 40f to 110f))) // p + assertTrue(result.isEmpty()) + } + + @Test + fun availabilityFollowsTheTable() { + assertTrue(provider.isAvailable) + val empty = StrokeMatchHandwritingProvider(emptyMap(), emptyList()) + assertFalse(empty.isAvailable) + } +} diff --git a/app/src/test/java/llc/slacker/openime/UserPhraseRepositoryTest.kt b/app/src/test/java/llc/slacker/openime/UserPhraseRepositoryTest.kt index 7febbbda..4f17d4c2 100644 --- a/app/src/test/java/llc/slacker/openime/UserPhraseRepositoryTest.kt +++ b/app/src/test/java/llc/slacker/openime/UserPhraseRepositoryTest.kt @@ -26,4 +26,21 @@ class UserPhraseRepositoryTest { repeat(3) { UserPhraseRepository.record("nihao", "常用词") } assertEquals(listOf("常用词"), UserPhraseRepository.candidatesFor("nihao")) } + + @Test + fun manualHotwordsLeadTheVoiceListAndBypassTheFrequencyGate() { + UserPhraseRepository.clear() + UserPhraseRepository.setManualVoiceHotwords(listOf(" 澎湃OS ", "澎湃OS", "", "语音识别")) + assertEquals(listOf("澎湃OS", "语音识别"), UserPhraseRepository.manualVoiceHotwords()) + // 只选过一次的词不满足频率门槛;手动热词不受门槛限制。 + UserPhraseRepository.record("nihao", "语音识别") + assertEquals(listOf("澎湃OS", "语音识别"), UserPhraseRepository.voiceHotwords()) + } + + @Test + fun manualHotwordsAreCappedAtSixtyFour() { + UserPhraseRepository.clear() + UserPhraseRepository.setManualVoiceHotwords((1..80).map { "词${it}号" }) + assertEquals(64, UserPhraseRepository.manualVoiceHotwords().size) + } } diff --git a/app/src/test/java/llc/slacker/openime/VoiceDecodingPolicyTest.kt b/app/src/test/java/llc/slacker/openime/VoiceDecodingPolicyTest.kt new file mode 100644 index 00000000..a6052d47 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/VoiceDecodingPolicyTest.kt @@ -0,0 +1,91 @@ +package llc.slacker.openime + +import llc.slacker.openime.voice.StreamingModelKind +import llc.slacker.openime.voice.VadSpeechTracker +import llc.slacker.openime.voice.VoiceEndpointPolicy +import llc.slacker.openime.voice.decodingFor +import llc.slacker.openime.voice.hotwordModelingUnit +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class VoiceDecodingPolicyTest { + @Test + fun transducerAlwaysUsesModifiedBeamSearch() { + assertEquals( + "modified_beam_search", + decodingFor(StreamingModelKind.TRANSDUCER, hasHotwords = false), + ) + assertEquals( + "modified_beam_search", + decodingFor(StreamingModelKind.TRANSDUCER, hasHotwords = true), + ) + } + + @Test + fun otherKindsKeepGreedySearchEvenWithHotwords() { + val others = StreamingModelKind.entries.filter { it != StreamingModelKind.TRANSDUCER } + for (kind in others) { + assertEquals(kind.id, "greedy_search", decodingFor(kind, hasHotwords = false)) + assertEquals(kind.id, "greedy_search", decodingFor(kind, hasHotwords = true)) + } + } + + @Test + fun hotwordModelingUnitAdmitsOnlyUnitsTheNativeEncoderSupports() { + // 原生 EncodeHotwords 遇到不支持的 modelingUnit 会直接结束进程,必须先准入。 + assertEquals("cjkchar", hotwordModelingUnit("", hasBpeVocab = false)) + assertEquals("cjkchar", hotwordModelingUnit("cjkchar", hasBpeVocab = false)) + assertEquals("bpe", hotwordModelingUnit("bpe", hasBpeVocab = true)) + assertEquals("cjkchar+bpe", hotwordModelingUnit("cjkchar+bpe", hasBpeVocab = true)) + assertNull(hotwordModelingUnit("bpe", hasBpeVocab = false)) + assertNull(hotwordModelingUnit("cjkchar+bpe", hasBpeVocab = false)) + assertNull(hotwordModelingUnit("whisper", hasBpeVocab = true)) + } + + @Test + fun noVadKeepsTheOriginalTailBehavior() { + assertTrue(VoiceEndpointPolicy.tailPaddingNeeded(vadActive = false, sawSpeech = false)) + assertTrue(VoiceEndpointPolicy.tailPaddingNeeded(vadActive = false, sawSpeech = true)) + assertTrue(VadSpeechTracker().tailPaddingNeeded()) + } + + @Test + fun vadSkipsTailPaddingOnlyWhenNoSpeechWasEverSeen() { + assertFalse(VoiceEndpointPolicy.tailPaddingNeeded(vadActive = true, sawSpeech = false)) + assertTrue(VoiceEndpointPolicy.tailPaddingNeeded(vadActive = true, sawSpeech = true)) + } + + @Test + fun trackerFollowsSpeechSilenceAndFailure() { + val tracker = VadSpeechTracker() + tracker.observe(false) + assertTrue(tracker.isVadActive) + assertFalse(tracker.hasSpeech) + assertFalse(tracker.tailPaddingNeeded()) + + repeat(3) { tracker.observe(true) } + assertTrue(tracker.hasSpeech) + assertTrue(tracker.tailPaddingNeeded()) + assertFalse(tracker.speechEnded()) + + repeat(VadSpeechTracker.DEFAULT_MIN_SILENCE_CHUNKS) { tracker.observe(false) } + assertTrue(tracker.speechEnded()) + + // VAD 中途失败:立刻退回原有端点行为。 + tracker.observe(null) + assertFalse(tracker.isVadActive) + assertFalse(tracker.speechEnded()) + assertTrue(tracker.tailPaddingNeeded()) + } + + @Test + fun silenceBeforeAnySpeechIsNotAnEndpoint() { + val tracker = VadSpeechTracker() + repeat(100) { tracker.observe(false) } + assertFalse(tracker.hasSpeech) + assertFalse(tracker.speechEnded()) + } +} diff --git a/app/src/test/java/llc/slacker/openime/VoiceHotwordProviderTest.kt b/app/src/test/java/llc/slacker/openime/VoiceHotwordProviderTest.kt index c7590efd..a6f6213a 100644 --- a/app/src/test/java/llc/slacker/openime/VoiceHotwordProviderTest.kt +++ b/app/src/test/java/llc/slacker/openime/VoiceHotwordProviderTest.kt @@ -25,4 +25,43 @@ class VoiceHotwordProviderTest { ) assertEquals("语音 识别 测试 :1.8", encoded) } + + @Test + fun keepsOnlyCjkTermsWithinTwoToSixteenCodePoints() { + val oneCodePoint = "语" + val seventeenCodePoints = "一二三四五六七八九十一二三四五六七" + val encoded = VoiceHotwordProvider.encode( + listOf(oneCodePoint, seventeenCodePoints, "语音识别", "openIME", "澎湃OS"), + ) + assertEquals(17, seventeenCodePoints.codePoints().count()) + assertEquals("语音识别 :1.8/澎湃OS :1.8", encoded) + } + + @Test + fun stripsControlCharactersAndCollapsesWhitespace() { + val encoded = VoiceHotwordProvider.encode( + listOf("语音\t识别\u0000测试", " 澎湃 OS "), + ) + assertEquals("语音 识别 测试 :1.8/澎湃 OS :1.8", encoded) + } + + @Test + fun capsTheEncodedContextAtSixtyFourTerms() { + val encoded = VoiceHotwordProvider.encode((1..80).map { "词${it}号" }) + assertEquals(64, encoded.split('/').size) + } + + @Test + fun parseLinesDropsBlankAndDuplicateLines() { + assertEquals( + listOf("澎湃OS", "语音识别", "好吧"), + VoiceHotwordProvider.parseLines("澎湃OS\r\n 语音识别 \n\n澎湃OS\n好吧\n"), + ) + } + + @Test + fun parseLinesCapsAtSixtyFourTerms() { + val text = (1..80).joinToString("\n") { "词${it}号" } + assertEquals(64, VoiceHotwordProvider.parseLines(text).size) + } } diff --git a/app/src/test/java/llc/slacker/openime/VoiceModelArchiveTest.kt b/app/src/test/java/llc/slacker/openime/VoiceModelArchiveTest.kt new file mode 100644 index 00000000..8806835e --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/VoiceModelArchiveTest.kt @@ -0,0 +1,147 @@ +package llc.slacker.openime + +import llc.slacker.openime.voice.VoiceModelArchive +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.file.Files +import java.util.zip.GZIPOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class VoiceModelArchiveTest { + + private fun zipOf(vararg entries: Pair): ByteArray { + val out = ByteArrayOutputStream() + ZipOutputStream(out).use { zip -> + entries.forEach { (name, bytes) -> + zip.putNextEntry(ZipEntry(name)) + zip.write(bytes) + zip.closeEntry() + } + } + return out.toByteArray() + } + + private fun writeOctal(target: ByteArray, offset: Int, length: Int, value: Long) { + val text = value.toString(8).padStart(length - 1, '0') + "\u0000" + text.toByteArray(Charsets.US_ASCII).copyInto(target, offset) + } + + private fun tarOf(vararg entries: Pair): ByteArray { + val out = ByteArrayOutputStream() + entries.forEach { (name, bytes) -> + val header = ByteArray(512) + name.toByteArray(Charsets.UTF_8).copyInto(header, 0, 0, minOf(100, name.length)) + writeOctal(header, 100, 8, 420L) + writeOctal(header, 108, 8, 0L) + writeOctal(header, 116, 8, 0L) + writeOctal(header, 124, 12, bytes.size.toLong()) + writeOctal(header, 136, 12, 0L) + header[156] = '0'.code.toByte() + "ustar\u000000".toByteArray(Charsets.US_ASCII).copyInto(header, 257) + out.write(header) + out.write(bytes) + val padding = (512 - bytes.size % 512) % 512 + out.write(ByteArray(padding)) + } + out.write(ByteArray(1024)) + return out.toByteArray() + } + + private fun staging(): File = Files.createTempDirectory("voice-archive-test").toFile() + + @Test + fun extractsZipWithNestedDirectories() { + val dir = staging() + try { + val bytes = zipOf( + "model/tokens.txt" to "a\n".toByteArray(), + "model/encoder.onnx" to ByteArray(700) { 1 }, + ) + val total = VoiceModelArchive.extract(bytes.inputStream(), VoiceModelArchive.Format.ZIP, dir) + assertEquals(702, total.toInt()) + assertArrayEquals(ByteArray(700) { 1 }, File(dir, "model/encoder.onnx").readBytes()) + } finally { + dir.deleteRecursively() + } + } + + @Test + fun rejectsZipPathTraversal() { + val dir = staging() + try { + val bytes = zipOf("../evil.txt" to "x".toByteArray()) + try { + VoiceModelArchive.extract(bytes.inputStream(), VoiceModelArchive.Format.ZIP, dir) + fail("expected a traversal rejection") + } catch (expected: IllegalArgumentException) { + assertTrue(expected.message!!.contains("非法路径")) + } + } finally { + dir.deleteRecursively() + } + } + + @Test + fun extractsPlainAndGzippedTar() { + val payload = "tokens\n".toByteArray() + val tar = tarOf("pkg/tokens.txt" to payload, "pkg/joiner.onnx" to ByteArray(3) { 2 }) + for ((format, bytes) in listOf( + VoiceModelArchive.Format.TAR to tar, + VoiceModelArchive.Format.TAR_GZ to gzip(tar), + )) { + val dir = staging() + try { + VoiceModelArchive.extract(bytes.inputStream(), format, dir) + assertArrayEquals(payload, File(dir, "pkg/tokens.txt").readBytes()) + assertArrayEquals(ByteArray(3) { 2 }, File(dir, "pkg/joiner.onnx").readBytes()) + } finally { + dir.deleteRecursively() + } + } + } + + @Test + fun enforcesTheSizeCap() { + val dir = staging() + try { + val bytes = zipOf("big.onnx" to ByteArray(4096)) + try { + VoiceModelArchive.extract(bytes.inputStream(), VoiceModelArchive.Format.ZIP, dir, maxBytes = 1024) + fail("expected the size cap to trip") + } catch (expected: IllegalArgumentException) { + assertTrue(expected.message!!.contains("上限")) + } + } finally { + dir.deleteRecursively() + } + } + + @Test + fun sniffsFormatsFromMagicBytes() { + assertEquals(VoiceModelArchive.Format.ZIP, VoiceModelArchive.sniff(zipOf("a" to ByteArray(1)))) + assertEquals(VoiceModelArchive.Format.TAR_GZ, VoiceModelArchive.sniff(gzip(ByteArray(512)))) + assertEquals(VoiceModelArchive.Format.TAR, VoiceModelArchive.sniff(tarOf("a" to ByteArray(1)))) + assertEquals(null, VoiceModelArchive.sniff("not an archive".toByteArray())) + } + + @Test + fun formatsComeFromFileNames() { + assertEquals(VoiceModelArchive.Format.ZIP, VoiceModelArchive.formatOf("m.zip")) + assertEquals(VoiceModelArchive.Format.TAR_GZ, VoiceModelArchive.formatOf("m.tgz")) + assertEquals(VoiceModelArchive.Format.TAR_GZ, VoiceModelArchive.formatOf("m.tar.gz")) + assertEquals(VoiceModelArchive.Format.TAR, VoiceModelArchive.formatOf("m.tar")) + assertEquals(null, VoiceModelArchive.formatOf("m.tar.bz2")) + } + + private fun gzip(bytes: ByteArray): ByteArray { + val out = ByteArrayOutputStream() + GZIPOutputStream(out).use { it.write(bytes) } + return out.toByteArray() + } +} diff --git a/app/src/test/java/llc/slacker/openime/VoiceModelDetectorTest.kt b/app/src/test/java/llc/slacker/openime/VoiceModelDetectorTest.kt new file mode 100644 index 00000000..7b1cc01f --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/VoiceModelDetectorTest.kt @@ -0,0 +1,99 @@ +package llc.slacker.openime + +import llc.slacker.openime.voice.StreamingModelKind +import llc.slacker.openime.voice.VoiceModelAutoDetector +import llc.slacker.openime.voice.VoiceModelLayoutResolver +import llc.slacker.openime.voice.VoiceModelManifest +import llc.slacker.openime.voice.trustFingerprint +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.nio.file.Files + +class VoiceModelDetectorTest { + + private fun packageRoot(name: String, vararg files: String): File { + val dir = Files.createTempDirectory(name).toFile() + files.forEach { relative -> + val file = File(dir, relative) + file.parentFile?.mkdirs() + file.writeBytes(ByteArray(8) { 1 }) + } + return dir + } + + @Test + fun transducerPackageInfersFamilyAndOptionalFiles() { + val root = packageRoot( + "sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20", + "encoder-epoch-99-avg-1.int8.onnx", + "decoder-epoch-99-avg-1.onnx", + "joiner-epoch-99-avg-1.onnx", + "tokens.txt", + "bpe.vocab", + "hotwords.txt", + ) + try { + val detected = VoiceModelAutoDetector.detect(root, "fallback").getOrThrow().manifest + assertEquals("transducer", detected.modelType) + assertEquals("zipformer", detected.family) + assertEquals("bpe.vocab", detected.roles["bpeVocab"]) + assertEquals("hotwords.txt", detected.roles["hotwords"]) + assertEquals(null, VoiceModelLayoutResolver.explain(detected)) + } finally { + root.deleteRecursively() + } + } + + @Test + fun singleFileCtcAlwaysAsksForAManifest() { + // 家族用错会让原生层直接失败,所以连名字线索也不猜(原有测试亦是此约定)。 + for (name in listOf( + "sherpa-onnx-streaming-nemo-ctc-en", + "sherpa-onnx-streaming-t-one-russian-2025-09-08", + "mystery-model", + )) { + val root = packageRoot(name, "model.onnx", "tokens.txt") + try { + val result = VoiceModelAutoDetector.detect(root, "fallback") + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull()!!.message!!.contains("manifest.json")) + } finally { + root.deleteRecursively() + } + } + } + + @Test + fun manifestCarriesFamilyAndModelingUnit() { + val manifest = VoiceModelManifest( + modelId = "m", modelVersion = "1", language = "zh", modelType = "transducer", + engineVersion = "e", fileHash = "0".repeat(64), supportsPunctuation = false, + requiredMemory = 1L, + files = listOf("e.onnx", "d.onnx", "j.onnx", "t.txt", "bpe.vocab"), + roles = mapOf( + "encoder" to "e.onnx", "decoder" to "d.onnx", "joiner" to "j.onnx", + "tokens" to "t.txt", "bpeVocab" to "bpe.vocab", + ), + family = "conformer", + modelingUnit = "cjkchar+bpe", + ) + assertEquals(null, manifest.validateShape()) + val parsed = VoiceModelManifest.parse(manifest.toJson()) + assertEquals("conformer", parsed.family) + assertEquals("cjkchar+bpe", parsed.modelingUnit) + assertEquals(manifest.trustFingerprint(), parsed.trustFingerprint()) + } + + @Test + fun familyOnlyFitsTransducers() { + val paraformer = VoiceModelManifest( + modelId = "m", modelVersion = "1", language = "zh", modelType = "paraformer", + engineVersion = "e", fileHash = "0".repeat(64), supportsPunctuation = false, + requiredMemory = 1L, files = listOf("t.txt"), + roles = mapOf("tokens" to "t.txt"), family = "zipformer", + ) + assertTrue(paraformer.validateShape()!!.contains("family")) + } +} diff --git a/app/src/test/java/llc/slacker/openime/VoiceModelLayoutTest.kt b/app/src/test/java/llc/slacker/openime/VoiceModelLayoutTest.kt new file mode 100644 index 00000000..e9b71e2f --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/VoiceModelLayoutTest.kt @@ -0,0 +1,164 @@ +package llc.slacker.openime + +import llc.slacker.openime.voice.SHERPA_DECODER +import llc.slacker.openime.voice.SHERPA_ENCODER +import llc.slacker.openime.voice.SHERPA_TOKENS +import llc.slacker.openime.voice.StreamingModelKind +import llc.slacker.openime.voice.VoiceModelAutoDetector +import llc.slacker.openime.voice.VoiceModelLayoutResolver +import llc.slacker.openime.voice.VoiceModelManifest +import llc.slacker.openime.voice.trustFingerprint +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.nio.file.Files + +class VoiceModelLayoutTest { + private fun manifest( + type: String = "paraformer", + files: List = listOf(SHERPA_ENCODER, SHERPA_DECODER, SHERPA_TOKENS), + roles: Map = emptyMap(), + ) = VoiceModelManifest( + modelId = "m", modelVersion = "1", language = "zh", modelType = type, + engineVersion = "e", fileHash = "0".repeat(64), supportsPunctuation = false, + requiredMemory = 1L, files = files, roles = roles, + ) + + @Test + fun legacyParaformerManifestStillResolvesToTheBuiltInPaths() { + val layout = VoiceModelLayoutResolver.resolve(manifest()) + assertNotNull(layout) + assertEquals(StreamingModelKind.PARAFORMER, layout!!.kind) + assertEquals(SHERPA_ENCODER, layout.path("encoder")) + } + + @Test + fun legacyManifestFingerprintIsUnchangedByTheRolesField() { + // 内置模型在受信目录里的指纹是按"没有 roles"算的,这个字段不能把它改掉。 + assertEquals(manifest().trustFingerprint(), manifest(roles = emptyMap()).trustFingerprint()) + } + + @Test + fun rolesAreCoveredByTheFingerprint() { + val files = listOf("a.onnx", "b.onnx", "t.txt") + val one = manifest(files = files, roles = mapOf("encoder" to "a.onnx", "decoder" to "b.onnx", "tokens" to "t.txt")) + val swapped = manifest(files = files, roles = mapOf("encoder" to "b.onnx", "decoder" to "a.onnx", "tokens" to "t.txt")) + assertNotEquals(one.trustFingerprint(), swapped.trustFingerprint()) + } + + @Test + fun transducerNeedsAJoiner() { + val files = listOf("e.onnx", "d.onnx", "t.txt") + val m = manifest("transducer", files, mapOf("encoder" to "e.onnx", "decoder" to "d.onnx", "tokens" to "t.txt")) + assertNull(VoiceModelLayoutResolver.resolve(m)) + assertTrue(VoiceModelLayoutResolver.explain(m)!!.contains("joiner")) + } + + @Test + fun transducerWithAllRolesResolves() { + val files = listOf("e.onnx", "d.onnx", "j.onnx", "t.txt") + val m = manifest("transducer", files, mapOf("encoder" to "e.onnx", "decoder" to "d.onnx", "joiner" to "j.onnx", "tokens" to "t.txt")) + assertEquals(StreamingModelKind.TRANSDUCER, VoiceModelLayoutResolver.resolve(m)!!.kind) + } + + @Test + fun nonParaformerWithoutRolesIsRejected() { + assertNull(VoiceModelLayoutResolver.resolve(manifest("transducer"))) + } + + @Test + fun unknownTypeNamesTheSupportedOnes() { + val why = VoiceModelLayoutResolver.explain(manifest("whisper"))!! + assertTrue(why.contains("paraformer") && why.contains("transducer")) + } + + @Test + fun roleMustPointAtAListedFile() { + val m = manifest("zipformer2-ctc", listOf("m.onnx", "t.txt"), mapOf("model" to "other.onnx", "tokens" to "t.txt")) + assertNull(VoiceModelLayoutResolver.resolve(m)) + } + + @Test + fun traversalInARolePathIsRejected() { + val m = manifest("zipformer2-ctc", listOf("../m.onnx", "t.txt"), mapOf("model" to "../m.onnx", "tokens" to "t.txt")) + assertNull(VoiceModelLayoutResolver.resolve(m)) + } + + @Test + fun unknownRoleIsRejected() { + val m = manifest("zipformer2-ctc", listOf("m.onnx", "t.txt", "x.onnx"), + mapOf("model" to "m.onnx", "tokens" to "t.txt", "weird" to "x.onnx")) + assertNull(VoiceModelLayoutResolver.resolve(m)) + } + + private fun dir(vararg names: String): File { + val root = Files.createTempDirectory("vm-detect").toFile() + names.forEach { n -> File(root, n).apply { parentFile.mkdirs(); writeText("x:$n") } } + return root + } + + @Test + fun detectsAnOfficialTransducerPackagePreferringInt8() { + val root = dir( + "encoder-epoch-99.int8.onnx", "encoder-epoch-99.onnx", + "decoder-epoch-99.onnx", "joiner-epoch-99.int8.onnx", "joiner-epoch-99.onnx", "tokens.txt", + ) + val m = VoiceModelAutoDetector.detect(root, "pkg").getOrThrow().manifest + assertEquals("transducer", m.modelType) + assertEquals("encoder-epoch-99.int8.onnx", m.roles["encoder"]) + assertEquals("joiner-epoch-99.int8.onnx", m.roles["joiner"]) + assertNull(m.validateShape()) + assertFalse(m.supportsPunctuation) + } + + @Test + fun detectsParaformerAndAPunctuationModel() { + val root = dir("encoder.int8.onnx", "decoder.int8.onnx", "tokens.txt", "punct/model.int8.onnx") + val m = VoiceModelAutoDetector.detect(root, "pkg").getOrThrow().manifest + assertEquals("paraformer", m.modelType) + assertTrue(m.supportsPunctuation) + assertEquals("punct/model.int8.onnx", VoiceModelLayoutResolver.punctuationPath(m)) + assertNull(m.validateShape()) + } + + @Test + fun refusesToGuessSingleFileCtcFamilyFromItsFilename() { + for (name in listOf("model.onnx", "nemo-ctc.int8.onnx", "zipformer2-ctc.int8.onnx")) { + val result = VoiceModelAutoDetector.detect(dir(name, "tokens.txt"), "pkg") + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull()!!.message!!.contains("无法仅凭文件名")) + } + } + + @Test + fun detectionFailsClearlyWithoutTokens() { + val r = VoiceModelAutoDetector.detect(dir("encoder.onnx", "decoder.onnx"), "pkg") + assertTrue(r.isFailure) + assertTrue(r.exceptionOrNull()!!.message!!.contains("tokens")) + } + + @Test + fun detectionRefusesAmbiguousPackages() { + val r = VoiceModelAutoDetector.detect(dir("a.onnx", "b.onnx", "tokens.txt"), "pkg") + assertTrue(r.isFailure) + } + + @Test + fun detectedIdIsSafeForTheFilesystem() { + assertEquals("a-b-c", VoiceModelAutoDetector.safeId("a b/c")) + assertEquals("imported-model", VoiceModelAutoDetector.safeId("///")) + } + + @Test + fun manifestSurvivesAJsonRoundTrip() { + val files = listOf("e.onnx", "d.onnx", "t.txt") + val m = manifest("paraformer", files, mapOf("encoder" to "e.onnx", "decoder" to "d.onnx", "tokens" to "t.txt")) + val back = VoiceModelManifest.parse(m.toJson()) + assertEquals(m, back) + } +} diff --git a/app/src/test/java/llc/slacker/openime/VoiceVadRoleTest.kt b/app/src/test/java/llc/slacker/openime/VoiceVadRoleTest.kt new file mode 100644 index 00000000..898a562c --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/VoiceVadRoleTest.kt @@ -0,0 +1,148 @@ +package llc.slacker.openime + +import llc.slacker.openime.voice.SHERPA_DECODER +import llc.slacker.openime.voice.SHERPA_ENCODER +import llc.slacker.openime.voice.SHERPA_TOKENS +import llc.slacker.openime.voice.StreamingModelKind +import llc.slacker.openime.voice.VoiceModelAutoDetector +import llc.slacker.openime.voice.VoiceModelLayoutResolver +import llc.slacker.openime.voice.VoiceModelManifest +import llc.slacker.openime.voice.VoiceModelSelection +import llc.slacker.openime.voice.resolveSherpaRuntimeModelFiles +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.nio.file.Files + +class VoiceVadRoleTest { + + private fun transducerManifest( + files: List = listOf("e.onnx", "d.onnx", "j.onnx", "t.txt"), + roles: Map = mapOf( + "encoder" to "e.onnx", + "decoder" to "d.onnx", + "joiner" to "j.onnx", + "tokens" to "t.txt", + ), + ) = VoiceModelManifest( + modelId = "vad-model", modelVersion = "1", language = "zh", modelType = "transducer", + engineVersion = "e", fileHash = "0".repeat(64), supportsPunctuation = false, + requiredMemory = 1L, files = files, roles = roles, + ) + + private fun dir(vararg names: String): File { + val root = Files.createTempDirectory("vm-vad").toFile() + names.forEach { n -> File(root, n).apply { parentFile.mkdirs(); writeText("x:$n") } } + return root + } + + @Test + fun vadIsAnOptionalRoleThatTheLayoutKeeps() { + assertTrue("vad" in StreamingModelKind.OPTIONAL_ROLES) + val m = transducerManifest( + files = listOf("e.onnx", "d.onnx", "j.onnx", "t.txt", "silero_vad.onnx"), + roles = mapOf( + "encoder" to "e.onnx", "decoder" to "d.onnx", "joiner" to "j.onnx", + "tokens" to "t.txt", "vad" to "silero_vad.onnx", + ), + ) + assertNull(m.validateShape()) + val layout = VoiceModelLayoutResolver.resolve(m) + assertNotNull(layout) + assertEquals("silero_vad.onnx", layout!!.path("vad")) + } + + @Test + fun detectorFindsSileroVadWithoutBreakingSingleFileCtcRules() { + val root = dir("encoder.onnx", "decoder.onnx", "joiner.onnx", "tokens.txt", "silero_vad.onnx") + try { + val detected = VoiceModelAutoDetector.detect(root, "pkg").getOrThrow() + assertEquals("silero_vad.onnx", detected.manifest.roles["vad"]) + assertTrue(detected.manifest.files.contains("silero_vad.onnx")) + assertTrue(detected.note.contains("VAD")) + assertNull(VoiceModelLayoutResolver.explain(detected.manifest)) + } finally { + root.deleteRecursively() + } + + // silero_vad.onnx 不能把单文件 CTC 包伪装成"多文件";家族猜测仍然必须拒绝。 + val ctcRoot = dir("model.onnx", "tokens.txt", "silero_vad.onnx") + try { + val failure = VoiceModelAutoDetector.detect(ctcRoot, "pkg") + assertTrue(failure.isFailure) + assertTrue(failure.exceptionOrNull()!!.message!!.contains("无法仅凭文件名")) + } finally { + ctcRoot.deleteRecursively() + } + } + + @Test + fun downloadedPackageResolvesTheVadFile() { + val downloadedRoot = Files.createTempDirectory("openime-vad-models").toFile() + val selection = VoiceModelSelection( + manifest = transducerManifest( + files = listOf("e.onnx", "d.onnx", "j.onnx", "t.txt", "silero_vad.onnx"), + roles = mapOf( + "encoder" to "e.onnx", "decoder" to "d.onnx", "joiner" to "j.onnx", + "tokens" to "t.txt", "vad" to "silero_vad.onnx", + ), + ), + source = VoiceModelSelection.Source.DOWNLOADED, + ) + val packageRoot = File(downloadedRoot, "vad-model") + try { + selection.manifest!!.files.forEach { path -> + File(packageRoot, path).apply { parentFile?.mkdirs(); writeText(path) } + } + val resolved = resolveSherpaRuntimeModelFiles(selection, downloadedRoot) + assertNotNull(resolved) + assertEquals( + File(packageRoot, "silero_vad.onnx").canonicalPath, + File(resolved!!.vad!!).canonicalPath, + ) + } finally { + downloadedRoot.deleteRecursively() + } + } + + @Test + fun declaredVadFileMissingFailsClosed() { + val downloadedRoot = Files.createTempDirectory("openime-vad-missing").toFile() + val selection = VoiceModelSelection( + manifest = transducerManifest( + files = listOf("e.onnx", "d.onnx", "j.onnx", "t.txt", "silero_vad.onnx"), + roles = mapOf( + "encoder" to "e.onnx", "decoder" to "d.onnx", "joiner" to "j.onnx", + "tokens" to "t.txt", "vad" to "silero_vad.onnx", + ), + ), + source = VoiceModelSelection.Source.DOWNLOADED, + ) + val packageRoot = File(downloadedRoot, "vad-model") + try { + // 声明了 vad 角色却缺文件:整包校验失败,不允许静默降级。 + listOf("e.onnx", "d.onnx", "j.onnx", "t.txt").forEach { path -> + File(packageRoot, path).apply { parentFile?.mkdirs(); writeText(path) } + } + assertNull(resolveSherpaRuntimeModelFiles(selection, downloadedRoot)) + } finally { + downloadedRoot.deleteRecursively() + } + } + + @Test + fun legacyBuiltInManifestHasNoVad() { + val legacy = VoiceModelManifest( + modelId = "built-in", modelVersion = "1", language = "zh-CN,en-US", modelType = "paraformer", + engineVersion = "e", fileHash = "0".repeat(64), supportsPunctuation = true, + requiredMemory = 1L, files = listOf(SHERPA_ENCODER, SHERPA_DECODER, SHERPA_TOKENS), + ) + val selection = VoiceModelSelection(legacy, VoiceModelSelection.Source.BUILT_IN) + val resolved = resolveSherpaRuntimeModelFiles(selection, File("/unused")) + assertNotNull(resolved) + assertNull(resolved!!.vad) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2f290a1d..10e51254 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -123,7 +123,8 @@ To add a dependency, first change the table in that test. | `setup` | Shared UI helpers for activities | data, theme | | `widget` | Reusable controls: `ImeKeyView`, `SwipeUpDetector` | theme | | `floating` | Floating keyboard window, drag, card style | theme | -| `handwriting` | Handwriting pad | data, theme | +| `handwriting` | Handwriting pad, stroke classifier, stroke-table provider | data, theme | +| `engine` | Clean-room pinyin engine: binary lexicon/bigram tables, syllable lattice, fuzzy pairs, Viterbi | core | | `rime` | librime wrapper, input normalization, native candidate references | core, data | | `candidate` | Candidate pipeline, snapshots, nine-key decoder, fuzzy pinyin, lexicons | core, rime | | `voice` | Speech recognition, model lifecycle, voice panel, text post-processing | data, editor, theme | @@ -158,6 +159,7 @@ Split them next. Follow the package rules above and add no cross-package depende | 26-key pinyin | `ImeKeyboardView`, `CandidatePipeline`, Rime | Production | | Nine-key pinyin | `ImeKeyboardView`, `NineKeyLocalDecoder`, Rime | Production. No English nine-key. | | Stroke | `StrokeKeyboardRenderer`, `StrokeLexicon` | Production | +| Wubi 26-key | `ImeKeyboardView`, `CandidatePipeline`, Rime (`wubi86`) | Production. The table ships only in builds with the local data. | | English 26-key | `CandidatePipeline`, `CandidateEngine` | Production | | Digits, phone, decimal | `EditorInfoAdapter`, `NumericKeyboardRenderer` | Production | | Symbols | `SymbolCatalog`, `CustomSymbolRepository` | Production | @@ -166,7 +168,7 @@ Split them next. Follow the package rules above and add no cross-package depende | Quick phrases | `QuickPhraseRepository`, `QuickPhraseEditActivity` | Production | | Text editing | `InputConnectionGateway` | Production. Available actions depend on the editor. | | Voice | `VoiceModelLifecycleManager`, sherpa-onnx | Production. Hold space to speak. | -| Handwriting | `HandwritingPadView` | Pad only. No recognition engine. The entry point is hidden. | +| Handwriting | `HandwritingPadView`, `StrokeMatchHandwritingProvider` | Stroke-table recognition. Builds without the local table hide the entry. | | Floating keyboard | `FloatingKeyboardController`, `LocalVoiceImeService` | Production | | Settings | `ImeSettingsRepository`, settings UI | Production | diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d063ccae..473c945e 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -93,3 +93,48 @@ Add new entries at the end. From 0.0.6-beta.1, the `hotword` module, the built-in lists, the management page and the typing candidate weighting are gone. *Reason:* homophone replacement is not mature. We do not ship it yet. The earlier code is on the `archive/voice-word-lists` branch (`f3bb5cc`, the 0.0.5-beta.1 commit). We can merge it back after a fix. + +## 2026-10-14 + +- **Wubi 26-key.** + Add a wubi86 keyboard that shares the 26-key surface with pinyin. + Rime selects the `wubi86` schema from the saved Chinese mode, and the keyboard switcher shows the entry only when the compiled table is in the APK. + The schema and the import script ship in the repository; the dictionary table does not (local data). + *Reason:* wubi codes are plain letters, so the existing composition, candidate and commit paths apply unchanged; a separate renderer would duplicate the 26-key layout. +- **Handwriting recognition by stroke table.** + `StrokeMatchHandwritingProvider` classifies each drawn stroke as one of the five basic strokes (h s p n z) and matches the sequence against a table. + Keep the `HandwritingProvider` boundary; a learned recognizer can replace the implementation later. + The tools panel shows the entry only when the table is in the APK. + *Reason:* an offline, deterministic matcher is honest about its limits and needs no model runtime. +- **Local dictionary import.** + `scripts/import_doubao_data.py` merges decoded external word data into the Rime tables and builds the wubi, handwriting and English tables. + The data files are git-ignored; the script is committed, and it reads pristine sources from its own backup so a rerun does not drift. + Phrase weights take the larger of the existing and imported value (an import can raise a word but never demote a tuned entry); the single-character table only gains missing readings, and they enter below weight 1000, because its weights order candidates and are hand-tuned. + *Reason:* the external data must not ship in the public repository, and the curated ordering must survive the import. +- **English completions from the ranked list.** + `EnglishLexicon` serves english-words.tsv (rank 1 = most common; unranked rows dropped) and merges with the small `ImeData.englishDict` before the casing fallback. + *Reason:* the decoded rank column is the only reliable frequency signal; 118k unranked rows would drown the completion rank. + +- **Self-developed pinyin engine (clean room).** + `engine/` implements the pinyin flow from the reverse-engineered behaviour (full-path syllable DAG, syllable-prefix completion for a trailing fragment, Viterbi sentences with a bigram bonus) over binary tables compiled from the locally decoded lexicon. + `CandidatePipeline` serves 26-key pinyin from it when the tables exist and fuzzy input is off; Rime stays as the source for fuzzy input, nine-key, stroke and wubi, and remains a supplement behind the engine's list. + *Reason:* the Rime path could not deliver the expected candidate behaviour, and the engine is our own code against our own binary format (no third-party source copied); the tables are not distributed in the repository. +- **Handwriting in the keyboard switcher.** + The handwriting surface gets an entry card beside the keyboards, not only inside the tools grid. + *Reason:* users look for input surfaces where the keyboards are; hiding it in the tools panel made it feel missing. + +- **Landscape matches the reference skin.** + The landscape keyboard copies the measured values from the reference APK's landscape skin (`assets/skin/default/land/style.xml`, archived beside the dictionary handover under `skin-reference/`): keyboard side insets 6.485% (nine-key 9.5%), toolbar 8.2%, expanded candidates 9.5%, and the 26-key space weight 133.6 -> 182.2 (openIME 154 -> 210, the same ratio). + *Reason:* landscape must feel like the reference product, and the numbers come from its shipped skin file instead of guesswork. The window stays full width and the background still spans it; only the content moves inward. + +- **One-hand mode.** + A 76%-width keyboard anchored to the chosen side (关闭 / 左手 / 右手), offered in the layout settings group next to the landscape options; it applies in both orientations and steps aside when the split layout is active. + *Reason:* the reference product ships a one-hand mode (`单手模式`), and a wide landscape screen makes single-thumb typing awkward; reusing the existing content-inset path keeps every renderer untouched. +- **Keyboard height drag (键盘调节).** + Long-press on the docked toolbar and drag vertically to change the keyboard height live (fixed sensitivity: a full screen height equals 200 points, so about 20% of the screen covers 40 points); reaching/keying persistence goes through the existing height path. The gesture only arms inside the toolbar row, so candidate long-presses and the input area keep their own gestures. + *Reason:* the reference product offers height adjustment (`键盘调节`, `is_change_keyboard_height`); dragging the toolbar needs no new controls and is discoverable. + +- **Double pinyin (小鹤 / 自然码).** + Two committed Rime schemas (`double_pinyin_flypy`, `double_pinyin`) share the luna_pinyin dictionary; the keyboard is the plain 26-key surface and a settings choice picks the scheme. + The chord tables come from rime-double-pinyin (GPL-3.0, credited in the schema header). The reference product ships the same two named schemes (`shuangpin_xiaohe` / `shuangpin_ziranma`, `oime_engine::ShuangPinScheme`); its mapping is compiled into native code without an extractable table, and 小鹤/自然码 chords are standardized layouts, so the standard tables are the same mapping. + *Reason:* keyboard-type parity with the reference product without Lua or external dependencies. diff --git a/docs/LOCAL_VOICE_MODEL.md b/docs/LOCAL_VOICE_MODEL.md index 5d7e106f..fbb0ffed 100644 --- a/docs/LOCAL_VOICE_MODEL.md +++ b/docs/LOCAL_VOICE_MODEL.md @@ -37,6 +37,14 @@ If the PCM ring buffer overflows, openIME reports an error and removes the compo It never commits a truncated result. After capture ends, openIME restores the media volume at once. Queued model work does not keep media muted. +## Hotwords + +「设置 → 语音模型 → 语音热词」 keeps a private list of terms, one term per line. +The list applies to transducer models only. +openIME rebuilds the hotword string for every recording, so an edit takes effect the next time the user holds space. +The list stays on the device. +After a save, openIME tells the user when the current model does not support hotwords. + ## Text processing - The punctuation model (CT-Transformer INT8) restores sentence breaks, commas and question marks. @@ -92,6 +100,19 @@ A downloaded model must pass the same checks and load in the background before i If a downloaded model fails, is corrupt, times out or crashes, openIME falls back to the built-in model. The model never changes during a recording. +## Import your own streaming model + +The app never downloads anything. 「设置 → 语音模型」 imports a local package: + +- Archives: `.zip`, `.tar`, `.tar.gz`, `.tgz` (repack `tar.bz2` as zip first). +- Streaming families (sherpa-onnx online), detected from the file set: + - transducer: `encoder*.onnx` + `decoder*.onnx` + `joiner*.onnx` + `tokens.txt` — zipformer, zipformer2, conformer, lstm, ebranchformer. The family is inferred from the package name and travels in the manifest `family` field; without it sherpa reads the ONNX metadata. + - paraformer: `encoder*.onnx` + `decoder*.onnx` + `tokens.txt`. + - zipformer2-ctc / nemo-ctc / t-one-ctc: one `model*.onnx` + `tokens.txt`. A single-file CTC package must declare `modelType` in its own `manifest.json`; the app never guesses the family, because a wrong one fails inside the native runtime. +- Optional files: `bpe.vocab` (with `modelingUnit` in the manifest for cjkchar+bpe models), `hotwords.txt` (transducer only), `silero_vad.onnx` (speech detection near the end of a voice segment), and a punctuation model (an `.onnx` with "punct" in its name). +- INT8 and FP32 both work; INT8 wins when a package carries both. +- Every package is unpacked into the app cache, checked (roles, files, SHA-256) and confirmed by the user before it installs. A failing import never replaces the current model. + ## Runtime `StreamingEmbeddedVoiceModelRuntime` connects the sherpa-onnx arm64 runtime: @@ -105,7 +126,20 @@ The model never changes during a recording. - Model loading and inference never block the main thread. The runtime uses `OnlineParaformerModelConfig`, `modelType="paraformer"` and `greedy_search`. -Because Paraformer does not use transducer hotwords, openIME sends no hotwords to the stream. + +Transducer models always use `modified_beam_search` (`maxActivePaths=4`). +Only transducers take hotwords, so every stream of a transducer model is opened with `createStream(hotwords)`. +The hotword string is rebuilt for each recording: typed terms from 「设置 → 语音模型 → 语音热词」, plus learned phrases and voice corrections. +A term must have 2 to 16 code points and contain at least one CJK character; the score is 1.8. +Hotwords need a usable `modelingUnit` in the manifest (`bpe` and `cjkchar+bpe` also need `bpe.vocab`); when it is absent, openIME encodes CJK terms as `cjkchar`. +An unsupported unit disables hotwords; decoding stays `modified_beam_search`. +If the hotword graph fails, openIME records the session without hotwords. +Paraformer and CTC models keep `greedy_search` and no hotwords, because the engine ignores them there. + +If the package carries `silero_vad.onnx` (role `vad`), openIME creates a Silero VAD (`sampleRate=16000`, `numThreads=1`, `provider="cpu"`) and feeds it the same 20 ms blocks as the recognizer. +The `isSpeechDetected()` state then decides whether the Paraformer tail pad is needed: when the session never detected speech, openIME skips the 300 ms zero pad. +It never stops a recording on its own; the user still ends a session by releasing space. +Without the model, endpoint behavior is exactly as before. The keyboard uses the service-level `VoiceModelLifecycleManager` for all voice work. The keyboard view only shows state. It does not build model providers. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index cc9879de..a8216296 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -59,14 +59,20 @@ We maintain only the newest MINOR line. Security fixes ship as PATCH versions. ## Release assets -The pipeline builds only an `arm64-v8a` release APK: +The pipeline builds two `arm64-v8a` release APKs from the same source: ```text openIME-v{VERSION}-arm64-release.apk +openIME-v{VERSION}-arm64-lite-release.apk ``` -The GitHub release has only this APK. -The release notes show its SHA-256 and the fingerprint of the signing certificate. +The full APK carries the offline voice models. +The lite APK leaves them out (`OPENIME_LITE=1`), so it is much smaller; the user imports a model in 设置 → 语音模型. +Both APKs use the same signing key, so they install over each other and keep the user data. +`scripts/release_build.sh` builds both, checks that the lite APK is smaller and holds no `.onnx` model, and signs both. + +The GitHub release has only these APKs. +The release notes show the SHA-256 of each APK and the fingerprint of the signing certificate. The third-party licenses are in the APK (`assets/licenses/`). The release notes link to `THIRD_PARTY_NOTICES.md`. The pipeline also creates `SHA256SUMS.txt` for its own check but does not upload it. diff --git a/scripts/build_imengine_data.py b/scripts/build_imengine_data.py new file mode 100644 index 00000000..bd5b3068 --- /dev/null +++ b/scripts/build_imengine_data.py @@ -0,0 +1,649 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Compile openIME's built-in pinyin engine tables from the decoded sources. + +This script is the only bridge between the external, read-only decoded data +(``--source``) and the binaries that ship in the APK assets (``--out``): + + lex.bin word index: syllable table, syllable-id sequence per word, + UTF-8 text blob and 0..10000 scores. + bigram.bin word-pair table keyed by ``(idA << 32) | idB``. + +Both files are little endian. Every lex.bin section starts on a 4-byte +boundary, the bigram key array starts on an 8-byte boundary:: + + lex.bin u32 magic 0x31454D49 ("IME1"), u32 word_count, + u32 syllable_count, u32 off_syllables, u32 off_syl_offsets, + u32 off_syl_ids, u32 off_text_offsets, u32 off_text_blob, + u32 off_scores, u32 text_blob_size (= 40 byte header) + syllables: u8 length + ASCII bytes per syllable, in + syllable-id-map.tsv order (id = position) + syl_offsets: u32[word_count + 1], first element index of each + word's syllable-id sequence inside syl_ids + syl_ids: u16[last syl_offsets entry], ids concatenated + text_offsets: u32[word_count + 1], start of each word's UTF-8 + text inside text_blob + text_blob: UTF-8 texts concatenated + scores: u16[word_count], round(score * 10000) + + bigram.bin u32 magic 0x31424D49 ("IMB1"), u32 pair_count, + u64[pair_count] keys (ascending), u16[pair_count] scores + +Words are ordered by syllable-id sequence (element-wise, shorter first), then +by score descending, then by text ascending. Bigram pairs are ordered by +key ascending. + +A build writes ``.tmp`` first and finishes with ``os.replace``, so a +failed build never leaves a truncated binary behind. Afterwards both files +are read back and the sanity checks are printed and appended to the build +report (``/编译报告.txt``). + +Inputs (columns are tab separated; the first line of ime-lexicon.tsv and +ime-bigram.tsv is a header): + ime-lexicon.tsv word / pinyin / score / weight / src + syllable-id-map.tsv id / syllable / ... syllable catalogue + ime-bigram.tsv w1 / w2 / score / src +""" + +import argparse +import io +import os +import re +import struct +import sys +import time +from array import array +from bisect import bisect_left +from pathlib import Path + +MAGIC_LEX = 0x31454D49 # "IME1" little endian +MAGIC_BIGRAM = 0x31424D49 # "IMB1" little endian +LEX_HEADER = struct.Struct("<10I") +BIGRAM_HEADER = struct.Struct("<2I") + +ALIGN = 4 # every lex.bin section starts on a 4-byte boundary +SYLLABLE_RE = re.compile(r"[a-z]{1,6}") +MAX_SYLLABLES = 10 # drop rows with more syllables than this +LEX_SCORE_MAX = 10000 # round(score * 10000) +BIGRAM_SCORE_MAX = 65535 # round(score * 65535) + +SPOT_WORDS = ("你好", "上海", "微信", "我想") +SPOT_BIGRAMS = (("你", "好"), ("上海", "什么")) +EXTRA_BIGRAMS = (("今天", "天气"), ("你好", "自私")) +BISECT_PROBES = (("ni hao", "你好"), ("shang hai", "上海")) + +REPORT_NAME = "编译报告.txt" + +assert array("H").itemsize == 2, "u16 arrays must be 2 bytes" +assert array("I").itemsize == 4, "u32 arrays must be 4 bytes" +assert array("Q").itemsize == 8, "u64 arrays must be 8 bytes" + + +def le_bytes(values, code): + """Return the little-endian byte string of an array of ``code`` items.""" + buf = array(code, values) + if sys.byteorder != "little": + buf.byteswap() + return buf.tobytes() + + +def read_le_array(data, offset, count, code): + """Read ``count`` little-endian items of ``code`` starting at ``offset``.""" + buf = array(code) + buf.frombytes(bytes(data[offset:offset + count * buf.itemsize])) + if sys.byteorder != "little": + buf.byteswap() + return buf + + +def atomic_write(path, data): + """Write ``path`` through ``path.tmp`` + os.replace.""" + tmp = path.with_name(path.name + ".tmp") + with io.open(tmp, "wb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(tmp, path) + + +def load_syllables(path): + """Parse syllable-id-map.tsv. + + Rows whose syllable column matches ``^[a-z]{1,6}$`` are kept and numbered + 0..N-1 in file order. A syllable that occurs several times keeps every + row in the table (ids stay stable) but maps to its first id when words + are encoded. + + Returns ``(syllables, syllable_to_id, rows, duplicates)``. + """ + syllables = [] + syllable_to_id = {} + duplicates = [] + rows = 0 + with io.open(path, encoding="utf-8") as stream: + for line in stream: + parts = line.rstrip("\n").split("\t") + if len(parts) < 2: + continue + rows += 1 + syllable = parts[1] + if SYLLABLE_RE.fullmatch(syllable) is None: + continue + if syllable in syllable_to_id: + duplicates.append(syllable) + else: + syllable_to_id[syllable] = len(syllables) + syllables.append(syllable) + return syllables, syllable_to_id, rows, duplicates + + +def build_lexicon(path, syllable_to_id, spot_words): + """Parse ime-lexicon.tsv into ``word -> (score_int, syllable ids)``. + + Rows with an empty pinyin, an unknown syllable, more than MAX_SYLLABLES + syllables or an unreadable score are dropped and counted by reason. The + table has unique words; should a duplicate appear, the highest score wins. + """ + entries = {} + spot_scores = {} + stats = { + "rows": 0, + "kept": 0, + "dropped_bad_row": 0, + "dropped_empty_pinyin": 0, + "dropped_unknown_syllable": 0, + "dropped_too_many_syllables": 0, + "dropped_bad_score": 0, + "duplicate_words_merged": 0, + "unknown_syllables": set(), + } + with io.open(path, encoding="utf-8") as stream: + for line_no, line in enumerate(stream): + if line_no == 0 and line.startswith("word\t"): + continue + stats["rows"] += 1 + parts = line.rstrip("\n").split("\t") + word = parts[0] + if len(parts) < 3 or not word: + stats["dropped_bad_row"] += 1 + continue + tokens = parts[1].split() + if not tokens: + stats["dropped_empty_pinyin"] += 1 + continue + try: + score = float(parts[2]) + except ValueError: + stats["dropped_bad_score"] += 1 + continue + ids = [] + unknown = False + for token in tokens: + syllable_id = syllable_to_id.get(token) + if syllable_id is None: + stats["unknown_syllables"].add(token) + unknown = True + break + ids.append(syllable_id) + if unknown: + stats["dropped_unknown_syllable"] += 1 + continue + if len(ids) > MAX_SYLLABLES: + stats["dropped_too_many_syllables"] += 1 + continue + score_int = int(round(score * LEX_SCORE_MAX)) + score_int = max(0, min(LEX_SCORE_MAX, score_int)) + previous = entries.get(word) + if previous is None: + entries[word] = (score_int, tuple(ids)) + stats["kept"] += 1 + else: + stats["duplicate_words_merged"] += 1 + if score_int > previous[0]: + entries[word] = (score_int, tuple(ids)) + if word in spot_words: + spot_scores[word] = score + return entries, stats, spot_scores + + +def sort_lexicon(entries): + """Order words by syllable ids, then score descending, then text.""" + return sorted(entries, key=lambda word: (entries[word][1], -entries[word][0], word)) + + +def encode_lex(words, entries, syllables): + """Serialize the complete lex.bin image.""" + syllable_blob = bytearray() + for syllable in syllables: + raw = syllable.encode("ascii") + syllable_blob.append(len(raw)) + syllable_blob += raw + + syl_offsets = array("I", [0]) + syl_ids = array("H") + text_offsets = array("I", [0]) + text_blob = bytearray() + scores = array("H") + for word in words: + score_int, ids = entries[word] + syl_ids.extend(ids) + syl_offsets.append(len(syl_ids)) + text_blob += word.encode("utf-8") + text_offsets.append(len(text_blob)) + scores.append(score_int) + + body = bytearray() + offsets = {} + + def add(name, blob, pad=True): + offsets[name] = LEX_HEADER.size + len(body) + body.extend(blob) + if pad: + body.extend(b"\x00" * (-(LEX_HEADER.size + len(body)) % ALIGN)) + + add("syllables", bytes(syllable_blob)) + add("syl_offsets", le_bytes(syl_offsets, "I")) + add("syl_ids", le_bytes(syl_ids, "H")) + add("text_offsets", le_bytes(text_offsets, "I")) + add("text_blob", bytes(text_blob)) + add("scores", le_bytes(scores, "H"), pad=False) + + header = LEX_HEADER.pack( + MAGIC_LEX, + len(words), + len(syllables), + offsets["syllables"], + offsets["syl_offsets"], + offsets["syl_ids"], + offsets["text_offsets"], + offsets["text_blob"], + offsets["scores"], + len(text_blob), + ) + return bytes(header + body) + + +def build_bigram(path, word_to_id, check_pairs): + """Parse ime-bigram.tsv into ``key -> score_int`` with max-score dedupe.""" + pairs = {} + stats = { + "rows": 0, + "kept": 0, + "dropped_bad_row": 0, + "dropped_bad_score": 0, + "dropped_w1_missing": 0, + "dropped_w2_missing": 0, + "dropped_both_missing": 0, + "duplicate_pairs_merged": 0, + "min_key": None, + "min_key_score": None, + } + source_check = {} + with io.open(path, encoding="utf-8") as stream: + for line_no, line in enumerate(stream): + if line_no == 0 and line.startswith("w1\t"): + continue + stats["rows"] += 1 + parts = line.rstrip("\n").split("\t") + if len(parts) < 3: + stats["dropped_bad_row"] += 1 + continue + w1, w2 = parts[0], parts[1] + try: + score = float(parts[2]) + except ValueError: + stats["dropped_bad_score"] += 1 + continue + id1 = word_to_id.get(w1) + id2 = word_to_id.get(w2) + if id1 is None and id2 is None: + stats["dropped_both_missing"] += 1 + elif id1 is None: + stats["dropped_w1_missing"] += 1 + elif id2 is None: + stats["dropped_w2_missing"] += 1 + else: + key = (id1 << 32) | id2 + score_int = int(round(score * BIGRAM_SCORE_MAX)) + score_int = max(0, min(BIGRAM_SCORE_MAX, score_int)) + previous = pairs.get(key) + if previous is None: + pairs[key] = score_int + stats["kept"] += 1 + else: + stats["duplicate_pairs_merged"] += 1 + if score_int > previous: + pairs[key] = score_int + if stats["min_key"] is None or key < stats["min_key"]: + stats["min_key"] = key + stats["min_key_score"] = score + elif key == stats["min_key"] and score > stats["min_key_score"]: + stats["min_key_score"] = score + if (w1, w2) in check_pairs: + source_check[(w1, w2)] = score + return pairs, stats, source_check + + +def encode_bigram(pairs): + """Serialize the complete bigram.bin image from sorted ``(key, score)``.""" + buf = bytearray(BIGRAM_HEADER.pack(MAGIC_BIGRAM, len(pairs))) + buf += le_bytes((key for key, _ in pairs), "Q") + buf += le_bytes((score for _, score in pairs), "H") + return bytes(buf) + + +def read_lex_bin(path): + """Read back lex.bin into plain Python containers for verification.""" + data = path.read_bytes() + (magic, word_count, syllable_count, off_syl, off_so, off_si, off_to, + off_tb, off_sc, text_size) = LEX_HEADER.unpack_from(data, 0) + if magic != MAGIC_LEX: + raise ValueError("bad lex.bin magic: 0x%08X" % magic) + + syllables = [] + pos, end = off_syl, off_so + while pos < end: + length = data[pos] + if length == 0: # alignment padding after the last entry + break + pos += 1 + syllables.append(data[pos:pos + length].decode("ascii")) + pos += length + + lex = { + "size": len(data), + "word_count": word_count, + "syllable_count": syllable_count, + "offsets": { + "syllables": off_syl, + "syl_offsets": off_so, + "syl_ids": off_si, + "text_offsets": off_to, + "text_blob": off_tb, + "scores": off_sc, + }, + "text_size": text_size, + "syllables": syllables, + } + lex["syl_offsets"] = read_le_array(data, off_so, word_count + 1, "I") + lex["syl_ids"] = read_le_array(data, off_si, lex["syl_offsets"][-1], "H") + lex["text_offsets"] = read_le_array(data, off_to, word_count + 1, "I") + lex["text_blob"] = data[off_tb:off_tb + text_size] + lex["scores"] = read_le_array(data, off_sc, word_count, "H") + return lex + + +def read_bigram_bin(path): + """Read back bigram.bin into plain Python containers.""" + data = path.read_bytes() + magic, pair_count = BIGRAM_HEADER.unpack_from(data, 0) + if magic != MAGIC_BIGRAM: + raise ValueError("bad bigram.bin magic: 0x%08X" % magic) + return { + "size": len(data), + "pair_count": pair_count, + "keys": read_le_array(data, BIGRAM_HEADER.size, pair_count, "Q"), + "scores": read_le_array(data, BIGRAM_HEADER.size + 8 * pair_count, pair_count, "H"), + } + + +def lex_seq(lex, index): + """Syllable-id tuple of entry ``index`` in a read-back lex.bin.""" + start = lex["syl_offsets"][index] + stop = lex["syl_offsets"][index + 1] + return tuple(lex["syl_ids"][start:stop]) + + +def lex_text(lex, index): + """UTF-8 word of entry ``index`` in a read-back lex.bin.""" + start = lex["text_offsets"][index] + stop = lex["text_offsets"][index + 1] + return lex["text_blob"][start:stop].decode("utf-8") + + +def lex_lower_bound(lex, target): + """First entry whose syllable-id sequence is >= ``target`` (dict order).""" + lo, hi = 0, lex["word_count"] + while lo < hi: + mid = (lo + hi) // 2 + if lex_seq(lex, mid) < target: + lo = mid + 1 + else: + hi = mid + return lo + + +def parse_args(argv): + parser = argparse.ArgumentParser( + description="Build app/src/main/assets/imengine/{lex,bigram}.bin from the decoded tables." + ) + parser.add_argument( + "--source", + type=Path, + default=Path("/shared/doubaoime-teardown"), + help="directory with ime-lexicon.tsv, syllable-id-map.tsv, ime-bigram.tsv", + ) + parser.add_argument( + "--out", + type=Path, + default=Path(__file__).resolve().parent.parent / "app/src/main/assets/imengine", + help="output directory for lex.bin, bigram.bin and the build report", + ) + return parser.parse_args(argv) + + +def main(argv=None): + args = parse_args(argv) + source = args.source + out_dir = args.out + out_dir.mkdir(parents=True, exist_ok=True) + lex_path = out_dir / "lex.bin" + bigram_path = out_dir / "bigram.bin" + + lines = [] + + def log(text=""): + lines.append(text) + print(text, flush=True) + + log("== openIME pinyin engine data build ==") + log("source: %s" % source) + log("time: %s" % time.strftime("%Y-%m-%d %H:%M:%S")) + + syllables, syllable_to_id, syl_rows, syl_duplicates = load_syllables(source / "syllable-id-map.tsv") + log("syllable table: %d rows -> %d syllable ids (%d unique); duplicate ids: %s" + % (syl_rows, len(syllables), len(syllable_to_id), + ", ".join(sorted(set(syl_duplicates))) if syl_duplicates else "none")) + + entries, lex_stats, spot_scores = build_lexicon( + source / "ime-lexicon.tsv", syllable_to_id, set(SPOT_WORDS)) + log("lexicon: %d rows -> %d words kept" % (lex_stats["rows"], lex_stats["kept"])) + log("lexicon dropped: unknown syllable %d, empty pinyin %d, >%d syllables %d, bad row %d, bad score %d; duplicate words merged %d" + % (lex_stats["dropped_unknown_syllable"], lex_stats["dropped_empty_pinyin"], + MAX_SYLLABLES, lex_stats["dropped_too_many_syllables"], + lex_stats["dropped_bad_row"], lex_stats["dropped_bad_score"], + lex_stats["duplicate_words_merged"])) + if lex_stats["unknown_syllables"]: + log("lexicon unknown syllable samples: %s" + % ", ".join(sorted(lex_stats["unknown_syllables"])[:20])) + + words = sort_lexicon(entries) + atomic_write(lex_path, encode_lex(words, entries, syllables)) + log("wrote %s (%d bytes) via .tmp + os.replace" % (lex_path, os.path.getsize(lex_path))) + + word_to_id = {word: index for index, word in enumerate(words)} + del entries, words + + check_pairs = set(SPOT_BIGRAMS) | set(EXTRA_BIGRAMS) + pairs, bigram_stats, bigram_source_scores = build_bigram( + source / "ime-bigram.tsv", word_to_id, check_pairs) + log("bigram: %d rows -> %d pairs kept" % (bigram_stats["rows"], bigram_stats["kept"])) + log("bigram dropped: w1 missing %d, w2 missing %d, both missing %d, bad row %d, bad score %d; duplicate pairs merged %d" + % (bigram_stats["dropped_w1_missing"], bigram_stats["dropped_w2_missing"], + bigram_stats["dropped_both_missing"], bigram_stats["dropped_bad_row"], + bigram_stats["dropped_bad_score"], bigram_stats["duplicate_pairs_merged"])) + + ordered_pairs = sorted(pairs.items()) + del pairs + atomic_write(bigram_path, encode_bigram(ordered_pairs)) + log("wrote %s (%d bytes) via .tmp + os.replace" % (bigram_path, os.path.getsize(bigram_path))) + del word_to_id, ordered_pairs + + # ------------------------------------------------------------- verify -- + log() + log("== read-back verification ==") + lex = read_lex_bin(lex_path) + bigram = read_bigram_bin(bigram_path) + + log("1) containers") + log(" lex.bin: %d bytes, word_count=%d, syllable_count=%d, text_blob_size=%d" + % (lex["size"], lex["word_count"], lex["syllable_count"], lex["text_size"])) + log(" bigram.bin: %d bytes, pair_count=%d, key_at=%d, scores_at=%d" + % (bigram["size"], bigram["pair_count"], BIGRAM_HEADER.size, + BIGRAM_HEADER.size + 8 * bigram["pair_count"])) + section_text = ", ".join("%s@%d" % (name, off) + for name, off in sorted(lex["offsets"].items(), key=lambda item: item[1])) + log(" lex sections: %s" % section_text) + aligned = all(off % ALIGN == 0 for off in lex["offsets"].values()) + log(" checks: first section right after header=%s; sections 4-byte aligned=%s; " + "lex ends after scores=%s; syl_offsets monotone=%s; text_offsets monotone=%s; " + "blob size matches=%s; syllable rows match=%s; max syllable id < count=%s; " + "bigram keys ascending=%s" + % (lex["offsets"]["syllables"] == LEX_HEADER.size, + aligned, + lex["size"] == lex["offsets"]["scores"] + 2 * lex["word_count"], + all(lex["syl_offsets"][i] <= lex["syl_offsets"][i + 1] for i in range(lex["word_count"])), + all(lex["text_offsets"][i] <= lex["text_offsets"][i + 1] for i in range(lex["word_count"])), + lex["text_size"] == len(lex["text_blob"]) == lex["text_offsets"][-1], + len(lex["syllables"]) == lex["syllable_count"], + (len(lex["syl_ids"]) == 0 or max(lex["syl_ids"]) < lex["syllable_count"]), + all(bigram["keys"][i] < bigram["keys"][i + 1] for i in range(bigram["pair_count"] - 1)))) + log(" bigram size check: 8 + 10 * pair_count = %d = %d" + % (BIGRAM_HEADER.size + 10 * bigram["pair_count"], bigram["size"])) + + text_index = {} + ordered_ok = True + previous_key = None + for index in range(lex["word_count"]): + word = lex_text(lex, index) + text_index[word] = index + key = (lex_seq(lex, index), -lex["scores"][index], word) + if previous_key is not None and key < previous_key: + ordered_ok = False + previous_key = key + log(" lex read-back order (syllables, -score, text) non-decreasing=%s" % ordered_ok) + lex["syllable_to_id"] = {} + for index, syllable in enumerate(lex["syllables"]): + lex["syllable_to_id"].setdefault(syllable, index) + + log("2) word spot check (read back vs source TSV)") + for word in SPOT_WORDS: + index = text_index.get(word) + if index is None: + log(" %s: MISSING from lex.bin" % word) + continue + ids = lex_seq(lex, index) + decoded = " ".join(lex["syllables"][sid] for sid in ids) + score = lex["scores"][index] + source_score = spot_scores.get(word) + if source_score is None: + verdict = "source row not captured" + else: + source_int = int(round(source_score * LEX_SCORE_MAX)) + verdict = "source score=%d match=%s" % (source_int, "yes" if source_int == score else "NO") + log(" %s: ids=%s (%s) score=%d text=%r %s" + % (word, list(ids), decoded, score, lex_text(lex, index), verdict)) + + log("3) bigram spot check") + for pair in list(SPOT_BIGRAMS) + list(EXTRA_BIGRAMS): + label = "(required)" if pair in SPOT_BIGRAMS else "(extra)" + id1 = text_index.get(pair[0]) + id2 = text_index.get(pair[1]) + source_score = bigram_source_scores.get(pair) + if id1 is None or id2 is None: + log(" %s %s: word missing from lex.bin" % (label, pair)) + continue + key = (id1 << 32) | id2 + position = bisect_left(bigram["keys"], key) + if position < bigram["pair_count"] and bigram["keys"][position] == key: + score = bigram["scores"][position] + if source_score is None: + verdict = "source score not captured" + else: + source_int = int(round(source_score * BIGRAM_SCORE_MAX)) + verdict = "source score=%d match=%s" % (source_int, "yes" if source_int == score else "NO") + log(" %s %s: key=0x%016X score=%d %s" % (label, pair, key, score, verdict)) + elif source_score is None: + absent = [word for word in pair if word not in text_index] + note = "; %s not in lex.bin" % "/".join(absent) if absent else "" + log(" %s %s: not present in source TSV -> skipped%s" % (label, pair, note)) + else: + log(" %s %s: source score=%g but MISSING from bigram.bin" + % (label, pair, source_score)) + + if bigram_stats["min_key"] is not None: + key = bigram_stats["min_key"] + position = bisect_left(bigram["keys"], key) + if position < bigram["pair_count"] and bigram["keys"][position] == key: + score = bigram["scores"][position] + expected = int(round(bigram_stats["min_key_score"] * BIGRAM_SCORE_MAX)) + log(" (extra) lowest key (%s, %s): key=0x%016X score=%d source score=%d match=%s" + % (lex_text(lex, key >> 32), lex_text(lex, key & 0xFFFFFFFF), key, score, expected, + "yes" if score == expected else "NO")) + else: + log(" (extra) lowest key 0x%016X MISSING from read-back bigram" % key) + + log("4) binary search self test (read-back data)") + for phrase, expected in BISECT_PROBES: + missing = [token for token in phrase.split() if token not in lex["syllable_to_id"]] + if missing: + log(" [%s] -> syllable(s) %s not in table" % (phrase, missing)) + continue + target = tuple(lex["syllable_to_id"][token] for token in phrase.split()) + index = lex_lower_bound(lex, target) + candidates = [] + scan = index + while scan < lex["word_count"] and lex_seq(lex, scan) == target: + candidates.append(scan) + scan += 1 + if not candidates: + log(" [%s] -> MISS (no entry)" % phrase) + continue + preview = ", ".join("%s(%d)" % (lex_text(lex, i), lex["scores"][i]) + for i in candidates[:3]) + log(" [%s] ids=%s -> entry#%d, %d candidate(s): %s%s" + % (phrase, list(target), candidates[0], len(candidates), preview, + " ..." if len(candidates) > 3 else "")) + first = lex_text(lex, candidates[0]) + if first == expected: + log(" first candidate matches expected %s (score %d)" + % (expected, lex["scores"][candidates[0]])) + else: + hit = next((i for i in candidates if lex_text(lex, i) == expected), None) + if hit is None: + log(" MISMATCH: expected %s not among candidates" % expected) + else: + log(" expected %s is candidate #%d (score %d); first candidate %s has the higher source score %d" + % (expected, candidates.index(hit) + 1, lex["scores"][hit], first, + lex["scores"][candidates[0]])) + + log() + log("done.") + + # Small asset the app compares against its copied tables; the big files are + # compressed inside the APK and cannot be opened through a file descriptor. + revision = "lex=%d\nbigram=%d\n" % ( + (out_dir / "lex.bin").stat().st_size, + (out_dir / "bigram.bin").stat().st_size, + ) + atomic_write(out_dir / "revision.txt", revision.encode("ascii")) + log("revision: %s" % revision.replace("\n", " ").strip()) + + report_path = out_dir / REPORT_NAME + atomic_write(report_path, ("\n".join(lines) + "\n").encode("utf-8")) + print("report: %s" % report_path, flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/build_rime_prebuilt.py b/scripts/build_rime_prebuilt.py index 685cf2b9..04d6ae03 100644 --- a/scripts/build_rime_prebuilt.py +++ b/scripts/build_rime_prebuilt.py @@ -59,9 +59,28 @@ "stroke.table.bin", "stroke.prism.bin", "stroke.reverse.bin", + "double_pinyin.schema.yaml", + "double_pinyin.prism.bin", + "double_pinyin_flypy.schema.yaml", + "double_pinyin_flypy.prism.bin", ] +def expected_outputs(): + """Required compiler outputs. The wubi table joins the list only when its + local dictionary sources are present (they are not part of the + repository), so clones without them still build.""" + names = list(EXPECTED) + if (SOURCES / "openime_dicts" / "wubi86.dict.yaml").is_file(): + names += [ + "wubi86.schema.yaml", + "wubi86.table.bin", + "wubi86.prism.bin", + "wubi86.reverse.bin", + ] + return names + + def cmake_tools(): """The SDK's CMake, the one the Android build uses, else the host's.""" for env in ("ANDROID_HOME", "ANDROID_SDK_ROOT"): @@ -114,14 +133,14 @@ def compile_dictionaries(deployer, out_dir): stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) - missing = [name for name in EXPECTED if not (staging / name).is_file()] + missing = [name for name in expected_outputs() if not (staging / name).is_file()] if missing: sys.exit("build_rime_prebuilt: librime did not produce " + ", ".join(missing)) target = out_dir / "rime-data" / "build" if target.exists(): shutil.rmtree(target) target.mkdir(parents=True) - for name in EXPECTED: + for name in expected_outputs(): shutil.copy2(staging / name, target / name) return target @@ -192,7 +211,7 @@ def main(): strokes = write_stroke_table(out) total = sum(path.stat().st_size for path in target.iterdir()) print( - f"build_rime_prebuilt: {len(EXPECTED)} files, {total // 1024} KiB in {target}, " + f"build_rime_prebuilt: {len(expected_outputs())} files, {total // 1024} KiB in {target}, " f"revision {revision[:12]}, {strokes} stroke codes" ) diff --git a/scripts/import_doubao_data.py b/scripts/import_doubao_data.py new file mode 100644 index 00000000..cd448eb8 --- /dev/null +++ b/scripts/import_doubao_data.py @@ -0,0 +1,499 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Merge the locally decoded Doubao IME lexicon into openIME's Rime data. + +The Doubao lexicon is external research data and is *not* part of this +repository. Keep it outside the checkout and pass its directory with +``--source``. The script only writes the repository files below; check +``git diff`` afterwards and keep the data changes uncommitted. + +Replacement rules ("replace whatever can be replaced", add-only coverage): + +1. ``rime-data/openime_dicts/base.dict.yaml`` + - Entries that exist in both Ice and Doubao get the Doubao frequency + mapped onto the Ice weight scale; the larger weight wins, so Doubao + can raise a word but never demote a tuned Ice entry. + - Every Doubao (text, pinyin) pair that Ice lacks is added. + - The score -> weight mapping is calibrated from the pairs present on + both sides (binned medians, monotone). +2. ``rime-data/openime_dicts/8105.dict.yaml`` + Missing single-character readings are added with weights from its own + calibration. Existing rows keep their tuned weights: this table orders + single-character candidates, so the import must not reshuffle it. +3. ``pinyin_phrases.tsv`` + Fully regenerated from the merged tables (2-8 Han characters, weight at + or above ``--min-phrase-weight``, deduplicated by (pinyin, text)). +4. ``pinyin_chars.tsv`` + Existing per-syllable lists are extended with Doubao-only characters. +5. ``rime-data/openime_dicts/wubi86.dict.yaml`` + Full wubi86 table from the decoded candidate list (code -> word, weight + mapped to the Rime scale). The file feeds the locally added ``wubi86`` + schema; skip it by not passing a source with ``wubi86-decoded.tsv``. +6. ``handwriting-strokes.tsv`` + Stroke-sequence table for the handwriting pad (h/s/p/n/z code -> Han + characters, characters ordered by frequency). +7. ``english-words.tsv`` + English completion word list from the decoded auxiliary table + (lower / word / rank, sorted by lower; unranked rows are dropped). + +Originals are backed up once to ``.local/doubao-import/originals/`` and a +report is written to ``.local/doubao-import/report.json``. +""" + +import argparse +import io +import json +import os +import re +import statistics +import sys +from pathlib import Path + +HAN_RE = re.compile( + "^[" + "\u3007" + "\u3400-\u4dbf" + "\u4e00-\u9fff" + "\uf900-\ufaff" + "\U00020000-\U0002fa1f" + "]+$" +) +PINYIN_RE = re.compile(r"^[a-z]+( [a-z]+)*$") +WUBI_CODE_RE = re.compile(r"^[a-z]{1,8}$") +STROKE_CODE_RE = re.compile(r"^[hspnz]{1,64}$") +ENGLISH_WORD_RE = re.compile(r"^[A-Za-z][A-Za-z'\-]{0,31}$") +MAX_SYLLABLES = 10 +MAX_WEIGHT = 20_000_000 + + +def parse_rime(path: Path): + """Return (header_lines, entries) where entries are (text, pinyin, weight, line).""" + header = [] + entries = [] + body = False + with io.open(path, encoding="utf-8") as f: + for raw in f: + line = raw.rstrip("\n") + if not body: + header.append(line) + if line.strip() == "...": + body = True + continue + if not line or line.startswith("#"): + continue + parts = line.split("\t") + if len(parts) < 3: + continue + try: + weight = int(parts[2]) + except ValueError: + continue + entries.append((parts[0], parts[1].strip(), weight, line)) + return header, entries + + +def calibrate(pairs, bins=48, min_bin=20): + """Map Doubao scores (0..1) to Ice-like weights using binned medians.""" + pairs = sorted(pairs) + xs = [p[0] for p in pairs] + lo, hi = xs[0], xs[-1] + knots = [] + if hi > lo: + for i in range(bins): + a = lo + (hi - lo) * i / bins + b = lo + (hi - lo) * (i + 1) / bins + seg = [w for s, w in pairs if a <= s < b] + if len(seg) >= min_bin: + knots.append([(a + b) / 2.0, float(statistics.median(seg))]) + if not knots: + knots = [[lo, float(statistics.median(w for _, w in pairs))]] + for i in range(1, len(knots)): + if knots[i][1] < knots[i - 1][1]: + knots[i][1] = knots[i - 1][1] + + def apply(score): + if len(knots) == 1: + w = knots[0][1] + elif score <= knots[0][0]: + x0, y0 = knots[0] + k = max(0.0, y0 - 1.0) / max(x0, 1e-9) + w = 1.0 + k * score + elif score >= knots[-1][0]: + (x1, y1), (x2, y2) = knots[-2], knots[-1] + k = (y2 - y1) / max(x2 - x1, 1e-9) + w = y2 + k * (score - x2) + else: + w = knots[-1][1] + for (xa, ya), (xb, yb) in zip(knots, knots[1:]): + if xa <= score <= xb: + t = (score - xa) / max(xb - xa, 1e-9) + w = ya + (yb - ya) * t + break + return max(1, min(MAX_WEIGHT, int(round(w)))) + + return apply, knots + + +def merge_entries(entries, doubao_score, weight_map, upgrade=True): + """Merge rows; return rows and the number of raised weights. + + ``upgrade`` controls the max rule. Phrase tables use it (the Doubao + frequencies live on a comparable scale and may raise a tuned entry). + The single-character table does not: its weights order character + candidates and are hand-tuned, so only missing readings are added. + """ + rows = [] + upgraded = 0 + for text, py, weight, line in entries: + score = doubao_score.get((text, py)) + if upgrade and score is not None: + mapped = weight_map(score) + if mapped > weight: + weight = mapped + line = "%s\t%s\t%d" % (text, py, weight) + upgraded += 1 + rows.append((py, weight, text, line)) + return rows, upgraded + + +def write_rows(path: Path, header, rows): + rows.sort(key=lambda r: (r[0], -r[1], r[2], r[3])) + tmp = path.with_suffix(path.suffix + ".tmp") + with io.open(tmp, "w", encoding="utf-8", newline="\n") as f: + for line in header: + f.write(line + "\n") + for row in rows: + f.write(row[3] + "\n") + os.replace(tmp, path) + + +def build_wubi_dict(src: Path, rime: Path, report): + """Generate openime_dicts/wubi86.dict.yaml from the decoded candidate table.""" + path = src / "wubi86-decoded.tsv" + if not path.is_file(): + report["wubi"] = {"skipped": "wubi86-decoded.tsv not found"} + return + best = {} + rows = 0 + with io.open(path, encoding="utf-8") as f: + next(f) + for line in f: + parts = line.rstrip("\n").split("\t") + if len(parts) < 6: + continue + code, word, weight = parts[1], parts[4], parts[5] + if not WUBI_CODE_RE.match(code) or not HAN_RE.match(word): + continue + try: + value = int(weight) + except ValueError: + continue + rows += 1 + score = max(0.0, min(1.0, (1400.0 - value) / 1190.0)) + mapped = int(round(score * 1_000_000)) + 1 + key = (code, word) + if mapped > best.get(key, 0): + best[key] = mapped + out = rime / "wubi86.dict.yaml" + tmp = out.with_suffix(".yaml.tmp") + with io.open(tmp, "w", encoding="utf-8", newline="\n") as f: + f.write("# Rime dictionary: wubi86\n") + f.write("# Generated locally by scripts/import_doubao_data.py; do not commit.\n") + f.write("---\nname: wubi86\nversion: \"1\"\nsort: by_weight\n...\n") + for (code, word), weight in sorted(best.items(), key=lambda kv: (kv[0][0], -kv[1], kv[0][1])): + f.write("%s\t%s\t%d\n" % (word, code, weight)) + os.replace(tmp, out) + codes = set(code for code, _ in best) + report["wubi"] = {"rows": rows, "entries": len(best), "codes": len(codes), + "max_code_length": max((len(c) for c in codes), default=0)} + + +def build_handwriting_asset(src: Path, assets: Path, doubao, report): + """Generate handwriting-strokes.tsv: stroke code -> characters, by frequency.""" + path = src / "bihua-decoded.tsv" + if not path.is_file(): + report["handwriting"] = {"skipped": "bihua-decoded.tsv not found"} + return + single_score = {} + for text, _py, score in doubao: + if len(text) == 1 and score > single_score.get(text, -1.0): + single_score[text] = score + codes = {} + with io.open(path, encoding="utf-8") as f: + next(f) + for line in f: + parts = line.rstrip("\n").split("\t") + if len(parts) < 5: + continue + code, chars = parts[1], parts[4] + if not STROKE_CODE_RE.match(code): + continue + bucket = codes.get(code) + if bucket is None: + bucket = codes[code] = [] + seen = set(bucket) + for ch in chars: + if ch in seen or not HAN_RE.match(ch): + continue + bucket.append(ch) + seen.add(ch) + total = 0 + for bucket in codes.values(): + bucket.sort(key=lambda ch: -single_score.get(ch, 0.0)) + total += len(bucket) + out = assets / "handwriting-strokes.tsv" + tmp = out.with_suffix(".tsv.tmp") + with io.open(tmp, "w", encoding="utf-8", newline="\n") as f: + f.write("# stroke code (h s p n z) to Han characters, most frequent first\n") + for code, chars in sorted(codes.items()): + f.write("%s\t%s\n" % (code, "".join(chars))) + os.replace(tmp, out) + report["handwriting"] = {"codes": len(codes), "entries": total} + + +def build_english_asset(src: Path, assets: Path, report): + """Generate english-words.tsv: lower / word / rank, sorted by lower. + + The auxiliary table carries a frequency rank (1 = most common); rank 0 + means unranked and those rows are dropped, so completions come from a + compact ranked list instead of 118k unordered entries. + """ + path = src / "englishsys-aux-decoded.tsv" + if not path.is_file(): + report["english"] = {"skipped": "englishsys-aux-decoded.tsv not found"} + return + best = {} + total = 0 + with io.open(path, encoding="utf-8") as f: + for line in f: + parts = line.rstrip("\n").split("\t") + if len(parts) < 7 or parts[0] == "rec_idx": + continue + word = parts[2] + if not ENGLISH_WORD_RE.match(word): + continue + try: + rank = int(parts[5]) + except ValueError: + continue + total += 1 + if rank <= 0: + continue + key = (word.lower(), word) + if rank < best.get(key, 1 << 30): + best[key] = rank + out = assets / "english-words.tsv" + tmp = out.with_suffix(".tsv.tmp") + with io.open(tmp, "w", encoding="utf-8", newline="\n") as f: + f.write("# English completion words: lower / word / rank (1 = most common; local build data)\n") + for (lower, word), rank in sorted(best.items(), key=lambda kv: (kv[0][0], kv[1], kv[0][1])): + f.write("%s\t%s\t%d\n" % (lower, word, rank)) + os.replace(tmp, out) + report["english"] = {"entries": len(best), "source_rows": total} + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--repo", default=str(Path(__file__).resolve().parent.parent)) + ap.add_argument("--source", default=os.environ.get("DOUBAO_DATA", "/shared/doubaoime-teardown")) + ap.add_argument("--min-phrase-weight", type=int, default=100_000) + ap.add_argument("--max-phrases", type=int, default=120_000) + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + repo = Path(args.repo) + src = Path(args.source) + lexicon_path = src / "ime-lexicon.tsv" + if not lexicon_path.is_file(): + sys.exit("missing %s (pass --source)" % lexicon_path) + + rime = repo / "app" / "src" / "main" / "assets" / "rime-data" / "openime_dicts" + assets = repo / "app" / "src" / "main" / "assets" + base_path = rime / "base.dict.yaml" + char_path = rime / "8105.dict.yaml" + phrases_path = assets / "pinyin_phrases.tsv" + chars_path = assets / "pinyin_chars.tsv" + + backup = repo / ".local" / "doubao-import" / "originals" + backup.mkdir(parents=True, exist_ok=True) + for src_path in (base_path, char_path, phrases_path, chars_path): + dst = backup / src_path.name + if not dst.exists() and src_path.exists(): + dst.write_bytes(src_path.read_bytes()) + + report = {"source": str(src), "backup": str(backup)} + + print("[1/5] parse Ice tables ...") + # Always calibrate and merge against the pristine Ice sources: reading the + # (possibly already merged) working files would let the score -> weight + # mapping feed on its own output and inflate weights on every rerun. + base_source = backup / "base.dict.yaml" + char_source = backup / "8105.dict.yaml" + base_header, base_entries = parse_rime(base_source if base_source.is_file() else base_path) + char_header, char_entries = parse_rime(char_source if char_source.is_file() else char_path) + base_pairs = set((t, p) for t, p, _, _ in base_entries) + char_pairs = set((t, p) for t, p, _, _ in char_entries) + report["ice_base_entries"] = len(base_entries) + report["ice_char_entries"] = len(char_entries) + + print("[2/5] read Doubao lexicon ...") + doubao = [] # (text, pinyin, score) + with io.open(lexicon_path, encoding="utf-8") as f: + next(f) + for line in f: + parts = line.rstrip("\n").split("\t") + if len(parts) < 4: + continue + text, py, score = parts[0], parts[1], parts[2] + if not HAN_RE.match(text): + continue + if not PINYIN_RE.match(py): + continue + if py.count(" ") + 1 > MAX_SYLLABLES: + continue + doubao.append((text, py, float(score))) + report["doubao_rows"] = len(doubao) + doubao_score = dict(((t, p), s) for t, p, s in doubao) + + print("[3/5] calibrate score -> weight ...") + join = [] + for t, p, w, _ in base_entries: + s = doubao_score.get((t, p)) + if s is not None: + join.append((s, w)) + phrase_map, phrase_knots = calibrate(join) + report["phrase_knots"] = [[round(k[0], 4), int(k[1])] for k in phrase_knots] + report["phrase_join_pairs"] = len(join) + + join_chars = [] + for t, p, w, _ in char_entries: + s = doubao_score.get((t, p)) + if s is not None: + join_chars.append((s, w)) + char_map, char_knots = calibrate(join_chars) + report["char_knots"] = [[round(k[0], 4), int(k[1])] for k in char_knots] + report["char_join_pairs"] = len(join_chars) + + print("[4/5] merge dictionaries ...") + base_rows, base_upgraded = merge_entries(base_entries, doubao_score, phrase_map) + char_rows, char_upgraded = merge_entries(char_entries, doubao_score, char_map, upgrade=False) + report["base_weight_upgraded"] = base_upgraded + report["char_weight_upgraded"] = char_upgraded + + base_add = [] + char_add = [] + for text, py, score in doubao: + if len(text) == 1: + if (text, py) not in char_pairs: + # Added readings enter below the tuned band: 8105 orders single + # character candidates, and an unlisted reading is rare by + # construction (variants, dialect characters, extra readings). + char_add.append((text, py, max(1, int(round(score * 1000))))) + else: + if (text, py) not in base_pairs: + base_add.append((text, py, phrase_map(score))) + report["base_additions"] = len(base_add) + report["char_additions"] = len(char_add) + for text, py, weight in base_add: + base_rows.append((py, weight, text, "%s\t%s\t%d" % (text, py, weight))) + for text, py, weight in char_add: + char_rows.append((py, weight, text, "%s\t%s\t%d" % (text, py, weight))) + + if not args.dry_run: + write_rows(base_path, base_header, base_rows) + write_rows(char_path, char_header, char_rows) + + print("[5/5] regenerate fast files ...") + merged = [(r[2], r[0], r[1]) for r in base_rows + char_rows] + phrase_entries = {} + for text, py, w in merged: + if not (2 <= len(text) <= 8): + continue + if not HAN_RE.match(text): + continue + if w < args.min_phrase_weight: + continue + key = (py, text) + if w > phrase_entries.get(key, 0): + phrase_entries[key] = w + while len(phrase_entries) > args.max_phrases: + weights = sorted(phrase_entries.values(), reverse=True) + cut = weights[args.max_phrases] + phrase_entries = {k: v for k, v in phrase_entries.items() if v >= cut} + report["phrase_entries"] = len(phrase_entries) + if not args.dry_run: + rows = sorted(phrase_entries.items(), key=lambda kv: (kv[0][0], -kv[1], kv[0][1])) + tmp = phrases_path.with_suffix(".tsv.tmp") + with io.open(tmp, "w", encoding="utf-8", newline="\n") as f: + f.write("# generated from bundled Rime Ice + local Doubao merge; minimum weight=%d\n" + % args.min_phrase_weight) + for (py, text), w in rows: + f.write("%s\t%s\t%d\n" % (py.replace(" ", ""), text, w)) + os.replace(tmp, phrases_path) + + char_keys = [] + existing = {} + with io.open(chars_path, encoding="utf-8") as f: + for line in f: + line = line.rstrip("\n") + if not line: + continue + py, chars = line.split("\t", 1) + char_keys.append(py) + existing[py] = set(chars) + char_extended = 0 + for text, py, _ in doubao: + if len(text) != 1 or " " in py: + continue + bucket = existing.get(py) + if bucket is None or text in bucket: + continue + bucket.add(text) + char_extended += 1 + report["pinyin_chars_added"] = char_extended + if not args.dry_run: + tmp = chars_path.with_suffix(".tsv.tmp") + with io.open(tmp, "w", encoding="utf-8", newline="\n") as f: + for py in char_keys: + f.write("%s\t%s\n" % (py, "".join(sorted(existing[py], key=ord)))) + os.replace(tmp, chars_path) + + print("[6/8] build wubi86 dictionary ...") + build_wubi_dict(src, rime, report) + print("[7/8] build handwriting stroke asset ...") + build_handwriting_asset(src, assets, doubao, report) + print("[8/8] build English word list ...") + build_english_asset(src, assets, report) + + sample = {} + for probe in ("上海", "伤害", "你好", "我想", "微信"): + s = doubao_score.get((probe, next((p for t, p, _ in doubao if t == probe), ""))) + if s is not None: + sample[probe] = phrase_map(s) + report["sanity_mapped_weights"] = sample + report_path = repo / ".local" / "doubao-import" / "report.json" + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n") + + print("base: weight-upgraded %d, +%d new (was %d entries)" + % (base_upgraded, len(base_add), len(base_entries))) + print("8105: weight-upgraded %d, +%d new (was %d entries)" + % (char_upgraded, len(char_add), len(char_entries))) + print("phrases: %d entries; pinyin_chars: +%d chars" + % (report["phrase_entries"], char_extended)) + if "entries" in report.get("wubi", {}): + print("wubi86: %d entries over %d codes (max code length %d)" + % (report["wubi"]["entries"], report["wubi"]["codes"], report["wubi"]["max_code_length"])) + if "entries" in report.get("handwriting", {}): + print("handwriting: %d codes / %d char slots" + % (report["handwriting"]["codes"], report["handwriting"]["entries"])) + if "entries" in report.get("english", {}): + print("english: %d words" % report["english"]["entries"]) + print("mapped sanity:", sample) + print("report: %s" % report_path) + if args.dry_run: + print("(dry run: no data file was written)") + + +if __name__ == "__main__": + main() diff --git a/scripts/release_build.sh b/scripts/release_build.sh index 7a7a948e..707cc3be 100755 --- a/scripts/release_build.sh +++ b/scripts/release_build.sh @@ -60,6 +60,20 @@ read -r -a extra_gradle_args <<< "${OPENIME_GRADLE_ARGS:-}" APK="$(find app/build/outputs/apk/release -maxdepth 1 -type f -name 'app-release*.apk' | head -n 1)" [[ -n "$APK" && -f "$APK" ]] || die "release APK not found (was signing configured?)" +# 2b. 精简包:同一份源码,不含语音模型。完整包先拷走,因为两次构建写同一个输出目录。 +FULL_APK="$OUT/.full.apk" +cp "$APK" "$FULL_APK" +if [[ "${OPENIME_SKIP_LITE:-0}" != "1" ]]; then + OPENIME_LITE=1 ./gradlew :app:assembleRelease --no-daemon --console=plain "${extra_gradle_args[@]}" + LITE_SOURCE="$(find app/build/outputs/apk/release -maxdepth 1 -type f -name 'app-release*.apk' | head -n 1)" + [[ -n "$LITE_SOURCE" && -f "$LITE_SOURCE" ]] || die "lite release APK not found" + LITE_APK="$OUT/.lite.apk" + cp "$LITE_SOURCE" "$LITE_APK" + [[ "$(stat -c %s "$LITE_APK")" -lt "$(stat -c %s "$FULL_APK")" ]] || die "lite APK is not smaller than the full APK" + if unzip -Z1 "$LITE_APK" | grep '\.onnx$' >/dev/null; then die "lite APK still contains an .onnx model"; fi +fi +APK="$FULL_APK" + # 3. The APK is signed with the release key and nothing else. "$APKSIGNER" verify --verbose --print-certs "$APK" | tee "$OUT/apksigner.txt" grep -Eq 'Verified using v[23][^:]*: true' "$OUT/apksigner.txt" || die "no v2/v3 signature" @@ -94,8 +108,21 @@ fi # 7. Release files. NAME="openIME-v${VERSION}-arm64-release.apk" cp "$APK" "$OUT/$NAME" -(cd "$OUT" && sha256sum "$NAME" > SHA256SUMS.txt) -APK_SHA256="$(cut -d' ' -f1 "$OUT/SHA256SUMS.txt")" +rm -f "$FULL_APK" +LITE_NAME="openIME-v${VERSION}-arm64-lite-release.apk" +if [[ -n "${LITE_APK:-}" ]]; then + "$APKSIGNER" verify --verbose --print-certs "$LITE_APK" > "$OUT/lite-apksigner.txt" || die "lite APK signature check failed" + LITE_CERT_SHA256="$(sed -n 's/^Signer #1 certificate SHA-256 digest: //p' "$OUT/lite-apksigner.txt" | head -n 1)" + [[ -n "$LITE_CERT_SHA256" ]] || die "could not read the lite signing certificate digest" + [[ "$(printf '%s' "$LITE_CERT_SHA256" | tr -d ':' | tr 'A-F' 'a-f')" == "$(printf '%s' "$CERT_SHA256" | tr -d ':' | tr 'A-F' 'a-f')" ]] || die "full and lite APK signing certificates differ" + if unzip -Z1 "$LITE_APK" | awk -F/ '/^lib\/[^/]+\/[^/]+$/ {print $2}' | sort -u | grep -vx 'arm64-v8a' >/dev/null; then die "lite APK contains a non-arm64 ABI"; fi + python3 scripts/release_check.py apk "$LITE_APK" --aapt2 "$AAPT2" + mv "$LITE_APK" "$OUT/$LITE_NAME" +fi +(cd "$OUT" && sha256sum "$NAME" ${LITE_APK:+"$LITE_NAME"} > SHA256SUMS.txt) +APK_SHA256="$(grep " $NAME\$" "$OUT/SHA256SUMS.txt" | cut -d' ' -f1)" +LITE_SHA256="" +[[ -n "${LITE_APK:-}" ]] && LITE_SHA256="$(grep " $LITE_NAME\$" "$OUT/SHA256SUMS.txt" | cut -d' ' -f1)" REPO_URL="${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY:-Slacker-LLC/openIME}" CHANNEL="$(python3 scripts/release_check.py channel)" @@ -116,8 +143,11 @@ fi ### 下载与校验 -- \`$NAME\`:$PACKAGE_NOTE。这是本次发布唯一的附件。 +- \`$NAME\`:$PACKAGE_NOTE,**完整版**,内置离线语音模型,开箱即用。 - APK SHA-256:\`$APK_SHA256\`。下载后执行 \`sha256sum $NAME\`,结果一致再安装。 +${LITE_SHA256:+- \`$LITE_NAME\`:**精简版**,不含语音模型,体积小很多。需要语音输入时到「设置 → 语音模型」导入自己的模型。 +- 精简版 SHA-256:\`$LITE_SHA256\`。 +- 两个版本签名相同,可以互相覆盖升级,用户数据保留。} - 第三方组件的许可证随 APK 一起打包;清单见仓库中的 [THIRD_PARTY_NOTICES.md]($REPO_URL/blob/v$VERSION/THIRD_PARTY_NOTICES.md)。 签名证书 SHA-256:\`$(printf '%s' "$CERT_SHA256" | tr -d ':' | tr 'A-F' 'a-f')\`。每个版本都应一致,可用 \`apksigner verify --print-certs\` 核对。 From b7df7150da21f7b929e8b4b3b2d735e417aad069 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:22:01 +0800 Subject: [PATCH 2/2] Skip the clipboard suppression read on a thread without a Looper Below API 28 a ClipboardManager builds its Handler on the calling thread, so getSystemService throws "Can't create handler inside thread that has not called Looper.prepare()". The API 26 compatibility job calls clearAll() from the instrumentation thread and failed on it. Every caller in the app runs on the UI thread, so the read is skipped where it cannot be made. Co-Authored-By: Claude Code --- .../llc/slacker/openime/data/ClipboardHistoryRepository.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/java/llc/slacker/openime/data/ClipboardHistoryRepository.kt b/app/src/main/java/llc/slacker/openime/data/ClipboardHistoryRepository.kt index 604c1b3a..acc763e7 100644 --- a/app/src/main/java/llc/slacker/openime/data/ClipboardHistoryRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/ClipboardHistoryRepository.kt @@ -4,6 +4,7 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.inputmethodservice.InputMethodService +import android.os.Looper import android.view.inputmethod.EditorInfo import llc.slacker.openime.editor.EditorInfoAdapter import org.json.JSONArray @@ -206,6 +207,12 @@ object ClipboardHistoryRepository { /** Records the clip that is on the clipboard now; caller holds [lock]. */ private fun suppressCurrentSystemClip(context: Context) { + // Below API 28 the ClipboardManager builds its Handler on the calling + // thread, so a thread without a Looper (a worker, an instrumentation + // test) cannot create the service at all: getSystemService throws. + // Every caller in the app runs on the UI thread; elsewhere the + // suppression is skipped instead of crashing. + if (Looper.myLooper() == null) return val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return val clip = runCatching { clipboard.primaryClip }.getOrNull() ?: return val text = runCatching {