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())