From d35cc1b4dcf4e8753bf6b4dcc6ece1bc7b56af5c Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:44:56 +0800 Subject: [PATCH 01/10] Ship Rime dictionaries precompiled instead of compiling on the phone librime compiled every dictionary on the phone after each install or upgrade: about a minute (over fifteen on CI's software emulator) with only the Kotlin fallback lexicon available. prebuildRimeData now builds the vendored librime's deployer for the host, compiles rime-data at build time, and adds the tables to the APK as rime-data/build, librime's prebuilt data dir. The output is byte-identical to what librime compiles on device. The dictionary sources that only the compiler reads stay out of the APK, so it shrinks by about 4 MB. Without sources librime cannot tell a stale staged table from a current one, so a new APK also clears user/build (the user dictionary is beside it, untouched), and startup crash recovery restores the prebuilt tables instead of deleting them. Fresh install to librime ready: 60.1 s -> 2.7 s on the API 36 emulator, 1.7 s on a Xiaomi 15. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + app/build.gradle.kts | 68 +++++++++ .../openime/RimeNineKeyInstrumentedTest.kt | 31 +++++ app/src/main/cpp/CMakeLists.txt | 18 +++ .../llc/slacker/openime/rime/RimeEngine.kt | 40 ++++-- docs/LICENSING.md | 4 + scripts/README.md | 1 + scripts/build_rime_prebuilt.py | 130 ++++++++++++++++++ 8 files changed, 281 insertions(+), 13 deletions(-) create mode 100644 scripts/build_rime_prebuilt.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bcb1e29f..b9f339ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- Rime 词库改为打包时预编译:安装或升级后不再在手机上编译约一分钟,几秒内就能用完整词库打字,小米 15 实测 1.7 秒。此前这段时间只有备用小词库,九键单按 4、5、7、8、9 只能出数字。安装包里的词库文本源文件换成编译好的二进制,安装包小约 4MB,手机上少占约 37MB。用户词库不受影响。 + ## [0.0.5-beta.1] - 2026-10-05 第五个测试版:按键震动更清脆、可选手感和强度,按键音效恢复并内置多种音效,“中/英”键移到空格右边,九键单按一个键时拼音栏显示字母,整理“关于与数据”和工具面板。从 0.0.4-beta.1 覆盖安装即可,用户数据保留。 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f6648169..938d3cea 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,4 +1,6 @@ import org.gradle.api.tasks.testing.Test +import org.gradle.process.ExecOperations +import javax.inject.Inject val customBuildDir = providers.gradleProperty("customBuildDir").orNull if (customBuildDir != null) { @@ -13,6 +15,16 @@ val releaseKeystorePath = releaseValue("OPENIME_KEYSTORE_PATH") val releaseKeystorePassword = releaseValue("OPENIME_KEYSTORE_PASSWORD") val releaseKeyAlias = releaseValue("OPENIME_KEY_ALIAS") val releaseKeyPassword = releaseValue("OPENIME_KEY_PASSWORD") +/** Rime sources that only the dictionary compiler reads; kept out of the APK. */ +val rimeCompiledOnlySources = listOf( + "luna_pinyin.dict.yaml", + "stroke.dict.yaml", + "base.dict.yaml", + "ext.dict.yaml", + "others.dict.yaml", + "essay.txt", +) + val releaseSigningReady = listOf( releaseKeystorePath, releaseKeystorePassword, @@ -107,6 +119,13 @@ android { // sherpa-onnx can map the bundled models directly from the APK only // when these large assets are stored without ZIP compression. noCompress += listOf("onnx", "txt") + // The Rime dictionary sources are compiled at build time (see + // prebuildRimeData below); the phone only needs the compiled tables. + // 8105.dict.yaml stays: the Kotlin lexicon reads it directly. The + // 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" } } compileOptions { @@ -121,6 +140,55 @@ android { } } +/** + * Compiles the Rime dictionaries with a host build of the vendored librime and + * adds the result to the APK as rime-data/build, librime's prebuilt data dir. + * The phone then copies the tables instead of compiling them for a minute + * after every install or upgrade. See scripts/build_rime_prebuilt.py. + */ +abstract class PrebuildRimeData : DefaultTask() { + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val dictionarySources: DirectoryProperty + + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val compilerSources: ConfigurableFileCollection + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @get:Internal + abstract val script: RegularFileProperty + + @get:Inject + abstract val execOperations: ExecOperations + + @TaskAction + fun compile() { + execOperations.exec { + commandLine("python3", script.get().asFile.absolutePath, "--out", outputDir.get().asFile.absolutePath) + } + } +} + +val prebuildRimeData = tasks.register("prebuildRimeData") { + dictionarySources.set(layout.projectDirectory.dir("src/main/assets/rime-data")) + script.set(rootProject.layout.projectDirectory.file("scripts/build_rime_prebuilt.py")) + compilerSources.from( + script, + layout.projectDirectory.file("src/main/cpp/CMakeLists.txt"), + layout.projectDirectory.dir("src/main/cpp/vendor/librime/src"), + ) + outputDir.set(layout.buildDirectory.dir("generated/rime-prebuilt")) +} + +androidComponents { + onVariants { variant -> + variant.sources.assets?.addGeneratedSourceDirectory(prebuildRimeData, PrebuildRimeData::outputDir) + } +} + dependencies { implementation(files("libs/sherpa-onnx-1.13.6.aar")) testImplementation("junit:junit:4.13.2") diff --git a/app/src/androidTest/java/llc/slacker/openime/RimeNineKeyInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/RimeNineKeyInstrumentedTest.kt index 88f11cb0..109a7404 100644 --- a/app/src/androidTest/java/llc/slacker/openime/RimeNineKeyInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/RimeNineKeyInstrumentedTest.kt @@ -63,6 +63,37 @@ class RimeNineKeyInstrumentedTest { } } + @Test + fun productionDictionariesStartFromThePrebuiltTables() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val originalFuzzy = ImeSettingsRepository.loadFuzzy(context) + ImeSettingsRepository.saveFuzzy(context, false) + val rime = RimeEngine(context = context) + try { + rime.start() + // Compiling on the phone takes about a minute (far longer on CI's + // software emulator); copying the APK's prebuilt tables, seconds. + val startupTimeoutMs = 60_000L + val deadline = SystemClock.elapsedRealtime() + startupTimeoutMs + while (!rime.isReady && rime.errorMessage.isBlank() && SystemClock.elapsedRealtime() < deadline) { + SystemClock.sleep(100L) + } + assertTrue("librime failed to start within ${startupTimeoutMs}ms: ${rime.errorMessage}", rime.isReady) + assertTrue("64426 should resolve 你好", "你好" in rime.candidates("64426")) + // librime stages what it compiles in the user dir; nothing there + // means it used the APK's tables as they are. + val staged = java.io.File(context.filesDir, "rime-user/build") + .listFiles().orEmpty() + .filter { it.name.endsWith(".bin") } + .map { it.name } + assertEquals("librime recompiled dictionaries on the phone", emptyList(), staged) + } finally { + ImeSettingsRepository.saveFuzzy(context, originalFuzzy) + rime.invalidateSettingsCache() + rime.shutdown() + } + } + @Test fun auditProductionPresetsAgainstNativeRimePreeditWithoutPresetLayer() { val instrumentation = InstrumentationRegistry.getInstrumentation() diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index dfb3e928..e6dc4f83 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -2,6 +2,8 @@ cmake_minimum_required(VERSION 3.22.1) project(local_rime) +option(OPENIME_HOST_DEPLOYER "Build the host Rime dictionary compiler instead of the JNI library" OFF) + set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_POSITION_INDEPENDENT_CODE ON) @@ -74,6 +76,22 @@ set(ENABLE_TIMESTAMP OFF CACHE BOOL "" FORCE) add_subdirectory(vendor/librime) target_include_directories(rime-static PRIVATE "${RIME_BUNDLED_INCLUDE}") +# Host-only build: the same engine as a dictionary compiler, so the APK can +# ship compiled dictionaries instead of compiling them on the phone. Driven by +# scripts/build_rime_prebuilt.py; never part of the Android build. +if(OPENIME_HOST_DEPLOYER) + add_executable(openime_rime_deployer vendor/librime/tools/rime_deployer.cc) + target_compile_definitions(openime_rime_deployer PRIVATE RIME_IMPORTS) + target_include_directories(openime_rime_deployer PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/vendor/librime/src" + "${RIME_BUNDLED_INCLUDE}" + "${CMAKE_BINARY_DIR}/vendor/librime/src" + "${CMAKE_BINARY_DIR}/include" + ) + target_link_libraries(openime_rime_deployer rime-static ${Opencc_LIBRARY}) + return() +endif() + add_library(local_rime SHARED local_rime_jni.cc) target_link_options(local_rime PRIVATE "-Wl,-z,max-page-size=16384") target_include_directories(local_rime PRIVATE 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 192d0240..6b215472 100644 --- a/app/src/main/java/llc/slacker/openime/rime/RimeEngine.kt +++ b/app/src/main/java/llc/slacker/openime/rime/RimeEngine.kt @@ -151,7 +151,7 @@ class RimeEngine( val userDirName = if (assetRoot == "rime-data") "rime-user" else "$dataDirName-user" val userDir = File(context.filesDir, userDirName).apply { mkdirs() } if (!startupGate.isCurrent(generation)) return@execute - copyAssetsIfNeeded(sharedDir) + copyAssetsIfNeeded(sharedDir, userDir) if (!startupGate.isCurrent(generation)) return@execute // A previous start that died inside native code leaves its marker @@ -176,9 +176,9 @@ class RimeEngine( } when (action) { RimeStartupRecovery.Action.NORMAL -> Unit - RimeStartupRecovery.Action.CLEAN_BUILD -> clearCompiledData(sharedDir, userDir) + RimeStartupRecovery.Action.CLEAN_BUILD -> restoreCompiledData(sharedDir, userDir) RimeStartupRecovery.Action.RESET_USER_DATA -> { - clearCompiledData(sharedDir, userDir) + restoreCompiledData(sharedDir, userDir) setUserDataAside(userDir) } RimeStartupRecovery.Action.SKIP_NATIVE -> { @@ -581,7 +581,15 @@ class RimeEngine( } .distinctBy { it.text } - private fun copyAssetsIfNeeded(sharedDir: File) { + /** + * The APK carries Rime's tables already compiled, in rime-data/build + * (librime's prebuilt data dir), and none of the dictionary sources they + * came from. librime prefers its own staging dir (user/build) over the + * prebuilt one and, without sources, cannot tell a stale staged table from + * a current one; so each new APK also clears user/build. The user + * dictionary lives beside it in the user dir and is never touched. + */ + private fun copyAssetsIfNeeded(sharedDir: File, userDir: File, force: Boolean = false) { // Read the identity of the actually installed APK instead of relying on // generated BuildConfig fields. This stays valid even when BuildConfig // generation is disabled and automatically changes on every upgrade. @@ -590,8 +598,9 @@ class RimeEngine( val requiredSchemasPresent = File(sharedDir, "luna_pinyin_simp.schema.yaml").exists() && File(sharedDir, "luna_pinyin_simp_fuzzy.schema.yaml").exists() - if (marker.exists() && requiredSchemasPresent) return + if (!force && marker.exists() && requiredSchemasPresent) return deleteChildren(sharedDir) + deleteDirectory(File(userDir, "build")) copyAssetTree(assetRoot, sharedDir) marker.writeText("openIME Rime data revision $revision\n") } @@ -621,14 +630,19 @@ class RimeEngine( } } - private fun clearCompiledData(sharedDir: File, userDir: File) { - listOf(File(sharedDir, "build"), File(userDir, "build")).forEach { build -> - if (build.isDirectory) { - deleteChildren(build) - build.delete() - } - } - Log.w(TAG, "cleared compiled librime data after a native startup failure") + /** + * After a native startup crash, put back the APK's prebuilt tables (they + * cannot be rebuilt on the phone) and drop anything librime staged. + */ + private fun restoreCompiledData(sharedDir: File, userDir: File) { + copyAssetsIfNeeded(sharedDir, userDir, force = true) + Log.w(TAG, "restored compiled librime data after a native startup failure") + } + + private fun deleteDirectory(directory: File) { + if (!directory.isDirectory) return + deleteChildren(directory) + directory.delete() } /** Keep one backup of a user database that may be damaged and start with an empty one. */ diff --git a/docs/LICENSING.md b/docs/LICENSING.md index 85075d55..8e184fdc 100644 --- a/docs/LICENSING.md +++ b/docs/LICENSING.md @@ -27,6 +27,10 @@ `app/src/main/assets/licenses/rime-ice-GPL-3.0.txt`,来源明细见根目录 `THIRD_PARTY_NOTICES.md`。 +APK 不再带这些词典的文本源文件,而是带构建时由 librime 编译好的二进制词库 +(`assets/rime-data/build/`,由 `scripts/build_rime_prebuilt.py` 生成)。对应的源文件 +始终在本仓库中公开,许可证文件照常随 APK 分发。 + 语音 runtime 以 `app/libs/sherpa-onnx-1.13.6.aar` 提供,上游 `k2-fsa/sherpa-onnx` 使用 Apache-2.0。内置中英双语 Streaming Paraformer 模型 `csukuangfj/sherpa-onnx-streaming-paraformer-bilingual-zh-en` diff --git a/scripts/README.md b/scripts/README.md index f325644f..3bfe229d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -10,6 +10,7 @@ ```bash python3 scripts/release_check.py check # VERSION 与 CHANGELOG.md 一致(CI 也会运行) python3 -m unittest discover -s scripts -p 'test_*.py' +python3 scripts/build_rime_prebuilt.py --out # 在电脑上预编译 Rime 词库(Gradle 的 prebuildRimeData 会自动运行) bash scripts/release_build.sh # 构建并校验已签名的 arm64 release(需要签名环境变量,见 docs/RELEASE.md) bash scripts/setup_release_signing.sh # 一次性:生成发布密钥并写入 Actions secrets bash scripts/apply_repo_settings.sh --dry-run # 查看将要应用的仓库规则(见 docs/REPOSITORY.md) diff --git a/scripts/build_rime_prebuilt.py b/scripts/build_rime_prebuilt.py new file mode 100644 index 00000000..a68a13c1 --- /dev/null +++ b/scripts/build_rime_prebuilt.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Compile openIME's Rime dictionaries on the build machine. + +librime compiles every *.dict.yaml into binary tables before it can answer a +query. Done on the phone, that takes about a minute after each install or +upgrade, during which only the small Kotlin fallback dictionary is available. +This script runs the same compiler on the host instead: + +1. builds `openime_rime_deployer` from the vendored librime (the same sources + and versions as the APK's native library) into build/rime-host; +2. runs `--build` over app/src/main/assets/rime-data; +3. writes the compiled files to /rime-data/build/. + +The APK ships that directory as librime's prebuilt data dir +(shared_data_dir/build), so the phone only copies it. The output is +byte-identical to what librime compiles on an x86_64 emulator and loads as is +on arm64 phones; the dictionary sources it came from stay out of the APK. + + python3 scripts/build_rime_prebuilt.py --out app/build/generated/rime-prebuilt +""" + +import argparse +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SOURCES = ROOT / "app" / "src" / "main" / "assets" / "rime-data" +NATIVE = ROOT / "app" / "src" / "main" / "cpp" +HOST_BUILD = ROOT / "build" / "rime-host" +CMAKE_VERSION = "3.22.1" + +# Every file the runtime needs from the compiler. A missing one means a +# dictionary failed to compile; the deployer's exit code cannot tell, because +# default.yaml also lists schemas openIME does not ship and those always fail. +EXPECTED = [ + "default.yaml", + "luna_pinyin.schema.yaml", + "luna_pinyin.table.bin", + "luna_pinyin.prism.bin", + "luna_pinyin.reverse.bin", + "luna_pinyin_simp.schema.yaml", + "luna_pinyin_simp.prism.bin", + "luna_pinyin_simp_fuzzy.schema.yaml", + "luna_pinyin_simp_fuzzy.prism.bin", + "stroke.schema.yaml", + "stroke.table.bin", + "stroke.prism.bin", + "stroke.reverse.bin", +] + + +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"): + sdk = os.environ.get(env) + if sdk: + bin_dir = Path(sdk) / "cmake" / CMAKE_VERSION / "bin" + if (bin_dir / "cmake").exists() and (bin_dir / "ninja").exists(): + return str(bin_dir / "cmake"), str(bin_dir / "ninja"), [] + cmake = shutil.which("cmake") + ninja = shutil.which("ninja") + if not cmake or not ninja: + sys.exit(f"build_rime_prebuilt: need cmake {CMAKE_VERSION} from the Android SDK, or cmake and ninja on PATH") + # CMake 4 refuses the vendored dependencies' old minimum versions. + return cmake, ninja, ["-DCMAKE_POLICY_VERSION_MINIMUM=3.5"] + + +def build_deployer(): + cmake, ninja, extra = cmake_tools() + HOST_BUILD.mkdir(parents=True, exist_ok=True) + if not (HOST_BUILD / "build.ninja").exists(): + subprocess.run( + [ + cmake, + "-G", "Ninja", + f"-DCMAKE_MAKE_PROGRAM={ninja}", + "-DCMAKE_BUILD_TYPE=Release", + "-DOPENIME_HOST_DEPLOYER=ON", + *extra, + str(NATIVE), + ], + cwd=HOST_BUILD, + check=True, + stdout=subprocess.DEVNULL, + ) + subprocess.run([ninja, "openime_rime_deployer"], cwd=HOST_BUILD, check=True, stdout=subprocess.DEVNULL) + return HOST_BUILD / "openime_rime_deployer" + + +def compile_dictionaries(deployer, out_dir): + with tempfile.TemporaryDirectory(prefix="openime-rime-") as work: + work = Path(work) + shared = work / "shared" + user = work / "user" + staging = work / "staging" + shutil.copytree(SOURCES, shared, ignore=shutil.ignore_patterns("build")) + user.mkdir() + subprocess.run( + [str(deployer), "--build", str(user), str(shared), str(staging)], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + missing = [name for name in EXPECTED 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: + shutil.copy2(staging / name, target / name) + return target + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--out", required=True, type=Path, help="generated assets root") + args = parser.parse_args() + target = compile_dictionaries(build_deployer(), args.out.resolve()) + total = sum(path.stat().st_size for path in target.iterdir()) + print(f"build_rime_prebuilt: {len(EXPECTED)} files, {total // 1024} KiB in {target}") + + +if __name__ == "__main__": + main() From 12815900a7b39efd6d495e1ca660e0b68dcac155 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:01:43 +0800 Subject: [PATCH 02/10] Release 0.0.6-beta.1 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ README.md | 3 +-- VERSION | 2 +- docs/RELEASE.md | 5 +++-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9f339ff..9d929355 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +## [0.0.6-beta.1] - 2026-10-05 + +第六个测试版:Rime 词库在打包时预先编译好,安装或升级后不用再等一分钟。从 0.0.5-beta.1 覆盖安装即可,用户数据保留。 + - Rime 词库改为打包时预编译:安装或升级后不再在手机上编译约一分钟,几秒内就能用完整词库打字,小米 15 实测 1.7 秒。此前这段时间只有备用小词库,九键单按 4、5、7、8、9 只能出数字。安装包里的词库文本源文件换成编译好的二进制,安装包小约 4MB,手机上少占约 37MB。用户词库不受影响。 ## [0.0.5-beta.1] - 2026-10-05 diff --git a/README.md b/README.md index 8954cf2c..a7d76d0f 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ openIME 是一款独立的 Android 系统输入法。拼音候选、用户词库 - **键盘**:26 键拼音、九键拼音、英文 26 键、数字与符号;Emoji、符号、剪贴板、文本编辑、浮动键盘。 - **拼音**:全拼、简拼、手动分词、候选展开、用户词库学习、简繁转换。 - 引擎为 librime,内置约 90 万条 Rime Ice 词典记录;首次部署完整词典期间,高频快速词库先提供候选。 + 引擎为 librime,内置约 90 万条 Rime Ice 词典记录,打包时已预先编译;安装或升级后第一次打开键盘,几秒内即可用完整词典。 - **九键**:输入时左栏列出下一个字的拼音,一个字选一个音节,选定后自动移到下一个字。 - **语音输入**:长按空格说话,松手后识别并上屏;使用内置的中英双语模型,不联网;可去掉“嗯”“呃”等语气词,也可把标点写成空格。 - **语音词表**:内置科技、应用、游戏词表,可导入自己的文本词表;识别后把同音词改成词表写法,不联网。 @@ -57,7 +57,6 @@ openIME 是一款独立的 Android 系统输入法。拼音候选、用户词库 - 手写输入尚未接入识别引擎,入口默认隐藏。 - 九键暂不支持与外接键盘同时使用。 - 部分厂商系统对输入法的后台限制不同,尚未在大量真机上验证。 -- 首次安装后需要几十秒完成完整词典部署,期间候选质量略低。 - 版本号小于此前已装版本时,系统会拒绝覆盖安装;需要先卸载(卸载前可导出用户数据)。 ## 隐私与安全 diff --git a/VERSION b/VERSION index b16e4f99..12e375e9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.5-beta.1 +0.0.6-beta.1 diff --git a/docs/RELEASE.md b/docs/RELEASE.md index a54535e0..d52ac458 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -29,8 +29,9 @@ | PATCH | 缺陷修复、性能、文案、依赖的安全更新 | | `-beta.N` | 同一个 `X.Y.Z` 的第 N 个测试快照,修复后递增 N;正式确认后去掉后缀发布 | -Rime 共享数据以 `versionCode` 作为部署标记:每次升级后首次启动都会重新部署共享词典 -(用户词库在独立目录,不受影响)。所以 PATCH 版本也会触发一次重新部署。 +Rime 词典在打包时由 `prebuildRimeData`(`scripts/build_rime_prebuilt.py`)预编译,APK 只带编译好的 +二进制词库。共享数据以 `versionCode` 作为标记:每次升级后首次启动会从 APK 重新拷贝词库、清掉旧的编译 +产物(用户词库在独立目录,不受影响),手机上不再编译。 只维护最新的一条 MINOR 版本线;安全修复以 PATCH 版本发布。 From 8621984221082f732d8f7d82eebbfef5160851da Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:14:53 +0800 Subject: [PATCH 03/10] Skip copying Rime data when an upgrade leaves it unchanged The build writes a SHA-256 of the packaged Rime data (sources, compiled tables, and the names kept out of the APK) to rime-data.revision; the phone copies again only when it changes. The ready log now reports the copy and native startup times. Emulator: upgrade with new data copy=357ms native=1387ms; upgrade with unchanged data copy=skipped native=236ms. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + app/build.gradle.kts | 10 +++++- .../llc/slacker/openime/rime/RimeEngine.kt | 36 +++++++++++++++---- .../slacker/openime/RimeStartupGateTest.kt | 13 +++++-- docs/RELEASE.md | 4 +-- scripts/build_rime_prebuilt.py | 33 +++++++++++++++-- 6 files changed, 82 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d929355..3e0747cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs 第六个测试版:Rime 词库在打包时预先编译好,安装或升级后不用再等一分钟。从 0.0.5-beta.1 覆盖安装即可,用户数据保留。 - Rime 词库改为打包时预编译:安装或升级后不再在手机上编译约一分钟,几秒内就能用完整词库打字,小米 15 实测 1.7 秒。此前这段时间只有备用小词库,九键单按 4、5、7、8、9 只能出数字。安装包里的词库文本源文件换成编译好的二进制,安装包小约 4MB,手机上少占约 37MB。用户词库不受影响。 +- 升级时词库没有变化就不再重新拷贝,直接沿用手机上已有的词库,升级后第一次打开键盘和平时一样快。 ## [0.0.5-beta.1] - 2026-10-05 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 938d3cea..5fdf6224 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -155,6 +155,10 @@ abstract class PrebuildRimeData : DefaultTask() { @get:PathSensitive(PathSensitivity.RELATIVE) abstract val compilerSources: ConfigurableFileCollection + /** Sources the APK leaves out; they feed the content hash the phone compares. */ + @get:Input + abstract val excludedSources: ListProperty + @get:OutputDirectory abstract val outputDir: DirectoryProperty @@ -167,7 +171,10 @@ abstract class PrebuildRimeData : DefaultTask() { @TaskAction fun compile() { execOperations.exec { - commandLine("python3", script.get().asFile.absolutePath, "--out", outputDir.get().asFile.absolutePath) + commandLine( + listOf("python3", script.get().asFile.absolutePath, "--out", outputDir.get().asFile.absolutePath) + + excludedSources.get().flatMap { listOf("--exclude", it) }, + ) } } } @@ -180,6 +187,7 @@ val prebuildRimeData = tasks.register("prebuildRimeData") { layout.projectDirectory.file("src/main/cpp/CMakeLists.txt"), layout.projectDirectory.dir("src/main/cpp/vendor/librime/src"), ) + excludedSources.set(rimeCompiledOnlySources) outputDir.set(layout.buildDirectory.dir("generated/rime-prebuilt")) } 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 6b215472..593908c4 100644 --- a/app/src/main/java/llc/slacker/openime/rime/RimeEngine.kt +++ b/app/src/main/java/llc/slacker/openime/rime/RimeEngine.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.res.AssetManager import android.inputmethodservice.InputMethodService import android.os.Build +import android.os.SystemClock import android.util.Log import llc.slacker.openime.RimeNative import llc.slacker.openime.core.CrashGuard @@ -27,7 +28,16 @@ internal data class RimeCandidateEntry( internal fun rimeProbeHasCandidate(snapshot: Array?): Boolean = snapshot.orEmpty().drop(2).any { !it.isNullOrBlank() } -internal fun rimeDataRevision(versionCode: Long): String = "apk-$versionCode" +/** + * Identity of the bundled Rime data. The build writes a content hash of it + * (`.revision`, see scripts/build_rime_prebuilt.py), so an upgrade + * that leaves the dictionaries unchanged keeps the copy already on the phone. + * Without one (test fixtures) every APK version counts as new data. + */ +internal fun rimeDataRevision(contentHash: String?, versionCode: Long): String = + contentHash?.trim()?.takeIf { RIME_CONTENT_HASH.matches(it) }?.let { "data-$it" } ?: "apk-$versionCode" + +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" @@ -151,7 +161,9 @@ class RimeEngine( val userDirName = if (assetRoot == "rime-data") "rime-user" else "$dataDirName-user" val userDir = File(context.filesDir, userDirName).apply { mkdirs() } if (!startupGate.isCurrent(generation)) return@execute - copyAssetsIfNeeded(sharedDir, userDir) + val copyStartMs = SystemClock.elapsedRealtime() + val copied = copyAssetsIfNeeded(sharedDir, userDir) + val copyMs = SystemClock.elapsedRealtime() - copyStartMs if (!startupGate.isCurrent(generation)) return@execute // A previous start that died inside native code leaves its marker @@ -192,7 +204,9 @@ class RimeEngine( // nativeStartup is internally serialized. Even if destroy races // this call, nativeShutdown will either run after it or this // stale worker will perform the same idempotent cleanup below. + val nativeStartMs = SystemClock.elapsedRealtime() RimeNative.nativeStartup(sharedDir.absolutePath, userDir.absolutePath) + val nativeMs = SystemClock.elapsedRealtime() - nativeStartMs nativeStartupReturned = true if (!startupGate.isCurrent(generation)) { cleanupNative() @@ -225,7 +239,10 @@ class RimeEngine( recovery.succeeded() errorMessage = "" isReady = true - Log.i(TAG, "librime ready schema=$activeSchemaId") + Log.i( + TAG, + "librime ready schema=$activeSchemaId copy=${if (copied) "${copyMs}ms" else "skipped"} native=${nativeMs}ms", + ) } catch (throwable: Throwable) { // A Java exception is not a native crash: lift the marker so it is not counted as one. runCatching { @@ -589,20 +606,25 @@ class RimeEngine( * a current one; so each new APK also clears user/build. The user * dictionary lives beside it in the user dir and is never touched. */ - private fun copyAssetsIfNeeded(sharedDir: File, userDir: File, force: Boolean = false) { + /** Returns whether it copied; false when the phone already holds this data. */ + private fun copyAssetsIfNeeded(sharedDir: File, userDir: File, force: Boolean = false): Boolean { // Read the identity of the actually installed APK instead of relying on // generated BuildConfig fields. This stays valid even when BuildConfig - // generation is disabled and automatically changes on every upgrade. - val revision = rimeDataRevision(installedVersionCode()) + // generation is disabled. + val contentHash = runCatching { + assetManager.open("$assetRoot.revision").bufferedReader().use { it.readText() } + }.getOrNull() + val revision = rimeDataRevision(contentHash, installedVersionCode()) val marker = File(sharedDir, ".openime-rime-$revision") val requiredSchemasPresent = File(sharedDir, "luna_pinyin_simp.schema.yaml").exists() && File(sharedDir, "luna_pinyin_simp_fuzzy.schema.yaml").exists() - if (!force && marker.exists() && requiredSchemasPresent) return + if (!force && marker.exists() && requiredSchemasPresent) return false deleteChildren(sharedDir) deleteDirectory(File(userDir, "build")) copyAssetTree(assetRoot, sharedDir) marker.writeText("openIME Rime data revision $revision\n") + return true } @Suppress("DEPRECATION") diff --git a/app/src/test/java/llc/slacker/openime/RimeStartupGateTest.kt b/app/src/test/java/llc/slacker/openime/RimeStartupGateTest.kt index 41d48b7b..b94aa95a 100644 --- a/app/src/test/java/llc/slacker/openime/RimeStartupGateTest.kt +++ b/app/src/test/java/llc/slacker/openime/RimeStartupGateTest.kt @@ -54,7 +54,16 @@ class RimeStartupGateTest { @Test fun rimeDataRevisionTracksApkVersionCode() { - assertEquals("apk-1", rimeDataRevision(1)) - assertEquals("apk-42", rimeDataRevision(42)) + assertEquals("apk-1", rimeDataRevision(null, 1)) + assertEquals("apk-42", rimeDataRevision(null, 42)) + assertEquals("apk-42", rimeDataRevision("not a hash", 42)) + } + + @Test + fun rimeDataRevisionPrefersTheBundledContentHash() { + val hash = "0123456789abcdef".repeat(4) + // Same data in a newer APK: the copy on the phone stays valid. + assertEquals("data-$hash", rimeDataRevision("$hash\n", 501)) + assertEquals("data-$hash", rimeDataRevision(hash, 601)) } } diff --git a/docs/RELEASE.md b/docs/RELEASE.md index d52ac458..ff3d4dfc 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -30,8 +30,8 @@ | `-beta.N` | 同一个 `X.Y.Z` 的第 N 个测试快照,修复后递增 N;正式确认后去掉后缀发布 | Rime 词典在打包时由 `prebuildRimeData`(`scripts/build_rime_prebuilt.py`)预编译,APK 只带编译好的 -二进制词库。共享数据以 `versionCode` 作为标记:每次升级后首次启动会从 APK 重新拷贝词库、清掉旧的编译 -产物(用户词库在独立目录,不受影响),手机上不再编译。 +二进制词库,并附带这份数据的内容哈希(`rime-data.revision`)。升级后只有哈希变了才从 APK 重新拷贝词库、 +清掉旧的编译产物(用户词库在独立目录,不受影响);词库没变的升级直接沿用手机上已有的那份。手机上不再编译。 只维护最新的一条 MINOR 版本线;安全修复以 PATCH 版本发布。 diff --git a/scripts/build_rime_prebuilt.py b/scripts/build_rime_prebuilt.py index a68a13c1..d80ae183 100644 --- a/scripts/build_rime_prebuilt.py +++ b/scripts/build_rime_prebuilt.py @@ -9,17 +9,22 @@ 1. builds `openime_rime_deployer` from the vendored librime (the same sources and versions as the APK's native library) into build/rime-host; 2. runs `--build` over app/src/main/assets/rime-data; -3. writes the compiled files to /rime-data/build/. +3. writes the compiled files to /rime-data/build/ and a content hash of + the packaged Rime data to /rime-data.revision. The APK ships that directory as librime's prebuilt data dir (shared_data_dir/build), so the phone only copies it. The output is byte-identical to what librime compiles on an x86_64 emulator and loads as is on arm64 phones; the dictionary sources it came from stay out of the APK. +The phone copies the data again only when that hash changes, so an upgrade +that leaves the dictionaries alone skips the copy. + python3 scripts/build_rime_prebuilt.py --out app/build/generated/rime-prebuilt """ import argparse +import hashlib import os import shutil import subprocess @@ -117,13 +122,35 @@ def compile_dictionaries(deployer, out_dir): return target +def content_hash(compiled, excluded): + """SHA-256 over every source file, the compiled tables and the names kept + out of the APK: anything that changes what the phone copies changes it.""" + digest = hashlib.sha256() + entries = [("src", path.relative_to(SOURCES), path) for path in SOURCES.rglob("*") if path.is_file()] + entries += [("bin", path.relative_to(compiled), path) for path in compiled.iterdir()] + for kind, relative, path in sorted(entries): + digest.update(f"{kind}:{relative.as_posix()}\0".encode()) + digest.update(path.read_bytes()) + digest.update(b"\0") + for name in sorted(excluded): + digest.update(f"exclude:{name}\0".encode()) + return digest.hexdigest() + + def main(): parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--out", required=True, type=Path, help="generated assets root") + parser.add_argument( + "--exclude", action="append", default=[], + help="a source file name the APK leaves out (repeatable); part of the content hash", + ) args = parser.parse_args() - target = compile_dictionaries(build_deployer(), args.out.resolve()) + out = args.out.resolve() + target = compile_dictionaries(build_deployer(), out) + revision = content_hash(target, args.exclude) + (out / "rime-data.revision").write_text(revision + "\n", encoding="utf-8") total = sum(path.stat().st_size for path in target.iterdir()) - print(f"build_rime_prebuilt: {len(EXPECTED)} files, {total // 1024} KiB in {target}") + print(f"build_rime_prebuilt: {len(EXPECTED)} files, {total // 1024} KiB in {target}, revision {revision[:12]}") if __name__ == "__main__": From 4e9a16740195ce84a98060b9c38bc53862ff5f3b Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:26:48 +0800 Subject: [PATCH 04/10] =?UTF-8?q?Add=20the=20stroke=20(=E7=AC=94=E7=94=BB)?= =?UTF-8?q?=20keyboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five stroke keys and 通配 in the nine-key frame. Characters come from a table generated at build time from Rime's stroke dictionary, ranked by the 8105 table's frequencies: exact stroke orders first, then longer ones; 通配 stands for one stroke. Choices are learned locally by stroke code. Co-Authored-By: Claude Opus 5.5 --- .../slacker/openime/LocalVoiceImeService.kt | 32 +++- .../openime/candidate/CandidatePipeline.kt | 3 + .../openime/candidate/CandidateSnapshot.kt | 4 +- .../openime/candidate/StrokeLexicon.kt | 138 +++++++++++++++ .../java/llc/slacker/openime/core/ImeState.kt | 6 + .../openime/data/ImeSettingsRepository.kt | 5 +- .../openime/keyboard/ImeKeyboardView.kt | 73 +++++++- .../keyboard/StrokeKeyboardRenderer.kt | 162 ++++++++++++++++++ .../slacker/openime/panel/ImePanelRenderer.kt | 3 +- .../llc/slacker/openime/StrokeLexiconTest.kt | 83 +++++++++ scripts/build_rime_prebuilt.py | 48 +++++- 11 files changed, 539 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/llc/slacker/openime/candidate/StrokeLexicon.kt create mode 100644 app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt create mode 100644 app/src/test/java/llc/slacker/openime/StrokeLexiconTest.kt diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index 30cc6935..a1de9bda 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -25,6 +25,8 @@ import llc.slacker.openime.candidate.CandidateSnapshotEntry import llc.slacker.openime.candidate.EmojiAssociationIndex import llc.slacker.openime.candidate.NineKeyReading import llc.slacker.openime.candidate.PinyinLexicon +import llc.slacker.openime.candidate.Stroke +import llc.slacker.openime.candidate.StrokeLexicon import llc.slacker.openime.candidate.personalizedLearningAllowed import llc.slacker.openime.core.CrashGuard import llc.slacker.openime.core.ImeState @@ -198,6 +200,12 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can runCatching { CandidatePipeline(CandidateEngine(PinyinLexicon.load(this))) } .onSuccess { candidatePipeline = it } .onFailure { Log.e(TAG, "lexicon/decoder initialisation failed; running on Rime only", it) } + // Only someone who uses the 笔画 keyboard pays for its table; the + // keyboard loads it on first use otherwise. + if (ImeSettingsRepository.loadPreferredChineseMode(this) == KeyboardMode.STROKE) { + runCatching { StrokeLexicon.load(this) } + .onFailure { Log.e(TAG, "stroke table failed to load", it) } + } }, "openime-lexicon").apply { isDaemon = true; start() } rime = RimeEngine(this).also { it.start() } candidateQueries = CandidateQueryCoordinator( @@ -1068,8 +1076,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can // Terminals and games need each letter as it is typed; composing English // there shows nothing until the word ends. Pinyin still composes. EditorInfoAdapter.kind(state.editorInfo) == EditorInfoAdapter.EditorKind.RAW_KEYS && - state.keyboardMode != KeyboardMode.PINYIN_26 && - state.keyboardMode != KeyboardMode.PINYIN_9 + !state.keyboardMode.isChineseLayout ) if (directCommit) { // Password fields never receive composing text, so the view's @@ -1448,6 +1455,14 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can composition, state.fuzzyPinyinEnabled, ) + if (mode == KeyboardMode.STROKE) { + // Characters the user picked for these strokes before come first. + val learned = strokeLearningKey(composition) + ?.takeIf { allowsPersonalizedLearning() } + ?.let(UserPhraseRepository::candidatesFor) + .orEmpty() + return (learned + normal).distinct().take(MAX_CANDIDATES) + } if (mode != KeyboardMode.PINYIN_26 && mode != KeyboardMode.ENGLISH_26) { return normal } @@ -1713,7 +1728,11 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can // librime owns normal learning through its userdb. Keep the old local // repository only as an offline fallback; never run two unconditional // ranking systems over the same successful native selection. - if (!rime.isReady && allowsPersonalizedLearning()) { + if (state.keyboardMode == KeyboardMode.STROKE) { + // Stroke input has no librime session, so it always learns locally. + strokeLearningKey(composition)?.takeIf { allowsPersonalizedLearning() } + ?.let { UserPhraseRepository.record(it, committed) } + } else if (!rime.isReady && allowsPersonalizedLearning()) { UserPhraseRepository.record(composition, committed) } gateway.commitText(committed) @@ -1726,6 +1745,13 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can keyboardView?.setAssociationCandidates(associationsAfterCommit(committed)) } + /** + * The learning key for a stroke composition: its stroke code, prefixed so it + * never meets a Pinyin code. Null with 通配, whose matches are a guess. + */ + private fun strokeLearningKey(composition: String): String? = + Stroke.codeOf(composition)?.takeIf { Stroke.WILDCARD_CODE !in it }?.let { "stroke:$it" } + private fun allowsPersonalizedLearning(): Boolean = personalizedLearningAllowed(state.passwordField, state.editorInfo?.imeOptions) 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 e5d70ba4..c6184bf7 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt @@ -55,6 +55,8 @@ class CandidatePipeline internal constructor( private val engine: CandidateEngine, private val nineKeyUiState: NineKeyUiState = NineKeyUiState(), private val nineKeyFallbackRegistry: NineKeyFallbackRegistry = NineKeyFallbackRegistry(), + /** The 笔画 table once loaded; null (no candidates yet) while it loads. */ + private val strokeLexicon: () -> StrokeLexicon? = StrokeLexicon::current, ) : CandidateResolver { /** * [pinyinPaths] keeps its historical name for Listener compatibility. For @@ -91,6 +93,7 @@ class CandidatePipeline internal constructor( } else { emptyList() } + KeyboardMode.STROKE -> strokeLexicon()?.candidatesFor(composition).orEmpty() KeyboardMode.DIGITS -> emptyList() } 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 ec51ffb3..41dfb195 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/CandidateSnapshot.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/CandidateSnapshot.kt @@ -29,7 +29,7 @@ internal data class CandidateSnapshot( /** * Space/enter commit the rendered first candidate. PINYIN_9 deliberately * does not fall back to committing the guessed/local preview as raw Latin - * text when no real candidate exists yet. + * text when no real candidate exists yet, nor STROKE its stroke glyphs. */ fun firstForCommit( currentGeneration: Long, @@ -38,7 +38,7 @@ internal data class CandidateSnapshot( ): CandidateSnapshotEntry? { if (!matches(currentGeneration, currentComposition, currentMode)) return null entries.firstOrNull()?.let { return it } - if (mode == KeyboardMode.PINYIN_9) return null + if (mode == KeyboardMode.PINYIN_9 || mode == KeyboardMode.STROKE) return null return composition.takeIf { it.isNotEmpty() }?.let(::CandidateSnapshotEntry) } diff --git a/app/src/main/java/llc/slacker/openime/candidate/StrokeLexicon.kt b/app/src/main/java/llc/slacker/openime/candidate/StrokeLexicon.kt new file mode 100644 index 00000000..0b1918b4 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/candidate/StrokeLexicon.kt @@ -0,0 +1,138 @@ +package llc.slacker.openime.candidate + +import android.content.Context + +/** + * The five strokes of the 笔画 keyboard, in the order mainstream stroke input + * uses (and numbers 1–5): 横 竖 撇 点 折. [code] is the letter Rime's stroke + * table uses for it. + */ +enum class Stroke(val code: Char, val glyph: String, val label: String, val digit: Char) { + HENG('h', "一", "横", '1'), + SHU('s', "丨", "竖", '2'), + PIE('p', "丿", "撇", '3'), + DIAN('n', "丶", "点", '4'), + ZHE('z', "乛", "折", '5'), + ; + + companion object { + /** Shown in the composition for 通配: any one stroke. */ + const val WILDCARD_GLYPH = "*" + const val WILDCARD_CODE = '?' + + /** + * The stroke code of a composition, or null when it holds anything but + * strokes. Accepts the glyphs the keyboard types as well as the letters, + * digits and `*`/`?` a user might type into the pre-edit field. + */ + fun codeOf(composition: String): String? { + if (composition.isEmpty()) return null + val code = StringBuilder(composition.length) + for (char in composition) { + code.append( + when (char) { + '*', '?', '*', '?' -> WILDCARD_CODE + else -> entries.firstOrNull { + it.glyph[0] == char || it.code == char || it.digit == char + }?.code ?: return null + }, + ) + } + return code.toString() + } + } +} + +/** + * Characters by stroke order for the 笔画 keyboard. + * + * The table (stroke_table.tsv, generated at build time from Rime's stroke + * dictionary) lists every character with its stroke code, most frequent first + * by the 8105 table's weights, so common simplified characters lead and rare or + * traditional ones still come up further down. A query returns the characters + * whose strokes start with what was typed: those it spells exactly first (一 + * for 一), then the rest, each group in frequency order. 通配 (`?`) stands for + * any one stroke. + * + * About 110,000 rows are kept as one packed code array instead of strings + * (~2 MB rather than ~12 MB), and loaded only once the keyboard is first used. + */ +class StrokeLexicon internal constructor(rows: Sequence>) { + private val characters: Array + private val codes: ByteArray + private val offsets: IntArray + + init { + val characterList = ArrayList(STROKE_TABLE_ROWS) + val codeBytes = java.io.ByteArrayOutputStream(STROKE_TABLE_ROWS * 10) + val offsetList = ArrayList(STROKE_TABLE_ROWS + 1) + offsetList += 0 + for ((character, code) in rows) { + if (character.isEmpty() || code.isEmpty() || code.any { it !in STROKE_CODES }) continue + characterList += character + code.forEach { codeBytes.write(it.code) } + offsetList += codeBytes.size() + } + characters = characterList.toTypedArray() + codes = codeBytes.toByteArray() + offsets = offsetList.toIntArray() + } + + val size: Int get() = characters.size + + /** Characters for [composition] (glyphs or codes, see [Stroke.codeOf]). */ + fun candidatesFor(composition: String, limit: Int = DEFAULT_LIMIT): List { + val pattern = Stroke.codeOf(composition) ?: return emptyList() + val exact = LinkedHashSet() + val longer = LinkedHashSet() + for (row in characters.indices) { + val start = offsets[row] + val length = offsets[row + 1] - start + if (length < pattern.length || !matches(start, pattern)) continue + val character = characters[row] + if (length == pattern.length) { + exact += character + } else if (longer.size < limit) { + longer += character + } + } + return (exact.asSequence() + longer.asSequence()).distinct().take(limit).toList() + } + + private fun matches(start: Int, pattern: String): Boolean { + for (index in pattern.indices) { + val wanted = pattern[index] + if (wanted != Stroke.WILDCARD_CODE && codes[start + index].toInt() != wanted.code) return false + } + return true + } + + companion object { + private const val ASSET = "stroke_table.tsv" + private const val STROKE_TABLE_ROWS = 112_000 + private const val DEFAULT_LIMIT = 96 + private val STROKE_CODES = Stroke.entries.map { it.code }.toSet() + + @Volatile + private var cached: StrokeLexicon? = null + + /** Loads the table once per process; later calls return the same instance. */ + fun load(context: Context): StrokeLexicon { + cached?.let { return it } + return synchronized(this) { + cached ?: context.assets.open(ASSET).bufferedReader(Charsets.UTF_8).useLines { lines -> + StrokeLexicon(parse(lines)) + }.also { cached = it } + } + } + + /** The loaded table, or null before [load] has finished. */ + fun current(): StrokeLexicon? = cached + + internal fun parse(lines: Sequence): Sequence> = + lines.mapNotNull { line -> + val tab = line.indexOf('\t') + if (tab <= 0) null else line.substring(0, tab) to line.substring(tab + 1).trim() + } + } +} 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 68970e2c..32f7cef4 100644 --- a/app/src/main/java/llc/slacker/openime/core/ImeState.kt +++ b/app/src/main/java/llc/slacker/openime/core/ImeState.kt @@ -8,7 +8,13 @@ enum class KeyboardMode { PINYIN_26, ENGLISH_26, PINYIN_9, + /** 笔画: five stroke keys and 通配; characters come from StrokeLexicon. */ + STROKE, 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 } enum class Panel { 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 68b02e9f..52cd1a3b 100644 --- a/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt @@ -38,12 +38,11 @@ object ImeSettingsRepository { .getString(KEY_PREFERRED_CHINESE_MODE, KeyboardMode.PINYIN_26.name) ?: KeyboardMode.PINYIN_26.name val parsed = KeyboardMode.valueOf(name) - if (parsed == KeyboardMode.PINYIN_9 || parsed == KeyboardMode.PINYIN_26) parsed - else KeyboardMode.PINYIN_26 + if (parsed.isChineseLayout) parsed else KeyboardMode.PINYIN_26 }.getOrDefault(KeyboardMode.PINYIN_26) fun savePreferredChineseMode(context: Context, mode: KeyboardMode) { - if (mode != KeyboardMode.PINYIN_26 && mode != KeyboardMode.PINYIN_9) return + if (!mode.isChineseLayout) return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .edit().putString(KEY_PREFERRED_CHINESE_MODE, mode.name).apply() } 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 05b44c1f..16135d47 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -28,6 +28,7 @@ import llc.slacker.openime.candidate.CandidateResolver import llc.slacker.openime.candidate.NineKeyLocalDecoder import llc.slacker.openime.candidate.NineKeyPerformanceTrace import llc.slacker.openime.candidate.NineKeyReading +import llc.slacker.openime.candidate.StrokeLexicon import llc.slacker.openime.core.ImeState import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.core.Panel @@ -329,7 +330,7 @@ open class ImeKeyboardView( } if (panel == Panel.NONE) { when (mode) { - KeyboardMode.PINYIN_9 -> { + KeyboardMode.PINYIN_9, KeyboardMode.STROKE -> { nineKeySymbolRailController?.refreshSymbols() applyThemeToSubtree(this) } @@ -462,6 +463,41 @@ open class ImeKeyboardView( onEnter = listener::onEnter, ) } + private val strokeRenderer: StrokeKeyboardRenderer by lazy { + StrokeKeyboardRenderer( + context = context, + keyboardBody = keyboardBody, + toPx = ::dp, + keyRowHeightDp = ::keyRowHeightDp, + gridHeightDp = ::nineGridHeightDp, + bodyHeightDp = ::nineBodyHeightDp, + createKey = { text, function, secondary, textSize, onTap -> + key( + text = text, + func = function, + secondary = secondary, + mainTextSizeOverride = textSize, + onTap = onTap, + ) + }, + createBackspaceKey = ::backspaceKey, + createSpaceVoiceKey = { label, onTap -> + spaceVoiceKey(label, white = true, onTap = onTap) + }, + // The nine-key rail: stroke glyphs are not digits, so it stays on symbols. + createSymbolRail = { requireNineKeySymbolRailController().buildRail() }, + markSideKey = { key -> key.setTag(MARK_SIDE_KEY, true) }, + markWhiteKey = { key -> key.setTag(MARK_WHITE_KEY, true) }, + onStroke = ::onStrokeKey, + swipeUpEnabled = { ImeSettingsRepository.loadSwipeUpDigits(context) }, + onCommitCharacter = ::commitKeyboardCharacter, + onDigits = { setMode(KeyboardMode.DIGITS) }, + onSpace = ::commitFirstCandidateOrSpace, + onModeSwitch = ::cycleMode, + onRetype = { publishComposition("", emptyList()) }, + onEnter = listener::onEnter, + ) + } private val numericKeyboardRenderer: NumericKeyboardRenderer by lazy { NumericKeyboardRenderer( context = context, @@ -1218,7 +1254,7 @@ open class ImeKeyboardView( fun cycleMode() { val next = when (mode) { - KeyboardMode.PINYIN_26, KeyboardMode.PINYIN_9 -> KeyboardMode.ENGLISH_26 + KeyboardMode.PINYIN_26, KeyboardMode.PINYIN_9, KeyboardMode.STROKE -> KeyboardMode.ENGLISH_26 KeyboardMode.ENGLISH_26 -> preferredChineseMode KeyboardMode.DIGITS -> lastTextMode } @@ -1233,8 +1269,8 @@ open class ImeKeyboardView( hidePopup() if (newMode != KeyboardMode.DIGITS) { lastTextMode = newMode - if (newMode == KeyboardMode.PINYIN_26 || newMode == KeyboardMode.PINYIN_9) { - // Persist the 26/9-key choice so it survives process death. + if (newMode.isChineseLayout) { + // Persist the 26-key/9-key/stroke choice so it survives process death. if (preferredChineseMode != newMode) { ImeSettingsRepository.savePreferredChineseMode(context, newMode) } @@ -1479,7 +1515,7 @@ open class ImeKeyboardView( val options = imeOptions ?: return val enter = findViewWithTag("key-enter") ?: return val composing = composition.text?.isNotEmpty() == true - val label = if (composing) "确定" else if (mode == KeyboardMode.PINYIN_9 || mode == KeyboardMode.DIGITS) "↵" else enterKeyPresentationFor(options).label + val label = if (composing) "确定" else if (mode == KeyboardMode.PINYIN_9 || mode == KeyboardMode.STROKE || mode == KeyboardMode.DIGITS) "↵" else enterKeyPresentationFor(options).label enter.setMainText(label) enter.applyMainTextScale(referenceScale) enter.contentDescription = label @@ -1885,6 +1921,7 @@ open class ImeKeyboardView( KeyboardMode.PINYIN_26 -> renderPinyin26() KeyboardMode.ENGLISH_26 -> renderEnglish26() KeyboardMode.PINYIN_9 -> renderPinyin9() + KeyboardMode.STROKE -> renderStroke() KeyboardMode.DIGITS -> renderDigits() } updateTopZone(composition.text?.isNotEmpty() == true) @@ -1905,8 +1942,12 @@ open class ImeKeyboardView( private fun syncModeAccessibility() { val modeKey = findViewWithTag("key:mode") ?: return val target = when (mode) { - KeyboardMode.PINYIN_26, KeyboardMode.PINYIN_9 -> "英文 26 键" - KeyboardMode.ENGLISH_26 -> if (preferredChineseMode == KeyboardMode.PINYIN_9) "中文九键" else "中文 26 键" + KeyboardMode.PINYIN_26, KeyboardMode.PINYIN_9, KeyboardMode.STROKE -> "英文 26 键" + KeyboardMode.ENGLISH_26 -> when (preferredChineseMode) { + KeyboardMode.PINYIN_9 -> "中文九键" + KeyboardMode.STROKE -> "笔画" + else -> "中文 26 键" + } KeyboardMode.DIGITS -> "文字键盘" } modeKey.contentDescription = when (mode) { @@ -1917,6 +1958,7 @@ open class ImeKeyboardView( modeKey.stateDescription = when (mode) { KeyboardMode.PINYIN_26 -> "当前中文 26 键" KeyboardMode.PINYIN_9 -> "当前中文九键" + KeyboardMode.STROKE -> "当前笔画" KeyboardMode.ENGLISH_26 -> "当前英文 26 键" KeyboardMode.DIGITS -> "当前数字键盘" } @@ -1969,6 +2011,23 @@ open class ImeKeyboardView( ).also { nineKeySymbolRailController = it } } + private fun renderStroke() { + if (StrokeLexicon.current() == null) { + Thread({ runCatching { StrokeLexicon.load(context) } }, "openime-stroke-table").apply { + isDaemon = true + start() + } + } + strokeRenderer.render(enterLabel = if (composition.text?.isNotEmpty() == true) "确定" else "↵") + } + + /** One stroke (or 通配) typed at the pre-edit cursor. */ + private fun onStrokeKey(glyph: String) { + clearAssociationCandidates() + val (next, selection) = replaceCompositionSelection(glyph) + publishComposition(next, candidatesForComposition(next), selection) + } + private fun renderDigits() { val info = (context as? android.inputmethodservice.InputMethodService) ?.currentInputEditorInfo diff --git a/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt new file mode 100644 index 00000000..d72ac7af --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt @@ -0,0 +1,162 @@ +package llc.slacker.openime.keyboard + +import android.content.Context +import android.widget.LinearLayout +import android.widget.ScrollView +import llc.slacker.openime.candidate.Stroke +import llc.slacker.openime.theme.ImeTypographyTokens +import llc.slacker.openime.widget.ImeKeyView + +/** + * The 笔画 keyboard. It keeps the nine-key frame (symbol rail, 删除/重输/确定 + * column, 123 · 空格 · 中/英 row) so switching between the two moves nothing + * the user already knows, and puts the five strokes and 通配 in two rows of + * large keys in their usual order: 一 丨 丿 / 丶 乛 通配, numbered 1–5 as in + * other stroke keyboards. + */ +internal class StrokeKeyboardRenderer( + private val context: Context, + private val keyboardBody: LinearLayout, + private val toPx: (Int) -> Int, + private val keyRowHeightDp: () -> Int, + private val gridHeightDp: () -> Int, + private val bodyHeightDp: () -> Int, + private val createKey: ( + text: String, + function: Boolean, + secondary: String?, + textSize: Float?, + onTap: () -> Unit, + ) -> ImeKeyView, + private val createBackspaceKey: () -> ImeKeyView, + private val createSpaceVoiceKey: (label: String, onTap: () -> Unit) -> ImeKeyView, + private val createSymbolRail: () -> ScrollView, + private val markSideKey: (ImeKeyView) -> Unit, + private val markWhiteKey: (ImeKeyView) -> Unit, + private val onStroke: (String) -> Unit, + private val swipeUpEnabled: () -> Boolean, + private val onCommitCharacter: (String) -> Unit, + private val onDigits: () -> Unit, + private val onSpace: () -> Unit, + private val onModeSwitch: () -> Unit, + private val onRetype: () -> Unit, + private val onEnter: () -> Unit, +) { + fun render(enterLabel: String) { + val container = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + tag = "stroke-layout" + } + + container.addView( + LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + addView( + createSymbolRail(), + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(bodyHeightDp())), + ) + }, + columnParams(1f), + ) + + val center = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } + center.addView( + buildStrokeGrid().apply { tag = "stroke-grid" }, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(gridHeightDp())), + ) + val bottom = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } + val weights = ProductionKeyPolicy.nineKeyBottomRowWeights() + bottom.addView( + createKey("123", true, null, ImeTypographyTokens.BODY_SP, onDigits).apply { markSideKey(this) }, + keyParams(keyRowHeightDp(), weights.side), + ) + bottom.addView(createSpaceVoiceKey("空格", onSpace), keyParams(keyRowHeightDp(), weights.space)) + bottom.addView( + createKey("中/英", true, null, ImeTypographyTokens.BODY_SP, onModeSwitch).apply { + tag = "key:mode" + markSideKey(this) + }, + keyParams(keyRowHeightDp(), weights.side), + ) + center.addView( + bottom, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(keyRowHeightDp())), + ) + container.addView(center, columnParams(25f / 7f)) + + val side = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + tag = "stroke-actions" + } + side.addView(createBackspaceKey().apply { markSideKey(this) }, sideKeyParams()) + side.addView( + createKey("重输", true, null, ImeTypographyTokens.BODY_SP, onRetype).apply { + tag = "key-retype" + markSideKey(this) + }, + sideKeyParams(), + ) + side.addView( + createKey(enterLabel, true, null, ImeTypographyTokens.BODY_SP, onEnter).apply { + tag = "key-enter" + markSideKey(this) + }, + sideKeyParams(), + ) + container.addView(side, columnParams(1f)) + + keyboardBody.addView( + container, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(bodyHeightDp())), + ) + } + + private fun buildStrokeGrid(): LinearLayout { + val rowHeightDp = gridHeightDp() / ROWS.size + val grid = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } + ROWS.forEach { strokes -> + val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } + strokes.forEach { stroke -> + val key = if (stroke == null) { + createKey("通配", false, null, ImeTypographyTokens.BODY_SP) { + onStroke(Stroke.WILDCARD_GLYPH) + }.apply { + tag = "key-stroke:wildcard" + contentDescription = "通配,代替一笔" + } + } else { + createKey(stroke.glyph, false, stroke.digit.toString(), ImeTypographyTokens.CANDIDATE_SP) { + onStroke(stroke.glyph) + }.apply { + tag = "key-stroke:${stroke.code}" + contentDescription = "${stroke.digit},${stroke.label}" + // Swipe up types the key's digit, as on the nine-key keyboard. + onSwipeUp = { onCommitCharacter(stroke.digit.toString()) } + swipeUpEnabled = this@StrokeKeyboardRenderer.swipeUpEnabled + } + } + markWhiteKey(key) + row.addView(key, keyParams(rowHeightDp)) + } + grid.addView(row, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(rowHeightDp))) + } + return grid + } + + private fun keyParams(heightDp: Int, weight: Float = 1f) = + LinearLayout.LayoutParams(0, toPx(heightDp), weight) + + private fun sideKeyParams() = + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(bodyHeightDp() / 3)) + + private fun columnParams(weight: Float) = + LinearLayout.LayoutParams(0, toPx(bodyHeightDp()), weight) + + private companion object { + /** null is 通配. */ + val ROWS: List> = listOf( + listOf(Stroke.HENG, Stroke.SHU, Stroke.PIE), + listOf(Stroke.DIAN, Stroke.ZHE, null), + ) + } +} 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 b3d2041b..446c6f84 100644 --- a/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt @@ -93,6 +93,7 @@ internal class ImePanelRenderer( val modes = listOf( KeyboardMode.PINYIN_26 to "拼音 26 键", KeyboardMode.PINYIN_9 to "拼音 9 键", + KeyboardMode.STROKE to "笔画", KeyboardMode.ENGLISH_26 to "英文 26 键", KeyboardMode.DIGITS to "数字键盘", ) @@ -712,7 +713,7 @@ internal class ImePanelRenderer( paint.color = tokens.functionKeyBackground for (i in 0..3) { val y = i * h - val w = if (mode == KeyboardMode.PINYIN_9 || mode == KeyboardMode.DIGITS) width * 0.19f else width * (0.12f + 0.03f * i) + val w = if (mode == KeyboardMode.PINYIN_9 || mode == KeyboardMode.STROKE || mode == KeyboardMode.DIGITS) width * 0.19f else width * (0.12f + 0.03f * i) canvas.drawRoundRect(0f, y, w, y + h * 0.72f, toPx(2).toFloat(), toPx(2).toFloat(), paint) if (mode != KeyboardMode.DIGITS || i == 0 || i == 3) { paint.color = if (selected && i >= 2) tokens.primary else tokens.functionKeyBackground diff --git a/app/src/test/java/llc/slacker/openime/StrokeLexiconTest.kt b/app/src/test/java/llc/slacker/openime/StrokeLexiconTest.kt new file mode 100644 index 00000000..4d8f18af --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/StrokeLexiconTest.kt @@ -0,0 +1,83 @@ +package llc.slacker.openime + +import llc.slacker.openime.candidate.Stroke +import llc.slacker.openime.candidate.StrokeLexicon +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class StrokeLexiconTest { + // Most frequent first, as stroke_table.tsv is generated. + private val lexicon = StrokeLexicon( + StrokeLexicon.parse( + sequenceOf( + "的\tpszhhpzn", + "一\th", + "是\tszhhhshpn", + "我\tphzhznp", + "我\tphshzpn", + "十\ths", + "二\thh", + "三\thhh", + "千\tphs", + "王\thhsh", + "丰\thhhs", + "bad line without a tab", + "坏\thxs", + ), + ), + ) + + @Test + fun compositionGlyphsLettersAndDigitsAllMapToStrokeCodes() { + assertEquals("hspnz", Stroke.codeOf("一丨丿丶乛")) + assertEquals("hspnz", Stroke.codeOf("hspnz")) + assertEquals("hspnz", Stroke.codeOf("12345")) + assertEquals("h?s", Stroke.codeOf("一*丨")) + assertEquals("h?s", Stroke.codeOf("h*s")) + assertNull(Stroke.codeOf("ni")) + assertNull(Stroke.codeOf("")) + } + + @Test + fun rowsWithUnknownStrokesAreDropped() { + assertEquals(11, lexicon.size) + } + + @Test + fun anExactStrokeOrderComesBeforeLongerOnesThenFrequency() { + // 一 is exactly 一; 十 二 三 王 丰 start with it, in table order. + assertEquals(listOf("一", "十", "二", "三", "王", "丰"), lexicon.candidatesFor("一")) + // 二 spells 一一 exactly, so it leads even though 三 王 丰 also match. + assertEquals(listOf("二", "三", "王", "丰"), lexicon.candidatesFor("一一")) + } + + @Test + fun aCharacterWithTwoStrokeOrdersIsFoundByEitherAndListedOnce() { + assertEquals(listOf("我"), lexicon.candidatesFor("丿一乛")) + // 丿一丨 spells 千 exactly, so it leads; 我's second order only starts so. + assertEquals(listOf("千", "我"), lexicon.candidatesFor("丿一丨")) + assertEquals(listOf("我", "千"), lexicon.candidatesFor("丿一")) + } + + @Test + fun wildcardStandsForExactlyOneStroke() { + // 一*: 十 (hs) and 二 (hh) exactly, then 三 王 丰 by frequency. + assertEquals(listOf("十", "二", "三", "王", "丰"), lexicon.candidatesFor("一*")) + // *丨: any first stroke, then 丨. 十 exactly; 的 (pszhhpzn) starts so. + assertEquals(listOf("十", "的"), lexicon.candidatesFor("*丨")) + assertTrue(lexicon.candidatesFor("***********").isEmpty()) + } + + @Test + fun anythingButStrokesHasNoCandidates() { + assertTrue(lexicon.candidatesFor("wo").isEmpty()) + assertTrue(lexicon.candidatesFor("").isEmpty()) + } + + @Test + fun theLimitCapsTheList() { + assertEquals(listOf("一", "十"), lexicon.candidatesFor("一", limit = 2)) + } +} diff --git a/scripts/build_rime_prebuilt.py b/scripts/build_rime_prebuilt.py index d80ae183..685cf2b9 100644 --- a/scripts/build_rime_prebuilt.py +++ b/scripts/build_rime_prebuilt.py @@ -10,7 +10,11 @@ and versions as the APK's native library) into build/rime-host; 2. runs `--build` over app/src/main/assets/rime-data; 3. writes the compiled files to /rime-data/build/ and a content hash of - the packaged Rime data to /rime-data.revision. + the packaged Rime data to /rime-data.revision; +4. writes /stroke_table.tsv for the 笔画 keyboard: every character of + stroke.dict.yaml with its stroke code (h s p n z), most frequent first by + the 8105 table's weights. The keyboard reads it directly, so + stroke.dict.yaml itself stays out of the APK. The APK ships that directory as librime's prebuilt data dir (shared_data_dir/build), so the phone only copies it. The output is @@ -122,6 +126,42 @@ def compile_dictionaries(deployer, out_dir): return target +def dict_body(path): + """The tab-separated rows after a Rime dict.yaml's `...` header line.""" + in_body = False + with open(path, encoding="utf-8") as lines: + for line in lines: + if not in_body: + in_body = line.strip() == "..." + continue + if line.startswith("#") or not line.strip(): + continue + yield line.rstrip("\n").split("\t") + + +def write_stroke_table(out_dir): + weights = {} + for fields in dict_body(SOURCES / "openime_dicts" / "8105.dict.yaml"): + if len(fields) >= 3 and fields[2].strip().isdigit(): + weights[fields[0]] = max(weights.get(fields[0], 0), int(fields[2])) + rows = [] + seen = set() + for fields in dict_body(SOURCES / "stroke.dict.yaml"): + if len(fields) < 2 or len(fields[0]) != 1: + continue + character, code = fields[0], fields[1].strip() + if not code or set(code) - set("hspnz") or (character, code) in seen: + continue + seen.add((character, code)) + rows.append((-weights.get(character, 0), len(rows), character, code)) + rows.sort() + target = out_dir / "stroke_table.tsv" + with open(target, "w", encoding="utf-8", newline="\n") as table: + for _, _, character, code in rows: + table.write(f"{character}\t{code}\n") + return len(rows) + + def content_hash(compiled, excluded): """SHA-256 over every source file, the compiled tables and the names kept out of the APK: anything that changes what the phone copies changes it.""" @@ -149,8 +189,12 @@ def main(): target = compile_dictionaries(build_deployer(), out) revision = content_hash(target, args.exclude) (out / "rime-data.revision").write_text(revision + "\n", encoding="utf-8") + 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}, revision {revision[:12]}") + print( + f"build_rime_prebuilt: {len(EXPECTED)} files, {total // 1024} KiB in {target}, " + f"revision {revision[:12]}, {strokes} stroke codes" + ) if __name__ == "__main__": From 6e2c346b48cd0b652c57e8d146b64c176b130da8 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:29:06 +0800 Subject: [PATCH 05/10] Remove voice word lists for now Homophone replacement is not good enough to ship. The hotword package, its built-in lists, management page and 26-key boost are removed; the code is kept on archive/voice-word-lists (f3bb5cc). Imported list files stay on the phone. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + README.md | 1 - .../hotword/HotwordPacksInstrumentedTest.kt | 116 ---------- .../java/llc/slacker/openime/VoiceAudioE2E.kt | 3 +- app/src/main/AndroidManifest.xml | 4 - app/src/main/assets/hotwords/apps.txt | 47 ---- app/src/main/assets/hotwords/games.txt | 91 -------- app/src/main/assets/hotwords/tech.txt | 61 ----- .../slacker/openime/DataManagementActivity.kt | 14 -- .../slacker/openime/LocalVoiceImeService.kt | 16 +- .../openime/hotword/HomophoneCorrector.kt | 102 --------- .../slacker/openime/hotword/HotwordPack.kt | 21 -- .../openime/hotword/HotwordPackStore.kt | 144 ------------ .../openime/hotword/HotwordPacksActivity.kt | 215 ------------------ .../slacker/openime/hotword/HotwordParser.kt | 92 -------- .../slacker/openime/hotword/HotwordRuntime.kt | 59 ----- .../openime/hotword/PinyinCandidateIndex.kt | 74 ------ .../slacker/openime/hotword/PinyinReadings.kt | 55 ----- .../openime/panel/SettingsPanelController.kt | 2 +- .../openime/voice/LocalAudioVoiceBackend.kt | 3 +- app/src/main/res/values/strings.xml | 1 - .../openime/ArchitectureLayeringTest.kt | 5 +- .../hotword/BundledHotwordPacksTest.kt | 45 ---- .../openime/hotword/HomophoneCorrectorTest.kt | 70 ------ .../hotword/HotwordModuleBoundaryTest.kt | 40 ---- .../openime/hotword/HotwordParserTest.kt | 56 ----- .../hotword/PinyinCandidateIndexTest.kt | 58 ----- docs/APP_UI_SPEC.md | 2 +- docs/ARCHITECTURE.md | 47 +--- docs/DECISIONS.md | 1 + docs/LOCAL_VOICE_MODEL.md | 4 +- scripts/design_reference_e2e.py | 2 +- 32 files changed, 25 insertions(+), 1428 deletions(-) delete mode 100644 app/src/androidTest/java/llc/slacker/openime/hotword/HotwordPacksInstrumentedTest.kt delete mode 100644 app/src/main/assets/hotwords/apps.txt delete mode 100644 app/src/main/assets/hotwords/games.txt delete mode 100644 app/src/main/assets/hotwords/tech.txt delete mode 100644 app/src/main/java/llc/slacker/openime/hotword/HomophoneCorrector.kt delete mode 100644 app/src/main/java/llc/slacker/openime/hotword/HotwordPack.kt delete mode 100644 app/src/main/java/llc/slacker/openime/hotword/HotwordPackStore.kt delete mode 100644 app/src/main/java/llc/slacker/openime/hotword/HotwordPacksActivity.kt delete mode 100644 app/src/main/java/llc/slacker/openime/hotword/HotwordParser.kt delete mode 100644 app/src/main/java/llc/slacker/openime/hotword/HotwordRuntime.kt delete mode 100644 app/src/main/java/llc/slacker/openime/hotword/PinyinCandidateIndex.kt delete mode 100644 app/src/main/java/llc/slacker/openime/hotword/PinyinReadings.kt delete mode 100644 app/src/test/java/llc/slacker/openime/hotword/BundledHotwordPacksTest.kt delete mode 100644 app/src/test/java/llc/slacker/openime/hotword/HomophoneCorrectorTest.kt delete mode 100644 app/src/test/java/llc/slacker/openime/hotword/HotwordModuleBoundaryTest.kt delete mode 100644 app/src/test/java/llc/slacker/openime/hotword/HotwordParserTest.kt delete mode 100644 app/src/test/java/llc/slacker/openime/hotword/PinyinCandidateIndexTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e0747cd..27d4cc8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - Rime 词库改为打包时预编译:安装或升级后不再在手机上编译约一分钟,几秒内就能用完整词库打字,小米 15 实测 1.7 秒。此前这段时间只有备用小词库,九键单按 4、5、7、8、9 只能出数字。安装包里的词库文本源文件换成编译好的二进制,安装包小约 4MB,手机上少占约 37MB。用户词库不受影响。 - 升级时词库没有变化就不再重新拷贝,直接沿用手机上已有的词库,升级后第一次打开键盘和平时一样快。 +- 新增笔画键盘:切换键盘里选“笔画”。五个笔画键“一 丨 丿 丶 乛”和“通配”(代替一笔,记不清时用),按笔顺点,每点一笔就出候选。笔顺完全吻合的字排在前面,其余按常用程度排;繁体和生僻字也能打出来。选过的字下次排在前面,选字后照常给联想。 +- 暂时去掉语音词表(数据管理里的“语音词表”、识别后的同音替换和 26 键候选加权):同音替换还不够准,改好后再加回来。已导入的词表文件保留在手机上。 ## [0.0.5-beta.1] - 2026-10-05 diff --git a/README.md b/README.md index a7d76d0f..5e3fdae9 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,6 @@ openIME 是一款独立的 Android 系统输入法。拼音候选、用户词库 引擎为 librime,内置约 90 万条 Rime Ice 词典记录,打包时已预先编译;安装或升级后第一次打开键盘,几秒内即可用完整词典。 - **九键**:输入时左栏列出下一个字的拼音,一个字选一个音节,选定后自动移到下一个字。 - **语音输入**:长按空格说话,松手后识别并上屏;使用内置的中英双语模型,不联网;可去掉“嗯”“呃”等语气词,也可把标点写成空格。 -- **语音词表**:内置科技、应用、游戏词表,可导入自己的文本词表;识别后把同音词改成词表写法,不联网。 - **手势**:删除键上滑清空;空格左右滑动移动光标,滑动时底行其他按键锁定。 - **表情联想**:选词后联想栏先给出相关表情(开心 → 😊),词表内置,不联网。 - **自动填充**:Android 11+ 上,密码管理器的账号、验证码直接显示在键盘工具栏位置。 diff --git a/app/src/androidTest/java/llc/slacker/openime/hotword/HotwordPacksInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/hotword/HotwordPacksInstrumentedTest.kt deleted file mode 100644 index 3a796df6..00000000 --- a/app/src/androidTest/java/llc/slacker/openime/hotword/HotwordPacksInstrumentedTest.kt +++ /dev/null @@ -1,116 +0,0 @@ -package llc.slacker.openime.hotword - -import android.net.Uri -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import java.io.File - -/** Bundled packs, user import, switches and the real correction path on a device. */ -@RunWith(AndroidJUnit4::class) -class HotwordPacksInstrumentedTest { - private val context = InstrumentationRegistry.getInstrumentation().targetContext - private val store = HotwordPackStore(context) - private val created = mutableListOf() - - @Before - fun cleanSlate() { - store.packs().filter { it.origin == HotwordPack.Origin.IMPORTED }.forEach(store::delete) - context.getSharedPreferences("hotword_packs", 0).edit().clear().apply() - HotwordRuntime.reload(context) - } - - @After - fun tearDown() { - created.forEach(store::delete) - context.getSharedPreferences("hotword_packs", 0).edit().clear().apply() - HotwordRuntime.reload(context) - } - - private fun tempFile(text: String): Uri { - val file = File(context.cacheDir, "hotwords-test-${System.nanoTime()}.txt") - file.writeText(text, Charsets.UTF_8) - return Uri.fromFile(file) - } - - private fun bundled(name: String): HotwordPack = - store.packs().first { it.id == "bundled-$name" } - - @Test - fun bundledPacksLoadWithTheirDefaultSwitches() { - val ids = store.packs().filter { it.origin == HotwordPack.Origin.BUNDLED }.map { it.id } - assertTrue(ids.containsAll(listOf("bundled-tech", "bundled-apps", "bundled-games"))) - assertTrue(store.isEnabled(bundled("tech"))) - assertFalse("game slang is opt-in", store.isEnabled(bundled("games"))) - } - - @Test - fun switchingAPackChangesWhatTheVoicePathCorrects() { - assertEquals("他是大爷", HotwordRuntime.apply("他是大爷")) - - store.setEnabled(bundled("games"), true) - HotwordRuntime.reload(context) - assertEquals("他是打野", HotwordRuntime.apply("他是大爷")) - - store.setEnabled(bundled("games"), false) - HotwordRuntime.reload(context) - assertEquals("他是大爷", HotwordRuntime.apply("他是大爷")) - } - - @Test - fun enabledPacksAlsoRankWhileTyping() { - val before = listOf("大爷", "大爷们") - assertEquals("a disabled pack must not touch typing", before, HotwordRuntime.boost("daye", before)) - - store.setEnabled(bundled("games"), true) - HotwordRuntime.reload(context) - assertEquals(listOf("大爷", "打野", "大爷们"), HotwordRuntime.boost("daye", before)) - assertEquals("other input is untouched", before, HotwordRuntime.boost("nihao", before)) - } - - @Test - fun importedListIsStoredPrivatelyAndStartsEnabled() { - val result = store.importFrom(tempFile("# title: 我的词表\n# 注释\n对抗路\nopenIME\n")) - val imported = result as HotwordPackStore.ImportResult.Imported - created += imported.pack - assertEquals("我的词表", imported.pack.title) - assertEquals(listOf("对抗路"), imported.pack.words) - assertEquals(1, imported.pack.rejectedLines) - assertTrue(store.isEnabled(imported.pack)) - assertTrue(File(context.filesDir, "hotwords/${imported.pack.id}.txt").isFile) - - HotwordRuntime.reload(context) - assertEquals("走队对抗路", HotwordRuntime.apply("走队对抗陆")) - } - - @Test - fun importWithoutUsableWordsFailsAndStoresNothing() { - val before = store.packs().size - val result = store.importFrom(tempFile("# only comments\nab\n x \n")) - assertTrue(result is HotwordPackStore.ImportResult.Failed) - assertEquals(before, store.packs().size) - } - - @Test - fun oversizedImportIsRefused() { - val big = "打野\n".repeat(HotwordParser.MAX_BYTES / 4 + 100) - val result = store.importFrom(tempFile(big)) - assertTrue(result is HotwordPackStore.ImportResult.Failed) - } - - @Test - fun deleteRemovesImportsButNeverBundledPacks() { - val imported = (store.importFrom(tempFile("# title: 临时\n打野\n")) as HotwordPackStore.ImportResult.Imported).pack - assertTrue(store.delete(imported)) - assertTrue(store.packs().none { it.id == imported.id }) - assertFalse(store.delete(bundled("tech"))) - assertNotNull(bundled("tech")) - } -} diff --git a/app/src/debug/java/llc/slacker/openime/VoiceAudioE2E.kt b/app/src/debug/java/llc/slacker/openime/VoiceAudioE2E.kt index 18f56e43..c4a7a5fe 100644 --- a/app/src/debug/java/llc/slacker/openime/VoiceAudioE2E.kt +++ b/app/src/debug/java/llc/slacker/openime/VoiceAudioE2E.kt @@ -3,7 +3,6 @@ package llc.slacker.openime import android.content.Context import android.os.Handler import android.os.Looper -import llc.slacker.openime.hotword.HotwordRuntime import llc.slacker.openime.voice.VoiceCorrectionRepository import llc.slacker.openime.voice.VoiceModelLifecycleManager import llc.slacker.openime.voice.VoiceRecognitionEvents @@ -70,7 +69,7 @@ internal object VoiceAudioE2E { .put("clauses", session.punctuate("今天天气很好我们一起去公园散步吧")) } report.put("rawFinal", raw) - val final = HotwordRuntime.apply(VoiceCorrectionRepository.apply(session.punctuate(raw).orEmpty())) + val final = VoiceCorrectionRepository.apply(session.punctuate(raw).orEmpty()) report.put("final", final) handler.post { try { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cdaf5a91..38581ecc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -45,10 +45,6 @@ android:label="@string/activity_label_symbols" android:windowSoftInputMode="adjustResize" /> - "none" }, ) - return boostHotwords(composition, (learned + fallback).distinct().take(MAX_CANDIDATES)) + return (learned + fallback).distinct().take(MAX_CANDIDATES) } - /** Hotword packs also rank while typing 26-key pinyin; other modes are left alone. */ - private fun boostHotwords(composition: String, candidates: List): List = - if (state.keyboardMode == KeyboardMode.PINYIN_26 && !state.passwordField) { - HotwordRuntime.boost(composition, candidates).take(MAX_CANDIDATES) - } else { - candidates - } - /** Query librime away from the IME input thread; stale answers are ignored. */ private fun requestNativeCandidates( composition: String, @@ -1573,7 +1563,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } else { (learned + fallback).distinct().take(MAX_CANDIDATES) } - val finalCandidates = boostHotwords(composition, rankedCandidates) + val finalCandidates = rankedCandidates val nativeReferences = if (native.isNotEmpty()) { native.associate { it.text to it.reference } } else { diff --git a/app/src/main/java/llc/slacker/openime/hotword/HomophoneCorrector.kt b/app/src/main/java/llc/slacker/openime/hotword/HomophoneCorrector.kt deleted file mode 100644 index 871e2499..00000000 --- a/app/src/main/java/llc/slacker/openime/hotword/HomophoneCorrector.kt +++ /dev/null @@ -1,102 +0,0 @@ -package llc.slacker.openime.hotword - -/** - * Replaces recognized text with a hotword that sounds the same. - * - * The bundled streaming model cannot take hotwords into its decoder, so this - * runs after recognition: a window of the output whose pinyin equals a hotword's - * pinyin, but whose characters differ, is swapped for that hotword. - * - * Matching is leftmost-longest. A window that already spells a hotword is left - * alone. A character without a known reading ends a candidate window. Pure and - * Android-free; the instance is immutable once built, so it is safe to share - * between threads. - */ -internal class HomophoneCorrector( - words: Collection, - private val readings: PinyinReadings, -) { - private val index: Map> - private val longest: Int - - init { - val map = LinkedHashMap>() - var max = 0 - for (word in words) { - val codePoints = word.codePoints().toArray() - if (codePoints.size !in MIN_LENGTH..HotwordParser.MAX_WORD_LENGTH) continue - val keys = keysFor(codePoints, 0, codePoints.size) ?: continue - for (key in keys) { - val bucket = map.getOrPut(key) { mutableListOf() } - if (word !in bucket) bucket += word - } - max = maxOf(max, codePoints.size) - } - index = map - longest = max - } - - val isEmpty: Boolean get() = index.isEmpty() - - fun apply(text: String): String { - if (index.isEmpty() || text.length < MIN_LENGTH) return text - val codePoints = text.codePoints().toArray() - val out = StringBuilder(text.length) - var changed = false - var i = 0 - while (i < codePoints.size) { - val match = matchAt(codePoints, i) - if (match == null) { - out.appendCodePoint(codePoints[i]) - i++ - } else { - out.append(match.text) - changed = changed || match.replaced - i += match.length - } - } - return if (changed) out.toString() else text - } - - private class Match(val text: String, val length: Int, val replaced: Boolean) - - private fun matchAt(codePoints: IntArray, start: Int): Match? { - val limit = minOf(longest, codePoints.size - start) - for (length in limit downTo MIN_LENGTH) { - val keys = keysFor(codePoints, start, length) ?: continue - val window = String(codePoints, start, length) - var firstCandidate: String? = null - for (key in keys) { - val bucket = index[key] ?: continue - if (window in bucket) return Match(window, length, replaced = false) - if (firstCandidate == null) firstCandidate = bucket.first() - } - if (firstCandidate != null) return Match(firstCandidate, length, replaced = true) - } - return null - } - - /** Space-joined syllable strings the window could be read as; null if any character is unknown. */ - private fun keysFor(codePoints: IntArray, start: Int, length: Int): List? { - var keys = listOf("") - for (offset in 0 until length) { - val options = readings.readings(codePoints[start + offset]) - if (options.isEmpty()) return null - val next = ArrayList(minOf(keys.size * options.size, MAX_KEYS)) - loop@ for (prefix in keys) { - for (option in options) { - next += if (prefix.isEmpty()) option else "$prefix $option" - if (next.size >= MAX_KEYS) break@loop - } - } - keys = next - } - return keys - } - - private companion object { - const val MIN_LENGTH = 2 - /** Bounds the reading combinations of a window made of polyphonic characters. */ - const val MAX_KEYS = 16 - } -} diff --git a/app/src/main/java/llc/slacker/openime/hotword/HotwordPack.kt b/app/src/main/java/llc/slacker/openime/hotword/HotwordPack.kt deleted file mode 100644 index f882bc37..00000000 --- a/app/src/main/java/llc/slacker/openime/hotword/HotwordPack.kt +++ /dev/null @@ -1,21 +0,0 @@ -package llc.slacker.openime.hotword - -/** - * One word list: either shipped in the APK or imported by the user. - * - * A pack is plain data. Where it came from and whether it is switched on are - * the store's business; what it does to recognized text is the corrector's. - */ -internal data class HotwordPack( - val id: String, - val title: String, - val description: String, - val origin: Origin, - /** Initial switch state for a pack the user has not touched yet. */ - val defaultEnabled: Boolean, - val words: List, - /** Lines that were dropped because they cannot be matched by sound. */ - val rejectedLines: Int, -) { - enum class Origin { BUNDLED, IMPORTED } -} diff --git a/app/src/main/java/llc/slacker/openime/hotword/HotwordPackStore.kt b/app/src/main/java/llc/slacker/openime/hotword/HotwordPackStore.kt deleted file mode 100644 index 3575d8cf..00000000 --- a/app/src/main/java/llc/slacker/openime/hotword/HotwordPackStore.kt +++ /dev/null @@ -1,144 +0,0 @@ -package llc.slacker.openime.hotword - -import android.content.Context -import android.net.Uri -import android.provider.OpenableColumns -import java.io.File - -/** - * Where hotword packs live: bundled in `assets/hotwords/`, imported into the - * app-private `files/hotwords/`, and the per-pack on/off switches. - * - * Nothing here touches the network. Imports are copied into private storage in - * the canonical format, so the original file can be moved or deleted. - */ -internal class HotwordPackStore(context: Context) { - private val appContext = context.applicationContext - private val prefs = appContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - private val importDir get() = File(appContext.filesDir, IMPORT_DIR) - - sealed interface ImportResult { - data class Imported(val pack: HotwordPack, val truncated: Boolean) : ImportResult - data class Failed(val reason: String) : ImportResult - } - - /** Bundled packs first (on-by-default ones ahead of opt-in ones), then imports in the order added. */ - fun packs(): List = bundledPacks() + importedPacks() - - fun isEnabled(pack: HotwordPack): Boolean = - prefs.getBoolean(enabledKey(pack.id), pack.defaultEnabled) - - fun setEnabled(pack: HotwordPack, enabled: Boolean) { - prefs.edit().putBoolean(enabledKey(pack.id), enabled).apply() - } - - fun enabledWords(): List = - packs().filter(::isEnabled).flatMap { it.words }.distinct() - - fun importFrom(uri: Uri): ImportResult { - val text = try { - appContext.contentResolver.openInputStream(uri)?.use { input -> - val bytes = readAtMost(input, HotwordParser.MAX_BYTES + 1) - if (bytes.size > HotwordParser.MAX_BYTES) { - return ImportResult.Failed("文件超过 ${HotwordParser.MAX_BYTES / 1024} KB,请拆分后再导入。") - } - String(bytes, Charsets.UTF_8) - } ?: return ImportResult.Failed("无法打开这个文件。") - } catch (error: Exception) { - return ImportResult.Failed("读取文件失败:${error.message.orEmpty()}") - } - - val parsed = HotwordParser.parse(text, fallbackTitle = displayName(uri)) - if (parsed.words.isEmpty()) { - return ImportResult.Failed( - "没有找到可用的词。词表每行一个词,只支持 2 到 ${HotwordParser.MAX_WORD_LENGTH} 个汉字的词。", - ) - } - - val id = IMPORT_PREFIX + java.lang.Long.toString(System.currentTimeMillis(), 36) - return try { - importDir.mkdirs() - val target = File(importDir, "$id.txt") - val temp = File(importDir, "$id.tmp") - temp.writeText(HotwordParser.render(parsed), Charsets.UTF_8) - if (!temp.renameTo(target)) { - temp.delete() - return ImportResult.Failed("保存词表失败。") - } - ImportResult.Imported(toPack(id, parsed, HotwordPack.Origin.IMPORTED), parsed.truncated) - } catch (error: Exception) { - ImportResult.Failed("保存词表失败:${error.message.orEmpty()}") - } - } - - /** Only imported packs can be removed; bundled packs can only be switched off. */ - fun delete(pack: HotwordPack): Boolean { - if (pack.origin != HotwordPack.Origin.IMPORTED) return false - prefs.edit().remove(enabledKey(pack.id)).apply() - return File(importDir, "${pack.id}.txt").delete() - } - - private fun bundledPacks(): List { - val names = appContext.assets.list(ASSET_DIR).orEmpty().filter { it.endsWith(".txt") }.sorted() - val packs = names.mapNotNull { name -> - runCatching { - val text = appContext.assets.open("$ASSET_DIR/$name").use { String(it.readBytes(), Charsets.UTF_8) } - val id = BUNDLED_PREFIX + name.removeSuffix(".txt") - toPack(id, HotwordParser.parse(text, name.removeSuffix(".txt")), HotwordPack.Origin.BUNDLED) - }.getOrNull() - } - return packs.sortedWith(compareByDescending { it.defaultEnabled }.thenBy { it.id }) - } - - private fun importedPacks(): List { - val files = importDir.listFiles { file -> file.name.endsWith(".txt") }.orEmpty().sortedBy { it.name } - return files.mapNotNull { file -> - runCatching { - val id = file.name.removeSuffix(".txt") - toPack(id, HotwordParser.parse(file.readText(Charsets.UTF_8), id), HotwordPack.Origin.IMPORTED) - }.getOrNull() - } - } - - private fun toPack(id: String, parsed: HotwordParser.Parsed, origin: HotwordPack.Origin) = HotwordPack( - id = id, - title = parsed.title, - description = parsed.description, - origin = origin, - // An import is something the user just asked for: start it switched on. - defaultEnabled = if (origin == HotwordPack.Origin.IMPORTED) true else parsed.defaultEnabled, - words = parsed.words, - rejectedLines = parsed.rejectedLines, - ) - - /** InputStream.readNBytes needs API 33; the app supports API 26. */ - private fun readAtMost(input: java.io.InputStream, limit: Int): ByteArray { - val out = java.io.ByteArrayOutputStream(minOf(limit, 64 * 1024)) - val buffer = ByteArray(8 * 1024) - while (out.size() < limit) { - val read = input.read(buffer, 0, minOf(buffer.size, limit - out.size())) - if (read < 0) break - out.write(buffer, 0, read) - } - return out.toByteArray() - } - - private fun displayName(uri: Uri): String { - val fromProvider = runCatching { - appContext.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) - ?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } - }.getOrNull() - val raw = fromProvider ?: uri.lastPathSegment ?: "导入词表" - return raw.substringBeforeLast('.').ifBlank { "导入词表" } - } - - private fun enabledKey(id: String) = "enabled:$id" - - private companion object { - const val PREFS = "hotword_packs" - const val ASSET_DIR = "hotwords" - const val IMPORT_DIR = "hotwords" - const val BUNDLED_PREFIX = "bundled-" - const val IMPORT_PREFIX = "user-" - } -} diff --git a/app/src/main/java/llc/slacker/openime/hotword/HotwordPacksActivity.kt b/app/src/main/java/llc/slacker/openime/hotword/HotwordPacksActivity.kt deleted file mode 100644 index 90045518..00000000 --- a/app/src/main/java/llc/slacker/openime/hotword/HotwordPacksActivity.kt +++ /dev/null @@ -1,215 +0,0 @@ -package llc.slacker.openime.hotword - -import android.app.Activity -import android.app.AlertDialog -import android.content.Intent -import android.content.res.ColorStateList -import android.graphics.Typeface -import android.os.Bundle -import android.view.View -import android.view.WindowInsets -import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.Switch -import android.widget.TextView -import android.widget.Toast -import llc.slacker.openime.R -import llc.slacker.openime.setup.SetupUi -import llc.slacker.openime.theme.ImeGeometryTokens -import llc.slacker.openime.theme.ImeSpacingTokens -import llc.slacker.openime.theme.ImeTypographyTokens - -/** - * Voice hotword lists: switch the bundled packs on or off, import a list from a - * text file, delete imported lists. All data stays on the device. - */ -class HotwordPacksActivity : Activity() { - override fun attachBaseContext(newBase: android.content.Context) { - super.attachBaseContext(SetupUi.appearanceContext(newBase)) - } - - private val store by lazy { HotwordRuntime.store(this) } - private lateinit var list: LinearLayout - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val content = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), 0, dp(16), dp(ImeSpacingTokens.XXL_DP)) - } - 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) }, - ) - content.addView( - card().apply { - addView(body( - "语音识别结束后,把读音相同、字不同的词改成词表里的写法,例如“大爷”改成“打野”。" + - "词表只在本机使用,不联网。\n\n" + - "导入的文件为 UTF-8 文本,每行一个词,只支持 2 到 ${HotwordParser.MAX_WORD_LENGTH} 个汉字;" + - "# 开头的行是注释,可以用 “# title: 名称” 给词表命名。", - ), wrap()) - addView( - SetupUi.primaryButton(this@HotwordPacksActivity, "导入词表文件") { pickFile() }, - LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(44)) - .apply { topMargin = dp(12) }, - ) - }, - wrap().apply { topMargin = dp(ImeSpacingTokens.LG_DP) }, - ) - list = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } - content.addView(list, wrap()) - - 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) - }, - ) - renderPacks() - } - - private fun renderPacks() { - list.removeAllViews() - store.packs().forEach { pack -> list.addView(packRow(pack), wrap().apply { topMargin = dp(12) }) } - } - - private fun packRow(pack: HotwordPack): View = card().apply { - val header = LinearLayout(this@HotwordPacksActivity).apply { - orientation = LinearLayout.HORIZONTAL - gravity = android.view.Gravity.CENTER_VERTICAL - } - header.addView(TextView(this@HotwordPacksActivity).apply { - text = pack.title - textSize = ImeTypographyTokens.BODY_SP - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - setTextColor(getColor(R.color.setup_title)) - }, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) - header.addView(Switch(this@HotwordPacksActivity).apply { - isChecked = store.isEnabled(pack) - contentDescription = "启用${pack.title}" - val accent = SetupUi.accent(this@HotwordPacksActivity) - trackTintList = ColorStateList.valueOf(accent) - setOnCheckedChangeListener { _, checked -> - store.setEnabled(pack, checked) - HotwordRuntime.reload(this@HotwordPacksActivity) - } - }, LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, dp(ImeGeometryTokens.TOUCH_TARGET_DP))) - addView(header, wrap()) - - val origin = if (pack.origin == HotwordPack.Origin.BUNDLED) "内置" else "已导入" - val skipped = if (pack.rejectedLines > 0) " · 跳过 ${pack.rejectedLines} 行" else "" - addView(body(listOf("${pack.words.size} 个词 · $origin$skipped", pack.description) - .filter { it.isNotBlank() }.joinToString("\n")), wrap()) - if (pack.origin == HotwordPack.Origin.IMPORTED) { - addView( - SetupUi.secondaryButton(this@HotwordPacksActivity, "删除") { confirmDelete(pack) }, - LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(44)) - .apply { topMargin = dp(8) }, - ) - } - } - - private fun pickFile() { - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "text/*" - } - @Suppress("DEPRECATION") - startActivityForResult(intent, REQUEST_IMPORT) - } - - @Deprecated("Deprecated in Java") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode != REQUEST_IMPORT || resultCode != RESULT_OK) return - val uri = data?.data ?: return - when (val result = store.importFrom(uri)) { - is HotwordPackStore.ImportResult.Imported -> { - HotwordRuntime.reload(this) - renderPacks() - val note = if (result.truncated) { - ",超过 ${HotwordParser.MAX_WORDS} 个词的部分已忽略" - } else { - "" - } - Toast.makeText(this, "已导入 ${result.pack.words.size} 个词$note", Toast.LENGTH_SHORT).show() - } - is HotwordPackStore.ImportResult.Failed -> showError(result.reason) - } - } - - private fun confirmDelete(pack: HotwordPack) { - val dialog = AlertDialog.Builder(this) - .setTitle("删除“${pack.title}”?") - .setMessage("这个词表会从本机移除,原文件不受影响。") - .setNegativeButton("取消", null) - .setPositiveButton("删除") { _, _ -> - store.delete(pack) - HotwordRuntime.reload(this) - renderPacks() - } - .create() - dialog.setOnShowListener { SetupUi.styleDialog(dialog, this, destructivePositive = true) } - dialog.show() - } - - 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 card() = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(ImeSpacingTokens.LG_DP), dp(ImeSpacingTokens.LG_DP), dp(ImeSpacingTokens.LG_DP), dp(ImeSpacingTokens.LG_DP)) - background = SetupUi.rounded( - getColor(R.color.setup_surface), - dp(ImeGeometryTokens.CARD_RADIUS_DP).toFloat(), - getColor(R.color.setup_input_line), - ) - } - - private fun body(text: String) = TextView(this).apply { - this.text = text - textSize = ImeTypographyTokens.BODY_SP - setTextColor(getColor(R.color.setup_body)) - setLineSpacing(0f, 1.3f) - } - - 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_IMPORT = 8201 - } -} diff --git a/app/src/main/java/llc/slacker/openime/hotword/HotwordParser.kt b/app/src/main/java/llc/slacker/openime/hotword/HotwordParser.kt deleted file mode 100644 index d7600d45..00000000 --- a/app/src/main/java/llc/slacker/openime/hotword/HotwordParser.kt +++ /dev/null @@ -1,92 +0,0 @@ -package llc.slacker.openime.hotword - -/** - * Parses the hotword list text format. Pure and Android-free. - * - * ``` - * # title: 手游与电竞 - * # description: 常见游戏和电竞用语 - * # default: off - * 打野 - * 王者荣耀 - * ``` - * - * One word per line. `#` lines are comments; `title`, `description` and - * `default` are recognised as headers. Only Chinese words of 2..[MAX_WORD_LENGTH] - * characters are kept: the corrector matches by pronunciation, which says - * nothing about Latin text or single characters. - */ -internal object HotwordParser { - const val MAX_WORD_LENGTH = 8 - const val MAX_WORDS = 5_000 - const val MAX_BYTES = 512 * 1024 - - data class Parsed( - val title: String, - val description: String, - val defaultEnabled: Boolean, - val words: List, - val rejectedLines: Int, - /** True when the list had more than [MAX_WORDS] usable words. */ - val truncated: Boolean, - ) - - fun parse(text: String, fallbackTitle: String): Parsed { - var title = "" - var description = "" - var defaultEnabled = true - var rejected = 0 - val words = LinkedHashSet() - var truncated = false - - for (raw in text.removePrefix("\uFEFF").lineSequence()) { - val line = raw.trim() - if (line.isEmpty()) continue - if (line.startsWith("#")) { - val header = line.removePrefix("#").trim() - val key = header.substringBefore(':', "").trim().lowercase() - val value = header.substringAfter(':', "").trim() - when (key) { - "title" -> if (value.isNotEmpty()) title = value.take(32) - "description" -> description = value.take(80) - "default" -> defaultEnabled = value.lowercase() !in OFF_VALUES - } - continue - } - if (!isUsable(line)) { - rejected++ - } else if (words.size >= MAX_WORDS) { - truncated = true - } else { - words += line - } - } - return Parsed( - title = title.ifEmpty { fallbackTitle.take(32) }, - description = description, - defaultEnabled = defaultEnabled, - words = words.toList(), - rejectedLines = rejected, - truncated = truncated, - ) - } - - /** Renders [parsed] back in the canonical format used for stored imports. */ - fun render(parsed: Parsed): String = buildString { - append("# title: ").append(parsed.title).append('\n') - if (parsed.description.isNotEmpty()) { - append("# description: ").append(parsed.description).append('\n') - } - parsed.words.forEach { append(it).append('\n') } - } - - internal fun isUsable(word: String): Boolean { - val codePoints = word.codePoints().toArray() - return codePoints.size in 2..MAX_WORD_LENGTH && codePoints.all(::isHan) - } - - internal fun isHan(codePoint: Int): Boolean = - codePoint in 0x3400..0x4DBF || codePoint in 0x4E00..0x9FFF - - private val OFF_VALUES = setOf("off", "false", "no", "0") -} diff --git a/app/src/main/java/llc/slacker/openime/hotword/HotwordRuntime.kt b/app/src/main/java/llc/slacker/openime/hotword/HotwordRuntime.kt deleted file mode 100644 index bfab5edc..00000000 --- a/app/src/main/java/llc/slacker/openime/hotword/HotwordRuntime.kt +++ /dev/null @@ -1,59 +0,0 @@ -package llc.slacker.openime.hotword - -import android.content.Context - -/** - * Entry point of the hotword module for the rest of the app. - * - * The voice pipeline calls [apply] on recognized text; the settings screen - * calls [reload] after a switch or an import changes which packs are active. - * Everything else in this package stays internal to it. - */ -internal object HotwordRuntime { - @Volatile private var corrector: HomophoneCorrector? = null - @Volatile private var candidates: PinyinCandidateIndex? = null - @Volatile private var store: HotwordPackStore? = null - @Volatile private var readings: PinyinReadings? = null - private val lock = Any() - - /** Idempotent. Building the index reads assets, so it runs off the caller's thread. */ - fun configure(context: Context) { - synchronized(lock) { - if (store != null) return - store = HotwordPackStore(context) - } - Thread({ reload(context) }, "openime-hotwords").start() - } - - fun store(context: Context): HotwordPackStore = - store ?: synchronized(lock) { store ?: HotwordPackStore(context).also { store = it } } - - /** Rebuilds the matcher from the packs that are currently switched on. */ - fun reload(context: Context) { - val activeStore = store(context) - val activeReadings = readings ?: loadReadings(context) - val words = activeStore.enabledWords() - corrector = HomophoneCorrector(words, activeReadings) - candidates = PinyinCandidateIndex(words, activeReadings) - } - - /** Returns [text] unchanged until the first [reload] has finished. */ - fun apply(text: String): String = corrector?.apply(text) ?: text - - /** - * Typing: moves hotwords whose pinyin equals [composition] up behind the top - * candidate. Returns [candidates] unchanged when nothing matches or the - * index is not ready yet. - */ - fun boost(composition: String, candidates: List): List = - this.candidates?.boost(composition, candidates) ?: candidates - - private fun loadReadings(context: Context): PinyinReadings { - val loaded = context.applicationContext.assets.open(READINGS_ASSET).bufferedReader(Charsets.UTF_8) - .use { reader -> PinyinReadings.parseRimeDict(reader.lineSequence().toList().asSequence()) } - readings = loaded - return loaded - } - - private const val READINGS_ASSET = "rime-data/openime_dicts/8105.dict.yaml" -} diff --git a/app/src/main/java/llc/slacker/openime/hotword/PinyinCandidateIndex.kt b/app/src/main/java/llc/slacker/openime/hotword/PinyinCandidateIndex.kt deleted file mode 100644 index 6a3f6d63..00000000 --- a/app/src/main/java/llc/slacker/openime/hotword/PinyinCandidateIndex.kt +++ /dev/null @@ -1,74 +0,0 @@ -package llc.slacker.openime.hotword - -/** - * Lets hotwords show up while typing pinyin. Pure and Android-free. - * - * A word is offered when the typed pinyin is exactly its full pinyin, so - * `daye` can offer 打野 even if the dictionary ranks it low or lacks it. - * [boost] only reorders: the top candidate keeps its place and at most - * [MAX_BOOSTED] hotwords follow it, so everyday typing is not rearranged. - */ -internal class PinyinCandidateIndex( - words: Collection, - readings: PinyinReadings, -) { - private val byPinyin: Map> - - init { - val map = LinkedHashMap>() - for (word in words) { - val codePoints = word.codePoints().toArray() - if (codePoints.size !in 2..HotwordParser.MAX_WORD_LENGTH) continue - for (key in joinedKeys(codePoints, readings)) { - val bucket = map.getOrPut(key) { mutableListOf() } - if (word !in bucket) bucket += word - } - } - byPinyin = map - } - - val isEmpty: Boolean get() = byPinyin.isEmpty() - - /** Hotwords whose full pinyin is exactly [composition]. */ - fun exact(composition: String): List { - val key = composition.lowercase().filter { it in 'a'..'z' } - if (key.length < MIN_COMPOSITION || key.length != composition.count { it != '\'' && it != ' ' }) { - return emptyList() - } - return byPinyin[key].orEmpty() - } - - fun boost(composition: String, candidates: List): List { - val hot = exact(composition).take(MAX_BOOSTED) - if (hot.isEmpty()) return candidates - val head = candidates.firstOrNull() - return buildList { - head?.let(::add) - hot.filter { it != head }.forEach(::add) - candidates.drop(1).filter { it !in hot }.forEach(::add) - } - } - - private fun joinedKeys(codePoints: IntArray, readings: PinyinReadings): List { - var keys = listOf("") - for (codePoint in codePoints) { - val options = readings.readings(codePoint) - if (options.isEmpty()) return emptyList() - val next = ArrayList(minOf(keys.size * options.size, MAX_KEYS)) - loop@ for (prefix in keys) { - for (option in options) { - next += prefix + option - if (next.size >= MAX_KEYS) break@loop - } - } - keys = next - } - return keys - } - - private companion object { - const val MIN_COMPOSITION = 3 - const val MAX_BOOSTED = 3 - const val MAX_KEYS = 16 - } -} diff --git a/app/src/main/java/llc/slacker/openime/hotword/PinyinReadings.kt b/app/src/main/java/llc/slacker/openime/hotword/PinyinReadings.kt deleted file mode 100644 index 2588ce1e..00000000 --- a/app/src/main/java/llc/slacker/openime/hotword/PinyinReadings.kt +++ /dev/null @@ -1,55 +0,0 @@ -package llc.slacker.openime.hotword - -/** - * Toneless pinyin readings per Chinese character. Pure; built from the bundled - * Rime single-character table (`rime-data/openime_dicts/8105.dict.yaml`, lines - * of `characterpinyinweight` after the `...` marker). - * - * A polyphonic character appears on several lines and so has several readings; - * matching treats any of them as a possible pronunciation. Readings that carry - * less than [MIN_RELATIVE_WEIGHT] of the character's heaviest reading are - * dropped, otherwise one-off readings would make unrelated words "sound alike". - */ -internal class PinyinReadings private constructor( - private val byCodePoint: Map>, -) { - fun readings(codePoint: Int): List = byCodePoint[codePoint].orEmpty() - - companion object { - const val MIN_RELATIVE_WEIGHT = 0.01 - - fun parseRimeDict(lines: Sequence): PinyinReadings { - val weights = HashMap>() - var inBody = false - for (line in lines) { - if (!inBody) { - inBody = line.trim() == "..." - continue - } - val fields = line.split('\t') - if (fields.size < 2) continue - val character = fields[0] - if (character.codePointCount(0, character.length) != 1) continue - val syllable = fields[1].trim() - if (syllable.isEmpty() || ' ' in syllable) continue - val weight = fields.getOrNull(2)?.trim()?.toLongOrNull() ?: 1L - val perChar = weights.getOrPut(character.codePointAt(0)) { LinkedHashMap() } - perChar.merge(syllable, weight, Long::plus) - } - return PinyinReadings( - weights.mapValues { (_, bySyllable) -> - val heaviest = bySyllable.values.max().toDouble() - bySyllable.filterValues { it >= heaviest * MIN_RELATIVE_WEIGHT }.keys.toList() - }, - ) - } - - fun of(vararg pairs: Pair): PinyinReadings { - val map = HashMap>() - pairs.forEach { (char, syllable) -> - map.getOrPut(char.code) { mutableListOf() } += syllable - } - return PinyinReadings(map) - } - } -} 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 86aadca0..b9133b16 100644 --- a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt @@ -201,7 +201,7 @@ internal class SettingsPanelController( content.addSection("关于与数据") content.addCard( navigationRow("关于", "版本、隐私与诊断", R.drawable.ic_pref_info, onOpenAbout), - navigationRow("数据管理", "导出与导入、语音词表", R.drawable.ic_pref_data, onOpenDataManagement), + navigationRow("数据管理", "导出与导入用户数据", R.drawable.ic_pref_data, onOpenDataManagement), ) } diff --git a/app/src/main/java/llc/slacker/openime/voice/LocalAudioVoiceBackend.kt b/app/src/main/java/llc/slacker/openime/voice/LocalAudioVoiceBackend.kt index b8d70de7..992b9846 100644 --- a/app/src/main/java/llc/slacker/openime/voice/LocalAudioVoiceBackend.kt +++ b/app/src/main/java/llc/slacker/openime/voice/LocalAudioVoiceBackend.kt @@ -9,7 +9,6 @@ import android.media.AudioRecord import android.media.MediaRecorder import android.os.SystemClock import android.util.Log -import llc.slacker.openime.hotword.HotwordRuntime import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong @@ -576,7 +575,7 @@ class LocalAudioVoiceBackend( ) VoicePerformanceTrace.markFinalAsr(session.traceToken) val punctuated = if (raw.isBlank()) raw else voiceSession.punctuate(raw) ?: raw - val final = HotwordRuntime.apply(VoiceCorrectionRepository.apply(punctuated)) + val final = VoiceCorrectionRepository.apply(punctuated) VoicePerformanceTrace.markPunctuationDone(session.traceToken) session.events.onFinal(final) VoicePerformanceTrace.finish(session.traceToken, session.ring.droppedSamples) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e3bc1510..acb94c34 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -50,7 +50,6 @@ 偏好设置 常用语 自定义符号 - 语音词表 关于 数据管理 diff --git a/app/src/test/java/llc/slacker/openime/ArchitectureLayeringTest.kt b/app/src/test/java/llc/slacker/openime/ArchitectureLayeringTest.kt index 9f2db437..5333fc2b 100644 --- a/app/src/test/java/llc/slacker/openime/ArchitectureLayeringTest.kt +++ b/app/src/test/java/llc/slacker/openime/ArchitectureLayeringTest.kt @@ -29,15 +29,14 @@ class ArchitectureLayeringTest { "handwriting" to setOf("data", "theme"), "rime" to setOf("core", "data"), "candidate" to setOf("core", "rime"), - "hotword" to setOf("setup", "theme"), - "voice" to setOf("data", "editor", "hotword", "theme"), + "voice" to setOf("data", "editor", "theme"), "panel" to setOf("core", "data", "handwriting", "setup", "theme", "widget"), "keyboard" to setOf( "candidate", "core", "data", "editor", "floating", "handwriting", "panel", "setup", "theme", "voice", "widget", ), "app" to setOf( - "candidate", "core", "data", "editor", "floating", "hotword", "keyboard", + "candidate", "core", "data", "editor", "floating", "keyboard", "rime", "setup", "theme", "voice", ), ) diff --git a/app/src/test/java/llc/slacker/openime/hotword/BundledHotwordPacksTest.kt b/app/src/test/java/llc/slacker/openime/hotword/BundledHotwordPacksTest.kt deleted file mode 100644 index 917eeb8b..00000000 --- a/app/src/test/java/llc/slacker/openime/hotword/BundledHotwordPacksTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -package llc.slacker.openime.hotword - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import java.io.File - -class BundledHotwordPacksTest { - private val files: List = sequenceOf("src/main/assets/hotwords", "app/src/main/assets/hotwords") - .map(::File).first { it.isDirectory }.listFiles { f -> f.extension == "txt" }!!.sortedBy { it.name } - - @Test - fun everyBundledPackIsWellFormedAndNonTrivial() { - assertTrue("expected bundled packs", files.size >= 3) - files.forEach { file -> - val parsed = HotwordParser.parse(file.readText(Charsets.UTF_8), file.nameWithoutExtension) - assertEquals("${file.name} must not contain unusable lines", 0, parsed.rejectedLines) - assertFalse("${file.name} must not hit the word cap", parsed.truncated) - assertTrue("${file.name} needs a title", parsed.title != file.nameWithoutExtension) - assertTrue("${file.name} is too small", parsed.words.size >= 30) - } - } - - @Test - fun gamePackIsOptInWhileGeneralPacksAreOnByDefault() { - val byName = files.associate { - it.nameWithoutExtension to HotwordParser.parse(it.readText(Charsets.UTF_8), it.name) - } - assertFalse("homophone swaps for game slang must not surprise everyday chat", byName.getValue("games").defaultEnabled) - assertTrue(byName.getValue("tech").defaultEnabled) - assertTrue(byName.getValue("apps").defaultEnabled) - } - - @Test - fun noWordIsListedInTwoBundledPacksWithDifferentIntent() { - val seen = HashMap() - files.forEach { file -> - HotwordParser.parse(file.readText(Charsets.UTF_8), file.name).words.forEach { word -> - val previous = seen.put(word, file.name) - assertTrue("$word appears in both $previous and ${file.name}", previous == null) - } - } - } -} diff --git a/app/src/test/java/llc/slacker/openime/hotword/HomophoneCorrectorTest.kt b/app/src/test/java/llc/slacker/openime/hotword/HomophoneCorrectorTest.kt deleted file mode 100644 index d1aa8bf8..00000000 --- a/app/src/test/java/llc/slacker/openime/hotword/HomophoneCorrectorTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -package llc.slacker.openime.hotword - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import java.io.File - -class HomophoneCorrectorTest { - private val small = PinyinReadings.of( - '打' to "da", '大' to "da", '野' to "ye", '爷' to "ye", '夜' to "ye", - '补' to "bu", '刀' to "dao", '到' to "dao", '不' to "bu", - '长' to "chang", '长' to "zhang", '场' to "chang", '张' to "zhang", - ) - - @Test - fun replacesASameSoundingWindow() { - val corrector = HomophoneCorrector(listOf("打野"), small) - assertEquals("我是打野。", corrector.apply("我是大爷。")) - } - - @Test - fun keepsAWindowThatAlreadySpellsTheHotword() { - val corrector = HomophoneCorrector(listOf("打野"), small) - assertEquals("打野来了", corrector.apply("打野来了")) - } - - @Test - fun leavesDifferentSoundingTextAndTextWithoutReadingsAlone() { - val corrector = HomophoneCorrector(listOf("打野"), small) - assertEquals("补刀 ok", corrector.apply("补刀 ok")) - assertEquals("你好", corrector.apply("你好")) - } - - @Test - fun matchesAnyReadingOfAPolyphonicCharacter() { - val corrector = HomophoneCorrector(listOf("场到"), small) - // 长 can be read chang, so 长到 sounds like 场到. - assertEquals("场到", corrector.apply("长到")) - } - - @Test - fun correctsSeveralHotwordsInOneSentenceLeftToRight() { - val corrector = HomophoneCorrector(listOf("打野", "补刀"), small) - assertEquals("打野在补刀", corrector.apply("大夜在不到")) - } - - @Test - fun anEmptyWordListIsANoOp() { - val corrector = HomophoneCorrector(emptyList(), small) - assertTrue(corrector.isEmpty) - assertEquals("大爷", corrector.apply("大爷")) - } - - @Test - fun bundledReadingsKnowCommonCharactersAndPolyphones() { - val lines = sequenceOf( - "src/main/assets/rime-data/openime_dicts/8105.dict.yaml", - "app/src/main/assets/rime-data/openime_dicts/8105.dict.yaml", - ).map(::File).first { it.isFile }.readLines(Charsets.UTF_8).asSequence() - val readings = PinyinReadings.parseRimeDict(lines) - assertTrue("da" in readings.readings('打'.code)) - assertTrue("ye" in readings.readings('野'.code)) - assertTrue(readings.readings('长'.code).containsAll(listOf("chang", "zhang"))) - assertTrue("one-off readings must be filtered", "heng" !in readings.readings('行'.code)) - assertTrue(readings.readings('行'.code).containsAll(listOf("hang", "xing"))) - val corrector = HomophoneCorrector(listOf("打野", "王者荣耀"), readings) - assertEquals("他是打野", corrector.apply("他是大爷")) - assertEquals("我在玩王者荣耀", corrector.apply("我在玩王者容耀")) - } -} diff --git a/app/src/test/java/llc/slacker/openime/hotword/HotwordModuleBoundaryTest.kt b/app/src/test/java/llc/slacker/openime/hotword/HotwordModuleBoundaryTest.kt deleted file mode 100644 index 10a39c40..00000000 --- a/app/src/test/java/llc/slacker/openime/hotword/HotwordModuleBoundaryTest.kt +++ /dev/null @@ -1,40 +0,0 @@ -package llc.slacker.openime.hotword - -import org.junit.Assert.fail -import org.junit.Test -import java.io.File - -/** - * Keeps the hotword module a module: pure logic stays free of Android, and the - * rest of the app talks to it only through [HotwordRuntime] and the screen. - */ -class HotwordModuleBoundaryTest { - private val root: File = sequenceOf(File("."), File("..")) - .first { File(it, "app/src/main/java").isDirectory } - private val sources = File(root, "app/src/main/java/llc/slacker/openime") - private val module = File(sources, "hotword") - - @Test - fun pureLogicDoesNotDependOnAndroid() { - val pure = listOf("HotwordPack", "HotwordParser", "PinyinReadings", "HomophoneCorrector") - val violations = pure.flatMap { name -> - File(module, "$name.kt").readLines() - .filter { it.startsWith("import android.") || it.startsWith("import llc.slacker.openime.") } - .map { "$name.kt: $it" } - } - if (violations.isNotEmpty()) fail("Pure hotword logic must stay Android-free:\n" + violations.joinToString("\n")) - } - - @Test - fun theRestOfTheAppOnlyUsesTheModuleEntryPoints() { - val allowed = setOf("HotwordRuntime", "HotwordPacksActivity") - val violations = sources.walkTopDown() - .filter { it.isFile && it.extension == "kt" && !it.path.contains("/hotword/") } - .flatMap { file -> - file.readLines().filter { it.startsWith("import llc.slacker.openime.hotword.") } - .filter { it.substringAfterLast('.') !in allowed } - .map { "${file.name}: $it" } - }.toList() - if (violations.isNotEmpty()) fail("Reach into the hotword module only via its entry points:\n" + violations.joinToString("\n")) - } -} diff --git a/app/src/test/java/llc/slacker/openime/hotword/HotwordParserTest.kt b/app/src/test/java/llc/slacker/openime/hotword/HotwordParserTest.kt deleted file mode 100644 index 2c9f85c1..00000000 --- a/app/src/test/java/llc/slacker/openime/hotword/HotwordParserTest.kt +++ /dev/null @@ -1,56 +0,0 @@ -package llc.slacker.openime.hotword - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class HotwordParserTest { - @Test - fun readsHeadersWordsAndSkipsCommentsAndBlankLines() { - val parsed = HotwordParser.parse( - "\uFEFF# title: 手游\n# description: 开黑用语\n# default: off\n\n打野\n# 注释\n 补刀 \n打野\n", - fallbackTitle = "文件名", - ) - assertEquals("手游", parsed.title) - assertEquals("开黑用语", parsed.description) - assertFalse(parsed.defaultEnabled) - assertEquals(listOf("打野", "补刀"), parsed.words) - assertEquals(0, parsed.rejectedLines) - } - - @Test - fun fallsBackToTheFileNameAndDefaultsToOn() { - val parsed = HotwordParser.parse("打野\n", fallbackTitle = "我的词表") - assertEquals("我的词表", parsed.title) - assertTrue(parsed.defaultEnabled) - } - - @Test - fun rejectsWordsThatCannotBeMatchedBySound() { - val parsed = HotwordParser.parse( - listOf("打", "openIME", "澎湃OS", "一二三四五六七八九", "打 野", "打野").joinToString("\n"), - fallbackTitle = "x", - ) - assertEquals(listOf("打野"), parsed.words) - assertEquals(5, parsed.rejectedLines) - } - - @Test - fun capsTheNumberOfWordsAndReportsIt() { - val many = (0 until HotwordParser.MAX_WORDS + 5) - .joinToString("\n") { i -> "我" + (0x4E00 + i).toChar() } - val parsed = HotwordParser.parse(many, fallbackTitle = "x") - assertEquals(HotwordParser.MAX_WORDS, parsed.words.size) - assertTrue(parsed.truncated) - } - - @Test - fun renderedFormatParsesBackToTheSamePack() { - val first = HotwordParser.parse("# title: 甲\n# description: 乙丙\n打野\n补刀\n", "x") - val again = HotwordParser.parse(HotwordParser.render(first), "y") - assertEquals(first.title, again.title) - assertEquals(first.description, again.description) - assertEquals(first.words, again.words) - } -} diff --git a/app/src/test/java/llc/slacker/openime/hotword/PinyinCandidateIndexTest.kt b/app/src/test/java/llc/slacker/openime/hotword/PinyinCandidateIndexTest.kt deleted file mode 100644 index 95f2fc8b..00000000 --- a/app/src/test/java/llc/slacker/openime/hotword/PinyinCandidateIndexTest.kt +++ /dev/null @@ -1,58 +0,0 @@ -package llc.slacker.openime.hotword - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import java.io.File - -class PinyinCandidateIndexTest { - private val readings = PinyinReadings.of('打' to "da", '野' to "ye", '大' to "da", '爷' to "ye") - private val index = PinyinCandidateIndex(listOf("打野"), readings) - - @Test - fun offersAHotwordForItsExactFullPinyin() { - assertEquals(listOf("打野"), index.exact("daye")) - assertEquals(listOf("打野"), index.exact("DaYe")) - assertEquals(listOf("打野"), index.exact("da'ye")) - } - - @Test - fun doesNotOfferPartialOrUnrelatedInput() { - assertTrue(index.exact("day").isEmpty()) - assertTrue(index.exact("dayex").isEmpty()) - assertTrue(index.exact("dy").isEmpty()) - assertTrue(index.exact("").isEmpty()) - } - - @Test - fun boostKeepsTheTopCandidateAndPlacesHotwordsRightBehindIt() { - assertEquals( - listOf("大爷", "打野", "大爷们", "达也"), - index.boost("daye", listOf("大爷", "大爷们", "达也", "打野")), - ) - } - - @Test - fun boostIsANoOpWhenNothingMatchesOrTheHotwordIsAlreadyFirst() { - val list = listOf("你好", "尼好") - assertEquals(list, index.boost("nihao", list)) - assertEquals(listOf("打野", "大爷"), index.boost("daye", listOf("打野", "大爷"))) - } - - @Test - fun aHotwordMissingFromTheDictionaryStillAppears() { - assertEquals(listOf("打野"), index.boost("daye", emptyList())) - assertEquals(listOf("大爷", "打野"), index.boost("daye", listOf("大爷"))) - } - - @Test - fun worksWithTheBundledReadings() { - val lines = sequenceOf( - "src/main/assets/rime-data/openime_dicts/8105.dict.yaml", - "app/src/main/assets/rime-data/openime_dicts/8105.dict.yaml", - ).map(::File).first { it.isFile }.readLines(Charsets.UTF_8).asSequence() - val real = PinyinCandidateIndex(listOf("王者荣耀", "蛋仔派对"), PinyinReadings.parseRimeDict(lines)) - assertEquals(listOf("王者荣耀"), real.exact("wangzherongyao")) - assertEquals(listOf("蛋仔派对"), real.exact("danzaipaidui")) - } -} diff --git a/docs/APP_UI_SPEC.md b/docs/APP_UI_SPEC.md index fb56eed3..92a44c72 100644 --- a/docs/APP_UI_SPEC.md +++ b/docs/APP_UI_SPEC.md @@ -72,7 +72,7 @@ Apple 的 pt 不是 Android 的 dp/sp。下面是本项目在 Android 上的实 - 设置面板与键盘同高,首屏依次是四个快捷开关(按键音效、触感震动、按键气泡、数字提示,每个 68dp 高)、外观(分段选项与标签同行,宽 216dp)、键盘高度;其余设置在下方滚动。行高 52dp,不带图标和说明。 - 工具面板:剪贴板、表情、符号、文本编辑、浮动键盘、设置、数据管理,4 列排布;每项为 56dp 圆角 16dp 的图标块加 12sp 名称,图标为中性色。不放语音输入(按住空格即可)和切换键盘(工具栏已有)。 -- 偏好设置与键盘内设置的最后一组标题为“关于与数据”,下面两行:“关于”(版本、隐私、诊断)和“数据管理”(导出与导入、语音词表、卸载前提示)。 +- 偏好设置与键盘内设置的最后一组标题为“关于与数据”,下面两行:“关于”(版本、隐私、诊断)和“数据管理”(导出与导入、卸载前提示)。 - 标题行与面板同底色,返回按钮无底色,按下才显示反馈。 ## 宽度与字体变化 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 43601f8f..86f01c7f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -92,12 +92,12 @@ LocalVoiceImeService ```text app(根包:Service、Activity、RimeNative) │ - keyboard ────────────────┐ - ┌──────────┬──────────┼──────────┬────────┤ - panel voice candidate floating hotword - │ │ │ │ │ - widget handwriting │ rime │ setup - └──────────┴──────────┴────┬─────┴─────────┘ + keyboard ──────┐ + ┌──────────┬──────────┼──────────┤ + panel voice candidate floating + │ │ │ │ + widget handwriting │ rime │ setup + └──────────┴──────────┴────┬─────┴───────┘ data editor core @@ -115,11 +115,10 @@ LocalVoiceImeService | `floating` | 浮动键盘窗口、拖动、卡片外观 | theme | | `handwriting` | 手写板 | data、theme | | `rime` | librime 引擎封装、输入规范化、native 候选引用 | core、data(及 JNI 类 `RimeNative`) | -| `candidate` | 候选管线、快照、九键本地解码、模糊音、拼音词典 | core、rime | -| `hotword` | 语音词表:解析、同音纠正、打字候选加权、管理界面 | setup、theme | -| `voice` | 语音识别、模型生命周期、语音面板、识别后处理 | data、editor、hotword、theme | +| `candidate` | 候选管线、快照、九键本地解码、模糊音、拼音词典、笔画表 | core、rime | +| `voice` | 语音识别、模型生命周期、语音面板、识别后处理 | data、editor、theme | | `panel` | 工具、剪贴板、设置、文本编辑等面板 | core、data、handwriting、setup、theme、widget | -| `keyboard` | `ImeKeyboardView` 编排、26 键/九键/数字键盘、顶部区、手势、弹窗 | 以上除 app 外的全部 | +| `keyboard` | `ImeKeyboardView` 编排、26 键/九键/笔画/数字键盘、顶部区、手势、弹窗 | 以上除 app 外的全部 | | `app`(根包) | Manifest 里的 Service 和 Activity、JNI 类 | 全部 | 规则: @@ -129,37 +128,13 @@ LocalVoiceImeService - 根包只放 Manifest、JNI 和测试脚本按名字引用的入口类,别的东西不要放进来。 - 语音层通过窄接口回到界面:`VoiceSessionHost`(键盘监听器继承它)和 `VoiceEditorContext`(Service 提供编辑器信息), 不直接依赖 `ImeKeyboardView` 或 Service。 -- 新功能自成一个包,按“纯逻辑 / Android 边界 / 唯一入口”拆分,样板是 `hotword`:纯逻辑文件不 import `android.*`, - 包外只通过 `HotwordRuntime` 和管理 Activity 使用,由 `HotwordModuleBoundaryTest` 检查。 +- 新功能自成一个包,按“纯逻辑 / Android 边界 / 唯一入口”拆分:纯逻辑文件不 import `android.*`, + 包外只通过一个入口对象和它的 Activity 使用。 - 可见性默认 `internal`,只有 Manifest 需要的 Activity 是 public。 仍然偏大的地方:`ImeKeyboardView` 约 3000 行,`LocalVoiceImeService` 约 1800 行,`ImeKeyboardView.Listener` 有数十个方法。 它们是下一步拆分的对象,拆分时沿用上面的包边界,不要新增跨包依赖。 -## 语音词表(hotword 模块) - -```text -VoiceRecognitionBackend(Paraformer) - │ 最终文本 - ▼ -LocalAudioVoiceBackend:标点 → VoiceCorrectionRepository.apply → HotwordRuntime.apply → onFinal - │ - HotwordPackStore ──启用的词──▶ HomophoneCorrector ◀── PinyinReadings - ├ assets/hotwords/*.txt(内置,随版本发布) - └ files/hotwords/*.txt(用户导入,规范化后保存) -``` - -- 流式 Paraformer 无法把热词传进解码器,所以词表在识别之后工作:文本里读音与某个热词相同、 - 但字不同的片段,改成热词的写法。最左最长匹配;已经写对的不动。 -- 读音来自 `rime-data/openime_dicts/8105.dict.yaml`,包含多音字;权重不足最大读音 1% 的冷僻读音 - 被丢弃,避免无关词被当成同音。 -- 词表格式:UTF-8 文本,一行一个 2~8 个汉字的词,`#` 开头为注释,支持 `# title:`、 - `# description:`、`# default: on|off`。单个文件上限 512 KB、5000 个词。 -- 内置词表由 `default` 头决定初始开关:科技、应用默认开,游戏默认关;导入的词表默认开。 -- 不联网:词表只随版本更新或由用户导入,应用不声明 `INTERNET` 权限。 -- 已知取舍:同音替换不看上下文,两个字的词在日常语句里也可能同音,所以游戏词表默认关闭, - 每个词表都可以单独关闭。 - ## Native 与第三方代码 `app/src/main/cpp/local_rime_jni.cc` 和 CMake glue 是本项目维护边界。vendored librime/OpenCC/Boost 等第三方源码不作为日常架构重构对象;除非有明确 native 缺陷和测试证据,否则不要改 vendor 源码。 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 8b645dd4..1651cec5 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -15,3 +15,4 @@ - 2026-10-04|字母键上的数字和符号|不加一行数字,而是像搜狗、讯飞、微信键盘那样把数字和符号印在字母键右上角,上滑(复用九键的“上滑输入数字”开关)或长按输入;第一排 q–p 是 1–0,其余是标点,中文模式用全角形式(! ¥ ? ( ) : ;),英文模式半角;提示可单独关闭(“数字和符号提示”,默认开启)|多一行数字要么挤压每一行(竖屏约 43dp,横屏约 34dp),要么让键盘变高;输入法窗口高度一变,面板、浮动键盘、九键都要跟着变。提示 + 上滑不改布局,也是国内主流输入法的做法。 - 2026-10-04|空格滑动光标锁定底行|进入光标模式(横向 18dp、横向分量大于纵向 1.25 倍、早于长按语音超时)后,同一行的其他按键 `touchLocked`(变灰、不响应);拼音预编辑存在时移动的是预编辑光标|手指一直在空格上,但第二根手指或漂移的拇指可能按到邻键;预编辑期间把方向键事件发给应用会打断组合。 - 2026-10-04|“标点用空格代替”|只作用于语音识别结果:逗号、句号、问号等写成一个空格,结尾标点直接去掉,括号和 3.5、a.b 不变|把这项需求理解为语音文本后处理的开关(与去语气词同属“语音输入”设置组),默认关闭。 +- 2026-10-05|移出语音词表|0.0.6-beta.1 起去掉语音词表(`hotword` 模块、内置词表、管理页、打字候选加权)|同音替换还不成熟,先不发;去掉前的代码保存在 `archive/voice-word-lists` 分支(即 0.0.5-beta.1 的 `f3bb5cc`),以后改好再合回。 diff --git a/docs/LOCAL_VOICE_MODEL.md b/docs/LOCAL_VOICE_MODEL.md index 356c610a..3c194522 100644 --- a/docs/LOCAL_VOICE_MODEL.md +++ b/docs/LOCAL_VOICE_MODEL.md @@ -86,9 +86,7 @@ app/src/main/assets/models/voice/ - Streaming Paraformer 不走 sherpa-onnx 的 transducer hotword graph,因此不再把 `VoiceHotwordProvider` 动态热词传给 native stream;本地 `VoiceCorrectionRepository` 的识别后纠正仍保留。 -- 语音词表(`hotword` 模块)在 `VoiceCorrectionRepository.apply` 之后对最终文本做同音替换, - 见 [ARCHITECTURE.md](ARCHITECTURE.md#语音词表hotword-模块)。它只改最终结果,不影响解码、 - 不联网;`VoiceHotwordProvider` 保留给将来的 transducer 模型,目前没有调用方。 +- `VoiceHotwordProvider` 保留给将来的 transducer 模型,目前没有调用方。 - 用户在语音上屏后立即删除并改正的文本会形成私有 `VoiceCorrectionRepository` 对; 后续相同 ASR 原结果先应用本地纠正,改正目标也会回流动态热词。 - 密码框可以使用语音,但只在结束时一次性上屏最终结果,不显示中间结果,也不进入热词或纠错学习;日志不记录 PCM、转写、热词、纠错内容。 diff --git a/scripts/design_reference_e2e.py b/scripts/design_reference_e2e.py index a726e2c0..79317b85 100644 --- a/scripts/design_reference_e2e.py +++ b/scripts/design_reference_e2e.py @@ -253,7 +253,7 @@ def pref_items(items): capture_app(stem + '-about', ('隐私','诊断')) adb('shell','input','keyevent','4'); time.sleep(.5) ui_tap('数据管理') - capture_app(stem + '-data', ('用户数据','导出','导入','语音词表')) + capture_app(stem + '-data', ('用户数据','导出','导入')) launch() panel('符号', stem + '-symbols-return') tap('自定义'); tap('管理自定义符号') From 799cf86ba85d0608c5cbc8aff3d0e5a433737870 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:45:56 +0800 Subject: [PATCH 06/10] =?UTF-8?q?Nine-key=20=E7=AC=A6=E5=8F=B7=20key,=20fl?= =?UTF-8?q?at=20=E9=87=8D=E8=BE=93,=20editable=20symbol=20rail,=20tool-sty?= =?UTF-8?q?le=20keyboard=20switcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nine-key and stroke keyboards get the numeric keyboard's 符号 key under the symbol rail. - 重输 always looks like 删除 and 确定; it does nothing with no input. - The rail shows the user's list (ten common marks by default) and ends in +, which opens RailSymbolsActivity to add, reorder and remove them. The list is part of the user-data archive. - The keyboard switcher uses the 工具 tile grid, one icon per keyboard; stroke candidates the system font cannot draw are dropped. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + app/src/main/AndroidManifest.xml | 6 + .../slacker/openime/RailSymbolsActivity.kt | 283 ++++++++++++++++++ .../openime/candidate/StrokeLexicon.kt | 10 +- .../openime/data/RailSymbolRepository.kt | 53 ++++ .../slacker/openime/data/UserDataArchive.kt | 10 + .../openime/keyboard/ImeKeyboardView.kt | 25 +- .../openime/keyboard/ImeThemeApplier.kt | 19 +- .../keyboard/NineKeySymbolRailController.kt | 14 +- .../keyboard/Pinyin9KeyboardRenderer.kt | 13 +- .../keyboard/StrokeKeyboardRenderer.kt | 17 +- .../openime/keyboard/SymbolRailRenderer.kt | 30 +- .../slacker/openime/panel/ImePanelRenderer.kt | 91 ++---- app/src/main/res/drawable/ic_kb_digits.xml | 1 + app/src/main/res/drawable/ic_kb_english.xml | 1 + app/src/main/res/drawable/ic_kb_pinyin26.xml | 1 + app/src/main/res/drawable/ic_kb_pinyin9.xml | 1 + app/src/main/res/drawable/ic_kb_stroke.xml | 1 + app/src/main/res/values/editor_ids.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../openime/ArchitectureLayeringTest.kt | 1 + .../slacker/openime/UserDataArchiveTest.kt | 15 + 22 files changed, 505 insertions(+), 93 deletions(-) create mode 100644 app/src/main/java/llc/slacker/openime/RailSymbolsActivity.kt create mode 100644 app/src/main/java/llc/slacker/openime/data/RailSymbolRepository.kt create mode 100644 app/src/main/res/drawable/ic_kb_digits.xml create mode 100644 app/src/main/res/drawable/ic_kb_english.xml create mode 100644 app/src/main/res/drawable/ic_kb_pinyin26.xml create mode 100644 app/src/main/res/drawable/ic_kb_pinyin9.xml create mode 100644 app/src/main/res/drawable/ic_kb_stroke.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 27d4cc8f..657f067c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - Rime 词库改为打包时预编译:安装或升级后不再在手机上编译约一分钟,几秒内就能用完整词库打字,小米 15 实测 1.7 秒。此前这段时间只有备用小词库,九键单按 4、5、7、8、9 只能出数字。安装包里的词库文本源文件换成编译好的二进制,安装包小约 4MB,手机上少占约 37MB。用户词库不受影响。 - 升级时词库没有变化就不再重新拷贝,直接沿用手机上已有的词库,升级后第一次打开键盘和平时一样快。 - 新增笔画键盘:切换键盘里选“笔画”。五个笔画键“一 丨 丿 丶 乛”和“通配”(代替一笔,记不清时用),按笔顺点,每点一笔就出候选。笔顺完全吻合的字排在前面,其余按常用程度排;繁体和生僻字也能打出来。选过的字下次排在前面,选字后照常给联想。 +- 九键和笔画键盘左下角加“符号”键,与数字键盘一致。 +- “重输”键始终与删除键、回车键同一样式,不再在没有输入时显示为灰色。 +- 九键和笔画键盘左侧的符号栏默认是 10 个常用标点(,。?!、:;“”……),最下面的“+”打开“常用符号栏”页:输入或一键加入符号,上移、下移排序,删除,恢复默认。符号栏随用户数据导出、导入。之前加入符号栏的自定义符号仍在符号面板里,可以从“+”重新加回符号栏。 +- “切换键盘”改成与“工具”页相同的图标小卡片,每个键盘有自己的图标,当前键盘高亮。 - 暂时去掉语音词表(数据管理里的“语音词表”、识别后的同音替换和 26 键候选加权):同音替换还不够准,改好后再加回来。已导入的词表文件保留在手机上。 ## [0.0.5-beta.1] - 2026-10-05 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 38581ecc..7198eae8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -39,6 +39,12 @@ android:label="@string/activity_label_quick_phrases" android:windowSoftInputMode="stateAlwaysVisible|adjustResize" /> + + + if (actionId == EditorInfo.IME_ACTION_DONE) { + addTyped() + true + } else { + false + } + } + } + SetupUi.styleInput(this, input) + val addRow = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + addView(input, LinearLayout.LayoutParams(0, dp(48), 1f).apply { marginEnd = dp(8) }) + addView(SetupUi.primaryButton(this@RailSymbolsActivity, "添加") { addTyped() }, LinearLayout.LayoutParams(dp(88), dp(48))) + } + form.addView(addRow, fullWrap()) + val suggestions = (ImeData.symbols["常用"].orEmpty() + ImeData.symbols["中文标点"].orEmpty()) + .distinct() + .filter { it !in symbols } + if (suggestions.isNotEmpty()) { + form.addView(note("点一下加入:"), fullWrap().apply { topMargin = dp(12) }) + val grid = GridLayout(this).apply { columnCount = SUGGESTION_COLUMNS } + suggestions.forEach { symbol -> + grid.addView( + SetupUi.secondaryButton(this, symbol) { add(symbol) }.apply { + contentDescription = "加入 $symbol" + }, + GridLayout.LayoutParams( + GridLayout.spec(GridLayout.UNDEFINED), + GridLayout.spec(GridLayout.UNDEFINED, 1f), + ).apply { + width = 0 + height = dp(ImeGeometryTokens.TOUCH_TARGET_DP) + setMargins(dp(2), dp(2), dp(2), dp(2)) + }, + ) + } + form.addView(grid, fullWrap().apply { topMargin = dp(4) }) + } + content.addView(form, fullWrap().apply { topMargin = dp(12) }) + + content.addView(sectionTitle("符号栏(${symbols.size})"), fullWrap().apply { topMargin = dp(18) }) + val list = card(padded = false) + if (symbols.isEmpty()) { + list.addView(note("符号栏是空的,上方添加,或恢复默认。").apply { setPadding(dp(16), dp(16), dp(16), dp(16)) }, fullWrap()) + } + symbols.forEachIndexed { index, symbol -> + if (index > 0) { + list.addView( + View(this).apply { setBackgroundColor(getColor(R.color.setup_input_line)); alpha = 0.25f }, + fullHeight(1), + ) + } + list.addView(symbolRow(symbols, index), fullHeight(56)) + } + content.addView(list, fullWrap().apply { topMargin = dp(6) }) + + if (RailSymbolRepository.isCustomized(this)) { + content.addView( + SetupUi.secondaryButton(this, "恢复默认") { + RailSymbolRepository.reset(this) + render() + }, + fullHeight(48).apply { topMargin = dp(16) }, + ) + } + + val scroll = ScrollView(this).apply { + setBackgroundColor(getColor(R.color.setup_page_bg)) + isFillViewport = true + isVerticalScrollBarEnabled = false + setOnScrollChangeListener { _, _, scrollY, _, _ -> savedScrollY = scrollY } + addView(content) + } + setContentView(LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setBackgroundColor(getColor(R.color.setup_page_bg)) + addView(scroll, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)) + addView( + SetupUi.secondaryButton(this@RailSymbolsActivity, "完成") { finish() }, + fullHeight(48).apply { setMargins(dp(16), dp(12), dp(16), dp(12)) }, + ) + setOnApplyWindowInsetsListener { view, insets -> + if (Build.VERSION.SDK_INT >= 30) { + val bars = insets.getInsets( + WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout() or WindowInsets.Type.ime(), + ) + view.setPadding(bars.left, bars.top, bars.right, bars.bottom) + } else { + @Suppress("DEPRECATION") + view.setPadding( + insets.systemWindowInsetLeft, + insets.systemWindowInsetTop, + insets.systemWindowInsetRight, + insets.systemWindowInsetBottom, + ) + } + insets + } + }) + scroll.post { scroll.scrollTo(0, savedScrollY.coerceAtLeast(0)) } + } + + private fun symbolRow(symbols: List, index: Int): LinearLayout = LinearLayout(this).apply { + val symbol = symbols[index] + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(12), 0, dp(8), 0) + tag = "rail-symbol-row" + addView(TextView(this@RailSymbolsActivity).apply { + text = symbol + textSize = ImeTypographyTokens.SYMBOL_SP + gravity = Gravity.CENTER + maxLines = 1 + setTextColor(getColor(R.color.setup_title)) + contentDescription = "第 ${index + 1} 个:$symbol" + }, LinearLayout.LayoutParams(0, dp(52), 1f)) + fun action(label: String, description: String, enabled: Boolean, onClick: () -> Unit) { + addView( + SetupUi.secondaryButton(this@RailSymbolsActivity, label, onClick).apply { + contentDescription = "$description $symbol" + isEnabled = enabled + alpha = if (enabled) 1f else ImeSurfacePolicy.DISABLED_ALPHA + }, + LinearLayout.LayoutParams(dp(64), dp(ImeGeometryTokens.TOUCH_TARGET_DP)).apply { marginStart = dp(4) }, + ) + } + action("上移", "上移", index > 0) { move(symbols, index, -1) } + action("下移", "下移", index < symbols.lastIndex) { move(symbols, index, 1) } + action("删除", "删除", true) { + RailSymbolRepository.save(this@RailSymbolsActivity, symbols.filterIndexed { i, _ -> i != index }) + render() + } + } + + private fun move(symbols: List, index: Int, by: Int) { + val target = index + by + if (target !in symbols.indices) return + val reordered = symbols.toMutableList().apply { add(target, removeAt(index)) } + RailSymbolRepository.save(this, reordered) + render() + } + + private fun addTyped() { + val value = input.text.toString().trim() + when { + value.isEmpty() -> input.error = "请输入符号" + value.codePointCount(0, value.length) > RailSymbolRepository.MAX_SYMBOL_LENGTH -> + input.error = "最多 ${RailSymbolRepository.MAX_SYMBOL_LENGTH} 个字" + else -> { + input.text.clear() + add(value) + } + } + } + + private fun add(symbol: String) { + val current = RailSymbolRepository.load(this) + if (current.size >= RailSymbolRepository.MAX_SYMBOLS) { + input.error = "符号栏最多 ${RailSymbolRepository.MAX_SYMBOLS} 个" + return + } + // A symbol already in the rail moves to the end instead of doubling. + RailSymbolRepository.save(this, current.filter { it != symbol } + symbol) + render(draft = "") + } + + private fun card(padded: Boolean = true) = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + if (padded) setPadding(dp(16), dp(16), dp(16), dp(16)) + background = SetupUi.rounded(getColor(R.color.setup_surface), dp(16).toFloat()) + } + + private fun note(text: String) = TextView(this).apply { + this.text = text + textSize = ImeTypographyTokens.SMALL_SP + setTextColor(getColor(R.color.setup_body)) + } + + private fun sectionTitle(text: String) = TextView(this).apply { + this.text = text + textSize = ImeTypographyTokens.SMALL_SP + setTextColor(getColor(R.color.setup_body)) + setPadding(dp(4), 0, 0, 0) + if (Build.VERSION.SDK_INT >= 28) isAccessibilityHeading = true + } + + private fun dp(value: Int): Int = SetupUi.dp(this, value) + + private fun fullWrap() = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ) + + private fun fullHeight(height: Int) = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + dp(height), + ) + + private companion object { + const val STATE_DRAFT = "draft" + const val STATE_SCROLL = "scroll_y" + const val SUGGESTION_COLUMNS = 6 + } +} diff --git a/app/src/main/java/llc/slacker/openime/candidate/StrokeLexicon.kt b/app/src/main/java/llc/slacker/openime/candidate/StrokeLexicon.kt index 0b1918b4..fe887948 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/StrokeLexicon.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/StrokeLexicon.kt @@ -1,6 +1,7 @@ package llc.slacker.openime.candidate import android.content.Context +import android.graphics.Paint /** * The five strokes of the 笔画 keyboard, in the order mainstream stroke input @@ -116,12 +117,17 @@ class StrokeLexicon internal constructor(rows: Sequence>) { @Volatile private var cached: StrokeLexicon? = null - /** Loads the table once per process; later calls return the same instance. */ + /** + * Loads the table once per process; later calls return the same + * instance. Characters the system font cannot draw are left out: a + * candidate shown as an empty box helps no one. + */ fun load(context: Context): StrokeLexicon { cached?.let { return it } return synchronized(this) { cached ?: context.assets.open(ASSET).bufferedReader(Charsets.UTF_8).useLines { lines -> - StrokeLexicon(parse(lines)) + val paint = Paint() + StrokeLexicon(parse(lines).filter { (character, _) -> paint.hasGlyph(character) }) }.also { cached = it } } } diff --git a/app/src/main/java/llc/slacker/openime/data/RailSymbolRepository.kt b/app/src/main/java/llc/slacker/openime/data/RailSymbolRepository.kt new file mode 100644 index 00000000..7c1a0c64 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/data/RailSymbolRepository.kt @@ -0,0 +1,53 @@ +package llc.slacker.openime.data + +import android.content.Context +import org.json.JSONArray + +/** + * The symbols in the left rail of the nine-key and stroke keyboards, in order. + * Until the user edits them (the rail's + opens the editor) the rail shows + * [DEFAULT], the ten punctuation marks typed most in Chinese. + */ +object RailSymbolRepository { + private const val PREFS = "ime_rail_symbols" + private const val KEY_SYMBOLS = "symbols" + + val DEFAULT: List = listOf(",", "。", "?", "!", "、", ":", ";", "“", "”", "……") + + /** A rail holds at most this many; each is a short symbol or text. */ + const val MAX_SYMBOLS = 60 + const val MAX_SYMBOL_LENGTH = 8 + + fun load(context: Context): List { + val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_SYMBOLS, null) + ?: return DEFAULT + return runCatching { + val array = JSONArray(raw) + normalize((0 until array.length()).map { array.optString(it) }) + }.getOrDefault(DEFAULT) + } + + /** Whether the user has changed the rail from [DEFAULT]. */ + fun isCustomized(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).contains(KEY_SYMBOLS) + + fun save(context: Context, symbols: List) { + val value = normalize(symbols) + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(KEY_SYMBOLS, JSONArray(value).toString()) + .apply() + } + + fun reset(context: Context) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().remove(KEY_SYMBOLS).apply() + } + + /** Trimmed, non-blank, short, unique, at most [MAX_SYMBOLS]; order kept. */ + fun normalize(symbols: List): List = + symbols.asSequence() + .map { it.trim() } + .filter { it.isNotEmpty() && it.codePointCount(0, it.length) <= MAX_SYMBOL_LENGTH } + .distinct() + .take(MAX_SYMBOLS) + .toList() +} 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 8d863a76..8e9c6ffa 100644 --- a/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt +++ b/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt @@ -45,6 +45,8 @@ internal data class ArchiveSettings( val hapticStrengthPercent: Int = 100, val hapticStyle: String = "crisp", val keySoundStyle: String = "system", + /** The nine-key/stroke symbol rail; null when it is still the default. */ + val railSymbols: List? = null, ) internal data class RimeUserDictionaryArchive( @@ -191,6 +193,7 @@ internal object UserDataArchiveCodec { .put("emoji_association", value.emojiAssociation) .put("voice_strip_fillers", value.voiceStripFillers) .put("voice_punctuation_as_space", value.voicePunctuationAsSpace) + .apply { value.railSymbols?.let { put("rail_symbols", JSONArray(it)) } } private fun settingsFromJson(value: JSONObject): ArchiveSettings = ArchiveSettings( @@ -219,6 +222,9 @@ internal object UserDataArchiveCodec { voiceStripFillers = value.optBoolean("voice_strip_fillers", true), voicePunctuationAsSpace = value.optBoolean("voice_punctuation_as_space", false), + railSymbols = value.optJSONArray("rail_symbols")?.let { array -> + RailSymbolRepository.normalize((0 until array.length()).map { array.optString(it) }) + }, ) private inline fun JSONArray?.toObjects(block: (JSONObject) -> T): List { @@ -349,6 +355,8 @@ internal object UserDataRepository { voiceStripFillers = ImeSettingsRepository.loadVoiceStripFillers(context), voicePunctuationAsSpace = ImeSettingsRepository.loadVoicePunctuationAsSpace(context), + railSymbols = RailSymbolRepository.load(context) + .takeIf { RailSymbolRepository.isCustomized(context) }, ), rimeUserDictionaries = rimeUserDictionaries, ) @@ -445,5 +453,7 @@ internal object UserDataRepository { context, value.voicePunctuationAsSpace, ) + // Backups from before the editable rail carry none; keep the current one. + value.railSymbols?.let { RailSymbolRepository.save(context, it) } } } 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 16135d47..ec1d92f2 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -22,6 +22,7 @@ import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.TextView import llc.slacker.openime.QuickPhraseEditActivity +import llc.slacker.openime.RailSymbolsActivity import llc.slacker.openime.R import llc.slacker.openime.candidate.CandidatePipeline import llc.slacker.openime.candidate.CandidateResolver @@ -459,7 +460,7 @@ open class ImeKeyboardView( onDigits = { setMode(KeyboardMode.DIGITS) }, onSpace = ::commitFirstCandidateOrSpace, onModeSwitch = ::cycleMode, - onRetranslate = { publishComposition("", emptyList()) }, + onRetranslate = ::retype, onEnter = listener::onEnter, ) } @@ -491,10 +492,11 @@ open class ImeKeyboardView( onStroke = ::onStrokeKey, swipeUpEnabled = { ImeSettingsRepository.loadSwipeUpDigits(context) }, onCommitCharacter = ::commitKeyboardCharacter, + onShowSymbols = { showPanel(Panel.SYMBOLS) }, onDigits = { setMode(KeyboardMode.DIGITS) }, onSpace = ::commitFirstCandidateOrSpace, onModeSwitch = ::cycleMode, - onRetype = { publishComposition("", emptyList()) }, + onRetype = ::retype, onEnter = listener::onEnter, ) } @@ -1888,10 +1890,6 @@ open class ImeKeyboardView( } syncEnterKeyPresentation((context as? android.inputmethodservice.InputMethodService)?.currentInputEditorInfo?.imeOptions) findViewWithTag("key-enter")?.let(::applyThemeToSubtree) - findViewWithTag("key-retype")?.apply { - isEnabled = composing - alpha = if (composing) 1f else ImeSurfacePolicy.DISABLED_ALPHA - } topZone.renderState( state = state, showCompositionEditor = (composing || candidateBarController.expandedOpen) && @@ -2008,6 +2006,7 @@ open class ImeKeyboardView( onRailChanged = ::applyThemeToSubtree, onChooseReading = ::chooseNineKeyReading, fixedPrefix = ::nineKeyFixedPrefix, + onEditSymbols = ::openRailSymbolEditor, ).also { nineKeySymbolRailController = it } } @@ -2021,6 +2020,14 @@ open class ImeKeyboardView( strokeRenderer.render(enterLabel = if (composition.text?.isNotEmpty() == true) "确定" else "↵") } + /** + * 重输 clears what is being composed. It looks like the other function keys + * at all times; with nothing composed a tap does nothing. + */ + private fun retype() { + if (composition.text?.isNotEmpty() == true) publishComposition("", emptyList()) + } + /** One stroke (or 通配) typed at the pre-edit cursor. */ private fun onStrokeKey(glyph: String) { clearAssociationCandidates() @@ -2230,6 +2237,12 @@ open class ImeKeyboardView( /** Called on the UI thread after the asynchronous clipboard body is populated. */ protected open fun onClipboardContentLoaded() = Unit + private fun openRailSymbolEditor() { + context.startActivity( + Intent(context, RailSymbolsActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } + private fun openQuickPhraseEditor(phrase: QuickPhrase?) { val intent = Intent(context, QuickPhraseEditActivity::class.java) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt index b9553d9e..a860a561 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -174,12 +174,8 @@ internal class ImeThemeApplier( ) } "nine-symbol-scroll-content", "digits-symbol-scroll-content" -> view.background = null - "keyboard-choice", "keyboard-choice-selected" -> { - val selected = view.tag == "keyboard-choice-selected" - view.background = ImeDrawableFactory.rounded(t.toolCardBackground, toPx(16), - if (selected) t.primary else Color.TRANSPARENT, if (selected) toPx(2) else 0) - view.invalidate() - } + // Tool-style tiles: the icon tile carries the colour, not the card. + "keyboard-choice", "keyboard-choice-selected" -> view.background = null "segmented-track" -> view.background = ImeDrawableFactory.rounded( if (hasAncestorTag(view, "settings-panel")) ImeSurfacePolicy.controlTrack(t) else t.functionKeyBackground, toPx(12), @@ -298,6 +294,11 @@ internal class ImeThemeApplier( if (selected) ImeDrawableFactory.contrastText(t.primary) else ImeSurfacePolicy.iconTint(t), ) } + view.tag == "tool-icon-selected" -> { + val selected = ImeSurfacePolicy.selectedSurface(t) + view.imageTintList = ColorStateList.valueOf(ImeSurfacePolicy.selectedText(t)) + view.background = statefulRounded(selected, ImeSurfacePolicy.pressedSurface(selected, t), toPx(16)) + } view.tag == "tool-icon" -> { view.imageTintList = ColorStateList.valueOf(ImeSurfacePolicy.iconTint(t)) view.background = statefulRounded( @@ -307,10 +308,6 @@ internal class ImeThemeApplier( ) } view.tag == "undo-toolbar" -> view.imageTintList = ColorStateList.valueOf(t.keySecondaryText) - view.tag == "keyboard-radio-selected" -> - view.imageTintList = ColorStateList.valueOf(ImeSurfacePolicy.selectedText(t)) - view.tag == "keyboard-radio-off" -> - view.imageTintList = ColorStateList.valueOf(t.keySecondaryText) view.tag == "key-panel-back" -> { view.imageTintList = ColorStateList.valueOf(t.keyText) view.background = statefulRounded( @@ -491,7 +488,7 @@ internal class ImeThemeApplier( view.typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) } tag == "segment-option-tall" -> { view.setTextColor(t.keySecondaryText); view.background = null } - tag == "keyboard-radio-selected" -> view.setTextColor(t.primary) + tag == "tool-label-selected" -> view.setTextColor(ImeSurfacePolicy.selectedText(t)) tag == "fuzzy-rules" -> view.background = ImeDrawableFactory.rounded(t.toolCardBackground, toPx(16)) tag == "textedit-spacer" -> view.background = null tag == "textedit-center" -> { diff --git a/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt b/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt index 55110f41..a2d2fd18 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt @@ -14,8 +14,7 @@ import android.widget.TextView import llc.slacker.openime.candidate.CandidatePipeline import llc.slacker.openime.candidate.CandidateResolver import llc.slacker.openime.candidate.NineKeyReading -import llc.slacker.openime.core.ImeData -import llc.slacker.openime.data.CustomSymbolRepository +import llc.slacker.openime.data.RailSymbolRepository /** * Owns the Chinese 9-key side rail. @@ -36,6 +35,7 @@ internal class NineKeySymbolRailController( private val onRailChanged: (View) -> Unit, private val onChooseReading: (NineKeyReading) -> Unit, private val fixedPrefix: () -> String, + private val onEditSymbols: () -> Unit, ) { private enum class RailMode { SYMBOLS, PINYIN } @@ -78,6 +78,7 @@ internal class NineKeySymbolRailController( tagPrefix = "punct:", onCommit = onCommit, onFeedback = onFeedback, + onAdd = onEditSymbols, ).also { rail = it railMode = RailMode.SYMBOLS @@ -165,6 +166,7 @@ internal class NineKeySymbolRailController( tagPrefix = "punct:", onCommit = onCommit, onFeedback = onFeedback, + onAdd = onEditSymbols, ) railMode = RailMode.SYMBOLS renderedChoices = emptyList() @@ -264,12 +266,8 @@ internal class NineKeySymbolRailController( private fun speakablePath(path: String): String = path.replace(" ", "、").replace("|", "、").replace("'", "、") - private fun commonSymbols(): List = - (listOf(",", "。", "?", "!") + - CustomSymbolRepository.load(context).map { it.symbol } + - ImeData.symbols["常用"].orEmpty()) - .filter { it.isNotBlank() } - .distinct() + /** The user's rail (ten common marks until edited), then + to edit it. */ + private fun commonSymbols(): List = RailSymbolRepository.load(context) private companion object { const val NINE_RAIL_TAG = "nine-punct-stack" diff --git a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt index 25913cd4..c01beed5 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt @@ -53,12 +53,23 @@ internal class Pinyin9KeyboardRenderer( tag = "pinyin9-layout" } + // Symbol rail over a 符号 key, as on the numeric keyboard. val left = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } left.addView( createSymbolRail(), LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - toPx(nineBodyHeightDp()), + toPx(nineGridHeightDp()), + ), + ) + left.addView( + createKey("符号", true, null, ImeTypographyTokens.BODY_SP, onShowSymbols).apply { + tag = "key-symbols" + markSideKey(this) + }, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + toPx(keyRowHeightDp()), ), ) container.addView(left, adaptiveColumnParams(1f)) diff --git a/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt index d72ac7af..dcfb92e0 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt @@ -8,8 +8,8 @@ import llc.slacker.openime.theme.ImeTypographyTokens import llc.slacker.openime.widget.ImeKeyView /** - * The 笔画 keyboard. It keeps the nine-key frame (symbol rail, 删除/重输/确定 - * column, 123 · 空格 · 中/英 row) so switching between the two moves nothing + * The 笔画 keyboard. It keeps the nine-key frame (symbol rail over 符号, + * 删除/重输/确定 column, 123 · 空格 · 中/英 row) so switching between the two moves nothing * the user already knows, and puts the five strokes and 通配 in two rows of * large keys in their usual order: 一 丨 丿 / 丶 乛 通配, numbered 1–5 as in * other stroke keyboards. @@ -36,6 +36,7 @@ internal class StrokeKeyboardRenderer( private val onStroke: (String) -> Unit, private val swipeUpEnabled: () -> Boolean, private val onCommitCharacter: (String) -> Unit, + private val onShowSymbols: () -> Unit, private val onDigits: () -> Unit, private val onSpace: () -> Unit, private val onModeSwitch: () -> Unit, @@ -48,12 +49,20 @@ internal class StrokeKeyboardRenderer( tag = "stroke-layout" } + // Symbol rail over a 符号 key, as on the nine-key and numeric keyboards. container.addView( LinearLayout(context).apply { orientation = LinearLayout.VERTICAL addView( createSymbolRail(), - LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(bodyHeightDp())), + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(gridHeightDp())), + ) + addView( + createKey("符号", true, null, ImeTypographyTokens.BODY_SP, onShowSymbols).apply { + tag = "key-symbols" + markSideKey(this) + }, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(keyRowHeightDp())), ) }, columnParams(1f), @@ -125,7 +134,7 @@ internal class StrokeKeyboardRenderer( contentDescription = "通配,代替一笔" } } else { - createKey(stroke.glyph, false, stroke.digit.toString(), ImeTypographyTokens.CANDIDATE_SP) { + createKey(stroke.glyph, false, stroke.digit.toString(), ImeTypographyTokens.SYMBOL_SP) { onStroke(stroke.glyph) }.apply { tag = "key-stroke:${stroke.code}" diff --git a/app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt index eb49a9e5..1c8227e6 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt @@ -30,6 +30,7 @@ internal object SymbolRailRenderer { tagPrefix: String, onCommit: (String) -> Unit, onFeedback: () -> Unit, + onAdd: (() -> Unit)? = null, ): ScrollView { val content = LinearLayout(context).apply { tag = contentTag @@ -57,6 +58,7 @@ internal object SymbolRailRenderer { tagPrefix = tagPrefix, onCommit = onCommit, onFeedback = onFeedback, + onAdd = onAdd, ) return scroll } @@ -70,6 +72,8 @@ internal object SymbolRailRenderer { toPx: (Int) -> Int = { dp(scroll.context, it) }, onCommit: (String) -> Unit, onFeedback: () -> Unit, + /** When set, a + cell after the symbols opens the rail editor. */ + onAdd: (() -> Unit)? = null, ) { val content = scroll.getChildAt(0) as? LinearLayout ?: return val inheritedTextColor = (0 until content.childCount) @@ -96,11 +100,35 @@ internal object SymbolRailRenderer { onCommit = onCommit, onFeedback = onFeedback, ), - cellParams(content.context, withGap = index < symbols.lastIndex, heightDp = cellHeightDp, toPx = toPx), + cellParams( + content.context, + withGap = index < symbols.lastIndex || onAdd != null, + heightDp = cellHeightDp, + toPx = toPx, + ), + ) + } + if (onAdd != null) { + content.addView( + symbolCell( + context = content.context, + symbol = ADD_LABEL, + inheritedTextColor = inheritedTextColor, + tagPrefix = tagPrefix, + onCommit = { onAdd() }, + onFeedback = onFeedback, + ).apply { + tag = "${tagPrefix}add" + contentDescription = "添加和排序常用符号" + }, + cellParams(content.context, withGap = false, heightDp = cellHeightDp, toPx = toPx), ) } } + /** The rail editor's cell: a plain plus, drawn like the symbols around it. */ + private const val ADD_LABEL = "+" + fun cellParams(context: Context, withGap: Boolean, heightDp: Int = CELL_HEIGHT_DP, toPx: (Int) -> Int = { dp(context, it) }) = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, toPx(heightDp), 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 446c6f84..8e1c427e 100644 --- a/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt @@ -83,39 +83,33 @@ internal class ImePanelRenderer( } } + /** Same grid as 工具: four per row, an icon tile over a short label. */ fun renderKeyboardSelect() { addHeader("切换键盘") val body = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL - setPadding(toPx(8), toPx(12), toPx(8), toPx(10)) + setPadding(toPx(12), toPx(12), toPx(4), 0) tag = "keyboard-select-panel" } val modes = listOf( - KeyboardMode.PINYIN_26 to "拼音 26 键", - KeyboardMode.PINYIN_9 to "拼音 9 键", - KeyboardMode.STROKE to "笔画", - KeyboardMode.ENGLISH_26 to "英文 26 键", - KeyboardMode.DIGITS to "数字键盘", + Triple(KeyboardMode.PINYIN_26, "拼音 26 键", R.drawable.ic_kb_pinyin26), + Triple(KeyboardMode.PINYIN_9, "拼音 9 键", R.drawable.ic_kb_pinyin9), + Triple(KeyboardMode.STROKE, "笔画", R.drawable.ic_kb_stroke), + Triple(KeyboardMode.ENGLISH_26, "英文 26 键", R.drawable.ic_kb_english), + Triple(KeyboardMode.DIGITS, "数字键盘", R.drawable.ic_kb_digits), ) - modes.chunked(2).forEach { chunk -> + modes.chunked(TOOL_COLUMNS).forEach { chunk -> val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } - chunk.forEach { (modeValue, label) -> - row.addView( - keyboardChoice(modeValue, label), - LinearLayout.LayoutParams(0, toPx(96), 1f).apply { - marginEnd = toPx(4); marginStart = toPx(4) - }, - ) - } - if (chunk.size == 1) { - row.addView(View(context), LinearLayout.LayoutParams(0, toPx(96), 1f)) + chunk.forEach { (mode, label, iconRes) -> + row.addView(keyboardChoice(mode, label, iconRes), toolCardParams()) } + repeat(TOOL_COLUMNS - chunk.size) { row.addView(View(context), toolCardParams()) } body.addView( row, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - toPx(96), - ).apply { bottomMargin = toPx(8) }, + toPx(ImeGeometryTokens.TOOL_CARD_HEIGHT_DP), + ).apply { bottomMargin = toPx(ImeSpacingTokens.LG_DP) }, ) } expandedPanel.addView( @@ -127,6 +121,9 @@ internal class ImePanelRenderer( ) } + private fun toolCardParams() = + LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOOL_CARD_HEIGHT_DP), 1f).apply { marginEnd = toPx(8) } + fun renderTools() { addHeader("工具") val body = LinearLayout(context).apply { @@ -147,7 +144,7 @@ internal class ImePanelRenderer( ToolEntry("数据管理", iconRes = R.drawable.ic_pref_data, action = onOpenDataManagement), ).filter { it.enabled } - cards.chunked(4).forEach { chunk -> + cards.chunked(TOOL_COLUMNS).forEach { chunk -> val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } chunk.forEach { entry -> val action = entry.action ?: { @@ -163,7 +160,7 @@ internal class ImePanelRenderer( ).apply { marginEnd = toPx(8) }, ) } - repeat(4 - chunk.size) { + repeat(TOOL_COLUMNS - chunk.size) { row.addView( View(context), LinearLayout.LayoutParams( @@ -680,6 +677,7 @@ internal class ImePanelRenderer( card.addView( TextView(context).apply { text = label + tag = "tool-label" typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) textSize = ImeTypographyTokens.SMALL_SP gravity = Gravity.CENTER @@ -694,48 +692,17 @@ internal class ImePanelRenderer( return card } - private fun keyboardChoice(mode: KeyboardMode, label: String): View { + /** A 工具-style tile; the current keyboard's tile is drawn selected. */ + private fun keyboardChoice(mode: KeyboardMode, label: String, iconRes: Int): View { val selected = currentMode() == mode - return LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - setPadding(toPx(14), toPx(12), toPx(14), toPx(8)) + return toolCard(iconRes, label) { onModeSelected(mode) }.apply { tag = if (selected) "keyboard-choice-selected" else "keyboard-choice" contentDescription = "$label,${if (selected) "已选中" else "未选中"}" - isClickable = true - isFocusable = true - setOnClickListener { onFeedback(); onModeSelected(mode) } - addView(object : View(context) { - private val paint = Paint(Paint.ANTI_ALIAS_FLAG) - override fun onDraw(canvas: Canvas) { - val dark = resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK == android.content.res.Configuration.UI_MODE_NIGHT_YES - val tokens = ImeTheme.IOS.tokens(ImeSettingsRepository.loadAppearance(context), dark) - val h = height / 4f - paint.color = tokens.functionKeyBackground - for (i in 0..3) { - val y = i * h - val w = if (mode == KeyboardMode.PINYIN_9 || mode == KeyboardMode.STROKE || mode == KeyboardMode.DIGITS) width * 0.19f else width * (0.12f + 0.03f * i) - canvas.drawRoundRect(0f, y, w, y + h * 0.72f, toPx(2).toFloat(), toPx(2).toFloat(), paint) - if (mode != KeyboardMode.DIGITS || i == 0 || i == 3) { - paint.color = if (selected && i >= 2) tokens.primary else tokens.functionKeyBackground - canvas.drawRoundRect(width * 0.81f, y, width.toFloat(), y + h * 0.72f, toPx(2).toFloat(), toPx(2).toFloat(), paint) - paint.color = tokens.functionKeyBackground - } - } - } - }, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)) - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView(TextView(context).apply { text = label; textSize = ImeTypographyTokens.BODY_SP; typeface = Typeface.DEFAULT_BOLD }, LinearLayout.LayoutParams(0, toPx(28), 1f)) - // A vector mark, not a text glyph: it is tinted and sized like every other icon. - addView(ImageView(context).apply { - setImageResource(if (selected) R.drawable.ic_check else R.drawable.ic_radio_off) - tag = if (selected) "keyboard-radio-selected" else "keyboard-radio-off" - scaleType = ImageView.ScaleType.FIT_CENTER - setPadding(toPx(2), toPx(2), toPx(2), toPx(2)) - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - }, LinearLayout.LayoutParams(toPx(24), toPx(24))) - }, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(28))) + isSelected = selected + if (selected) { + findViewWithTag("tool-icon")?.tag = "tool-icon-selected" + findViewWithTag("tool-label")?.tag = "tool-label-selected" + } } } @@ -785,4 +752,8 @@ internal class ImePanelRenderer( val enabled: Boolean = true, val action: (() -> Unit)? = null, ) + + private companion object { + const val TOOL_COLUMNS = 4 + } } diff --git a/app/src/main/res/drawable/ic_kb_digits.xml b/app/src/main/res/drawable/ic_kb_digits.xml new file mode 100644 index 00000000..b4633a04 --- /dev/null +++ b/app/src/main/res/drawable/ic_kb_digits.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_kb_english.xml b/app/src/main/res/drawable/ic_kb_english.xml new file mode 100644 index 00000000..2f42c176 --- /dev/null +++ b/app/src/main/res/drawable/ic_kb_english.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_kb_pinyin26.xml b/app/src/main/res/drawable/ic_kb_pinyin26.xml new file mode 100644 index 00000000..9c06f78a --- /dev/null +++ b/app/src/main/res/drawable/ic_kb_pinyin26.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_kb_pinyin9.xml b/app/src/main/res/drawable/ic_kb_pinyin9.xml new file mode 100644 index 00000000..04717d82 --- /dev/null +++ b/app/src/main/res/drawable/ic_kb_pinyin9.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_kb_stroke.xml b/app/src/main/res/drawable/ic_kb_stroke.xml new file mode 100644 index 00000000..76e71d5f --- /dev/null +++ b/app/src/main/res/drawable/ic_kb_stroke.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/values/editor_ids.xml b/app/src/main/res/values/editor_ids.xml index 7f77112f..7b3da791 100644 --- a/app/src/main/res/values/editor_ids.xml +++ b/app/src/main/res/values/editor_ids.xml @@ -5,6 +5,7 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index acb94c34..260d9553 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -50,6 +50,7 @@ 偏好设置 常用语 自定义符号 + 常用符号栏 关于 数据管理 diff --git a/app/src/test/java/llc/slacker/openime/ArchitectureLayeringTest.kt b/app/src/test/java/llc/slacker/openime/ArchitectureLayeringTest.kt index 5333fc2b..e1671b04 100644 --- a/app/src/test/java/llc/slacker/openime/ArchitectureLayeringTest.kt +++ b/app/src/test/java/llc/slacker/openime/ArchitectureLayeringTest.kt @@ -51,6 +51,7 @@ class ArchitectureLayeringTest { "panel" to "ImeSettingsActivity", "panel" to "SymbolManagerActivity", "keyboard" to "QuickPhraseEditActivity", + "keyboard" to "RailSymbolsActivity", ) private val sources: File = sequenceOf(File("."), File("..")) diff --git a/app/src/test/java/llc/slacker/openime/UserDataArchiveTest.kt b/app/src/test/java/llc/slacker/openime/UserDataArchiveTest.kt index ca2c13db..e8bf48b4 100644 --- a/app/src/test/java/llc/slacker/openime/UserDataArchiveTest.kt +++ b/app/src/test/java/llc/slacker/openime/UserDataArchiveTest.kt @@ -33,8 +33,23 @@ class UserDataArchiveTest { emojiAssociation = false, voiceStripFillers = false, voicePunctuationAsSpace = true, + railSymbols = listOf("~", ",", "@"), ) + @Test + fun aBackupWithoutARailKeepsTheCurrentOne() { + val json = UserDataArchiveCodec.encode( + UserDataArchive( + quickPhrases = emptyList(), + customSymbols = emptyList(), + userPhrases = emptyList(), + settings = settings.copy(railSymbols = null), + ), + ) + assertEquals(false, json.contains("rail_symbols")) + assertEquals(null, UserDataArchiveCodec.decode(json).settings.railSymbols) + } + @Test fun jsonRoundTripPreservesAllExportedSections() { val archive = UserDataArchive( From 45daa92520413a36509016f45d6ef84da5436d8c Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:02:35 +0800 Subject: [PATCH 07/10] Fuzzy pinyin: a master switch and one switch per pair Eleven pairs as in mainstream IMEs (z/zh c/ch s/sh n/l f/h r/l, an/ang en/eng in/ing ian/iang uan/uang), all off by default. FuzzyRule holds each pair as librime algebra and as the Kotlin mapping; a unit test checks they agree on every syllable. The fuzzy schema patches in openime_fuzzy:/rules; the user dir copy written from the switches shadows the shipped one, and a changed file starts librime with a full check (the quick check works on whole-second mtimes and missed same-second edits). Switches are part of the user-data archive. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../openime/FuzzyRulesInstrumentedTest.kt | 80 +++++++++++++ .../luna_pinyin_simp_fuzzy.schema.yaml | 10 +- .../main/assets/rime-data/openime_fuzzy.yaml | 3 + app/src/main/cpp/local_rime_jni.cc | 12 +- .../slacker/openime/ImeSettingsActivity.kt | 3 + .../slacker/openime/LocalVoiceImeService.kt | 21 +++- .../java/llc/slacker/openime/RimeNative.kt | 3 +- .../openime/candidate/PinyinFuzzyRules.kt | 79 +++++++------ .../llc/slacker/openime/core/FuzzyRule.kt | 92 +++++++++++++++ .../openime/data/ImeSettingsRepository.kt | 18 +++ .../slacker/openime/data/UserDataArchive.kt | 13 +++ .../openime/keyboard/ImeKeyboardView.kt | 4 + .../openime/panel/SettingsPanelController.kt | 110 +++++++++++++----- .../llc/slacker/openime/rime/RimeEngine.kt | 49 +++++++- .../slacker/openime/CandidateEngineTest.kt | 20 ++++ .../java/llc/slacker/openime/FuzzyRuleTest.kt | 99 ++++++++++++++++ .../openime/PinyinFuzzyFallbackTest.kt | 20 ++++ .../slacker/openime/PinyinFuzzyRulesTest.kt | 20 ++++ .../slacker/openime/RimeFuzzySchemaTest.kt | 8 +- .../slacker/openime/UserDataArchiveTest.kt | 1 + 21 files changed, 587 insertions(+), 79 deletions(-) create mode 100644 app/src/androidTest/java/llc/slacker/openime/FuzzyRulesInstrumentedTest.kt create mode 100644 app/src/main/assets/rime-data/openime_fuzzy.yaml create mode 100644 app/src/main/java/llc/slacker/openime/core/FuzzyRule.kt create mode 100644 app/src/test/java/llc/slacker/openime/FuzzyRuleTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 657f067c..57f0f2c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - Rime 词库改为打包时预编译:安装或升级后不再在手机上编译约一分钟,几秒内就能用完整词库打字,小米 15 实测 1.7 秒。此前这段时间只有备用小词库,九键单按 4、5、7、8、9 只能出数字。安装包里的词库文本源文件换成编译好的二进制,安装包小约 4MB,手机上少占约 37MB。用户词库不受影响。 - 升级时词库没有变化就不再重新拷贝,直接沿用手机上已有的词库,升级后第一次打开键盘和平时一样快。 - 新增笔画键盘:切换键盘里选“笔画”。五个笔画键“一 丨 丿 丶 乛”和“通配”(代替一笔,记不清时用),按笔顺点,每点一笔就出候选。笔顺完全吻合的字排在前面,其余按常用程度排;繁体和生僻字也能打出来。选过的字下次排在前面,选字后照常给联想。 +- 模糊音改为“总开关 + 11 个子开关”,与主流输入法一致:声母 z=zh、c=ch、s=sh、n=l、f=h、r=l,韵母 an=ang、en=eng、in=ing、ian=iang、uan=uang,每组单独开关,默认全部关闭。拼音 26 键、九键和词库未就绪时的备用候选都按同一套规则;改动后输入法在一两秒内按新规则重新准备词库。子开关随用户数据导出、导入。 - 九键和笔画键盘左下角加“符号”键,与数字键盘一致。 - “重输”键始终与删除键、回车键同一样式,不再在没有输入时显示为灰色。 - 九键和笔画键盘左侧的符号栏默认是 10 个常用标点(,。?!、:;“”……),最下面的“+”打开“常用符号栏”页:输入或一键加入符号,上移、下移排序,删除,恢复默认。符号栏随用户数据导出、导入。之前加入符号栏的自定义符号仍在符号面板里,可以从“+”重新加回符号栏。 diff --git a/app/src/androidTest/java/llc/slacker/openime/FuzzyRulesInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/FuzzyRulesInstrumentedTest.kt new file mode 100644 index 00000000..32b9e4d5 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/FuzzyRulesInstrumentedTest.kt @@ -0,0 +1,80 @@ +package llc.slacker.openime + +import android.os.SystemClock +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import llc.slacker.openime.core.FuzzyRule +import llc.slacker.openime.data.ImeSettingsRepository +import llc.slacker.openime.rime.RimeEngine +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** Each 模糊音 switch reaches librime: the rules file changes and librime re-deploys. */ +@RunWith(AndroidJUnit4::class) +class FuzzyRulesInstrumentedTest { + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + private fun awaitReady(rime: RimeEngine) { + val deadline = SystemClock.elapsedRealtime() + 60_000L + while (!rime.isReady && rime.errorMessage.isBlank() && SystemClock.elapsedRealtime() < deadline) { + SystemClock.sleep(100L) + } + assertTrue("librime not ready: ${rime.errorMessage}", rime.isReady) + } + + /** Re-deploy and wait for it: isReady drops while librime restarts. */ + private fun redeploy(rime: RimeEngine) { + rime.invalidateSettingsCache() + rime.applyFuzzyRules() + val deadline = SystemClock.elapsedRealtime() + 5_000L + while (rime.isReady && SystemClock.elapsedRealtime() < deadline) SystemClock.sleep(20L) + awaitReady(rime) + } + + @Test + fun eachSwitchChangesWhatLibrimeOffers() { + val originalFuzzy = ImeSettingsRepository.loadFuzzy(context) + val originalRules = ImeSettingsRepository.loadFuzzyRules(context) + val userRules = File(context.filesDir, "rime-user/openime_fuzzy.yaml") + ImeSettingsRepository.saveFuzzy(context, true) + ImeSettingsRepository.saveFuzzyRules(context, setOf(FuzzyRule.Z_ZH)) + val rime = RimeEngine(context = context) + try { + rime.start() + awaitReady(rime) + assertTrue("rules file written", userRules.isFile) + assertTrue("z = zh: zi offers 知", "知" in rime.candidates("zi")) + assertFalse("an = ang is off: shan does not offer 上", "上" in rime.candidates("shan").take(30)) + + ImeSettingsRepository.saveFuzzyRules(context, setOf(FuzzyRule.AN_ANG)) + redeploy(rime) + assertFalse("z = zh off: zi no longer offers 知", "知" in rime.candidates("zi").take(30)) + assertTrue("an = ang: shan offers 上", "上" in rime.candidates("shan")) + + // Every pair: both widen at once. + ImeSettingsRepository.saveFuzzyRules(context, FuzzyRule.entries.toSet()) + redeploy(rime) + assertTrue("zi offers 知", "知" in rime.candidates("zi")) + assertTrue("shan offers 上", "上" in rime.candidates("shan")) + + // Back to the shipped defaults: nothing fuzzy even with 模糊音 on. + ImeSettingsRepository.saveFuzzyRules(context, FuzzyRule.DEFAULTS) + redeploy(rime) + assertEquals(FuzzyRule.rimeYaml(FuzzyRule.DEFAULTS), userRules.readText()) + assertFalse("defaults: zi does not offer 知", "知" in rime.candidates("zi").take(30)) + + // 模糊音 off: the plain schema, nothing fuzzy. + ImeSettingsRepository.saveFuzzy(context, false) + rime.invalidateSettingsCache() + assertFalse("fuzzy off: zi does not offer 知", "知" in rime.candidates("zi").take(30)) + } finally { + ImeSettingsRepository.saveFuzzy(context, originalFuzzy) + ImeSettingsRepository.saveFuzzyRules(context, originalRules) + rime.shutdown() + } + } +} diff --git a/app/src/main/assets/rime-data/luna_pinyin_simp_fuzzy.schema.yaml b/app/src/main/assets/rime-data/luna_pinyin_simp_fuzzy.schema.yaml index 7d2b0f59..9dd5d428 100644 --- a/app/src/main/assets/rime-data/luna_pinyin_simp_fuzzy.schema.yaml +++ b/app/src/main/assets/rime-data/luna_pinyin_simp_fuzzy.schema.yaml @@ -6,11 +6,11 @@ __include: luna_pinyin_simp.schema:/ schema: schema_id: luna_pinyin_simp_fuzzy name: 朙月拼音·简化字·模糊音 - version: "0.24" + version: "0.25" author: - openIME description: | - 朙月拼音简化字方案,额外启用 openIME 模糊音规则;全拼和 2-9 九键数字码共用同一词库/userdb。 + 朙月拼音简化字方案,额外启用 openIME 模糊音规则(openime_fuzzy.yaml);全拼和 2-9 九键数字码共用同一词库/userdb。 speller: alphabet: zyxwvutsrqponmlkjihgfedcbaZYXWVUTSRQPONMLKJIHGFEDCBA987654321 @@ -18,9 +18,9 @@ speller: delimiter: " '" algebra: __patch: - - pinyin:/zh_z_bufen - - pinyin:/n_l_bufen - - pinyin:/en_eng_bufen + # The 模糊音 pairs: openime_fuzzy.yaml in rime-data holds the defaults; + # one written to the user dir (by the switches) takes precedence. + - openime_fuzzy:/rules - pinyin:/abbreviation - pinyin:/spelling_correction - pinyin:/key_correction diff --git a/app/src/main/assets/rime-data/openime_fuzzy.yaml b/app/src/main/assets/rime-data/openime_fuzzy.yaml new file mode 100644 index 00000000..64efc919 --- /dev/null +++ b/app/src/main/assets/rime-data/openime_fuzzy.yaml @@ -0,0 +1,3 @@ +# Generated by openIME from the 模糊音 switches; do not edit. +rules: + __append: [] diff --git a/app/src/main/cpp/local_rime_jni.cc b/app/src/main/cpp/local_rime_jni.cc index 21b25384..53958697 100644 --- a/app/src/main/cpp/local_rime_jni.cc +++ b/app/src/main/cpp/local_rime_jni.cc @@ -310,7 +310,7 @@ void shutdown_locked() { extern "C" JNIEXPORT void JNICALL Java_llc_slacker_openime_RimeNative_nativeStartup( - JNIEnv* env, jclass, jstring shared_dir, jstring user_dir) { + JNIEnv* env, jclass, jstring shared_dir, jstring user_dir, jboolean full_check) { std::lock_guard lock(g_mutex); if (g_api && g_session) return; // Recover from any previous partial initialization before retrying. @@ -339,9 +339,13 @@ Java_llc_slacker_openime_RimeNative_nativeStartup( // Deployment is performed off the Android main thread by RimeEngine. Wait // here so the first keyboard session never races schema generation. if (g_api->start_maintenance) { - // False makes Rime check the data signature and skip a full rebuild on - // every service restart. The first install still deploys all schemas. - g_api->start_maintenance(False); + // A quick check compares whole-second modification times and skips the + // workspace update when nothing looks newer than the last deploy; it is + // enough on an ordinary start. A full check always recompiles the schema + // configs (and rebuilds only the prisms whose input changed): the caller + // asks for it when it has just changed a config, as the 模糊音 rules, + // which a same-second edit could hide from the quick check. + g_api->start_maintenance(full_check ? True : False); if (g_api->join_maintenance_thread) g_api->join_maintenance_thread(); } diff --git a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt index 869a2b89..66fe0133 100644 --- a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt +++ b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt @@ -200,6 +200,9 @@ class ImeSettingsActivity : Activity(), ImeKeyboardView.Listener { ImeSettingsRepository.saveFuzzy(this, enabled) refreshLiveIme() } + override fun onFuzzyRulesChanged() { + refreshLiveIme() + } override fun onHapticStrengthChanged(percent: Int) { refreshLiveIme() } diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index a486a6ce..49667cde 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -25,6 +25,7 @@ import llc.slacker.openime.candidate.CandidateSnapshotEntry import llc.slacker.openime.candidate.EmojiAssociationIndex import llc.slacker.openime.candidate.NineKeyReading import llc.slacker.openime.candidate.PinyinLexicon +import llc.slacker.openime.candidate.FuzzyPinyin import llc.slacker.openime.candidate.Stroke import llc.slacker.openime.candidate.StrokeLexicon import llc.slacker.openime.candidate.personalizedLearningAllowed @@ -193,6 +194,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can { ImeSettingsRepository.loadVoicePunctuationAsSpace(this) } UserPhraseRepository.configure(this) VoiceCorrectionRepository.configure(this) + FuzzyPinyin.rules = ImeSettingsRepository.loadFuzzyRules(this) voiceLifecycle = VoiceModelLifecycleManager(this) Thread({ runCatching { CandidatePipeline(CandidateEngine(PinyinLexicon.load(this))) } @@ -424,6 +426,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can popupEnabled = ImeSettingsRepository.loadPopup(this), fuzzyPinyinEnabled = ImeSettingsRepository.loadFuzzy(this), ) + syncFuzzyRules() keyboardView?.applyPersistedSettings( newTheme = state.theme, newAppearance = state.appearance, @@ -1265,8 +1268,22 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can override fun onFuzzyChanged(enabled: Boolean) { state = state.copy(fuzzyPinyinEnabled = enabled) ImeSettingsRepository.saveFuzzy(this, enabled) - // RimeEngine mirrors this value on the candidate hot path. - if (::rime.isInitialized) rime.invalidateSettingsCache() + syncFuzzyRules() + } + + override fun onFuzzyRulesChanged() = syncFuzzyRules() + + /** + * Hand the 模糊音 switches to both candidate sources: the Kotlin fallback + * reads [FuzzyPinyin.rules]; librime re-deploys when the rules file changes. + */ + private fun syncFuzzyRules() { + FuzzyPinyin.rules = ImeSettingsRepository.loadFuzzyRules(this) + if (::rime.isInitialized) { + // RimeEngine mirrors the master switch on the candidate hot path. + rime.invalidateSettingsCache() + rime.applyFuzzyRules() + } } override fun onShiftStateChanged(state: ShiftState) { diff --git a/app/src/main/java/llc/slacker/openime/RimeNative.kt b/app/src/main/java/llc/slacker/openime/RimeNative.kt index 7006b73b..7cd31938 100644 --- a/app/src/main/java/llc/slacker/openime/RimeNative.kt +++ b/app/src/main/java/llc/slacker/openime/RimeNative.kt @@ -7,7 +7,8 @@ internal object RimeNative { } @JvmStatic - external fun nativeStartup(sharedDir: String, userDir: String) + /** [fullCheck] recompiles every schema config; see RimeEngine.syncFuzzyRulesFile. */ + external fun nativeStartup(sharedDir: String, userDir: String, fullCheck: Boolean) @JvmStatic external fun nativeSelectSchema(schemaId: String): Boolean diff --git a/app/src/main/java/llc/slacker/openime/candidate/PinyinFuzzyRules.kt b/app/src/main/java/llc/slacker/openime/candidate/PinyinFuzzyRules.kt index 6fb8eff1..6fea5fd7 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/PinyinFuzzyRules.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/PinyinFuzzyRules.kt @@ -1,43 +1,55 @@ package llc.slacker.openime.candidate +import llc.slacker.openime.core.FuzzyRule import llc.slacker.openime.core.ImeData +/** + * The 模糊音 pairs switched on. The service sets it from the settings (empty + * when 模糊音 is off); the callers' `fuzzy` flag still decides whether fuzzy + * spellings are looked up at all. + */ +object FuzzyPinyin { + @Volatile + var rules: Set = FuzzyRule.DEFAULTS +} + /** * Kotlin fallback counterpart of the fuzzy algebra enabled by - * luna_pinyin_simp_fuzzy.schema.yaml. Fuzzy rules are applied per Pinyin - * syllable so fallback and librime keep the same semantics for continuous - * multi-syllable input as well as single syllables. + * luna_pinyin_simp_fuzzy.schema.yaml (openime_fuzzy.yaml). Fuzzy rules are + * applied per Pinyin syllable so fallback and librime keep the same semantics + * for continuous multi-syllable input as well as single syllables. */ -internal fun pinyinFuzzyVariants(rawPinyin: String): List { +internal fun pinyinFuzzyVariants(rawPinyin: String, rules: Set = FuzzyPinyin.rules): List { val pinyin = rawPinyin.lowercase() - if (pinyin.isEmpty()) return emptyList() + if (pinyin.isEmpty() || rules.isEmpty()) return emptyList() val cached = fuzzyVariantCache - if (cached.input == pinyin) return cached.variants + if (cached.input == pinyin && cached.rules == rules) return cached.variants val variants = linkedSetOf() - singleSyllableFuzzyVariants(pinyin).forEach { variant -> + singleSyllableFuzzyVariants(pinyin, rules).forEach { variant -> if (variant != pinyin && variants.size < MAX_FUZZY_VARIANTS) variants += variant } if (variants.size < MAX_FUZZY_VARIANTS) { - segmentedCanonicalVariants(pinyin).forEach { variant -> + segmentedCanonicalVariants(pinyin, rules).forEach { variant -> if (variant != pinyin && variants.size < MAX_FUZZY_VARIANTS) variants += variant } } return variants.toList().also { result -> - fuzzyVariantCache = FuzzyVariantCache(pinyin, result) + fuzzyVariantCache = FuzzyVariantCache(pinyin, rules, result) } } private data class FuzzyVariantCache( val input: String, + val rules: Set, val variants: List, ) @Volatile -private var fuzzyVariantCache = FuzzyVariantCache("", emptyList()) +private var fuzzyVariantCache = FuzzyVariantCache("", emptySet(), emptyList()) private data class FuzzySyllableMatch( val spelling: String, @@ -45,7 +57,21 @@ private data class FuzzySyllableMatch( val exact: Boolean, ) -private val fuzzySyllableMatchesByFirst: Map> by lazy { +private data class FuzzySyllableIndex( + val rules: Set, + val byFirst: Map>, +) + +@Volatile +private var fuzzySyllableIndex: FuzzySyllableIndex? = null + +/** Syllable spellings under [rules], by first letter; rebuilt when the rules change. */ +private fun fuzzySyllableMatchesByFirst(rules: Set): Map> { + fuzzySyllableIndex?.takeIf { it.rules == rules }?.let { return it.byFirst } + return buildFuzzySyllableMatches(rules).also { fuzzySyllableIndex = FuzzySyllableIndex(rules, it) } +} + +private fun buildFuzzySyllableMatches(rules: Set): Map> { val matches = LinkedHashMap>() ImeData.pinyinDict.keys .asSequence() @@ -53,7 +79,7 @@ private val fuzzySyllableMatchesByFirst: Map> by .distinct() .forEach { canonical -> val spellings = linkedSetOf(canonical) - spellings.addAll(singleSyllableFuzzyVariants(canonical)) + spellings.addAll(singleSyllableFuzzyVariants(canonical, rules)) spellings.forEach spellingLoop@{ spelling -> val first = spelling.firstOrNull() ?: return@spellingLoop matches.getOrPut(first) { mutableListOf() } @@ -66,7 +92,7 @@ private val fuzzySyllableMatchesByFirst: Map> by ) } } - matches.mapValues { (_, values) -> + return matches.mapValues { (_, values) -> values .distinctBy { it.spelling to it.canonical } .sortedWith( @@ -82,7 +108,7 @@ private val fuzzySyllableMatchesByFirst: Map> by * accepting the same fuzzy spelling alternatives that Rime applies to every * syllable. The output is canonical Pinyin used for dictionary lookup. */ -private fun segmentedCanonicalVariants(input: String): List { +private fun segmentedCanonicalVariants(input: String, rules: Set): List { if (input.length > MAX_FUZZY_INPUT_LENGTH) return emptyList() val states = Array(input.length + 1) { linkedSetOf() } states[0] += "" @@ -91,7 +117,7 @@ private fun segmentedCanonicalVariants(input: String): List { val prefixes = states[start] if (prefixes.isEmpty()) continue val first = input[start] - for (match in fuzzySyllableMatchesByFirst[first].orEmpty()) { + for (match in fuzzySyllableMatchesByFirst(rules)[first].orEmpty()) { if (!input.startsWith(match.spelling, start)) continue val end = start + match.spelling.length for (prefix in prefixes) { @@ -109,8 +135,8 @@ private fun segmentedCanonicalVariants(input: String): List { .toList() } -/** Apply the configured fuzzy groups to one syllable. */ -private fun singleSyllableFuzzyVariants(pinyin: String): List { +/** Apply [rules] to one syllable, repeatedly (z=zh and an=ang give zang for zhan). */ +private fun singleSyllableFuzzyVariants(pinyin: String, rules: Set): List { if (pinyin.isEmpty()) return emptyList() val variants = linkedSetOf() @@ -126,23 +152,8 @@ private fun singleSyllableFuzzyVariants(pinyin: String): List { while (cursor < pending.size && variants.size < MAX_FUZZY_VARIANTS) { val value = pending[cursor++] - listOf("zh" to "z", "ch" to "c", "sh" to "s").forEach { (long, short) -> - when { - value.startsWith(long) -> enqueue(short + value.substring(long.length)) - value.startsWith(short) -> enqueue(long + value.substring(short.length)) - } - } - - when { - value.startsWith("n") -> enqueue("l" + value.substring(1)) - value.startsWith("l") -> enqueue("n" + value.substring(1)) - } - - when { - value.endsWith("eng") -> enqueue(value.dropLast(3) + "en") - value.endsWith("en") -> enqueue(value.dropLast(2) + "eng") - value.endsWith("ing") -> enqueue(value.dropLast(3) + "in") - value.endsWith("in") -> enqueue(value.dropLast(2) + "ing") + for (rule in FuzzyRule.entries) { + if (rule in rules) rule.variant(value)?.let(::enqueue) } } diff --git a/app/src/main/java/llc/slacker/openime/core/FuzzyRule.kt b/app/src/main/java/llc/slacker/openime/core/FuzzyRule.kt new file mode 100644 index 00000000..d2aca272 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/core/FuzzyRule.kt @@ -0,0 +1,92 @@ +package llc.slacker.openime.core + +/** + * One fuzzy-pinyin pair the user can switch on or off on its own. + * + * The eleven pairs are the ones mainstream IMEs offer (Sogou, Baidu, iFlytek, + * Microsoft Pinyin): six initials and five finals. All start off + * ([defaultOn]); the user turns on the ones their accent needs. + * + * Each pair works both ways and is defined twice, kept equal by tests: [rime] + * is the librime speller algebra (written to openime_fuzzy.yaml), [variants] + * the same mapping for the Kotlin fallback and the nine-key decoder. + */ +enum class FuzzyRule( + val key: String, + val label: String, + val initial: Boolean, + val defaultOn: Boolean, + val rime: List, +) { + Z_ZH("z_zh", "z = zh", true, false, listOf("derive/^zh/z/", "derive/^z([^h])/zh\$1/")), + C_CH("c_ch", "c = ch", true, false, listOf("derive/^ch/c/", "derive/^c([^h])/ch\$1/")), + S_SH("s_sh", "s = sh", true, false, listOf("derive/^sh/s/", "derive/^s([^h])/sh\$1/")), + N_L("n_l", "n = l", true, false, listOf("derive/^n/l/", "derive/^l/n/")), + F_H("f_h", "f = h", true, false, listOf("derive/^f/h/", "derive/^h/f/")), + R_L("r_l", "r = l", true, false, listOf("derive/^r/l/", "derive/^l/r/")), + AN_ANG("an_ang", "an = ang", false, false, listOf("derive/^([^iuv]*)an\$/\$1ang/", "derive/^([^iuv]*)ang\$/\$1an/")), + EN_ENG("en_eng", "en = eng", false, false, listOf("derive/en\$/eng/", "derive/eng\$/en/")), + IN_ING("in_ing", "in = ing", false, false, listOf("derive/in\$/ing/", "derive/ing\$/in/")), + IAN_IANG("ian_iang", "ian = iang", false, false, listOf("derive/ian\$/iang/", "derive/iang\$/ian/")), + UAN_UANG("uan_uang", "uan = uang", false, false, listOf("derive/uan\$/uang/", "derive/uang\$/uan/")), + ; + + /** The other spelling of [syllable] under this pair, or null when it does not apply. */ + fun variant(syllable: String): String? = when (this) { + Z_ZH -> swapInitial(syllable, "zh", "z") + C_CH -> swapInitial(syllable, "ch", "c") + S_SH -> swapInitial(syllable, "sh", "s") + N_L -> swapInitial(syllable, "n", "l") + F_H -> swapInitial(syllable, "f", "h") + R_L -> swapInitial(syllable, "r", "l") + AN_ANG -> if (syllable.dropLastWhile { it != 'a' }.dropLast(1).none { it in "iuv" }) { + swapFinal(syllable, "ang", "an") + } else { + null + } + EN_ENG -> swapFinal(syllable, "eng", "en") + IN_ING -> swapFinal(syllable, "ing", "in") + IAN_IANG -> swapFinal(syllable, "iang", "ian") + UAN_UANG -> swapFinal(syllable, "uang", "uan") + } + + companion object { + val DEFAULTS: Set = entries.filter { it.defaultOn }.toSet() + + fun fromKey(key: String): FuzzyRule? = entries.firstOrNull { it.key == key } + + fun fromLabel(label: String): FuzzyRule? = entries.firstOrNull { it.label == label } + + /** + * openime_fuzzy.yaml for [rules]: the `rules` list the fuzzy schema + * patches into its speller algebra, in the order of [entries]. + */ + fun rimeYaml(rules: Set): String = buildString { + append("# Generated by openIME from the 模糊音 switches; do not edit.\n") + append("rules:\n") + val lines = entries.filter { it in rules }.flatMap { it.rime } + if (lines.isEmpty()) { + append(" __append: []\n") + } else { + append(" __append:\n") + lines.forEach { append(" - ").append(it).append('\n') } + } + } + + // "z" must not match the start of "zh" (and "c"/"ch", "s"/"sh"). + private fun swapInitial(syllable: String, a: String, b: String): String? = when { + syllable.startsWith(a) && !(a.length == 1 && syllable.startsWith(a + "h") && a in "zcs") -> + b + syllable.substring(a.length) + syllable.startsWith(b) && !(b.length == 1 && syllable.startsWith(b + "h") && b in "zcs") -> + a + syllable.substring(b.length) + else -> null + } + + // Longer final first: "ang" before "an", "eng" before "en", ... + private fun swapFinal(syllable: String, long: String, short: String): String? = when { + syllable.endsWith(long) -> syllable.dropLast(long.length) + short + syllable.endsWith(short) -> syllable.dropLast(short.length) + long + else -> null + } + } +} 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 52cd1a3b..7dbe8889 100644 --- a/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt @@ -1,6 +1,7 @@ package llc.slacker.openime.data import android.content.Context +import llc.slacker.openime.core.FuzzyRule import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.theme.ImeAppearance import llc.slacker.openime.theme.ImeTheme @@ -18,6 +19,7 @@ object ImeSettingsRepository { private const val KEY_SOUND_STYLE = "key_sound_style" private const val KEY_POPUP = "popup" private const val KEY_FUZZY = "fuzzy" + private const val KEY_FUZZY_RULES = "fuzzy_rules" private const val KEY_SWIPE_UP_DIGITS = "swipe_up_digits" private const val KEY_LETTER_HINTS = "letter_hints" private const val KEY_EMOJI_ASSOCIATION = "emoji_association" @@ -225,4 +227,20 @@ object ImeSettingsRepository { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .edit().putBoolean(KEY_FUZZY, enabled).apply() } + + /** The 模糊音 pairs the user switched on; [FuzzyRule.DEFAULTS] until changed. */ + fun loadFuzzyRules(context: Context): Set { + val keys = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getStringSet(KEY_FUZZY_RULES, null) ?: return FuzzyRule.DEFAULTS + return keys.mapNotNull(FuzzyRule::fromKey).toSet() + } + + fun saveFuzzyRules(context: Context, rules: Set) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putStringSet(KEY_FUZZY_RULES, rules.map { it.key }.toSet()).apply() + } + + /** The pairs in effect: none while 模糊音 is off. */ + fun activeFuzzyRules(context: Context): Set = + if (loadFuzzy(context)) loadFuzzyRules(context) else emptySet() } 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 8e9c6ffa..29806bfa 100644 --- a/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt +++ b/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt @@ -1,6 +1,7 @@ package llc.slacker.openime.data import android.content.Context +import llc.slacker.openime.core.FuzzyRule import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.theme.ImeAppearance import llc.slacker.openime.theme.ImeTheme @@ -47,6 +48,8 @@ internal data class ArchiveSettings( val keySoundStyle: String = "system", /** The nine-key/stroke symbol rail; null when it is still the default. */ val railSymbols: List? = null, + /** Keys of the 模糊音 pairs switched on; null in backups made before the switches. */ + val fuzzyRules: List? = null, ) internal data class RimeUserDictionaryArchive( @@ -194,6 +197,7 @@ internal object UserDataArchiveCodec { .put("voice_strip_fillers", value.voiceStripFillers) .put("voice_punctuation_as_space", value.voicePunctuationAsSpace) .apply { value.railSymbols?.let { put("rail_symbols", JSONArray(it)) } } + .apply { value.fuzzyRules?.let { put("fuzzy_rules", JSONArray(it)) } } private fun settingsFromJson(value: JSONObject): ArchiveSettings = ArchiveSettings( @@ -225,6 +229,9 @@ internal object UserDataArchiveCodec { railSymbols = value.optJSONArray("rail_symbols")?.let { array -> RailSymbolRepository.normalize((0 until array.length()).map { array.optString(it) }) }, + fuzzyRules = value.optJSONArray("fuzzy_rules")?.let { array -> + (0 until array.length()).map { array.optString(it) }.filter { FuzzyRule.fromKey(it) != null } + }, ) private inline fun JSONArray?.toObjects(block: (JSONObject) -> T): List { @@ -357,6 +364,9 @@ internal object UserDataRepository { ImeSettingsRepository.loadVoicePunctuationAsSpace(context), railSymbols = RailSymbolRepository.load(context) .takeIf { RailSymbolRepository.isCustomized(context) }, + fuzzyRules = FuzzyRule.entries + .filter { it in ImeSettingsRepository.loadFuzzyRules(context) } + .map { it.key }, ), rimeUserDictionaries = rimeUserDictionaries, ) @@ -455,5 +465,8 @@ internal object UserDataRepository { ) // Backups from before the editable rail carry none; keep the current one. value.railSymbols?.let { RailSymbolRepository.save(context, it) } + value.fuzzyRules?.let { keys -> + ImeSettingsRepository.saveFuzzyRules(context, keys.mapNotNull(FuzzyRule::fromKey).toSet()) + } } } 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 ec1d92f2..914281eb 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -123,6 +123,9 @@ open class ImeKeyboardView( fun onHapticChanged(enabled: Boolean) fun onPopupChanged(enabled: Boolean) fun onFuzzyChanged(enabled: Boolean) + + /** A 模糊音 pair was switched; the new set is already saved. */ + fun onFuzzyRulesChanged() {} fun onKeyboardHeightChanged(percent: Int) {} fun onHapticStrengthChanged(percent: Int) {} @@ -692,6 +695,7 @@ open class ImeKeyboardView( onKeySoundStyleChanged = ::setKeySoundStyle, onFloatingStyleChanged = ::setFloatingStyle, onShowFuzzySettings = { showPanel(Panel.FUZZY_SETTINGS) }, + onFuzzyRulesChanged = listener::onFuzzyRulesChanged, onOpenAbout = listener::onOpenAbout, onOpenDataManagement = listener::onOpenDataManagement, onFeedback = ::feedback, 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 b9133b16..3e9e1f34 100644 --- a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt @@ -1,5 +1,8 @@ package llc.slacker.openime.panel +import llc.slacker.openime.theme.ImeSurfacePolicy +import llc.slacker.openime.data.ImeSettingsRepository +import llc.slacker.openime.core.FuzzyRule import android.content.Context import android.os.Build import android.text.TextUtils @@ -72,6 +75,8 @@ internal class SettingsPanelController( private val onKeySoundStyleChanged: (KeySoundStyle) -> Unit, private val onFloatingStyleChanged: (Int, Int) -> Unit, private val onShowFuzzySettings: () -> Unit, + /** A 模糊音 pair was switched; the new set is already saved. */ + private val onFuzzyRulesChanged: () -> Unit, private val onOpenAbout: () -> Unit, private val onOpenDataManagement: () -> Unit, private val onFeedback: () -> Unit, @@ -273,7 +278,7 @@ internal class SettingsPanelController( tag = "fuzzy-settings-panel" } content.addView( - noteText("近音输入时,候选会同时尝试相近声母;不会改变你已输入的拼音。"), + noteText("读不准的音也能打出来:例如打 zi 时,“知 zhi”也会出现在候选里。只影响候选,不改你输入的拼音。"), LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT, @@ -283,28 +288,39 @@ internal class SettingsPanelController( bottomMargin = toPx(ImeSpacingTokens.MD_DP) }, ) + // Each pair is its own switch; they only count while 启用模糊音 is on. + val ruleRows = ArrayList() + fun syncRuleRows(masterOn: Boolean) { + ruleRows.forEach { row -> + row.isEnabled = masterOn + row.alpha = if (masterOn) 1f else ImeSurfacePolicy.DISABLED_ALPHA + (row as? ViewGroup)?.let { group -> + for (index in 0 until group.childCount) group.getChildAt(index).isEnabled = masterOn + } + } + } content.addCard( toggleRow( "启用模糊音", - if (standalone) "z/zh · c/ch · s/sh · l/n" else null, + if (standalone) "下面每一组都可以单独开关" else null, if (standalone) R.drawable.ic_pref_fuzzy else 0, + onChanged = ::syncRuleRows, ), ) - content.addSection("当前规则") - content.addView( - TextView(context).apply { - text = "z / zh · c / ch · s / sh · l / n · en / eng · in / ing" - textSize = ImeTypographyTokens.BODY_SP - setPadding(toPx(16), toPx(14), toPx(16), toPx(14)) - tag = "fuzzy-rules" - }, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT, - ), + content.addSection("声母") + content.addCard( + *FuzzyRule.entries.filter { it.initial } + .map { rule -> toggleRow(rule.label, FUZZY_EXAMPLES[rule], 0).also(ruleRows::add) } + .toTypedArray(), + ) + content.addSection("韵母") + content.addCard( + *FuzzyRule.entries.filterNot { it.initial } + .map { rule -> toggleRow(rule.label, FUZZY_EXAMPLES[rule], 0).also(ruleRows::add) } + .toTypedArray(), ) content.addView( - noteText("规则由输入法自动参与候选计算,暂不单独修改每一组映射。"), + noteText("每一组默认关闭,按自己容易混的音打开。开得越多,候选越杂。"), LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT, @@ -314,6 +330,7 @@ internal class SettingsPanelController( topMargin = toPx(ImeSpacingTokens.SM_DP) }, ) + syncRuleRows(currentFuzzy()) scroll.addView( content, ViewGroup.LayoutParams( @@ -451,7 +468,12 @@ internal class SettingsPanelController( tag = "setting-label" } - private fun toggleRow(label: String, sub: String?, iconRes: Int): LinearLayout { + private fun toggleRow( + label: String, + sub: String?, + iconRes: Int, + onChanged: (Boolean) -> Unit = {}, + ): LinearLayout { val row = rowShell(iconRes) fun updateRowAccessibility(enabled: Boolean) { row.contentDescription = listOfNotNull(label, sub, if (enabled) "已开启" else "已关闭").joinToString(",") @@ -459,7 +481,10 @@ internal class SettingsPanelController( row.stateDescription = if (enabled) "已开启" else "已关闭" } } - val toggleView = toggle(label, ::updateRowAccessibility).apply { + val toggleView = toggle(label) { enabled -> + updateRowAccessibility(enabled) + onChanged(enabled) + }.apply { isFocusable = false importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS } @@ -474,7 +499,7 @@ internal class SettingsPanelController( info.isChecked = toggleState(label) } } - setOnClickListener { toggleView.performClick() } + setOnClickListener { if (isEnabled) toggleView.performClick() } addView(labelBlock(label, sub), LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) addView(toggleView, wrapParams().apply { marginStart = toPx(ImeSpacingTokens.MD_DP) }) } @@ -801,7 +826,7 @@ internal class SettingsPanelController( setOnClickListener { onFeedback() val next = !toggleState(seed) - onToggleChanged(seed, next) + dispatchToggle(seed, next) isSelected = next describe(next) applyTheme() @@ -861,7 +886,7 @@ internal class SettingsPanelController( setOnClickListener { onFeedback() val next = !toggleState(seed) - onToggleChanged(seed, next) + dispatchToggle(seed, next) updateAccessibilityState(next) onChanged(next) @@ -879,15 +904,29 @@ internal class SettingsPanelController( } } - private fun toggleState(seed: String): Boolean = when (seed) { - "按键音效" -> currentSound() - "触感震动" -> currentHaptic() - "模糊音纠错", "启用模糊音" -> currentFuzzy() - "按键气泡" -> currentPopup() - "上滑输入数字" -> currentSwipeUpDigits() - else -> currentExtraToggle(seed) + /** A 模糊音 pair is saved here; every other switch goes to the host. */ + private fun dispatchToggle(seed: String, enabled: Boolean) { + val rule = FuzzyRule.fromLabel(seed) + if (rule == null) { + onToggleChanged(seed, enabled) + return + } + val rules = ImeSettingsRepository.loadFuzzyRules(context) + ImeSettingsRepository.saveFuzzyRules(context, if (enabled) rules + rule else rules - rule) + onFuzzyRulesChanged() } + private fun toggleState(seed: String): Boolean = FuzzyRule.fromLabel(seed) + ?.let { it in ImeSettingsRepository.loadFuzzyRules(context) } + ?: when (seed) { + "按键音效" -> currentSound() + "触感震动" -> currentHaptic() + "模糊音纠错", "启用模糊音" -> currentFuzzy() + "按键气泡" -> currentPopup() + "上滑输入数字" -> currentSwipeUpDigits() + else -> currentExtraToggle(seed) + } + private fun noteText(value: String): TextView = TextView(context).apply { text = value textSize = ImeTypographyTokens.DETAIL_SP @@ -919,4 +958,21 @@ internal class SettingsPanelController( LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT, ) + + private companion object { + /** A character pair each 模糊音 switch makes interchangeable. */ + val FUZZY_EXAMPLES: Map = mapOf( + FuzzyRule.Z_ZH to "资 zi · 知 zhi", + FuzzyRule.C_CH to "此 ci · 吃 chi", + FuzzyRule.S_SH to "四 si · 是 shi", + FuzzyRule.N_L to "你 ni · 里 li", + FuzzyRule.F_H to "飞 fei · 黑 hei", + FuzzyRule.R_L to "热 re · 乐 le", + FuzzyRule.AN_ANG to "山 shan · 上 shang", + FuzzyRule.EN_ENG to "分 fen · 风 feng", + FuzzyRule.IN_ING to "心 xin · 星 xing", + FuzzyRule.IAN_IANG to "先 xian · 香 xiang", + FuzzyRule.UAN_UANG to "关 guan · 光 guang", + ) + } } 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 593908c4..83655a8f 100644 --- a/app/src/main/java/llc/slacker/openime/rime/RimeEngine.kt +++ b/app/src/main/java/llc/slacker/openime/rime/RimeEngine.kt @@ -8,6 +8,7 @@ import android.os.SystemClock import android.util.Log import llc.slacker.openime.RimeNative import llc.slacker.openime.core.CrashGuard +import llc.slacker.openime.core.FuzzyRule import llc.slacker.openime.core.RimeStartupRecovery import llc.slacker.openime.data.ImeSettingsRepository import llc.slacker.openime.data.PersonalizationPolicy @@ -164,6 +165,7 @@ class RimeEngine( val copyStartMs = SystemClock.elapsedRealtime() val copied = copyAssetsIfNeeded(sharedDir, userDir) val copyMs = SystemClock.elapsedRealtime() - copyStartMs + val rulesChanged = syncFuzzyRulesFile(userDir) if (!startupGate.isCurrent(generation)) return@execute // A previous start that died inside native code leaves its marker @@ -205,7 +207,7 @@ class RimeEngine( // this call, nativeShutdown will either run after it or this // stale worker will perform the same idempotent cleanup below. val nativeStartMs = SystemClock.elapsedRealtime() - RimeNative.nativeStartup(sharedDir.absolutePath, userDir.absolutePath) + RimeNative.nativeStartup(sharedDir.absolutePath, userDir.absolutePath, fullCheck = rulesChanged) val nativeMs = SystemClock.elapsedRealtime() - nativeStartMs nativeStartupReturned = true if (!startupGate.isCurrent(generation)) { @@ -562,6 +564,50 @@ class RimeEngine( cachedFuzzyPinyin = null } + /** + * Re-deploy librime when the 模糊音 switches changed: the rules live in + * the user dir's openime_fuzzy.yaml, and librime reads them (rebuilding + * only the small fuzzy prism) when it starts. Until it is ready again the + * Kotlin fallback answers, as during any startup. + */ + fun applyFuzzyRules() { + startupExecutor.execute { + val file = File(userDir(), FUZZY_RULES_FILE) + val current = if (file.exists()) runCatching { file.readText() }.getOrNull() else null + if (current == fuzzyRulesFileText() || !isReady) return@execute + synchronized(lock) { + isReady = false + activeSchemaId = null + runCatching { RimeNative.nativeShutdown() } + } + Log.i(TAG, "librime redeploy for new fuzzy rules") + start() + } + } + + private fun fuzzyRulesFileText(): String = + FuzzyRule.rimeYaml(ImeSettingsRepository.loadFuzzyRules(context)) + + /** + * Writes the user's openime_fuzzy.yaml (it shadows the shipped defaults; + * the defaults are written too, so a change always replaces the file). + * Returns whether it changed: librime's quick startup check works on + * whole-second file times and can miss an edit made right after a deploy, + * so a changed file asks native startup for a full check. + */ + private fun syncFuzzyRulesFile(userDir: File): Boolean { + val file = File(userDir, FUZZY_RULES_FILE) + val wanted = fuzzyRulesFileText() + if (file.exists() && runCatching { file.readText() }.getOrNull() == wanted) return false + file.writeText(wanted) + return true + } + + private fun userDir(): File { + val dataDirName = assetRoot.replace('/', '_') + return File(context.filesDir, if (assetRoot == "rime-data") "rime-user" else "$dataDirName-user") + } + private fun fuzzyPinyinEnabled(): Boolean { cachedFuzzyPinyin?.let { return it } val value = ImeSettingsRepository.loadFuzzy(context) @@ -690,6 +736,7 @@ class RimeEngine( private companion object { const val TAG = "RimeEngine" const val HEALTH_PROBE_INPUT = "ni" + const val FUZZY_RULES_FILE = "openime_fuzzy.yaml" /** * Upper bound for waiting on an in-flight startup before tearing down. * Short enough to stay off the IME shutdown path, long enough for the diff --git a/app/src/test/java/llc/slacker/openime/CandidateEngineTest.kt b/app/src/test/java/llc/slacker/openime/CandidateEngineTest.kt index 55c91974..c9680691 100644 --- a/app/src/test/java/llc/slacker/openime/CandidateEngineTest.kt +++ b/app/src/test/java/llc/slacker/openime/CandidateEngineTest.kt @@ -7,6 +7,26 @@ import org.junit.Assert.assertTrue import org.junit.Test class CandidateEngineTest { + // These cases were written for the pairs that used to be always on. + private val previousFuzzyRules = llc.slacker.openime.candidate.FuzzyPinyin.rules + + @org.junit.Before + fun enableFormerFuzzyPairs() { + llc.slacker.openime.candidate.FuzzyPinyin.rules = setOf( + llc.slacker.openime.core.FuzzyRule.Z_ZH, + llc.slacker.openime.core.FuzzyRule.C_CH, + llc.slacker.openime.core.FuzzyRule.S_SH, + llc.slacker.openime.core.FuzzyRule.N_L, + llc.slacker.openime.core.FuzzyRule.EN_ENG, + llc.slacker.openime.core.FuzzyRule.IN_ING, + ) + } + + @org.junit.After + fun restoreFuzzyPairs() { + llc.slacker.openime.candidate.FuzzyPinyin.rules = previousFuzzyRules + } + private val engine = CandidateEngine() diff --git a/app/src/test/java/llc/slacker/openime/FuzzyRuleTest.kt b/app/src/test/java/llc/slacker/openime/FuzzyRuleTest.kt new file mode 100644 index 00000000..4749bdbd --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/FuzzyRuleTest.kt @@ -0,0 +1,99 @@ +package llc.slacker.openime + +import llc.slacker.openime.candidate.pinyinFuzzyVariants +import llc.slacker.openime.core.FuzzyRule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class FuzzyRuleTest { + private fun asset(relative: String): File = sequenceOf( + File("src/main/assets/$relative"), + File("app/src/main/assets/$relative"), + ).first { it.isFile } + + /** Every syllable openIME knows, from the generated Pinyin table. */ + private val syllables: List by lazy { + asset("pinyin_chars.tsv").readLines() + .map { it.substringBefore('\t') } + .filter { it.isNotEmpty() && it.all { ch -> ch in 'a'..'z' } } + .distinct() + } + + /** Apply one librime `derive/pattern/replacement/` the way librime does: as an extra spelling. */ + private fun rimeDerive(rule: String, syllable: String): String? { + val (pattern, replacement) = rule.removePrefix("derive/").removeSuffix("/").split('/') + val regex = Regex(pattern) + if (!regex.containsMatchIn(syllable)) return null + return regex.replaceFirst(syllable, replacement).takeIf { it != syllable } + } + + @Test + fun theShippedRulesFileIsTheDefaultSwitches() { + assertEquals(FuzzyRule.rimeYaml(FuzzyRule.DEFAULTS), asset("rime-data/openime_fuzzy.yaml").readText()) + } + + @Test + fun elevenPairsAllStartOff() { + assertEquals(11, FuzzyRule.entries.size) + assertEquals(6, FuzzyRule.entries.count { it.initial }) + assertTrue(FuzzyRule.DEFAULTS.isEmpty()) + } + + @Test + fun kotlinAndLibrimeSpellEverySyllableTheSameWayForEveryPair() { + FuzzyRule.entries.forEach { rule -> + syllables.forEach { syllable -> + val rime = rule.rime.mapNotNull { rimeDerive(it, syllable) }.toSet() + val kotlin = setOfNotNull(rule.variant(syllable)) + assertEquals("${rule.label} on $syllable", rime, kotlin) + } + } + } + + @Test + fun eachPairWorksBothWays() { + assertEquals("zi", FuzzyRule.Z_ZH.variant("zhi")) + assertEquals("zhi", FuzzyRule.Z_ZH.variant("zi")) + assertEquals("li", FuzzyRule.N_L.variant("ni")) + assertEquals("ni", FuzzyRule.N_L.variant("li")) + assertEquals("shang", FuzzyRule.AN_ANG.variant("shan")) + assertEquals("shan", FuzzyRule.AN_ANG.variant("shang")) + // an = ang leaves ian/uan to their own switches. + assertEquals(null, FuzzyRule.AN_ANG.variant("xian")) + assertEquals(null, FuzzyRule.AN_ANG.variant("guan")) + assertEquals("xiang", FuzzyRule.IAN_IANG.variant("xian")) + assertEquals("guang", FuzzyRule.UAN_UANG.variant("guan")) + assertEquals("hei", FuzzyRule.F_H.variant("fei")) + assertEquals("le", FuzzyRule.R_L.variant("re")) + // zh is not z + h: n = l does not touch it, z = zh does. + assertEquals(null, FuzzyRule.N_L.variant("zhi")) + assertEquals(null, FuzzyRule.F_H.variant("zhi")) + } + + @Test + fun onlyTheSwitchedOnPairsWidenTheLookup() { + val onlyZ = pinyinFuzzyVariants("zi", setOf(FuzzyRule.Z_ZH)) + assertTrue("zhi" in onlyZ) + assertFalse("ci" in onlyZ) + + val onlyAn = pinyinFuzzyVariants("shan", setOf(FuzzyRule.AN_ANG)) + assertTrue("shang" in onlyAn) + assertFalse("san" in onlyAn) + + // Both on: they combine, as in librime. + assertTrue("sang" in pinyinFuzzyVariants("shan", setOf(FuzzyRule.S_SH, FuzzyRule.AN_ANG))) + + assertTrue(pinyinFuzzyVariants("zi", emptySet()).isEmpty()) + } + + @Test + fun aRulesFileWithNothingOnIsStillValid() { + assertEquals( + "# Generated by openIME from the 模糊音 switches; do not edit.\nrules:\n __append: []\n", + FuzzyRule.rimeYaml(emptySet()), + ) + } +} diff --git a/app/src/test/java/llc/slacker/openime/PinyinFuzzyFallbackTest.kt b/app/src/test/java/llc/slacker/openime/PinyinFuzzyFallbackTest.kt index 3eb9570d..4945ac60 100644 --- a/app/src/test/java/llc/slacker/openime/PinyinFuzzyFallbackTest.kt +++ b/app/src/test/java/llc/slacker/openime/PinyinFuzzyFallbackTest.kt @@ -6,6 +6,26 @@ import org.junit.Assert.assertTrue import org.junit.Test class PinyinFuzzyFallbackTest { + // These cases were written for the pairs that used to be always on. + private val previousFuzzyRules = llc.slacker.openime.candidate.FuzzyPinyin.rules + + @org.junit.Before + fun enableFormerFuzzyPairs() { + llc.slacker.openime.candidate.FuzzyPinyin.rules = setOf( + llc.slacker.openime.core.FuzzyRule.Z_ZH, + llc.slacker.openime.core.FuzzyRule.C_CH, + llc.slacker.openime.core.FuzzyRule.S_SH, + llc.slacker.openime.core.FuzzyRule.N_L, + llc.slacker.openime.core.FuzzyRule.EN_ENG, + llc.slacker.openime.core.FuzzyRule.IN_ING, + ) + } + + @org.junit.After + fun restoreFuzzyPairs() { + llc.slacker.openime.candidate.FuzzyPinyin.rules = previousFuzzyRules + } + @Test fun fuzzyInitialAppliesInsideLaterSyllable() { diff --git a/app/src/test/java/llc/slacker/openime/PinyinFuzzyRulesTest.kt b/app/src/test/java/llc/slacker/openime/PinyinFuzzyRulesTest.kt index a04ad25f..feb85d5d 100644 --- a/app/src/test/java/llc/slacker/openime/PinyinFuzzyRulesTest.kt +++ b/app/src/test/java/llc/slacker/openime/PinyinFuzzyRulesTest.kt @@ -7,6 +7,26 @@ import org.junit.Assert.assertTrue import org.junit.Test class PinyinFuzzyRulesTest { + // These cases were written for the pairs that used to be always on. + private val previousFuzzyRules = llc.slacker.openime.candidate.FuzzyPinyin.rules + + @org.junit.Before + fun enableFormerFuzzyPairs() { + llc.slacker.openime.candidate.FuzzyPinyin.rules = setOf( + llc.slacker.openime.core.FuzzyRule.Z_ZH, + llc.slacker.openime.core.FuzzyRule.C_CH, + llc.slacker.openime.core.FuzzyRule.S_SH, + llc.slacker.openime.core.FuzzyRule.N_L, + llc.slacker.openime.core.FuzzyRule.EN_ENG, + llc.slacker.openime.core.FuzzyRule.IN_ING, + ) + } + + @org.junit.After + fun restoreFuzzyPairs() { + llc.slacker.openime.candidate.FuzzyPinyin.rules = previousFuzzyRules + } + @Test fun coversZhZInBothDirections() { diff --git a/app/src/test/java/llc/slacker/openime/RimeFuzzySchemaTest.kt b/app/src/test/java/llc/slacker/openime/RimeFuzzySchemaTest.kt index a57c9259..ac3ce0fb 100644 --- a/app/src/test/java/llc/slacker/openime/RimeFuzzySchemaTest.kt +++ b/app/src/test/java/llc/slacker/openime/RimeFuzzySchemaTest.kt @@ -68,9 +68,7 @@ class RimeFuzzySchemaTest { assertTrue(schema.contains("schema_id: luna_pinyin_simp_fuzzy")) assertTrue(schema.contains("prism: luna_pinyin_simp_fuzzy")) listOf( - "pinyin:/zh_z_bufen", - "pinyin:/n_l_bufen", - "pinyin:/en_eng_bufen", + "openime_fuzzy:/rules", "pinyin:/abbreviation", "pinyin:/spelling_correction", "pinyin:/key_correction", @@ -82,8 +80,8 @@ class RimeFuzzySchemaTest { @Test fun normalSchemaDoesNotEnableFuzzyRules() { val schema = asset("rime-data/luna_pinyin_simp.schema.yaml").readText() - listOf("zh_z_bufen", "n_l_bufen", "en_eng_bufen").forEach { rule -> - assertTrue("normal schema unexpectedly enables $rule", !schema.contains("pinyin:/$rule")) + listOf("pinyin:/zh_z_bufen", "pinyin:/n_l_bufen", "pinyin:/en_eng_bufen", "openime_fuzzy:/rules").forEach { rule -> + assertTrue("normal schema unexpectedly enables $rule", !schema.contains(rule)) } } } diff --git a/app/src/test/java/llc/slacker/openime/UserDataArchiveTest.kt b/app/src/test/java/llc/slacker/openime/UserDataArchiveTest.kt index e8bf48b4..bc778a8c 100644 --- a/app/src/test/java/llc/slacker/openime/UserDataArchiveTest.kt +++ b/app/src/test/java/llc/slacker/openime/UserDataArchiveTest.kt @@ -34,6 +34,7 @@ class UserDataArchiveTest { voiceStripFillers = false, voicePunctuationAsSpace = true, railSymbols = listOf("~", ",", "@"), + fuzzyRules = listOf("z_zh", "f_h"), ) @Test From 18b86fc1f9b0290b15d74e7e20818cfd793ac415 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:09:44 +0800 Subject: [PATCH 08/10] Fix swipe-up clear repeating text; English words commit with a space Clear-all's surrounding-text fallback ran inside a batch edit. Editors such as Minis (Compose) answer reads in a batch from the state before it, so the loop never saw its deletions, hit its round limit and 'restored' every round's text, repeating the document. The fallback now runs outside the batch, stops when a read shows no change, and only restores text it saw disappear. Two fake-editor tests reproduce both behaviours. English 26-key commits now end in a space, as on Gboard and Sogou. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + .../slacker/openime/LocalVoiceImeService.kt | 5 +- .../openime/editor/InputConnectionGateway.kt | 50 +++++++++++++------ .../openime/InputConnectionGatewayTest.kt | 41 +++++++++++++-- 4 files changed, 77 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57f0f2c7..60e0e912 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 升级时词库没有变化就不再重新拷贝,直接沿用手机上已有的词库,升级后第一次打开键盘和平时一样快。 - 新增笔画键盘:切换键盘里选“笔画”。五个笔画键“一 丨 丿 丶 乛”和“通配”(代替一笔,记不清时用),按笔顺点,每点一笔就出候选。笔顺完全吻合的字排在前面,其余按常用程度排;繁体和生僻字也能打出来。选过的字下次排在前面,选字后照常给联想。 - 模糊音改为“总开关 + 11 个子开关”,与主流输入法一致:声母 z=zh、c=ch、s=sh、n=l、f=h、r=l,韵母 an=ang、en=eng、in=ing、ian=iang、uan=uang,每组单独开关,默认全部关闭。拼音 26 键、九键和词库未就绪时的备用候选都按同一套规则;改动后输入法在一两秒内按新规则重新准备词库。子开关随用户数据导出、导入。 +- 修复上滑清空在部分应用里(如 Minis)不但没清空,反而把文字重复了很多遍:这类输入框在批量编辑中读到的是旧文字,清空循环看不到自己的删除,最后把“删掉的”文字多次写回。现在逐轮确认删除结果,读不到变化就停下,绝不写回未确认删除的文字。 +- 英文键盘按空格或点候选上屏单词时,后面带一个空格,连续输入的单词不再粘在一起。 - 九键和笔画键盘左下角加“符号”键,与数字键盘一致。 - “重输”键始终与删除键、回车键同一样式,不再在没有输入时显示为灰色。 - 九键和笔画键盘左侧的符号栏默认是 10 个常用标点(,。?!、:;“”……),最下面的“+”打开“常用符号栏”页:输入或一键加入符号,上移、下移排序,删除,恢复默认。符号栏随用户数据导出、导入。之前加入符号栏的自定义符号仍在符号面板里,可以从“+”重新加回符号栏。 diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index 49667cde..236e892b 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -1742,7 +1742,10 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } else if (!rime.isReady && allowsPersonalizedLearning()) { UserPhraseRepository.record(composition, committed) } - gateway.commitText(committed) + // An English word is followed by a space, as space and a suggestion + // tap do on Gboard and Sogou: otherwise "hello world" comes out as + // "helloworld". + gateway.commitText(if (state.keyboardMode == KeyboardMode.ENGLISH_26) "$committed " else committed) gateway.finishComposing() voiceCorrectionTracker.finalizeIfNeeded() rime.clear() diff --git a/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt b/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt index 78ecac7d..ebda286e 100644 --- a/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt +++ b/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt @@ -290,8 +290,16 @@ class InputConnectionGateway( fun clearAllText(): Boolean { if (isPassword()) return false val ic = connection() ?: return false + // The surrounding-text fallback runs outside the batch: some editors + // (Compose, web) answer reads inside a batch from the state before it, + // and a loop that cannot see its own deletions never ends well. + return clearThroughSelection(ic) ?: clearThroughSurroundingText(ic) + } + + /** The select-all / ExtractedText paths, in one batch; null when neither applies. */ + private fun clearThroughSelection(ic: InputConnection): Boolean? { ic.beginBatchEdit() - return try { + try { val originalSelection = selectionBeforeDestructiveSelectAll(ic) if (runCatching { ic.performContextMenuAction(android.R.id.selectAll) }.getOrDefault(false)) { @@ -327,26 +335,22 @@ class InputConnectionGateway( } restoreSelectionAfterFailedClear(ic, originalSelection) // The editor answered select-all but exposed nothing usable. - return clearThroughSurroundingText(ic) + return null } // No select-all and no complete ExtractedText: custom, Compose and // web editors typically expose only before/after-cursor text. val window = extractedWindow(ic) - if (window == null || !window.isCompleteDocument) { - return clearThroughSurroundingText(ic) - } + if (window == null || !window.isCompleteDocument) return null if (window.text.isEmpty()) return true - if (!runCatching { ic.setSelection(0, window.text.length) }.getOrDefault(false)) { - return clearThroughSurroundingText(ic) - } + if (!runCatching { ic.setSelection(0, window.text.length) }.getOrDefault(false)) return null val cleared = runCatching { ic.commitText("", 1) }.getOrDefault(false) if (!cleared) { restoreSelectionAfterFailedClear(ic, originalSelection) } else { ic.finishComposingText() } - cleared + return cleared } finally { ic.endBatchEdit() } @@ -377,6 +381,8 @@ class InputConnectionGateway( if (selected.isNotEmpty() && !runCatching { ic.commitText("", 1) }.getOrDefault(false)) { return false } + // Only text the editor shows gone is "taken": a restore puts back + // exactly that, never a round the editor did not apply. val beforeParts = ArrayList() // nearest to the cursor first val afterParts = ArrayList() var before: String = firstBefore @@ -386,24 +392,36 @@ class InputConnectionGateway( val deleted = rounds < MAX_SURROUNDING_ROUNDS && runCatching { ic.deleteSurroundingText(before.length, after.length) }.getOrDefault(false) if (!deleted) { - // Put back what was already taken so a failed clear loses nothing. - val restored = beforeParts.asReversed().joinToString("") + selected + afterParts.joinToString("") - if (restored.isNotEmpty()) runCatching { ic.commitText(restored, 1) } + restoreTaken(ic, beforeParts, selected, afterParts) return false } rounds++ - beforeParts.add(before) - afterParts.add(after) - before = runCatching { ic.getTextBeforeCursor(SURROUNDING_CHUNK, 0)?.toString().orEmpty() } + val nextBefore = runCatching { ic.getTextBeforeCursor(SURROUNDING_CHUNK, 0)?.toString().orEmpty() } .getOrDefault("") - after = runCatching { ic.getTextAfterCursor(SURROUNDING_CHUNK, 0)?.toString().orEmpty() } + val nextAfter = runCatching { ic.getTextAfterCursor(SURROUNDING_CHUNK, 0)?.toString().orEmpty() } .getOrDefault("") + if (nextBefore == before && nextAfter == after) { + // The editor reports no change: either it ignored the deletion + // or it answers with stale text. Re-inserting anything now could + // duplicate the document, so stop with what is confirmed. + return false + } + beforeParts.add(before) + afterParts.add(after) + before = nextBefore + after = nextAfter } ic.finishComposingText() return true } + /** Put back what was confirmed deleted, so a failed clear loses nothing. */ + private fun restoreTaken(ic: InputConnection, beforeParts: List, selected: String, afterParts: List) { + val restored = beforeParts.asReversed().joinToString("") + selected + afterParts.joinToString("") + if (restored.isNotEmpty()) runCatching { ic.commitText(restored, 1) } + } + fun performEditorAction(action: Int) { connection()?.performEditorAction(action) } diff --git a/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt b/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt index aa86c32d..1307fd86 100644 --- a/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt +++ b/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt @@ -508,15 +508,23 @@ class InputConnectionGatewayTest { var selEnd: Int, private val answerCap: Int = Int.MAX_VALUE, private val refuseDelete: Boolean = false, + /** Like some Compose/web editors: reads inside a batch see the text from before it. */ + private val staleWhileBatched: Boolean = false, + /** Reads never reflect edits at all. */ + private val alwaysStale: Boolean = false, ) : InputConnection { val text = StringBuilder(initial) private val lo get() = minOf(selStart, selEnd) private val hi get() = maxOf(selStart, selEnd) + private var batchDepth = 0 + private var snapshot: Triple? = if (alwaysStale) Triple(initial, selStart, selEnd) else null + + private fun view(): Triple = snapshot ?: Triple(text.toString(), lo, hi) override fun getTextBeforeCursor(length: Int, flags: Int): CharSequence = - text.substring(0, lo).takeLast(minOf(length, answerCap)) + view().let { (value, start, _) -> value.substring(0, start).takeLast(minOf(length, answerCap)) } override fun getTextAfterCursor(length: Int, flags: Int): CharSequence = - text.substring(hi).take(minOf(length, answerCap)) + view().let { (value, _, end) -> value.substring(end).take(minOf(length, answerCap)) } override fun getSelectedText(flags: Int): CharSequence? = if (lo == hi) null else text.substring(lo, hi) override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean { @@ -546,8 +554,14 @@ class InputConnectionGatewayTest { override fun performContextMenuAction(id: Int): Boolean = false override fun getExtractedText(request: ExtractedTextRequest?, flags: Int): ExtractedText? = null - override fun beginBatchEdit(): Boolean = true - override fun endBatchEdit(): Boolean = true + override fun beginBatchEdit(): Boolean { + if (staleWhileBatched && batchDepth++ == 0) snapshot = Triple(text.toString(), lo, hi) + return true + } + override fun endBatchEdit(): Boolean { + if (staleWhileBatched && --batchDepth == 0 && !alwaysStale) snapshot = null + return true + } override fun clearMetaKeyStates(states: Int): Boolean = false override fun closeConnection() = Unit override fun commitCompletion(text: CompletionInfo?): Boolean = false @@ -593,6 +607,25 @@ class InputConnectionGatewayTest { assertEquals("abcdef", editor.text.toString()) } + @Test + fun clearAllEmptiesAnEditorWhoseReadsInsideABatchAreStale() { + // Minis for Android: swipe-up clear used to repeat the text many times. + val editor = SurroundingOnlyEditor("helloworldtest", selStart = 14, selEnd = 14, staleWhileBatched = true) + val gateway = InputConnectionGateway(null, { editor }) + + assertTrue(gateway.clearAllText()) + assertEquals("", editor.text.toString()) + } + + @Test + fun clearAllNeverAddsTextWhenAnEditorNeverReportsItsEdits() { + val editor = SurroundingOnlyEditor("helloworldtest", selStart = 14, selEnd = 14, alwaysStale = true) + val gateway = InputConnectionGateway(null, { editor }) + + gateway.clearAllText() + assertTrue("text grew to '${editor.text}'", editor.text.length <= "helloworldtest".length) + } + @Test fun clearAllOnAnAlreadyEmptySurroundingTextEditorSucceeds() { val editor = SurroundingOnlyEditor("", selStart = 0, selEnd = 0) From 68558b389ef42d70b655d214bbe6070f5870565b Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:25:53 +0800 Subject: [PATCH 09/10] Keep the Chinese pre-edit on the keyboard; add a typing stress test Pinyin, nine-key digits and strokes were also set as the app's composing text, so QQ and WeChat showed '669' or ni'hao in their field and offered stickers for it. Chinese layouts now keep the pre-edit on the keyboard, as Sogou and iFlytek do; English still composes in place. The gateway tracks whether it owns a composing span (a cancel without one could delete a selection) and marks selection updates caused by its own commits, which would otherwise drop the rest of a partially committed pre-edit. scripts/typing_stress_e2e.py taps real key positions to type 50+ characters on every keyboard and a mixed session, and checks the text, that the pre-edit never reaches the field, and that nothing crashed. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../slacker/openime/LocalVoiceImeService.kt | 9 +- .../openime/editor/InputConnectionGateway.kt | 44 ++- scripts/typing_stress_e2e.py | 330 ++++++++++++++++++ 4 files changed, 378 insertions(+), 6 deletions(-) create mode 100644 scripts/typing_stress_e2e.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 60e0e912..5b314ba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 新增笔画键盘:切换键盘里选“笔画”。五个笔画键“一 丨 丿 丶 乛”和“通配”(代替一笔,记不清时用),按笔顺点,每点一笔就出候选。笔顺完全吻合的字排在前面,其余按常用程度排;繁体和生僻字也能打出来。选过的字下次排在前面,选字后照常给联想。 - 模糊音改为“总开关 + 11 个子开关”,与主流输入法一致:声母 z=zh、c=ch、s=sh、n=l、f=h、r=l,韵母 an=ang、en=eng、in=ing、ian=iang、uan=uang,每组单独开关,默认全部关闭。拼音 26 键、九键和词库未就绪时的备用候选都按同一套规则;改动后输入法在一两秒内按新规则重新准备词库。子开关随用户数据导出、导入。 - 修复上滑清空在部分应用里(如 Minis)不但没清空,反而把文字重复了很多遍:这类输入框在批量编辑中读到的是旧文字,清空循环看不到自己的删除,最后把“删掉的”文字多次写回。现在逐轮确认删除结果,读不到变化就停下,绝不写回未确认删除的文字。 +- 中文键盘(拼音 26 键、九键、笔画)正在输入的拼音、数字和笔画只显示在键盘上方,不再写进对方应用的输入框;之前在 QQ、微信里会看到“669”“ni'hao”,还会触发应用自己的表情推荐。英文键盘照旧在输入框里显示正在输入的单词。 - 英文键盘按空格或点候选上屏单词时,后面带一个空格,连续输入的单词不再粘在一起。 - 九键和笔画键盘左下角加“符号”键,与数字键盘一致。 - “重输”键始终与删除键、回车键同一样式,不再在没有输入时显示为灰色。 diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index 236e892b..45799943 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -351,6 +351,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) { super.onStartInput(attribute, restarting) + if (::gateway.isInitialized) gateway.onEditorStarted() reloadPersistedSettings() voiceCorrectionTracker.clear() val previousRimeInputs = candidateQueries.activeInputs @@ -555,6 +556,8 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can candidatesStart, candidatesEnd, ) + // A cursor move caused by our own commit never drops the pre-edit. + if (gateway.consumeOwnEdit()) return if (!shouldClearCompositionForSelectionUpdate( hasComposition = lastComposition.isNotEmpty(), oldSelStart = oldSelStart, @@ -1446,7 +1449,11 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can // editor text in many apps. Deleting the final Pinyin character // must remove that span instead of leaving a raw letter behind. gateway.cancelComposing() - } else { + } else if (!state.keyboardMode.isChineseLayout) { + // Chinese pre-edit (pinyin, nine-key digits, strokes) stays on the + // keyboard, as on Sogou and iFlytek: written into the app's field it + // showed "669" or "ni'hao" there and set off the app's own + // suggestions. An English word is composed in place, as on Gboard. gateway.setComposingText(next) } } diff --git a/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt b/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt index ebda286e..5b522b5e 100644 --- a/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt +++ b/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt @@ -82,6 +82,40 @@ class InputConnectionGateway( // transcript in one call throws TransactionTooLargeException and takes the // keyboard down with it. Chunk it, never splitting a surrogate pair. chunksForCommit(text).forEach { chunk -> ic.commitText(chunk, 1) } + // commitText replaces an active composing span. + composingInEditor = false + ownEditPending = true + } + + /** + * Set by our own commit, cleared by the selection update it causes. A + * Chinese pre-edit has no span in the editor, so without this the update + * after a partial commit would look like the user moving the cursor and + * drop the rest of the pre-edit. + */ + @Volatile + private var ownEditPending = false + + /** Whether the selection update now arriving was caused by our own commit. */ + fun consumeOwnEdit(): Boolean { + val own = ownEditPending + ownEditPending = false + return own + } + + /** + * Whether the editor holds a composing span this IME put there. Chinese + * input keeps its pre-edit on the keyboard and never sets one, and + * setComposingText("") without a span can delete the user's selection in + * some editors, so a cancel only touches the editor when this is true. + */ + @Volatile + private var composingInEditor = false + + /** A new editor (or a restart) starts without any span of ours. */ + fun onEditorStarted() { + composingInEditor = false + ownEditPending = false } /** @@ -105,8 +139,10 @@ class InputConnectionGateway( val ic = connection() ?: return if (text.isEmpty()) { ic.finishComposingText() + composingInEditor = false } else { ic.setComposingText(text, 1) + composingInEditor = true } } @@ -118,17 +154,15 @@ class InputConnectionGateway( fun finishComposing() { connection()?.finishComposingText() + composingInEditor = false } /** Remove the active pre-edit text without committing it to the editor. */ fun cancelComposing() { val ic = connection() ?: return - if (isPassword()) { - ic.finishComposingText() - return - } - ic.setComposingText("", 1) + if (!isPassword() && composingInEditor) ic.setComposingText("", 1) ic.finishComposingText() + composingInEditor = false } fun clearComposition() { diff --git a/scripts/typing_stress_e2e.py b/scripts/typing_stress_e2e.py new file mode 100644 index 00000000..c5d0b03d --- /dev/null +++ b/scripts/typing_stress_e2e.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +"""Type more than fifty characters on every keyboard with real touches. + +For each keyboard (拼音 26 键, 拼音 9 键, 笔画, 英文 26 键, 数字) and one mixed +session, this taps the keys' real screen positions (from the keyboard's own +`bounds` report) into the debug ImeTestLabActivity's multi-line field, commits +words with the space bar, and then checks: + + * the field holds at least --min characters, exactly the expected text where + that is known (English, digits), CJK characters only for Chinese; + * the IME and the field's app kept their processes (no crash, no restart); + * no crash or ANR for openIME in logcat. + +Needs a debug build (the E2E receiver and the lab activity) on one device or +emulator; it selects openIME as the default input method. Screenshots of each +finished field go to --out. + + python3 scripts/typing_stress_e2e.py [--serial SERIAL] [--out DIR] [--min 50] [case ...] +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +import time +import xml.etree.ElementTree as ET +from pathlib import Path + +PKG = "llc.slacker.openime" +IME = f"{PKG}/.LocalVoiceImeService" +LAB = f"{PKG}/.ImeTestLabActivity" +RECEIVER = f"{PKG}/.E2ETestReceiver" +ACTION = f"{PKG}.TEST_COMMAND" +FIELD = "lab_multiline" +ROOT = Path(__file__).resolve().parent.parent +STROKE_TABLE = ROOT / "app" / "build" / "generated" / "assets" / "prebuildRimeData" / "stroke_table.tsv" + +PINYIN_WORDS = ( + "nihao women zhongguo jintian tianqi henhao shurufa xiexie pengyou gongzuo " + "xuexi shijian wenti dianhua shouji diannao kaixin mingtian zaijian huanying " + "shenghuo jiating yinyue dianying chifan shuijiao lvxing kafei pingguo xiangjiao" +).split() +ENGLISH_WORDS = "the quick brown fox jumps over the lazy dog and keeps on typing every word".split() +T9 = {c: d for d, letters in {"2": "abc", "3": "def", "4": "ghi", "5": "jkl", "6": "mno", + "7": "pqrs", "8": "tuv", "9": "wxyz"}.items() for c in letters} + + +class Device: + def __init__(self, serial: str | None, out: Path): + self.base = ["adb"] + (["-s", serial] if serial else []) + self.out = out + out.mkdir(parents=True, exist_ok=True) + self.bounds: list[tuple[str, str, float, float]] = [] + + def run(self, *args: str, binary: bool = False): + result = subprocess.run(self.base + list(args), capture_output=True) + return result.stdout if binary else result.stdout.decode("utf-8", "replace") + + def shell(self, *args: str) -> str: + return self.run("shell", *args) + + def command(self, cmd: str) -> str: + self.run("logcat", "-c") + self.shell("am", "broadcast", "-n", RECEIVER, "-a", ACTION, "--es", "cmd", cmd) + time.sleep(0.5) + return self.run("logcat", "-d", "-s", "OpenIme:I", "OpenImeE2E:I") + + def refresh_bounds(self) -> None: + log = self.command("bounds") + window = re.search(r"window=(\d+),(\d+),(\d+),(\d+)", log) + if not window: + raise RuntimeError("keyboard bounds unavailable (is the keyboard shown?)") + wx, wy, ww, wh = map(int, window.groups()) + self.bounds = [] + for line in log.splitlines(): + match = re.search(r"tag=([^|]*)\|desc=([^|]*)\|([-\d.E]+),([-\d.E]+),([-\d.E]+),([-\d.E]+)", line) + if match: + tag, desc = match.group(1), match.group(2) + left, top, width, height = map(float, match.groups()[2:]) + self.bounds.append((tag, desc, wx + (left + width / 2) * ww, wy + (top + height / 2) * wh)) + + def tap_tag(self, tag: str, index: int = 0) -> None: + hits = [b for b in self.bounds if b[0] == tag] + if len(hits) <= index: + raise RuntimeError(f"no key tagged {tag!r}") + _, _, x, y = hits[index] + self.shell("input", "tap", str(int(x)), str(int(y))) + time.sleep(0.09) + + def pid(self, package: str) -> str: + return self.shell("pidof", package).strip() + + def field_text(self) -> str: + for _ in range(3): + self.shell("uiautomator", "dump", "/sdcard/stress.xml") + xml = self.shell("cat", "/sdcard/stress.xml") + start = xml.find(" tuple[int, int, int, int] | None: + self.shell("uiautomator", "dump", "/sdcard/stress.xml") + xml = self.shell("cat", "/sdcard/stress.xml") + match = re.search(rf'resource-id="[^"]*/{FIELD}"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml) + return tuple(map(int, match.groups())) if match else None + + def shot(self, name: str) -> None: + (self.out / f"{name}.png").write_bytes(self.run("exec-out", "screencap", "-p", binary=True)) + + +def show_keyboard(dev: Device) -> None: + if dev.shell("settings", "get", "secure", "default_input_method").strip() != IME: + dev.shell("ime", "enable", IME) + dev.shell("ime", "set", IME) + time.sleep(1) + dev.shell("am", "start", "-n", LAB, "--es", "focus_id", FIELD, "-f", "0x10008000") + time.sleep(2.5) + for _ in range(6): + if "mInputShown=true" in dev.shell("dumpsys", "input_method"): + return + # After a reinstall the first focus may not bring the keyboard up: tap the field. + bounds = dev.field_bounds() + if bounds: + dev.shell("input", "tap", str((bounds[0] + bounds[2]) // 2), str((bounds[1] + bounds[3]) // 2)) + time.sleep(1.5) + raise RuntimeError("keyboard did not show") + + +def clear_field(dev: Device) -> None: + dev.command("clear-swipe") + time.sleep(0.4) + + +def set_mode(dev: Device, mode: str) -> None: + dev.command(f"mode:{mode}") + time.sleep(0.6) + dev.refresh_bounds() + + +def type_pinyin26(dev: Device, words: list[str]) -> None: + for word in words: + for letter in word: + dev.tap_tag(f"key:{letter}") + time.sleep(0.35) # let librime answer before committing + dev.tap_tag("key-space") + time.sleep(0.15) + + +def type_nine_key(dev: Device, words: list[str]) -> None: + for word in words: + for letter in word.replace("v", "u"): + dev.tap_tag(f"key-9:{T9[letter]}") + time.sleep(0.4) + dev.tap_tag("key-space") + time.sleep(0.15) + + +def stroke_codes(count: int) -> list[tuple[str, str]]: + if not STROKE_TABLE.is_file(): + raise RuntimeError(f"{STROKE_TABLE} missing; build the debug APK first") + seen: dict[str, str] = {} + for line in STROKE_TABLE.read_text(encoding="utf-8").splitlines(): + char, code = line.split("\t") + if char not in seen and 2 <= len(code) <= 9: + seen[char] = code + if len(seen) >= count: + break + return list(seen.items()) + + +def type_strokes(dev: Device, codes: list[tuple[str, str]]) -> None: + for _, code in codes: + for stroke in code: + dev.tap_tag(f"key-stroke:{stroke}") + time.sleep(0.25) + dev.tap_tag("key-space") + time.sleep(0.15) + + +def type_english(dev: Device, words: list[str]) -> None: + for word in words: + for letter in word: + dev.tap_tag(f"key:{letter}") + time.sleep(0.2) + dev.tap_tag("key-space") + time.sleep(0.1) + + +def type_digits(dev: Device, digits: str) -> None: + for digit in digits: + dev.tap_tag(f"key:{digit}") + + +def probe_preedit_stays_on_keyboard(dev: Device, keys: list[str]) -> str | None: + """Type a pre-edit without committing: the app's field must not change.""" + before = dev.field_text() + for tag in keys: + dev.tap_tag(tag) + time.sleep(0.5) + during = dev.field_text() + for _ in keys: + dev.tap_tag("key-backspace") + time.sleep(0.3) + if during != before: + return f"pre-edit reached the app's field: {before!r} → {during!r}" + return None + + +def is_cjk(text: str) -> bool: + return all("㐀" <= ch <= "鿿" or "\U00020000" <= ch <= "\U0003134f" for ch in text) + + +def run_case(dev: Device, name: str, minimum: int) -> tuple[bool, str]: + show_keyboard(dev) + clear_field(dev) + expected: str | None = None + chinese = False + probe = None + if name == "pinyin26": + set_mode(dev, "PINYIN_26") + probe = probe_preedit_stays_on_keyboard(dev, [f"key:{c}" for c in "nihao"]) + type_pinyin26(dev, PINYIN_WORDS) + chinese = True + elif name == "pinyin9": + set_mode(dev, "PINYIN_9") + probe = probe_preedit_stays_on_keyboard(dev, [f"key-9:{d}" for d in "669"]) + type_nine_key(dev, PINYIN_WORDS) + chinese = True + elif name == "stroke": + set_mode(dev, "STROKE") + probe = probe_preedit_stays_on_keyboard(dev, [f"key-stroke:{c}" for c in "phz"]) + type_strokes(dev, stroke_codes(minimum + 6)) + chinese = True + elif name == "english": + set_mode(dev, "ENGLISH_26") + words = ENGLISH_WORDS[:] + type_english(dev, words) + expected = " ".join(words) + " " + elif name == "digits": + set_mode(dev, "DIGITS") + digits = "1234567890" * 6 + type_digits(dev, digits) + expected = digits + elif name == "mixed": + set_mode(dev, "PINYIN_26") + type_pinyin26(dev, PINYIN_WORDS[:8]) + dev.tap_tag("key-backspace") + dev.tap_tag("key:mode") # 中/英 → English + time.sleep(0.6) + dev.refresh_bounds() + type_english(dev, ENGLISH_WORDS[:4]) + set_mode(dev, "PINYIN_9") + type_nine_key(dev, PINYIN_WORDS[8:16]) + set_mode(dev, "STROKE") + type_strokes(dev, stroke_codes(10)) + set_mode(dev, "DIGITS") + type_digits(dev, "2026100512") + set_mode(dev, "PINYIN_26") + type_pinyin26(dev, PINYIN_WORDS[16:24]) + else: + return False, f"unknown case {name}" + time.sleep(1) + text = dev.field_text() + dev.shot(name) + count = len(text) + if probe: + return False, probe + if count < minimum: + return False, f"{count} characters, need {minimum}: {text!r}" + if expected is not None and text != expected: + return False, f"text differs:\n got {text!r}\n expected {expected!r}" + if chinese and not is_cjk(text): + return False, f"non-Chinese characters in {text!r}" + return True, f"{count} characters: {text[:40]}…" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--serial") + parser.add_argument("--out", type=Path, default=ROOT / "build" / "typing-stress") + parser.add_argument("--min", type=int, default=50) + parser.add_argument("cases", nargs="*", default=["pinyin26", "pinyin9", "stroke", "english", "digits", "mixed"]) + args = parser.parse_args() + dev = Device(args.serial, args.out) + + show_keyboard(dev) + time.sleep(3) # librime ready + ime_pid = dev.pid(PKG) + dev.run("logcat", "-b", "crash", "-c") + failures = 0 + for name in args.cases: + try: + ok, detail = run_case(dev, name, args.min) + except RuntimeError as error: + ok, detail = False, str(error) + failures += not ok + print(f"{'PASS' if ok else 'FAIL'} {name}: {detail}", flush=True) + + crashes = dev.run("logcat", "-b", "crash", "-d") + anr = dev.run("logcat", "-d", "-s", "ActivityManager:E") + pid_now = dev.pid(PKG) + if PKG in crashes: + failures += 1 + print("FAIL crash log:\n" + crashes[-2000:]) + if f"ANR in {PKG}" in anr: + failures += 1 + print("FAIL ANR reported for openIME") + if pid_now != ime_pid: + failures += 1 + print(f"FAIL openIME process restarted ({ime_pid} → {pid_now})") + else: + print(f"PASS process stayed up (pid {pid_now})") + print(f"{'ALL PASSED' if failures == 0 else f'{failures} FAILED'}; screenshots in {args.out}") + return 0 if failures == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) From b8967fb34a5769f0ea87e8aa955e2889bee65335 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:43:54 +0800 Subject: [PATCH 10/10] Voice cancel clears the span it owns; isolate the Rime device tests cancelComposing takes owned=true from voice paths that know a partial of theirs is in the editor. The fuzzy test shuts down a librime session the in-process IME may hold and removes what it staged; the prebuilt test starts from a clean staging dir. Typing stress script: --package for a suffixed debug build. Co-Authored-By: Claude Opus 5.5 --- .../AuditInteractionInstrumentedTest.kt | 10 +++++++- .../openime/FuzzyRulesInstrumentedTest.kt | 7 ++++++ .../openime/RimeNineKeyInstrumentedTest.kt | 3 +++ .../slacker/openime/LocalVoiceImeService.kt | 11 +++++---- .../openime/editor/InputConnectionGateway.kt | 10 +++++--- scripts/typing_stress_e2e.py | 24 +++++++++++++------ 6 files changed, 49 insertions(+), 16 deletions(-) diff --git a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt index ea3f221a..70686c99 100644 --- a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt @@ -694,6 +694,11 @@ class AuditInteractionInstrumentedTest { .targetContext val custom = CustomSymbolRepository.upsert(context, 0L, "测试", "⌘") ?: error("Unable to create the custom symbol fixture") + // The nine-key rail is the user's own list (edited from its +), not the custom symbols. + llc.slacker.openime.data.RailSymbolRepository.save( + context, + llc.slacker.openime.data.RailSymbolRepository.DEFAULT + "※", + ) try { withKeyboard { harness, _, keyboard -> harness.awaitMain { @@ -704,7 +709,9 @@ class AuditInteractionInstrumentedTest { val rail = keyboard.findViewWithTag("nine-punct-stack") if (rail !is ScrollView) return@awaitMain null assertTrue("Nine-key symbols must advertise vertical scrolling", rail.contentDescription.toString().contains("上下滑动")) - assertNotNull("Custom symbols must appear in the nine-key rail", keyboard.findViewWithTag("punct:⌘")) + assertNotNull("The user's rail symbols must appear in the nine-key rail", keyboard.findViewWithTag("punct:※")) + assertNotNull("The nine-key rail ends in its + editor cell", keyboard.findViewWithTag("punct:add")) + assertEquals("Custom symbols stay in the symbol panel", null, keyboard.findViewWithTag("punct:⌘")) true } harness.awaitMain { @@ -721,6 +728,7 @@ class AuditInteractionInstrumentedTest { } } finally { CustomSymbolRepository.remove(context, custom.id) + llc.slacker.openime.data.RailSymbolRepository.reset(context) } } diff --git a/app/src/androidTest/java/llc/slacker/openime/FuzzyRulesInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/FuzzyRulesInstrumentedTest.kt index 32b9e4d5..9ab17e94 100644 --- a/app/src/androidTest/java/llc/slacker/openime/FuzzyRulesInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/FuzzyRulesInstrumentedTest.kt @@ -42,6 +42,10 @@ class FuzzyRulesInstrumentedTest { val userRules = File(context.filesDir, "rime-user/openime_fuzzy.yaml") ImeSettingsRepository.saveFuzzy(context, true) ImeSettingsRepository.saveFuzzyRules(context, setOf(FuzzyRule.Z_ZH)) + // librime is one per process. When this app is also the active IME its + // service may already hold the session, and a second engine would reuse + // it as deployed: take it down first so this engine deploys the rules. + llc.slacker.openime.RimeNative.nativeShutdown() val rime = RimeEngine(context = context) try { rime.start() @@ -75,6 +79,9 @@ class FuzzyRulesInstrumentedTest { ImeSettingsRepository.saveFuzzy(context, originalFuzzy) ImeSettingsRepository.saveFuzzyRules(context, originalRules) rime.shutdown() + // Leave no prism built for these rules behind for later tests. + userRules.delete() + File(context.filesDir, "rime-user/build").listFiles().orEmpty().forEach { it.delete() } } } } diff --git a/app/src/androidTest/java/llc/slacker/openime/RimeNineKeyInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/RimeNineKeyInstrumentedTest.kt index 109a7404..c1a26d8f 100644 --- a/app/src/androidTest/java/llc/slacker/openime/RimeNineKeyInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/RimeNineKeyInstrumentedTest.kt @@ -68,6 +68,9 @@ class RimeNineKeyInstrumentedTest { val context = InstrumentationRegistry.getInstrumentation().targetContext val originalFuzzy = ImeSettingsRepository.loadFuzzy(context) ImeSettingsRepository.saveFuzzy(context, false) + // From a clean staging dir, with librime not already running in this process. + RimeNative.nativeShutdown() + java.io.File(context.filesDir, "rime-user/build").listFiles().orEmpty().forEach { it.delete() } val rime = RimeEngine(context = context) try { rime.start() diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index 45799943..af694044 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -466,9 +466,10 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can // A subtype switch changes the input language contract. Discard only // text actually owned by this IME; setComposingText("") without an // active composing span can otherwise delete a user's normal selection. - val hadComposingText = lastComposition.isNotEmpty() || voiceComposing + val hadVoiceComposing = voiceComposing + val hadComposingText = lastComposition.isNotEmpty() || hadVoiceComposing clearImeCompositionState(render = false) - if (hadComposingText) gateway.cancelComposing() + if (hadComposingText) gateway.cancelComposing(owned = hadVoiceComposing) voiceComposing = false voiceCorrectionTracker.clear() state = state.copy( @@ -520,7 +521,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can // shutdown() cancels an active voice session and its callback clears // voiceComposing. Check ownership afterwards so we never cancel twice. keyboardView?.shutdown() - if (lastComposition.isNotEmpty() || voiceComposing) gateway.cancelComposing() + if (lastComposition.isNotEmpty() || voiceComposing) gateway.cancelComposing(owned = voiceComposing) rime.clear() voiceComposing = false lastComposition = "" @@ -987,7 +988,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can override fun onVoiceError(message: String) { voiceMediaMute.restore() - if (voiceComposing) gateway.cancelComposing() + if (voiceComposing) gateway.cancelComposing(owned = true) voiceComposing = false state = state.copy( voiceState = state.voiceState.copy( @@ -1012,7 +1013,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can override fun onVoiceCancel() { voiceMediaMute.restore() - if (!state.passwordField && voiceComposing) gateway.cancelComposing() + if (!state.passwordField && voiceComposing) gateway.cancelComposing(owned = true) voiceComposing = false state = state.copy( voiceState = state.voiceState.copy( diff --git a/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt b/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt index 5b522b5e..142ebcff 100644 --- a/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt +++ b/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt @@ -157,10 +157,14 @@ class InputConnectionGateway( composingInEditor = false } - /** Remove the active pre-edit text without committing it to the editor. */ - fun cancelComposing() { + /** + * Remove the active pre-edit text without committing it to the editor. + * [owned] is for a caller that knows a span of ours is there (a voice + * partial) even if this gateway did not write it. + */ + fun cancelComposing(owned: Boolean = false) { val ic = connection() ?: return - if (!isPassword() && composingInEditor) ic.setComposingText("", 1) + if (!isPassword() && (composingInEditor || owned)) ic.setComposingText("", 1) ic.finishComposingText() composingInEditor = false } diff --git a/scripts/typing_stress_e2e.py b/scripts/typing_stress_e2e.py index c5d0b03d..4efbddd9 100644 --- a/scripts/typing_stress_e2e.py +++ b/scripts/typing_stress_e2e.py @@ -29,10 +29,11 @@ from pathlib import Path PKG = "llc.slacker.openime" -IME = f"{PKG}/.LocalVoiceImeService" -LAB = f"{PKG}/.ImeTestLabActivity" -RECEIVER = f"{PKG}/.E2ETestReceiver" -ACTION = f"{PKG}.TEST_COMMAND" +CLASSES = "llc.slacker.openime" # class names stay put when the applicationId gets a suffix +IME = f"{PKG}/{CLASSES}.LocalVoiceImeService" +LAB = f"{PKG}/{CLASSES}.ImeTestLabActivity" +RECEIVER = f"{PKG}/{CLASSES}.E2ETestReceiver" +ACTION = f"{CLASSES}.TEST_COMMAND" FIELD = "lab_multiline" ROOT = Path(__file__).resolve().parent.parent STROKE_TABLE = ROOT / "app" / "build" / "generated" / "assets" / "prebuildRimeData" / "stroke_table.tsv" @@ -121,9 +122,12 @@ def shot(self, name: str) -> None: def show_keyboard(dev: Device) -> None: - if dev.shell("settings", "get", "secure", "default_input_method").strip() != IME: - dev.shell("ime", "enable", IME) - dev.shell("ime", "set", IME) + # Use the id exactly as the system lists it (short form for the plain package). + ime_id = next((line.strip() for line in dev.shell("ime", "list", "-a", "-s").splitlines() + if line.strip().startswith(f"{PKG}/")), IME) + if dev.shell("settings", "get", "secure", "default_input_method").strip() != ime_id: + dev.shell("ime", "enable", ime_id) + dev.shell("ime", "set", ime_id) time.sleep(1) dev.shell("am", "start", "-n", LAB, "--es", "focus_id", FIELD, "-f", "0x10008000") time.sleep(2.5) @@ -287,12 +291,18 @@ def run_case(dev: Device, name: str, minimum: int) -> tuple[bool, str]: def main() -> int: + global PKG, IME, LAB, RECEIVER parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--serial") parser.add_argument("--out", type=Path, default=ROOT / "build" / "typing-stress") parser.add_argument("--min", type=int, default=50) + parser.add_argument("--package", default=PKG, help="applicationId of the debug build (e.g. with a .dev suffix)") parser.add_argument("cases", nargs="*", default=["pinyin26", "pinyin9", "stroke", "english", "digits", "mixed"]) args = parser.parse_args() + PKG = args.package + IME = f"{PKG}/{CLASSES}.LocalVoiceImeService" + LAB = f"{PKG}/{CLASSES}.ImeTestLabActivity" + RECEIVER = f"{PKG}/{CLASSES}.E2ETestReceiver" dev = Device(args.serial, args.out) show_keyboard(dev)