Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 覆盖安装即可,用户数据保留。
Expand Down
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,9 @@ openIME 是一款独立的 Android 系统输入法。拼音候选、用户词库

- **键盘**:26 键拼音、九键拼音、英文 26 键、数字与符号;Emoji、符号、剪贴板、文本编辑、浮动键盘。
- **拼音**:全拼、简拼、手动分词、候选展开、用户词库学习、简繁转换。
引擎为 librime,内置约 90 万条 Rime Ice 词典记录;首次部署完整词典期间,高频快速词库先提供候选。
引擎为 librime,内置约 90 万条 Rime Ice 词典记录,打包时已预先编译;安装或升级后第一次打开键盘,几秒内即可用完整词典。
- **九键**:输入时左栏列出下一个字的拼音,一个字选一个音节,选定后自动移到下一个字。
- **语音输入**:长按空格说话,松手后识别并上屏;使用内置的中英双语模型,不联网;可去掉“嗯”“呃”等语气词,也可把标点写成空格。
- **语音词表**:内置科技、应用、游戏词表,可导入自己的文本词表;识别后把同音词改成词表写法,不联网。
- **手势**:删除键上滑清空;空格左右滑动移动光标,滑动时底行其他按键锁定。
- **表情联想**:选词后联想栏先给出相关表情(开心 → 😊),词表内置,不联网。
- **自动填充**:Android 11+ 上,密码管理器的账号、验证码直接显示在键盘工具栏位置。
Expand Down Expand Up @@ -57,7 +56,6 @@ openIME 是一款独立的 Android 系统输入法。拼音候选、用户词库
- 手写输入尚未接入识别引擎,入口默认隐藏。
- 九键暂不支持与外接键盘同时使用。
- 部分厂商系统对输入法的后台限制不同,尚未在大量真机上验证。
- 首次安装后需要几十秒完成完整词典部署,期间候选质量略低。
- 版本号小于此前已装版本时,系统会拒绝覆盖安装;需要先卸载(卸载前可导出用户数据)。

## 隐私与安全
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.0.5-beta.1
0.0.6-beta.1
76 changes: 76 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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", ".*", "<dir>_*", "!CVS", "!thumbs.db", "!picasa.ini", "!*~",
) + rimeCompiledOnlySources.map { "!$it" }
}

compileOptions {
Expand All @@ -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<String>

@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>("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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -704,7 +709,9 @@ class AuditInteractionInstrumentedTest {
val rail = keyboard.findViewWithTag<View>("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<View>("punct:⌘"))
assertNotNull("The user's rail symbols must appear in the nine-key rail", keyboard.findViewWithTag<View>("punct:※"))
assertNotNull("The nine-key rail ends in its + editor cell", keyboard.findViewWithTag<View>("punct:add"))
assertEquals("Custom symbols stay in the symbol panel", null, keyboard.findViewWithTag<View>("punct:⌘"))
true
}
harness.awaitMain {
Expand All @@ -721,6 +728,7 @@ class AuditInteractionInstrumentedTest {
}
} finally {
CustomSymbolRepository.remove(context, custom.id)
llc.slacker.openime.data.RailSymbolRepository.reset(context)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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() }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>(), staged)
} finally {
ImeSettingsRepository.saveFuzzy(context, originalFuzzy)
rime.invalidateSettingsCache()
rime.shutdown()
}
}

@Test
fun auditProductionPresetsAgainstNativeRimePreeditWithoutPresetLayer() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
Expand Down
Loading
Loading