diff --git a/CHANGELOG.md b/CHANGELOG.md
index bcb1e29f..5b314ba1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,23 @@ 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。用户词库不受影响。
+- 升级时词库没有变化就不再重新拷贝,直接沿用手机上已有的词库,升级后第一次打开键盘和平时一样快。
+- 新增笔画键盘:切换键盘里选“笔画”。五个笔画键“一 丨 丿 丶 乛”和“通配”(代替一笔,记不清时用),按笔顺点,每点一笔就出候选。笔顺完全吻合的字排在前面,其余按常用程度排;繁体和生僻字也能打出来。选过的字下次排在前面,选字后照常给联想。
+- 模糊音改为“总开关 + 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”,还会触发应用自己的表情推荐。英文键盘照旧在输入框里显示正在输入的单词。
+- 英文键盘按空格或点候选上屏单词时,后面带一个空格,连续输入的单词不再粘在一起。
+- 九键和笔画键盘左下角加“符号”键,与数字键盘一致。
+- “重输”键始终与删除键、回车键同一样式,不再在没有输入时显示为灰色。
+- 九键和笔画键盘左侧的符号栏默认是 10 个常用标点(,。?!、:;“”……),最下面的“+”打开“常用符号栏”页:输入或一键加入符号,上移、下移排序,删除,恢复默认。符号栏随用户数据导出、导入。之前加入符号栏的自定义符号仍在符号面板里,可以从“+”重新加回符号栏。
+- “切换键盘”改成与“工具”页相同的图标小卡片,每个键盘有自己的图标,当前键盘高亮。
+- 暂时去掉语音词表(数据管理里的“语音词表”、识别后的同音替换和 26 键候选加权):同音替换还不够准,改好后再加回来。已导入的词表文件保留在手机上。
+
## [0.0.5-beta.1] - 2026-10-05
第五个测试版:按键震动更清脆、可选手感和强度,按键音效恢复并内置多种音效,“中/英”键移到空格右边,九键单按一个键时拼音栏显示字母,整理“关于与数据”和工具面板。从 0.0.4-beta.1 覆盖安装即可,用户数据保留。
diff --git a/README.md b/README.md
index 8954cf2c..5e3fdae9 100644
--- a/README.md
+++ b/README.md
@@ -22,10 +22,9 @@ openIME 是一款独立的 Android 系统输入法。拼音候选、用户词库
- **键盘**:26 键拼音、九键拼音、英文 26 键、数字与符号;Emoji、符号、剪贴板、文本编辑、浮动键盘。
- **拼音**:全拼、简拼、手动分词、候选展开、用户词库学习、简繁转换。
- 引擎为 librime,内置约 90 万条 Rime Ice 词典记录;首次部署完整词典期间,高频快速词库先提供候选。
+ 引擎为 librime,内置约 90 万条 Rime Ice 词典记录,打包时已预先编译;安装或升级后第一次打开键盘,几秒内即可用完整词典。
- **九键**:输入时左栏列出下一个字的拼音,一个字选一个音节,选定后自动移到下一个字。
- **语音输入**:长按空格说话,松手后识别并上屏;使用内置的中英双语模型,不联网;可去掉“嗯”“呃”等语气词,也可把标点写成空格。
-- **语音词表**:内置科技、应用、游戏词表,可导入自己的文本词表;识别后把同音词改成词表写法,不联网。
- **手势**:删除键上滑清空;空格左右滑动移动光标,滑动时底行其他按键锁定。
- **表情联想**:选词后联想栏先给出相关表情(开心 → 😊),词表内置,不联网。
- **自动填充**:Android 11+ 上,密码管理器的账号、验证码直接显示在键盘工具栏位置。
@@ -57,7 +56,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/app/build.gradle.kts b/app/build.gradle.kts
index f6648169..5fdf6224 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,63 @@ 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
+
+ /** 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
+
+ @get:Internal
+ abstract val script: RegularFileProperty
+
+ @get:Inject
+ abstract val execOperations: ExecOperations
+
+ @TaskAction
+ fun compile() {
+ execOperations.exec {
+ commandLine(
+ listOf("python3", script.get().asFile.absolutePath, "--out", outputDir.get().asFile.absolutePath) +
+ excludedSources.get().flatMap { listOf("--exclude", it) },
+ )
+ }
+ }
+}
+
+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"),
+ )
+ excludedSources.set(rimeCompiledOnlySources)
+ 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/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
new file mode 100644
index 00000000..9ab17e94
--- /dev/null
+++ b/app/src/androidTest/java/llc/slacker/openime/FuzzyRulesInstrumentedTest.kt
@@ -0,0 +1,87 @@
+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))
+ // 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()
+ 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()
+ // 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 88f11cb0..c1a26d8f 100644
--- a/app/src/androidTest/java/llc/slacker/openime/RimeNineKeyInstrumentedTest.kt
+++ b/app/src/androidTest/java/llc/slacker/openime/RimeNineKeyInstrumentedTest.kt
@@ -63,6 +63,40 @@ class RimeNineKeyInstrumentedTest {
}
}
+ @Test
+ fun productionDictionariesStartFromThePrebuiltTables() {
+ 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()
+ // 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/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..7198eae8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -40,15 +40,17 @@
android:windowSoftInputMode="stateAlwaysVisible|adjustResize" />
+ android:label="@string/activity_label_symbols"
+ android:windowSoftInputMode="adjustResize" />
+
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/DataManagementActivity.kt b/app/src/main/java/llc/slacker/openime/DataManagementActivity.kt
index d31c4506..59fb33d7 100644
--- a/app/src/main/java/llc/slacker/openime/DataManagementActivity.kt
+++ b/app/src/main/java/llc/slacker/openime/DataManagementActivity.kt
@@ -14,7 +14,6 @@ import llc.slacker.openime.data.UserDataArchive
import llc.slacker.openime.data.UserDataArchiveCodec
import llc.slacker.openime.data.UserDataImportPreview
import llc.slacker.openime.data.UserDataRepository
-import llc.slacker.openime.hotword.HotwordPacksActivity
import llc.slacker.openime.setup.SetupUi
import llc.slacker.openime.theme.ImeGeometryTokens
import llc.slacker.openime.theme.ImeSpacingTokens
@@ -89,19 +88,6 @@ class DataManagementActivity : Activity() {
dp(44),
).apply { topMargin = dp(12); marginStart = dp(44) },
)
- val hotwordCard = infoCard(
- title = "语音词表",
- body = "内置游戏、科技和应用词表,也可以导入自己的词表文件。识别后把同音的词改成词表里的写法,不联网。",
- iconRes = R.drawable.ic_pref_waveform,
- )
- addView(hotwordCard, wrap().apply { topMargin = dp(ImeSpacingTokens.LG_DP) })
- hotwordCard.addView(
- SetupUi.secondaryButton(this@DataManagementActivity, "管理词表") {
- startActivity(Intent(this@DataManagementActivity, HotwordPacksActivity::class.java))
- },
- LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(44))
- .apply { topMargin = dp(12); marginStart = dp(44) },
- )
addView(
infoCard(
title = "卸载前",
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 30cc6935..af694044 100644
--- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt
+++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt
@@ -25,6 +25,9 @@ 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
import llc.slacker.openime.core.CrashGuard
import llc.slacker.openime.core.ImeState
@@ -46,7 +49,6 @@ import llc.slacker.openime.editor.InputMethodSubtypePolicy
import llc.slacker.openime.editor.editorActionForEnter
import llc.slacker.openime.editor.shouldClearCompositionForSelectionUpdate
import llc.slacker.openime.floating.FloatingWindowController
-import llc.slacker.openime.hotword.HotwordRuntime
import llc.slacker.openime.keyboard.EnglishShiftPolicy
import llc.slacker.openime.keyboard.HardwareContext
import llc.slacker.openime.keyboard.HardwareKey
@@ -192,12 +194,18 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
{ ImeSettingsRepository.loadVoicePunctuationAsSpace(this) }
UserPhraseRepository.configure(this)
VoiceCorrectionRepository.configure(this)
- HotwordRuntime.configure(this)
+ FuzzyPinyin.rules = ImeSettingsRepository.loadFuzzyRules(this)
voiceLifecycle = VoiceModelLifecycleManager(this)
Thread({
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(
@@ -343,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
@@ -418,6 +427,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
popupEnabled = ImeSettingsRepository.loadPopup(this),
fuzzyPinyinEnabled = ImeSettingsRepository.loadFuzzy(this),
)
+ syncFuzzyRules()
keyboardView?.applyPersistedSettings(
newTheme = state.theme,
newAppearance = state.appearance,
@@ -456,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(
@@ -510,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 = ""
@@ -546,6 +557,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,
@@ -673,7 +686,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
} else {
VoicePerformanceTrace.abandon()
onVoiceSessionStarted(autoCommitOnFinal = true)
- onVoiceFinal(HotwordRuntime.apply(VoiceCorrectionRepository.apply(processed)))
+ onVoiceFinal(VoiceCorrectionRepository.apply(processed))
true
}
}.getOrDefault(false)
@@ -975,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(
@@ -1000,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(
@@ -1068,8 +1081,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
@@ -1260,8 +1272,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) {
@@ -1424,7 +1450,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)
}
}
@@ -1448,6 +1478,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
}
@@ -1476,17 +1514,9 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
else -> "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,
@@ -1558,7 +1588,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 {
@@ -1713,10 +1743,17 @@ 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)
+ // 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()
@@ -1726,6 +1763,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/RailSymbolsActivity.kt b/app/src/main/java/llc/slacker/openime/RailSymbolsActivity.kt
new file mode 100644
index 00000000..939f2520
--- /dev/null
+++ b/app/src/main/java/llc/slacker/openime/RailSymbolsActivity.kt
@@ -0,0 +1,283 @@
+package llc.slacker.openime
+
+import android.app.Activity
+import android.os.Build
+import android.os.Bundle
+import android.view.Gravity
+import android.view.View
+import android.view.WindowInsets
+import android.view.inputmethod.EditorInfo
+import android.widget.EditText
+import android.widget.GridLayout
+import android.widget.LinearLayout
+import android.widget.ScrollView
+import android.widget.TextView
+import llc.slacker.openime.core.CrashGuard
+import llc.slacker.openime.core.ImeData
+import llc.slacker.openime.data.RailSymbolRepository
+import llc.slacker.openime.setup.SetupUi
+import llc.slacker.openime.theme.ImeGeometryTokens
+import llc.slacker.openime.theme.ImeSpacingTokens
+import llc.slacker.openime.theme.ImeSurfacePolicy
+import llc.slacker.openime.theme.ImeTypographyTokens
+
+/**
+ * Edits the left symbol rail of the nine-key and stroke keyboards, opened from
+ * the rail's +: add a symbol (typed, or one tap on a common mark), move it up
+ * or down, remove it, or go back to the ten defaults. Every change is saved at
+ * once and the keyboard picks it up when this page closes.
+ */
+class RailSymbolsActivity : Activity() {
+ override fun attachBaseContext(newBase: android.content.Context) {
+ super.attachBaseContext(SetupUi.appearanceContext(newBase))
+ }
+
+ private lateinit var content: LinearLayout
+ private lateinit var input: EditText
+ private var savedScrollY = 0
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ CrashGuard.install(this)
+ savedScrollY = savedInstanceState?.getInt(STATE_SCROLL, 0) ?: 0
+ render(savedInstanceState?.getString(STATE_DRAFT).orEmpty())
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ outState.putString(STATE_DRAFT, input.text.toString())
+ outState.putInt(STATE_SCROLL, (content.parent as? ScrollView)?.scrollY ?: savedScrollY)
+ super.onSaveInstanceState(outState)
+ }
+
+ override fun onDestroy() {
+ LocalVoiceImeService.activeInstance?.refreshAuxiliaryContentFromActivity()
+ super.onDestroy()
+ }
+
+ private fun render(draft: String = if (::input.isInitialized) input.text.toString() else "") {
+ val symbols = RailSymbolRepository.load(this)
+ content = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(dp(ImeSpacingTokens.LG_DP), 0, dp(ImeSpacingTokens.LG_DP), dp(ImeSpacingTokens.LG_DP))
+ }
+ content.addView(
+ SetupUi.activityTopBar(context = this, title = "常用符号栏", onBack = ::finish),
+ fullHeight(ImeGeometryTokens.TOP_BAR_HEIGHT_DP).apply { marginStart = -dp(16); marginEnd = -dp(16) },
+ )
+ content.addView(note("九键和笔画键盘左侧的符号栏,从上到下按这里的顺序排列。"), fullWrap().apply { topMargin = dp(4) })
+
+ // Add: type anything short, or tap one of the common marks below.
+ val form = card()
+ input = EditText(this).apply {
+ id = R.id.rail_symbol_editor
+ hint = "输入符号,例如 ~"
+ setSingleLine(true)
+ imeOptions = EditorInfo.IME_ACTION_DONE
+ textSize = ImeTypographyTokens.TITLE_SP
+ setText(draft)
+ setOnEditorActionListener { _, actionId, _ ->
+ 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/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/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/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/candidate/StrokeLexicon.kt b/app/src/main/java/llc/slacker/openime/candidate/StrokeLexicon.kt
new file mode 100644
index 00000000..fe887948
--- /dev/null
+++ b/app/src/main/java/llc/slacker/openime/candidate/StrokeLexicon.kt
@@ -0,0 +1,144 @@
+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
+ * 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. 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 ->
+ val paint = Paint()
+ StrokeLexicon(parse(lines).filter { (character, _) -> paint.hasGlyph(character) })
+ }.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/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/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..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"
@@ -38,12 +40,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()
}
@@ -226,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/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..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
@@ -45,6 +46,10 @@ 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,
+ /** Keys of the 模糊音 pairs switched on; null in backups made before the switches. */
+ val fuzzyRules: List? = null,
)
internal data class RimeUserDictionaryArchive(
@@ -191,6 +196,8 @@ 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)) } }
+ .apply { value.fuzzyRules?.let { put("fuzzy_rules", JSONArray(it)) } }
private fun settingsFromJson(value: JSONObject): ArchiveSettings =
ArchiveSettings(
@@ -219,6 +226,12 @@ 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) })
+ },
+ 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 {
@@ -349,6 +362,11 @@ internal object UserDataRepository {
voiceStripFillers = ImeSettingsRepository.loadVoiceStripFillers(context),
voicePunctuationAsSpace =
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,
)
@@ -445,5 +463,10 @@ 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) }
+ value.fuzzyRules?.let { keys ->
+ ImeSettingsRepository.saveFuzzyRules(context, keys.mapNotNull(FuzzyRule::fromKey).toSet())
+ }
}
}
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..142ebcff 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,19 @@ class InputConnectionGateway(
fun finishComposing() {
connection()?.finishComposingText()
+ 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()) {
- ic.finishComposingText()
- return
- }
- ic.setComposingText("", 1)
+ if (!isPassword() && (composingInEditor || owned)) ic.setComposingText("", 1)
ic.finishComposingText()
+ composingInEditor = false
}
fun clearComposition() {
@@ -290,8 +328,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 +373,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 +419,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 +430,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/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/keyboard/ImeKeyboardView.kt b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt
index 05b44c1f..914281eb 100644
--- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt
+++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt
@@ -22,12 +22,14 @@ 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
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
@@ -121,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) {}
@@ -329,7 +334,7 @@ open class ImeKeyboardView(
}
if (panel == Panel.NONE) {
when (mode) {
- KeyboardMode.PINYIN_9 -> {
+ KeyboardMode.PINYIN_9, KeyboardMode.STROKE -> {
nineKeySymbolRailController?.refreshSymbols()
applyThemeToSubtree(this)
}
@@ -458,7 +463,43 @@ open class ImeKeyboardView(
onDigits = { setMode(KeyboardMode.DIGITS) },
onSpace = ::commitFirstCandidateOrSpace,
onModeSwitch = ::cycleMode,
- onRetranslate = { publishComposition("", emptyList()) },
+ onRetranslate = ::retype,
+ 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,
+ onShowSymbols = { showPanel(Panel.SYMBOLS) },
+ onDigits = { setMode(KeyboardMode.DIGITS) },
+ onSpace = ::commitFirstCandidateOrSpace,
+ onModeSwitch = ::cycleMode,
+ onRetype = ::retype,
onEnter = listener::onEnter,
)
}
@@ -654,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,
@@ -1218,7 +1260,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 +1275,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 +1521,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
@@ -1852,10 +1894,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) &&
@@ -1885,6 +1923,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 +1944,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 +1960,7 @@ open class ImeKeyboardView(
modeKey.stateDescription = when (mode) {
KeyboardMode.PINYIN_26 -> "当前中文 26 键"
KeyboardMode.PINYIN_9 -> "当前中文九键"
+ KeyboardMode.STROKE -> "当前笔画"
KeyboardMode.ENGLISH_26 -> "当前英文 26 键"
KeyboardMode.DIGITS -> "当前数字键盘"
}
@@ -1966,9 +2010,35 @@ open class ImeKeyboardView(
onRailChanged = ::applyThemeToSubtree,
onChooseReading = ::chooseNineKeyReading,
fixedPrefix = ::nineKeyFixedPrefix,
+ onEditSymbols = ::openRailSymbolEditor,
).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 "↵")
+ }
+
+ /**
+ * 重输 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()
+ val (next, selection) = replaceCompositionSelection(glyph)
+ publishComposition(next, candidatesForComposition(next), selection)
+ }
+
private fun renderDigits() {
val info = (context as? android.inputmethodservice.InputMethodService)
?.currentInputEditorInfo
@@ -2171,6 +2241,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
new file mode 100644
index 00000000..dcfb92e0
--- /dev/null
+++ b/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt
@@ -0,0 +1,171 @@
+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 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.
+ */
+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 onShowSymbols: () -> 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"
+ }
+
+ // 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(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),
+ )
+
+ 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.SYMBOL_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/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 b3d2041b..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,38 +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.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(
@@ -126,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 {
@@ -146,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 ?: {
@@ -162,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(
@@ -679,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
@@ -693,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.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"
+ }
}
}
@@ -784,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/java/llc/slacker/openime/panel/SettingsPanelController.kt b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt
index 86aadca0..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,
@@ -201,7 +206,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),
)
}
@@ -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 192d0240..83655a8f 100644
--- a/app/src/main/java/llc/slacker/openime/rime/RimeEngine.kt
+++ b/app/src/main/java/llc/slacker/openime/rime/RimeEngine.kt
@@ -4,9 +4,11 @@ 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
+import llc.slacker.openime.core.FuzzyRule
import llc.slacker.openime.core.RimeStartupRecovery
import llc.slacker.openime.data.ImeSettingsRepository
import llc.slacker.openime.data.PersonalizationPolicy
@@ -27,7 +29,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 +162,10 @@ 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)
+ 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
@@ -176,9 +190,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 -> {
@@ -192,7 +206,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.
- RimeNative.nativeStartup(sharedDir.absolutePath, userDir.absolutePath)
+ val nativeStartMs = SystemClock.elapsedRealtime()
+ RimeNative.nativeStartup(sharedDir.absolutePath, userDir.absolutePath, fullCheck = rulesChanged)
+ val nativeMs = SystemClock.elapsedRealtime() - nativeStartMs
nativeStartupReturned = true
if (!startupGate.isCurrent(generation)) {
cleanupNative()
@@ -225,7 +241,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 {
@@ -545,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)
@@ -581,19 +644,33 @@ 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.
+ */
+ /** 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 (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")
@@ -621,14 +698,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. */
@@ -654,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/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/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 e3bc1510..260d9553 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -50,7 +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 9f2db437..e1671b04 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",
),
)
@@ -52,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/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/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)
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/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/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/app/src/test/java/llc/slacker/openime/UserDataArchiveTest.kt b/app/src/test/java/llc/slacker/openime/UserDataArchiveTest.kt
index ca2c13db..bc778a8c 100644
--- a/app/src/test/java/llc/slacker/openime/UserDataArchiveTest.kt
+++ b/app/src/test/java/llc/slacker/openime/UserDataArchiveTest.kt
@@ -33,8 +33,24 @@ class UserDataArchiveTest {
emojiAssociation = false,
voiceStripFillers = false,
voicePunctuationAsSpace = true,
+ railSymbols = listOf("~", ",", "@"),
+ fuzzyRules = listOf("z_zh", "f_h"),
)
+ @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(
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/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/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/docs/RELEASE.md b/docs/RELEASE.md
index a54535e0..ff3d4dfc 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 只带编译好的
+二进制词库,并附带这份数据的内容哈希(`rime-data.revision`)。升级后只有哈希变了才从 APK 重新拷贝词库、
+清掉旧的编译产物(用户词库在独立目录,不受影响);词库没变的升级直接沿用手机上已有的那份。手机上不再编译。
只维护最新的一条 MINOR 版本线;安全修复以 PATCH 版本发布。
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..685cf2b9
--- /dev/null
+++ b/scripts/build_rime_prebuilt.py
@@ -0,0 +1,201 @@
+#!/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/ and a content hash of
+ 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
+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
+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 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."""
+ 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()
+ 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")
+ strokes = write_stroke_table(out)
+ total = sum(path.stat().st_size for path in target.iterdir())
+ print(
+ f"build_rime_prebuilt: {len(EXPECTED)} files, {total // 1024} KiB in {target}, "
+ f"revision {revision[:12]}, {strokes} stroke codes"
+ )
+
+
+if __name__ == "__main__":
+ main()
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('管理自定义符号')
diff --git a/scripts/typing_stress_e2e.py b/scripts/typing_stress_e2e.py
new file mode 100644
index 00000000..4efbddd9
--- /dev/null
+++ b/scripts/typing_stress_e2e.py
@@ -0,0 +1,340 @@
+#!/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"
+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"
+
+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:
+ # 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)
+ 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:
+ 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)
+ 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())