diff --git a/CHANGELOG.md b/CHANGELOG.md index 27bba519..74631ae8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +### 新增 +- 语音词表:内置科技与开发、应用与品牌、游戏与电竞三个词表,也可以在“关于与数据 → 管理词表”导入自己的文本词表(每行一个词)。语音识别结束后,读音相同、字不同的词会改成词表里的写法,例如“大爷”改成“打野”。全部在本机完成,不联网;游戏词表默认关闭。 +- 26 键底行在空格右边增加“,”键:点按输入逗号,长按输入句号(右上角有提示);英文模式下是 , 和 . 。 +- 九键长按:长按带字母的按键弹出“数字 + 小写字母 + 大写字母”两行选择,不用切换键盘;长按“1”仍是 @ # /。 +- 九键上滑输入数字:在按键上向上一划直接输入该键的数字,可在“设置 → 按键与输入 → 上滑输入数字”关闭,设置会随用户数据一起导出和导入。 +- 语音词表同时用于打字:启用的词表里的词,在 26 键拼音输入与其全拼完全一致时,会排到第一候选之后(最多 3 个),词典里没有的词也能出现。 + +### 变更 +- 内部:源码按功能分成 theme、core、editor、data、setup、widget、floating、handwriting、rime、candidate、hotword、voice、panel、keyboard 等包,依赖方向由测试固定;语音层不再直接依赖键盘视图和 Service。不影响功能。 +- 26 键底行左右对称:空格键现在位于键盘正中,“中/英”键变窄。 +- 九键底行:空格键更宽,两侧的“123”和“中/英”键变窄。 +- 英文模式不再自动首字母大写,键帽显示小写字母;只有要求全大写的输入框才默认开启大写锁定,随时可以点 Shift 输入大写。 +- 26 键字母整体小一号(21sp 改为 19sp)。 +- 浮动键盘的顶部工具栏精简为:切换键盘、常用语、表情、更多、收起;不再显示文本编辑和撤销,常用语一键直达。 +- 浮动键盘横屏时和竖屏一样大:宽度按竖屏宽度计算,键高保持竖屏尺寸,不再用横屏的扁平键盘。 +- 浮动键盘可以长按顶部区域(工具栏、候选栏)后拖动来移动,原有的拖动手柄仍可用。 + ## [0.0.1-beta.1] - 2026-10-03 openIME 的首个公开测试版。功能已经可以日常试用,但稳定性和兼容性还在验证,请不要把它作为唯一的输入法;欢迎在 Issues 反馈问题。 diff --git a/README.md b/README.md index 06a66271..1fe56671 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ openIME 是一款独立的 Android 系统输入法。拼音候选、用户词库 引擎为 librime,内置约 90 万条 Rime Ice 词典记录;首次部署完整词典期间,高频快速词库先提供候选。 - **九键**:输入时左栏列出下一个字的拼音,一个字选一个音节,选定后自动移到下一个字。 - **语音输入**:长按空格说话,松手后识别并上屏;使用内置的中英双语模型,不联网。 +- **语音词表**:内置科技、应用、游戏词表,可导入自己的文本词表;识别后把同音词改成词表写法,不联网。 - **手势**:删除键上滑清空,5 秒内下滑可撤回。 - **适配**:横竖屏、平板与折叠屏、深色模式、大字号;终端、远程桌面、游戏等原始按键输入框; 外接键盘可直接打拼音。详见 [兼容性说明](docs/COMPATIBILITY.md)。 diff --git a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt index a629e233..4b0217fb 100644 --- a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt @@ -1,13 +1,13 @@ package llc.slacker.openime -import android.os.Handler -import android.os.Looper -import android.os.SystemClock +import android.content.res.Configuration import android.graphics.Color import android.graphics.Rect import android.graphics.drawable.GradientDrawable import android.graphics.drawable.StateListDrawable -import android.content.res.Configuration +import android.os.Handler +import android.os.Looper +import android.os.SystemClock import android.view.InputDevice import android.view.MotionEvent import android.view.View @@ -16,6 +16,20 @@ import android.view.ViewGroup import android.widget.ScrollView import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.core.ImeState +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.core.Panel +import llc.slacker.openime.data.CustomSymbolRepository +import llc.slacker.openime.data.QuickPhraseRepository +import llc.slacker.openime.floating.FloatingWindowController +import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.theme.AccentPalette +import llc.slacker.openime.theme.ImeAppearance +import llc.slacker.openime.theme.ImeGeometryTokens +import llc.slacker.openime.theme.ImeSurfacePolicy +import llc.slacker.openime.theme.ImeTheme +import llc.slacker.openime.voice.VoiceModelLifecycleState +import llc.slacker.openime.voice.VoiceRecognitionEvents import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull diff --git a/app/src/androidTest/java/llc/slacker/openime/CandidatePresentationInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/CandidatePresentationInstrumentedTest.kt index 3985389d..349ab0b2 100644 --- a/app/src/androidTest/java/llc/slacker/openime/CandidatePresentationInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/CandidatePresentationInstrumentedTest.kt @@ -2,9 +2,11 @@ package llc.slacker.openime import android.view.View import android.view.ViewGroup -import android.widget.ScrollView import android.widget.EditText +import android.widget.ScrollView import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.core.ImeState +import llc.slacker.openime.keyboard.ImeKeyboardView import org.junit.Assert.assertEquals import org.junit.Assert.assertSame import org.junit.Assert.assertTrue diff --git a/app/src/androidTest/java/llc/slacker/openime/ClipboardRetentionInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/ClipboardRetentionInstrumentedTest.kt index 9742fcc6..a785dcc3 100644 --- a/app/src/androidTest/java/llc/slacker/openime/ClipboardRetentionInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/ClipboardRetentionInstrumentedTest.kt @@ -9,13 +9,22 @@ import android.view.ViewGroup import android.view.inputmethod.BaseInputConnection import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.core.Panel +import llc.slacker.openime.core.ShiftState +import llc.slacker.openime.data.ClipboardEntry +import llc.slacker.openime.data.ClipboardHistoryRepository +import llc.slacker.openime.editor.InputConnectionGateway +import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.theme.ImeAppearance +import llc.slacker.openime.theme.ImeTheme import org.junit.After -import org.junit.Before import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull -import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith diff --git a/app/src/androidTest/java/llc/slacker/openime/CompatibilityApiInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/CompatibilityApiInstrumentedTest.kt index fc049b15..f466c689 100644 --- a/app/src/androidTest/java/llc/slacker/openime/CompatibilityApiInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/CompatibilityApiInstrumentedTest.kt @@ -11,6 +11,12 @@ import android.view.View import android.view.inputmethod.BaseInputConnection import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import llc.slacker.openime.candidate.CandidateEngine +import llc.slacker.openime.data.ClipboardHistoryRepository +import llc.slacker.openime.data.ClipboardSensitivityPolicy +import llc.slacker.openime.editor.InputConnectionGateway +import llc.slacker.openime.voice.LocalAudioVoiceBackend +import llc.slacker.openime.voice.VoiceRecognitionEvents import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Assume.assumeTrue diff --git a/app/src/androidTest/java/llc/slacker/openime/DebugKeyboardActivityTest.kt b/app/src/androidTest/java/llc/slacker/openime/DebugKeyboardActivityTest.kt index f574bce2..d812c439 100644 --- a/app/src/androidTest/java/llc/slacker/openime/DebugKeyboardActivityTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/DebugKeyboardActivityTest.kt @@ -6,6 +6,7 @@ import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.keyboard.ImeKeyboardView import org.junit.After import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue diff --git a/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt index 2c5d59c9..e1015a37 100644 --- a/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt @@ -5,12 +5,15 @@ import android.content.res.Configuration import android.view.View import android.view.ViewGroup import androidx.test.ext.junit.runners.AndroidJUnit4 -import java.lang.reflect.Proxy +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.widget.ImeKeyView import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith +import java.lang.reflect.Proxy /** * The keyboard has to stay usable in every display environment, not just the diff --git a/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt new file mode 100644 index 00000000..5c74f518 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt @@ -0,0 +1,217 @@ +package llc.slacker.openime + +import android.content.res.Resources +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.util.DisplayMetrics +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.core.Panel +import llc.slacker.openime.floating.FloatingWindowController +import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.voice.VoiceModelLifecycleState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Proxy + +/** Game-oriented floating keyboard: compact toolbar, long-press drag, portrait-sized landscape. */ +@RunWith(AndroidJUnit4::class) +class FloatingGameKeyboardInstrumentedTest { + private class Recorder { + lateinit var keyboard: ImeKeyboardView + val drags = mutableListOf>() + val floatingChanges = mutableListOf() + val listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, args -> + when (method.name) { + "voiceModelState" -> VoiceModelLifecycleState.COLD + "onFloatingKeyboardChanged" -> { + floatingChanges += args!![0] as Boolean + keyboard.setFloatingWindowMode(args[0] as Boolean) + null + } + "onFloatingKeyboardDragged" -> { + drags += (args!![0] as Float) to (args[1] as Float) + null + } + else -> if (method.returnType == java.lang.Boolean.TYPE) false else null + } + } as ImeKeyboardView.Listener + } + + private fun withKeyboard( + test: (DirectActivityHarness, Recorder, ImeKeyboardView) -> Any?, + ) { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val recorder = Recorder() + val keyboard = harness.awaitMain { activity -> + ImeKeyboardView(activity, recorder.listener).also { + recorder.keyboard = it + activity.findViewById(android.R.id.content).addView(it) + } + } + try { + harness.awaitMain { if (keyboard.width > 0) true else null } + test(harness, recorder, keyboard) + } finally { + harness.awaitMain { + keyboard.shutdown() + (keyboard.parent as? ViewGroup)?.removeView(keyboard) + true + } + } + } + } + + private fun View.visibleByTag(tag: String): Boolean = + findViewWithTag(tag)?.visibility == View.VISIBLE + + private fun View.toolbarIconByDescription(description: String): View? { + val row = findViewWithTag("toolbar-row") ?: return null + return (0 until row.childCount).map(row::getChildAt) + .firstOrNull { it.contentDescription == description } + } + + @Test + fun floatingToolbarDropsTextEditAndUndoAndAddsQuickPhrases() = withKeyboard { harness, recorder, keyboard -> + harness.awaitMain { + assertTrue("Docked toolbar keeps the clipboard", keyboard.visibleByTag("clipboard-toolbar")) + assertTrue("Docked toolbar keeps text editing", keyboard.toolbarIconByDescription("文本编辑")!!.visibility == View.VISIBLE) + assertTrue("Docked toolbar keeps undo", keyboard.visibleByTag("undo-toolbar")) + assertFalse("Docked toolbar has no quick-phrase shortcut", keyboard.visibleByTag("quick-phrase-toolbar")) + + keyboard.setFloatingWindowMode(true) + assertTrue("Floating toolbar exposes quick phrases", keyboard.visibleByTag("quick-phrase-toolbar")) + assertFalse(keyboard.visibleByTag("clipboard-toolbar")) + assertFalse(keyboard.visibleByTag("undo-toolbar")) + assertEquals(View.GONE, keyboard.toolbarIconByDescription("文本编辑")!!.visibility) + assertTrue("Emoji stays", keyboard.toolbarIconByDescription("表情")!!.visibility == View.VISIBLE) + assertTrue("Keyboard switch stays", keyboard.visibleByTag("keyboard-selector")) + + keyboard.setFloatingWindowMode(false) + assertTrue("Docking restores the full toolbar", keyboard.visibleByTag("clipboard-toolbar")) + assertTrue(keyboard.toolbarIconByDescription("文本编辑")!!.visibility == View.VISIBLE) + assertFalse(keyboard.visibleByTag("quick-phrase-toolbar")) + true + } + } + + @Test + fun quickPhraseShortcutOpensThePhraseTab() = withKeyboard { harness, _, keyboard -> + harness.awaitMain { + keyboard.setFloatingWindowMode(true) + assertTrue(keyboard.findViewWithTag("quick-phrase-toolbar").performClick()) + assertEquals(Panel.CLIPBOARD, keyboard.currentPanel()) + assertNotNull( + "The phrase tab shows its add button, not the clipboard refresh", + keyboard.findViewWithTag("quick-phrase-add"), + ) + assertNull(keyboard.findViewWithTag("clipboard-refresh")) + true + } + } + + private fun touch(view: View, action: Int, downTime: Long, x: Float, y: Float): Boolean { + val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, x, y, 0) + return try { + view.dispatchTouchEvent(event) + } finally { + event.recycle() + } + } + + @Test + fun longPressOnTopZoneDragsTheFloatingKeyboardWithoutDockingOrClicking() = withKeyboard { harness, recorder, keyboard -> + harness.awaitMain { keyboard.setFloatingWindowMode(true); true } + val topZone = harness.awaitMain { keyboard.findViewWithTag("ime_toolbar") } + val downTime = SystemClock.uptimeMillis() + // Press on an icon, so a leaked click would be visible as a panel opening. + val (x, y) = harness.awaitMain { + val icon = keyboard.findViewWithTag("quick-phrase-toolbar") + val loc = IntArray(2).also { icon.getLocationInWindow(it) } + val zoneLoc = IntArray(2).also { topZone.getLocationInWindow(it) } + (loc[0] - zoneLoc[0] + icon.width / 2f) to (loc[1] - zoneLoc[1] + icon.height / 2f) + } + harness.awaitMain { touch(topZone, MotionEvent.ACTION_DOWN, downTime, x, y); true } + SystemClock.sleep(android.view.ViewConfiguration.getLongPressTimeout() + 250L) + harness.awaitMain { + touch(topZone, MotionEvent.ACTION_MOVE, downTime, x + 30f, y + 20f) + touch(topZone, MotionEvent.ACTION_MOVE, downTime, x + 90f, y + 60f) + touch(topZone, MotionEvent.ACTION_UP, downTime, x + 90f, y + 60f) + true + } + harness.awaitMain { + assertTrue("Long-press drag must move the window", recorder.drags.isNotEmpty()) + val totalX = recorder.drags.sumOf { it.first.toDouble() } + val totalY = recorder.drags.sumOf { it.second.toDouble() } + assertEquals("Drag deltas follow the finger after the grab point", 60.0, totalX, 1.0) + assertEquals(40.0, totalY, 1.0) + assertTrue("Releasing a drag must not dock the keyboard", recorder.floatingChanges.isEmpty()) + assertEquals("A drag on an icon must not open its panel", Panel.NONE, keyboard.currentPanel()) + true + } + } + + @Test + fun shortTapOnTopZoneStillClicksAndDoesNotDrag() = withKeyboard { harness, recorder, keyboard -> + harness.awaitMain { keyboard.setFloatingWindowMode(true); true } + harness.awaitMain { + assertTrue(keyboard.findViewWithTag("quick-phrase-toolbar").performClick()) + assertTrue(recorder.drags.isEmpty()) + assertEquals(Panel.CLIPBOARD, keyboard.currentPanel()) + true + } + } + + @Test + fun floatingWindowUsesPortraitWidthOnLandscapeScreen() = withKeyboard { harness, _, keyboard -> + val controller = harness.awaitMain { activity -> + val real = keyboard.resources + val landscape = DisplayMetrics().apply { + setTo(real.displayMetrics) + // A landscape phone: the long side is the width. + widthPixels = maxOf(real.displayMetrics.widthPixels, real.displayMetrics.heightPixels) + heightPixels = minOf(real.displayMetrics.widthPixels, real.displayMetrics.heightPixels) + } + @Suppress("DEPRECATION") + val resources = Resources(real.assets, landscape, real.configuration) + FloatingWindowController( + resources = resources, + mainHandler = Handler(Looper.getMainLooper()), + windowProvider = { activity.window }, + keyboardHeightPx = { 600 }, + floatingWidthPercent = { 88 }, + floatingOpacityPercent = { 100 }, + ).also { it.enable() } + } + try { + harness.awaitMain { activity -> + val metrics = activity.resources.displayMetrics + val shortSide = minOf(metrics.widthPixels, metrics.heightPixels) + val longSide = maxOf(metrics.widthPixels, metrics.heightPixels) + val expected = minOf((shortSide * 0.88f).toInt(), (420 * metrics.density).toInt()) + val width = activity.window.attributes.width + // scheduleLayout runs on a posted message; poll until it applied. + if (width != expected) return@awaitMain null + assertTrue( + "Landscape floating width ($width) must follow the portrait width, not the $longSide px long side", + width < longSide * 0.6f, + ) + true + } + } finally { + controller.restore() + } + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/ImeInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/ImeInstrumentedTest.kt index a33ef405..24aa9d50 100644 --- a/app/src/androidTest/java/llc/slacker/openime/ImeInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/ImeInstrumentedTest.kt @@ -1,6 +1,7 @@ package llc.slacker.openime import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.candidate.CandidateEngine import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith diff --git a/app/src/androidTest/java/llc/slacker/openime/ImeSettingsLifecycleInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/ImeSettingsLifecycleInstrumentedTest.kt index 602182ef..d5070f7f 100644 --- a/app/src/androidTest/java/llc/slacker/openime/ImeSettingsLifecycleInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/ImeSettingsLifecycleInstrumentedTest.kt @@ -3,6 +3,8 @@ package llc.slacker.openime import android.view.View import android.view.ViewGroup import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.core.Panel +import llc.slacker.openime.keyboard.ImeKeyboardView import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue diff --git a/app/src/androidTest/java/llc/slacker/openime/InteractionPolishInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/InteractionPolishInstrumentedTest.kt index b7e48b03..d0474b51 100644 --- a/app/src/androidTest/java/llc/slacker/openime/InteractionPolishInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/InteractionPolishInstrumentedTest.kt @@ -2,6 +2,7 @@ package llc.slacker.openime import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.data.EmojiRecentRepository import org.junit.After import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt new file mode 100644 index 00000000..0f1fd8ed --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt @@ -0,0 +1,124 @@ +package llc.slacker.openime + +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.voice.VoiceModelLifecycleState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Proxy + +/** 26-key and nine-key bottom rows, the comma/period key and English letter case. */ +@RunWith(AndroidJUnit4::class) +class KeyboardBottomRowInstrumentedTest { + private class Recorder { + val characters = mutableListOf() + val listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, args -> + when (method.name) { + "voiceModelState" -> VoiceModelLifecycleState.COLD + "onCharacter" -> { characters += args!![0] as String; null } + else -> if (method.returnType == java.lang.Boolean.TYPE) false else null + } + } as ImeKeyboardView.Listener + } + + private fun withKeyboard(test: (DirectActivityHarness, Recorder, ImeKeyboardView) -> Any?) { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val recorder = Recorder() + val keyboard = harness.awaitMain { activity -> + ImeKeyboardView(activity, recorder.listener).also { + activity.findViewById(android.R.id.content).addView(it) + } + } + try { + harness.awaitMain { if (keyboard.width > 0) true else null } + test(harness, recorder, keyboard) + } finally { + harness.awaitMain { + keyboard.shutdown() + (keyboard.parent as? ViewGroup)?.removeView(keyboard) + true + } + } + } + } + + private fun View.centerX(root: View): Float { + val loc = IntArray(2).also(::getLocationOnScreen) + val rootLoc = IntArray(2).also(root::getLocationOnScreen) + return loc[0] - rootLoc[0] + width / 2f + } + + private fun settled(harness: DirectActivityHarness, keyboard: ImeKeyboardView, tag: String): View = + harness.awaitMain { keyboard.findViewWithTag(tag)?.takeIf { it.width > 0 } } + + @Test + fun twentySixKeySpaceIsCenteredAndTheRowMirrors() = withKeyboard { harness, _, keyboard -> + harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } + val space = settled(harness, keyboard, "key-space") + val mode = settled(harness, keyboard, "key:mode") + val punctuation = settled(harness, keyboard, "key-punctuation") + val digits = settled(harness, keyboard, "key:123") + val enter = settled(harness, keyboard, "key-enter") + harness.awaitMain { + val tolerance = 3f + assertEquals(keyboard.width / 2f, space.centerX(keyboard), tolerance) + assertEquals(mode.width.toFloat(), punctuation.width.toFloat(), tolerance) + assertEquals(digits.width.toFloat(), enter.width.toFloat(), tolerance) + assertTrue("the mode key must be narrower than the digits key", mode.width < digits.width) + true + } + } + + @Test + fun commaKeyTapsCommaAndLongPressesPeriodInBothLanguages() = withKeyboard { harness, recorder, keyboard -> + harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } + val zh = settled(harness, keyboard, "key-punctuation") + harness.awaitMain { assertTrue(zh.performClick()); assertTrue(zh.performLongClick()); true } + assertEquals(listOf(",", "。"), recorder.characters.toList()) + + recorder.characters.clear() + harness.awaitMain { keyboard.setMode(KeyboardMode.ENGLISH_26); true } + val en = harness.awaitMain { keyboard.findViewWithTag("key-punctuation")?.takeIf { it.width > 0 } } + harness.awaitMain { assertTrue(en.performClick()); assertTrue(en.performLongClick()); true } + assertEquals(listOf(",", "."), recorder.characters.toList()) + } + + @Test + fun englishKeysShowLowercaseLetters() = withKeyboard { harness, _, keyboard -> + harness.awaitMain { keyboard.setMode(KeyboardMode.ENGLISH_26); true } + harness.awaitMain { + val q = keyboard.findViewWithTag("key:q") ?: return@awaitMain null + val labels = mutableListOf() + fun collect(view: View) { + if (view is TextView) labels += view.text.toString() + if (view is ViewGroup) for (i in 0 until view.childCount) collect(view.getChildAt(i)) + } + collect(q) + assertTrue("English keys must show lowercase, got $labels", "q" in labels && "Q" !in labels) + true + } + } + + @Test + fun nineKeySpaceIsWiderThanTheSideKeys() = withKeyboard { harness, _, keyboard -> + harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_9); true } + val space = settled(harness, keyboard, "key-space") + val mode = settled(harness, keyboard, "key:mode") + val digits = settled(harness, keyboard, "key:123") + harness.awaitMain { + assertTrue("space ${space.width} must be wider than 2x the side keys ${digits.width}", space.width > digits.width * 2) + assertEquals(digits.width.toFloat(), mode.width.toFloat(), 3f) + true + } + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/ManualVoiceOwnershipInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/ManualVoiceOwnershipInstrumentedTest.kt index 7bacee9d..5fd7c695 100644 --- a/app/src/androidTest/java/llc/slacker/openime/ManualVoiceOwnershipInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/ManualVoiceOwnershipInstrumentedTest.kt @@ -5,12 +5,15 @@ import android.os.Build import android.view.inputmethod.InputConnection import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import java.lang.reflect.Proxy +import llc.slacker.openime.data.EmojiRecentRepository +import llc.slacker.openime.editor.InputConnectionGateway +import llc.slacker.openime.voice.VoiceMediaMuteController import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith +import java.lang.reflect.Proxy @RunWith(AndroidJUnit4::class) class ManualVoiceOwnershipInstrumentedTest { diff --git a/app/src/androidTest/java/llc/slacker/openime/NineKeyGesturesInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/NineKeyGesturesInstrumentedTest.kt new file mode 100644 index 00000000..6d794091 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/NineKeyGesturesInstrumentedTest.kt @@ -0,0 +1,152 @@ +package llc.slacker.openime + +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import android.view.ViewGroup +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.data.ImeSettingsRepository +import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.voice.VoiceModelLifecycleState +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Proxy + +/** Nine-key long press (letters and digit) and the swipe-up digit shortcut. */ +@RunWith(AndroidJUnit4::class) +class NineKeyGesturesInstrumentedTest { + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + private class Recorder { + val characters = mutableListOf() + val listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, args -> + when (method.name) { + "voiceModelState" -> VoiceModelLifecycleState.COLD + "onCharacter" -> { characters += args!![0] as String; null } + else -> if (method.returnType == java.lang.Boolean.TYPE) false else null + } + } as ImeKeyboardView.Listener + } + + @After + fun restoreSetting() { + ImeSettingsRepository.saveSwipeUpDigits(context, true) + } + + private fun withNineKey(test: (DirectActivityHarness, Recorder, ImeKeyboardView) -> Any?) { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val recorder = Recorder() + val keyboard = harness.awaitMain { activity -> + ImeKeyboardView(activity, recorder.listener).also { + activity.findViewById(android.R.id.content).addView(it) + } + } + try { + harness.awaitMain { if (keyboard.width > 0) true else null } + harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_9); true } + test(harness, recorder, keyboard) + } finally { + harness.awaitMain { + keyboard.shutdown() + (keyboard.parent as? ViewGroup)?.removeView(keyboard) + true + } + } + } + } + + private fun key(harness: DirectActivityHarness, keyboard: ImeKeyboardView, digit: String): View = + harness.awaitMain { keyboard.findViewWithTag("key-9:$digit")?.takeIf { it.width > 0 } } + + private fun send(view: View, action: Int, downTime: Long, x: Float, y: Float) { + val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, x, y, 0) + try { + view.dispatchTouchEvent(event) + } finally { + event.recycle() + } + } + + private fun swipeUp(harness: DirectActivityHarness, view: View, distancePx: Float) { + val downTime = SystemClock.uptimeMillis() + val x = view.width / 2f + val y = view.height / 2f + harness.awaitMain { + send(view, MotionEvent.ACTION_DOWN, downTime, x, y) + send(view, MotionEvent.ACTION_MOVE, downTime, x, y - distancePx / 2) + send(view, MotionEvent.ACTION_MOVE, downTime, x, y - distancePx) + send(view, MotionEvent.ACTION_UP, downTime, x, y - distancePx) + true + } + } + + @Test + fun swipeUpTypesTheDigitOnceAndNothingElse() = withNineKey { harness, recorder, keyboard -> + val five = key(harness, keyboard, "5") + swipeUp(harness, five, distancePx = five.height * 1.2f) + harness.awaitMain { true } + assertEquals(listOf("5"), recorder.characters.toList()) + val composition = harness.awaitMain { keyboard.findViewWithTag("pinyin-composition-editor")?.text?.toString().orEmpty() } + assertEquals("a swipe must not also start a composition", "", composition) + } + + @Test + fun aShortUpwardWobbleIsStillATap() = withNineKey { harness, recorder, keyboard -> + val five = key(harness, keyboard, "5") + swipeUp(harness, five, distancePx = 4f) + assertTrue("a tap must not commit a digit", recorder.characters.isEmpty()) + } + + @Test + fun theSwipeCanBeSwitchedOff() = withNineKey { harness, recorder, keyboard -> + ImeSettingsRepository.saveSwipeUpDigits(context, false) + val five = key(harness, keyboard, "5") + swipeUp(harness, five, distancePx = five.height * 3f) + assertTrue("with the setting off nothing is committed", recorder.characters.isEmpty()) + } + + @Test + fun longPressOffersTheDigitAndBothCasesOfTheLetters() = withNineKey { harness, recorder, keyboard -> + val two = key(harness, keyboard, "2") + assertTrue(harness.awaitMain { two.performLongClick() }) + val labels = harness.awaitMain { + val found = mutableListOf() + fun collect(view: View) { + if (view is TextView && view.contentDescription?.startsWith("输入") == true) { + found += view.text.toString() + } + if (view is ViewGroup) for (i in 0 until view.childCount) collect(view.getChildAt(i)) + } + collect(keyboard) + found.takeIf { it.isNotEmpty() } + } + assertEquals(listOf("2", "a", "b", "c", "A", "B", "C"), labels) + + harness.awaitMain { + fun find(view: View, description: String): View? { + if (view.contentDescription == description) return view + if (view is ViewGroup) for (i in 0 until view.childCount) find(view.getChildAt(i), description)?.let { return it } + return null + } + val upperB = find(keyboard, "输入B") + assertNotNull(upperB) + upperB!!.performClick() + true + } + assertEquals(listOf("B"), recorder.characters.toList()) + // Long-press timing sanity: the platform default is what drives this popup. + assertTrue(ViewConfiguration.getLongPressTimeout() > 0) + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/QuickPhraseRepositoryInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/QuickPhraseRepositoryInstrumentedTest.kt index 2c3b4434..e6bfc3d7 100644 --- a/app/src/androidTest/java/llc/slacker/openime/QuickPhraseRepositoryInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/QuickPhraseRepositoryInstrumentedTest.kt @@ -2,6 +2,7 @@ package llc.slacker.openime import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.data.QuickPhraseRepository import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull diff --git a/app/src/androidTest/java/llc/slacker/openime/RealUiInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/RealUiInstrumentedTest.kt index 871a5216..4911a21c 100644 --- a/app/src/androidTest/java/llc/slacker/openime/RealUiInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/RealUiInstrumentedTest.kt @@ -3,6 +3,7 @@ package llc.slacker.openime import android.view.View import android.view.ViewGroup import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.keyboard.ImeKeyboardView import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/androidTest/java/llc/slacker/openime/RimeNineKeyInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/RimeNineKeyInstrumentedTest.kt index 52bf7b61..88f11cb0 100644 --- a/app/src/androidTest/java/llc/slacker/openime/RimeNineKeyInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/RimeNineKeyInstrumentedTest.kt @@ -4,6 +4,9 @@ import android.os.SystemClock import android.util.Log import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import llc.slacker.openime.candidate.NineKeyPresets +import llc.slacker.openime.data.ImeSettingsRepository +import llc.slacker.openime.rime.RimeEngine import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Assume.assumeTrue diff --git a/app/src/androidTest/java/llc/slacker/openime/SelectionUpdatePolicyInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/SelectionUpdatePolicyInstrumentedTest.kt index a100e9f2..2a811518 100644 --- a/app/src/androidTest/java/llc/slacker/openime/SelectionUpdatePolicyInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/SelectionUpdatePolicyInstrumentedTest.kt @@ -1,6 +1,7 @@ package llc.slacker.openime import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.editor.shouldClearCompositionForSelectionUpdate import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/androidTest/java/llc/slacker/openime/SetupFormActionInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/SetupFormActionInstrumentedTest.kt index 319b09bd..3db80815 100644 --- a/app/src/androidTest/java/llc/slacker/openime/SetupFormActionInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/SetupFormActionInstrumentedTest.kt @@ -1,13 +1,14 @@ package llc.slacker.openime import android.content.Intent -import android.view.inputmethod.EditorInfo import android.view.View import android.view.ViewGroup +import android.view.inputmethod.EditorInfo import android.widget.Button import android.widget.EditText import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.theme.ImeSurfacePolicy import org.junit.Assert.assertFalse import org.junit.Assert.assertSame import org.junit.Assert.assertTrue diff --git a/app/src/androidTest/java/llc/slacker/openime/TextEditControlsInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/TextEditControlsInstrumentedTest.kt index e0091549..fba18410 100644 --- a/app/src/androidTest/java/llc/slacker/openime/TextEditControlsInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/TextEditControlsInstrumentedTest.kt @@ -5,6 +5,15 @@ import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.core.ImeState +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.core.Panel +import llc.slacker.openime.core.ShiftState +import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.theme.ImeAppearance +import llc.slacker.openime.theme.ImeSurfacePolicy +import llc.slacker.openime.theme.ImeTheme +import llc.slacker.openime.widget.ImeKeyView import org.junit.After import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull diff --git a/app/src/androidTest/java/llc/slacker/openime/VoiceMediaMuteRecoveryInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/VoiceMediaMuteRecoveryInstrumentedTest.kt index d3272c39..968ed834 100644 --- a/app/src/androidTest/java/llc/slacker/openime/VoiceMediaMuteRecoveryInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/VoiceMediaMuteRecoveryInstrumentedTest.kt @@ -4,6 +4,7 @@ import android.content.Context import android.media.AudioManager import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import llc.slacker.openime.voice.VoiceMediaMuteController import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue diff --git a/app/src/androidTest/java/llc/slacker/openime/VoiceViewLifecycleInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/VoiceViewLifecycleInstrumentedTest.kt index ed76bdf2..0a1f82de 100644 --- a/app/src/androidTest/java/llc/slacker/openime/VoiceViewLifecycleInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/VoiceViewLifecycleInstrumentedTest.kt @@ -6,6 +6,9 @@ import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.voice.VoiceModelLifecycleState +import llc.slacker.openime.voice.VoiceRecognitionEvents import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith diff --git a/app/src/androidTest/java/llc/slacker/openime/hotword/HotwordPacksInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/hotword/HotwordPacksInstrumentedTest.kt new file mode 100644 index 00000000..3a796df6 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/hotword/HotwordPacksInstrumentedTest.kt @@ -0,0 +1,116 @@ +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/DebugKeyboardActivity.kt b/app/src/debug/java/llc/slacker/openime/DebugKeyboardActivity.kt index c81e874e..a9492058 100644 --- a/app/src/debug/java/llc/slacker/openime/DebugKeyboardActivity.kt +++ b/app/src/debug/java/llc/slacker/openime/DebugKeyboardActivity.kt @@ -9,6 +9,18 @@ import android.widget.Button import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.TextView +import llc.slacker.openime.candidate.CandidateEngine +import llc.slacker.openime.candidate.CandidatePipeline +import llc.slacker.openime.candidate.CandidateResolver +import llc.slacker.openime.candidate.NineKeyReading +import llc.slacker.openime.candidate.PinyinLexicon +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.core.Panel +import llc.slacker.openime.core.ShiftState +import llc.slacker.openime.data.UserPhraseRepository +import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.theme.ImeAppearance +import llc.slacker.openime.theme.ImeTheme /** Debug-only keyboard renderer used by instrumented and visual tests. */ class DebugKeyboardActivity : Activity(), ImeKeyboardView.Listener, CandidateResolver { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 60b250f3..18eb25eb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -43,6 +43,11 @@ android:label="@string/activity_label_symbols" android:windowSoftInputMode="adjustResize" /> + + "none" }, ) - return (learned + fallback).distinct().take(MAX_CANDIDATES) + return boostHotwords(composition, (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, @@ -1458,7 +1518,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } else { emptyList() } - val finalCandidates = if (native.isNotEmpty()) { + val rankedCandidates = if (native.isNotEmpty()) { val nativeText = native.map { it.text }.let { texts -> if (mode == KeyboardMode.PINYIN_9) { candidatePipeline.preferExactNineKeyMatches(queryInputs.firstOrNull(), texts) @@ -1492,6 +1552,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } else { (learned + fallback).distinct().take(MAX_CANDIDATES) } + val finalCandidates = boostHotwords(composition, rankedCandidates) val nativeReferences = if (native.isNotEmpty()) { native.associate { it.text to it.reference } } else { @@ -1670,26 +1731,14 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } private fun desiredEnglishShiftState(info: EditorInfo?): ShiftState { - if (info == null || EditorInfoAdapter.isPassword(EditorInfoAdapter.kind(info))) { - return ShiftState.LOWERCASE - } - val inputType = info.inputType - if (inputType and InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS != 0) { - return ShiftState.CAPS_LOCK - } - var requestedModes = 0 - if (inputType and InputType.TYPE_TEXT_FLAG_CAP_WORDS != 0) { - requestedModes = requestedModes or TextUtils.CAP_MODE_WORDS - } - if (inputType and InputType.TYPE_TEXT_FLAG_CAP_SENTENCES != 0) { - requestedModes = requestedModes or TextUtils.CAP_MODE_SENTENCES - } - if (requestedModes == 0) return ShiftState.LOWERCASE - val capsMode = currentInputConnection?.getCursorCapsMode(requestedModes) ?: 0 - return if (capsMode != 0) ShiftState.SHIFT_ONCE else ShiftState.LOWERCASE + if (info == null) return ShiftState.LOWERCASE + return EnglishShiftPolicy.initial( + inputType = info.inputType, + password = EditorInfoAdapter.isPassword(EditorInfoAdapter.kind(info)), + ) } - private fun refreshEnglishShiftFromEditor() { + private fun resetEnglishShiftForEditor() { if (state.keyboardMode != KeyboardMode.ENGLISH_26 || state.passwordField || lastComposition.isNotEmpty()) return val next = desiredEnglishShiftState(state.editorInfo) if (next == state.shiftState) return diff --git a/app/src/main/java/llc/slacker/openime/MainActivity.kt b/app/src/main/java/llc/slacker/openime/MainActivity.kt index 0509ffe1..21892556 100644 --- a/app/src/main/java/llc/slacker/openime/MainActivity.kt +++ b/app/src/main/java/llc/slacker/openime/MainActivity.kt @@ -3,24 +3,33 @@ package llc.slacker.openime import android.Manifest import android.app.Activity import android.content.Intent -import android.content.res.ColorStateList import android.content.pm.PackageManager -import android.os.Bundle +import android.content.res.ColorStateList +import android.graphics.drawable.StateListDrawable +import android.net.Uri import android.os.Build +import android.os.Bundle +import android.provider.Settings import android.text.Editable import android.text.TextWatcher import android.view.HapticFeedbackConstants import android.view.View import android.view.ViewGroup import android.view.WindowInsets -import android.provider.Settings -import android.net.Uri import android.view.inputmethod.InputMethodManager -import android.widget.TextView -import android.widget.ImageView import android.widget.EditText +import android.widget.ImageView +import android.widget.TextView import android.widget.Toast -import android.graphics.drawable.StateListDrawable +import llc.slacker.openime.core.CrashGuard +import llc.slacker.openime.data.ImeSettingsRepository +import llc.slacker.openime.setup.SetupUi +import llc.slacker.openime.theme.ImeAppearance +import llc.slacker.openime.theme.ImeContrastPolicy +import llc.slacker.openime.theme.ImeDrawableFactory +import llc.slacker.openime.theme.ImeGeometryTokens +import llc.slacker.openime.theme.ImeSurfacePolicy +import llc.slacker.openime.theme.ImeTypographyTokens /** Match the selected IME by the exact package component, never by substring. */ internal fun matchesSelectedInputMethod(defaultInputMethodId: String, packageName: String): Boolean { diff --git a/app/src/main/java/llc/slacker/openime/QuickPhraseEditActivity.kt b/app/src/main/java/llc/slacker/openime/QuickPhraseEditActivity.kt index 4f173e3b..9594729f 100644 --- a/app/src/main/java/llc/slacker/openime/QuickPhraseEditActivity.kt +++ b/app/src/main/java/llc/slacker/openime/QuickPhraseEditActivity.kt @@ -14,6 +14,10 @@ import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TextView import android.window.OnBackInvokedCallback +import llc.slacker.openime.data.QuickPhraseRepository +import llc.slacker.openime.setup.SetupUi +import llc.slacker.openime.theme.ImeSurfacePolicy +import llc.slacker.openime.theme.ImeTypographyTokens /** Full-screen editor so the active IME can be used to edit the phrase itself. */ class QuickPhraseEditActivity : Activity() { diff --git a/app/src/main/java/llc/slacker/openime/SymbolManagerActivity.kt b/app/src/main/java/llc/slacker/openime/SymbolManagerActivity.kt index 172bd13b..727c6798 100644 --- a/app/src/main/java/llc/slacker/openime/SymbolManagerActivity.kt +++ b/app/src/main/java/llc/slacker/openime/SymbolManagerActivity.kt @@ -2,6 +2,7 @@ package llc.slacker.openime import android.app.Activity import android.content.ClipData +import android.content.res.ColorStateList import android.os.Build import android.os.Bundle import android.text.Editable @@ -13,13 +14,17 @@ import android.view.View import android.view.WindowInsets import android.view.inputmethod.EditorInfo import android.widget.EditText +import android.widget.ImageView import android.widget.LinearLayout +import android.widget.PopupMenu import android.widget.ScrollView import android.widget.TextView -import android.widget.ImageView -import android.widget.PopupMenu -import android.content.res.ColorStateList import android.window.OnBackInvokedCallback +import llc.slacker.openime.data.CustomSymbol +import llc.slacker.openime.data.CustomSymbolRepository +import llc.slacker.openime.setup.SetupUi +import llc.slacker.openime.theme.ImeGeometryTokens +import llc.slacker.openime.theme.ImeTypographyTokens /** Touch-friendly manager for user symbols and their order. */ class SymbolManagerActivity : Activity() { diff --git a/app/src/main/java/llc/slacker/openime/CandidateEngine.kt b/app/src/main/java/llc/slacker/openime/candidate/CandidateEngine.kt similarity index 99% rename from app/src/main/java/llc/slacker/openime/CandidateEngine.kt rename to app/src/main/java/llc/slacker/openime/candidate/CandidateEngine.kt index b3827bf3..aff7d82a 100644 --- a/app/src/main/java/llc/slacker/openime/CandidateEngine.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/CandidateEngine.kt @@ -1,4 +1,6 @@ -package llc.slacker.openime +package llc.slacker.openime.candidate + +import llc.slacker.openime.core.ImeData /** * Kotlin port of ui-suite/js/ime-engine.js IMEEngine. diff --git a/app/src/main/java/llc/slacker/openime/CandidatePipeline.kt b/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt similarity index 99% rename from app/src/main/java/llc/slacker/openime/CandidatePipeline.kt rename to app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt index 47a4f1cc..90f49cc3 100644 --- a/app/src/main/java/llc/slacker/openime/CandidatePipeline.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt @@ -1,4 +1,6 @@ -package llc.slacker.openime +package llc.slacker.openime.candidate + +import llc.slacker.openime.core.KeyboardMode /** * One way to read the open nine-key digits as pinyin, for the left rail. diff --git a/app/src/main/java/llc/slacker/openime/CandidateQueryCoordinator.kt b/app/src/main/java/llc/slacker/openime/candidate/CandidateQueryCoordinator.kt similarity index 96% rename from app/src/main/java/llc/slacker/openime/CandidateQueryCoordinator.kt rename to app/src/main/java/llc/slacker/openime/candidate/CandidateQueryCoordinator.kt index c9819df0..2445684e 100644 --- a/app/src/main/java/llc/slacker/openime/CandidateQueryCoordinator.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/CandidateQueryCoordinator.kt @@ -1,7 +1,10 @@ -package llc.slacker.openime +package llc.slacker.openime.candidate import android.os.Handler import android.os.SystemClock +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.rime.RimeCandidateEntry +import llc.slacker.openime.rime.RimeEngine import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicLong diff --git a/app/src/main/java/llc/slacker/openime/CandidateSnapshot.kt b/app/src/main/java/llc/slacker/openime/candidate/CandidateSnapshot.kt similarity index 95% rename from app/src/main/java/llc/slacker/openime/CandidateSnapshot.kt rename to app/src/main/java/llc/slacker/openime/candidate/CandidateSnapshot.kt index bb24a040..ec51ffb3 100644 --- a/app/src/main/java/llc/slacker/openime/CandidateSnapshot.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/CandidateSnapshot.kt @@ -1,4 +1,8 @@ -package llc.slacker.openime +package llc.slacker.openime.candidate + +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.rime.NativeCandidateReference +import llc.slacker.openime.rime.RimeInputNormalizer /** One candidate exactly as it was rendered, optionally carrying its Rime identity. */ internal data class CandidateSnapshotEntry( diff --git a/app/src/main/java/llc/slacker/openime/NativeCandidatePipeline.kt b/app/src/main/java/llc/slacker/openime/candidate/NativeCandidatePipeline.kt similarity index 73% rename from app/src/main/java/llc/slacker/openime/NativeCandidatePipeline.kt rename to app/src/main/java/llc/slacker/openime/candidate/NativeCandidatePipeline.kt index daa2c26d..c47747d9 100644 --- a/app/src/main/java/llc/slacker/openime/NativeCandidatePipeline.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/NativeCandidatePipeline.kt @@ -1,36 +1,7 @@ -package llc.slacker.openime +package llc.slacker.openime.candidate -internal data class NativeCandidateReference( - val input: String, - val nativeIndex: Int, - /** Normalized-input characters the candidate spells; -1 when unknown. */ - val consumed: Int = -1, -) { - companion object { - private const val DEFERRED_PREFIX = "\u0000openime-deferred\u0000" - private const val SEP = '\u0000' - const val DEFERRED_INDEX = -1 - - /** - * Reference used before the async native snapshot exists. The editor can - * commit immediately; RimeEngine later resolves [candidate] by text on - * its mutation queue and learns the exact visible choice. - */ - fun deferred(input: String, candidate: String): NativeCandidateReference = - NativeCandidateReference( - input = DEFERRED_PREFIX + input + SEP + candidate, - nativeIndex = DEFERRED_INDEX, - ) - - fun decodeDeferred(encoded: String): Pair? { - if (!encoded.startsWith(DEFERRED_PREFIX)) return null - val payload = encoded.removePrefix(DEFERRED_PREFIX) - val split = payload.indexOf(SEP) - if (split <= 0 || split >= payload.lastIndex) return null - return payload.substring(0, split) to payload.substring(split + 1) - } - } -} +import llc.slacker.openime.rime.NativeCandidateReference +import llc.slacker.openime.rime.RimeCandidateEntry internal data class NativeCandidateChoice( val text: String, diff --git a/app/src/main/java/llc/slacker/openime/NineKeyLocalDecoder.kt b/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt similarity index 99% rename from app/src/main/java/llc/slacker/openime/NineKeyLocalDecoder.kt rename to app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt index a224ade3..61b16c65 100644 --- a/app/src/main/java/llc/slacker/openime/NineKeyLocalDecoder.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt @@ -1,5 +1,6 @@ -package llc.slacker.openime +package llc.slacker.openime.candidate +import llc.slacker.openime.core.ImeData import kotlin.math.ln import kotlin.math.roundToInt diff --git a/app/src/main/java/llc/slacker/openime/NineKeyPerformanceTrace.kt b/app/src/main/java/llc/slacker/openime/candidate/NineKeyPerformanceTrace.kt similarity index 98% rename from app/src/main/java/llc/slacker/openime/NineKeyPerformanceTrace.kt rename to app/src/main/java/llc/slacker/openime/candidate/NineKeyPerformanceTrace.kt index 2816c0be..e91f6eb5 100644 --- a/app/src/main/java/llc/slacker/openime/NineKeyPerformanceTrace.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/NineKeyPerformanceTrace.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.candidate import android.util.Log import java.util.Locale diff --git a/app/src/main/java/llc/slacker/openime/NineKeyPresets.kt b/app/src/main/java/llc/slacker/openime/candidate/NineKeyPresets.kt similarity index 95% rename from app/src/main/java/llc/slacker/openime/NineKeyPresets.kt rename to app/src/main/java/llc/slacker/openime/candidate/NineKeyPresets.kt index 3b896c5c..11cf1de6 100644 --- a/app/src/main/java/llc/slacker/openime/NineKeyPresets.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/NineKeyPresets.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.candidate /** Small high-confidence 9-key overrides. Every entry is code-validated in tests. */ internal object NineKeyPresets { diff --git a/app/src/main/java/llc/slacker/openime/NineKeyUiState.kt b/app/src/main/java/llc/slacker/openime/candidate/NineKeyUiState.kt similarity index 98% rename from app/src/main/java/llc/slacker/openime/NineKeyUiState.kt rename to app/src/main/java/llc/slacker/openime/candidate/NineKeyUiState.kt index 0bb2bf20..b510e4a2 100644 --- a/app/src/main/java/llc/slacker/openime/NineKeyUiState.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/NineKeyUiState.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.candidate /** * Session-scoped ambiguity state owned by one CandidatePipeline instance. diff --git a/app/src/main/java/llc/slacker/openime/PersonalizedLearningPolicy.kt b/app/src/main/java/llc/slacker/openime/candidate/PersonalizedLearningPolicy.kt similarity index 87% rename from app/src/main/java/llc/slacker/openime/PersonalizedLearningPolicy.kt rename to app/src/main/java/llc/slacker/openime/candidate/PersonalizedLearningPolicy.kt index 5ff58434..9a61fbd5 100644 --- a/app/src/main/java/llc/slacker/openime/PersonalizedLearningPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/PersonalizedLearningPolicy.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.candidate import android.view.inputmethod.EditorInfo diff --git a/app/src/main/java/llc/slacker/openime/PinyinFuzzyRules.kt b/app/src/main/java/llc/slacker/openime/candidate/PinyinFuzzyRules.kt similarity index 98% rename from app/src/main/java/llc/slacker/openime/PinyinFuzzyRules.kt rename to app/src/main/java/llc/slacker/openime/candidate/PinyinFuzzyRules.kt index e8b64f45..6fb8eff1 100644 --- a/app/src/main/java/llc/slacker/openime/PinyinFuzzyRules.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/PinyinFuzzyRules.kt @@ -1,4 +1,6 @@ -package llc.slacker.openime +package llc.slacker.openime.candidate + +import llc.slacker.openime.core.ImeData /** * Kotlin fallback counterpart of the fuzzy algebra enabled by diff --git a/app/src/main/java/llc/slacker/openime/PinyinLexicon.kt b/app/src/main/java/llc/slacker/openime/candidate/PinyinLexicon.kt similarity index 72% rename from app/src/main/java/llc/slacker/openime/PinyinLexicon.kt rename to app/src/main/java/llc/slacker/openime/candidate/PinyinLexicon.kt index 153aa5bf..edffe198 100644 --- a/app/src/main/java/llc/slacker/openime/PinyinLexicon.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/PinyinLexicon.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.candidate import android.content.Context @@ -6,6 +6,7 @@ import android.content.Context object PinyinLexicon { private const val CHAR_ASSET = "pinyin_chars.tsv" private const val PHRASE_ASSET = "pinyin_phrases.tsv" + private const val CHAR_WEIGHT_ASSET = "rime-data/openime_dicts/8105.dict.yaml" private data class WeightedValue( val text: String, @@ -53,6 +54,14 @@ object PinyinLexicon { } } + // Frequencies for single characters, from the same Rime Ice corpus as the + // phrases. Without them every character ties at 0 and a syllable's first + // candidate is whatever comes first in the table (伱 before 你 for "ni"), + // which is what typing shows until librime has finished deploying. + context.assets.open(CHAR_WEIGHT_ASSET).bufferedReader(Charsets.UTF_8).useLines { lines -> + charWeights(lines).forEach { (pinyin, character, weight) -> add(pinyin, character, weight) } + } + // pinyin_phrases.tsv is generated with a real corpus weight in // column 3. The old loader parsed three columns but discarded the // weight, making the local nine-key decoder effectively frequency-blind. @@ -87,6 +96,27 @@ object PinyinLexicon { } } + /** `(pinyin, character, weight)` rows of a Rime single-character table (after its `...` marker). */ + internal fun charWeights(lines: Sequence): List> { + val rows = ArrayList>() + var inBody = false + for (line in lines) { + if (!inBody) { + inBody = line.trim() == "..." + continue + } + val fields = line.split('\t') + if (fields.size < 3) continue + val character = fields[0] + if (character.codePointCount(0, character.length) != 1) continue + val pinyin = fields[1].trim() + val weight = fields[2].trim().toLongOrNull() ?: continue + if (pinyin.isEmpty() || ' ' in pinyin) continue + rows += Triple(pinyin, character, weight.coerceIn(0L, Int.MAX_VALUE.toLong()).toInt()) + } + return rows + } + /** Already-loaded generated lexicon; empty only in isolated JVM tests before [load]. */ fun current(): Map> = cached.orEmpty() diff --git a/app/src/main/java/llc/slacker/openime/PrefixRangeIndex.kt b/app/src/main/java/llc/slacker/openime/candidate/PrefixRangeIndex.kt similarity index 97% rename from app/src/main/java/llc/slacker/openime/PrefixRangeIndex.kt rename to app/src/main/java/llc/slacker/openime/candidate/PrefixRangeIndex.kt index 3a78361d..548aa157 100644 --- a/app/src/main/java/llc/slacker/openime/PrefixRangeIndex.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/PrefixRangeIndex.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.candidate /** * Prefix lookup over a map without scanning the whole source on every query. diff --git a/app/src/main/java/llc/slacker/openime/CrashGuard.kt b/app/src/main/java/llc/slacker/openime/core/CrashGuard.kt similarity index 99% rename from app/src/main/java/llc/slacker/openime/CrashGuard.kt rename to app/src/main/java/llc/slacker/openime/core/CrashGuard.kt index 27a75d09..1949e6a9 100644 --- a/app/src/main/java/llc/slacker/openime/CrashGuard.kt +++ b/app/src/main/java/llc/slacker/openime/core/CrashGuard.kt @@ -1,8 +1,9 @@ -package llc.slacker.openime +package llc.slacker.openime.core import android.content.Context import android.os.Build import android.util.Log +import llc.slacker.openime.R import java.io.File /** diff --git a/app/src/main/java/llc/slacker/openime/ImeData.kt b/app/src/main/java/llc/slacker/openime/core/ImeData.kt similarity index 99% rename from app/src/main/java/llc/slacker/openime/ImeData.kt rename to app/src/main/java/llc/slacker/openime/core/ImeData.kt index 21d7cb8f..fc3708b0 100644 --- a/app/src/main/java/llc/slacker/openime/ImeData.kt +++ b/app/src/main/java/llc/slacker/openime/core/ImeData.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.core /** * Built-in fallback dictionaries, symbol catalogs and emoji metadata used by diff --git a/app/src/main/java/llc/slacker/openime/ImeState.kt b/app/src/main/java/llc/slacker/openime/core/ImeState.kt similarity index 91% rename from app/src/main/java/llc/slacker/openime/ImeState.kt rename to app/src/main/java/llc/slacker/openime/core/ImeState.kt index 98f443f2..dfc79270 100644 --- a/app/src/main/java/llc/slacker/openime/ImeState.kt +++ b/app/src/main/java/llc/slacker/openime/core/ImeState.kt @@ -1,6 +1,9 @@ -package llc.slacker.openime +package llc.slacker.openime.core import android.view.inputmethod.EditorInfo +import llc.slacker.openime.theme.AccentPalette +import llc.slacker.openime.theme.ImeAppearance +import llc.slacker.openime.theme.ImeTheme enum class KeyboardMode { PINYIN_26, @@ -31,11 +34,6 @@ enum class ShiftState { CAPS_LOCK, } -enum class ImeAppearance(val label: String) { - SYSTEM("跟随系统"), - LIGHT("浅色"), - DARK("深色"), -} data class ImeState( val keyboardMode: KeyboardMode = KeyboardMode.PINYIN_26, diff --git a/app/src/main/java/llc/slacker/openime/UnicodeText.kt b/app/src/main/java/llc/slacker/openime/core/UnicodeText.kt similarity index 97% rename from app/src/main/java/llc/slacker/openime/UnicodeText.kt rename to app/src/main/java/llc/slacker/openime/core/UnicodeText.kt index f1482516..f2000ac9 100644 --- a/app/src/main/java/llc/slacker/openime/UnicodeText.kt +++ b/app/src/main/java/llc/slacker/openime/core/UnicodeText.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.core /** * Unicode helpers shared by composition editing and legacy editor deletion. diff --git a/app/src/main/java/llc/slacker/openime/ClipboardHistoryRepository.kt b/app/src/main/java/llc/slacker/openime/data/ClipboardHistoryRepository.kt similarity index 98% rename from app/src/main/java/llc/slacker/openime/ClipboardHistoryRepository.kt rename to app/src/main/java/llc/slacker/openime/data/ClipboardHistoryRepository.kt index 173ce5a6..5039afc3 100644 --- a/app/src/main/java/llc/slacker/openime/ClipboardHistoryRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/ClipboardHistoryRepository.kt @@ -1,10 +1,11 @@ -package llc.slacker.openime +package llc.slacker.openime.data -import android.content.ClipboardManager import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.inputmethodservice.InputMethodService import android.view.inputmethod.EditorInfo +import llc.slacker.openime.editor.EditorInfoAdapter import org.json.JSONArray import org.json.JSONObject diff --git a/app/src/main/java/llc/slacker/openime/CustomSymbolRepository.kt b/app/src/main/java/llc/slacker/openime/data/CustomSymbolRepository.kt similarity index 99% rename from app/src/main/java/llc/slacker/openime/CustomSymbolRepository.kt rename to app/src/main/java/llc/slacker/openime/data/CustomSymbolRepository.kt index afb33fe9..6a6390dd 100644 --- a/app/src/main/java/llc/slacker/openime/CustomSymbolRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/CustomSymbolRepository.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.data import android.content.Context import org.json.JSONArray diff --git a/app/src/main/java/llc/slacker/openime/EmojiRecentRepository.kt b/app/src/main/java/llc/slacker/openime/data/EmojiRecentRepository.kt similarity index 97% rename from app/src/main/java/llc/slacker/openime/EmojiRecentRepository.kt rename to app/src/main/java/llc/slacker/openime/data/EmojiRecentRepository.kt index 380f3d1b..67c7f580 100644 --- a/app/src/main/java/llc/slacker/openime/EmojiRecentRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/EmojiRecentRepository.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.data import android.content.Context import org.json.JSONArray diff --git a/app/src/main/java/llc/slacker/openime/FluentEmojiAssetRepository.kt b/app/src/main/java/llc/slacker/openime/data/FluentEmojiAssetRepository.kt similarity index 97% rename from app/src/main/java/llc/slacker/openime/FluentEmojiAssetRepository.kt rename to app/src/main/java/llc/slacker/openime/data/FluentEmojiAssetRepository.kt index 7f44066d..067b2c92 100644 --- a/app/src/main/java/llc/slacker/openime/FluentEmojiAssetRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/FluentEmojiAssetRepository.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.data import android.content.Context diff --git a/app/src/main/java/llc/slacker/openime/ImeSettingsRepository.kt b/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt similarity index 92% rename from app/src/main/java/llc/slacker/openime/ImeSettingsRepository.kt rename to app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt index 74004077..3a91e5fd 100644 --- a/app/src/main/java/llc/slacker/openime/ImeSettingsRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt @@ -1,6 +1,10 @@ -package llc.slacker.openime +package llc.slacker.openime.data import android.content.Context +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.theme.AccentPalette +import llc.slacker.openime.theme.ImeAppearance +import llc.slacker.openime.theme.ImeTheme enum class ImeHandedness(val label: String) { STANDARD("标准"), @@ -18,6 +22,7 @@ object ImeSettingsRepository { private const val KEY_HAPTIC = "haptic" private const val KEY_POPUP = "popup" private const val KEY_FUZZY = "fuzzy" + private const val KEY_SWIPE_UP_DIGITS = "swipe_up_digits" private const val KEY_SKIN_OPACITY = "skin_opacity" private const val KEY_SKIN_RADIUS = "skin_radius" private const val KEY_SKIN_FONT = "skin_font" @@ -143,6 +148,15 @@ object ImeSettingsRepository { .edit().putBoolean(KEY_HAPTIC, enabled).apply() } + fun loadSwipeUpDigits(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_SWIPE_UP_DIGITS, true) + + fun saveSwipeUpDigits(context: Context, enabled: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putBoolean(KEY_SWIPE_UP_DIGITS, enabled).apply() + } + fun loadPopup(context: Context): Boolean = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .getBoolean(KEY_POPUP, true) diff --git a/app/src/main/java/llc/slacker/openime/PersonalizationRepository.kt b/app/src/main/java/llc/slacker/openime/data/PersonalizationRepository.kt similarity index 98% rename from app/src/main/java/llc/slacker/openime/PersonalizationRepository.kt rename to app/src/main/java/llc/slacker/openime/data/PersonalizationRepository.kt index bc44d132..d1d2d5a4 100644 --- a/app/src/main/java/llc/slacker/openime/PersonalizationRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/PersonalizationRepository.kt @@ -1,7 +1,8 @@ -package llc.slacker.openime +package llc.slacker.openime.data import android.content.Context import android.view.inputmethod.EditorInfo +import llc.slacker.openime.editor.EditorInfoAdapter import org.json.JSONArray import org.json.JSONObject diff --git a/app/src/main/java/llc/slacker/openime/QuickPhraseRepository.kt b/app/src/main/java/llc/slacker/openime/data/QuickPhraseRepository.kt similarity index 98% rename from app/src/main/java/llc/slacker/openime/QuickPhraseRepository.kt rename to app/src/main/java/llc/slacker/openime/data/QuickPhraseRepository.kt index 88cc5396..9975d8fd 100644 --- a/app/src/main/java/llc/slacker/openime/QuickPhraseRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/QuickPhraseRepository.kt @@ -1,6 +1,7 @@ -package llc.slacker.openime +package llc.slacker.openime.data import android.content.Context +import llc.slacker.openime.core.ImeData import org.json.JSONArray import org.json.JSONObject diff --git a/app/src/main/java/llc/slacker/openime/UserDataArchive.kt b/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt similarity index 97% rename from app/src/main/java/llc/slacker/openime/UserDataArchive.kt rename to app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt index b1d9b39f..e7231fd6 100644 --- a/app/src/main/java/llc/slacker/openime/UserDataArchive.kt +++ b/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt @@ -1,6 +1,10 @@ -package llc.slacker.openime +package llc.slacker.openime.data import android.content.Context +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.theme.AccentPalette +import llc.slacker.openime.theme.ImeAppearance +import llc.slacker.openime.theme.ImeTheme import org.json.JSONArray import org.json.JSONObject @@ -30,6 +34,7 @@ internal data class ArchiveSettings( val haptic: Boolean, val popup: Boolean, val fuzzy: Boolean, + val swipeUpDigits: Boolean, val skinOpacity: Int, val skinRadius: Int, val skinFont: Int, @@ -173,6 +178,7 @@ internal object UserDataArchiveCodec { .put("haptic", value.haptic) .put("popup", value.popup) .put("fuzzy", value.fuzzy) + .put("swipe_up_digits", value.swipeUpDigits) .put("skin_opacity", value.skinOpacity) .put("skin_radius", value.skinRadius) .put("skin_font", value.skinFont) @@ -191,6 +197,7 @@ internal object UserDataArchiveCodec { haptic = value.optBoolean("haptic", true), popup = value.optBoolean("popup", false), fuzzy = value.optBoolean("fuzzy", false), + swipeUpDigits = value.optBoolean("swipe_up_digits", true), skinOpacity = value.optInt("skin_opacity", 95).coerceIn(70, 100), skinRadius = value.optInt("skin_radius", 8).coerceIn(0, 24), skinFont = value.optInt("skin_font", 17).coerceIn(14, 22), @@ -319,6 +326,7 @@ internal object UserDataRepository { haptic = ImeSettingsRepository.loadHaptic(context), popup = ImeSettingsRepository.loadPopup(context), fuzzy = ImeSettingsRepository.loadFuzzy(context), + swipeUpDigits = ImeSettingsRepository.loadSwipeUpDigits(context), skinOpacity = ImeSettingsRepository.loadSkinOpacity(context), skinRadius = ImeSettingsRepository.loadSkinRadius(context), skinFont = ImeSettingsRepository.loadSkinFont(context), @@ -400,6 +408,7 @@ internal object UserDataRepository { ImeSettingsRepository.saveHaptic(context, value.haptic) ImeSettingsRepository.savePopup(context, value.popup) ImeSettingsRepository.saveFuzzy(context, value.fuzzy) + ImeSettingsRepository.saveSwipeUpDigits(context, value.swipeUpDigits) ImeSettingsRepository.saveSkin( context, opacity = value.skinOpacity.coerceIn(70, 100), diff --git a/app/src/main/java/llc/slacker/openime/UserPhraseRepository.kt b/app/src/main/java/llc/slacker/openime/data/UserPhraseRepository.kt similarity index 99% rename from app/src/main/java/llc/slacker/openime/UserPhraseRepository.kt rename to app/src/main/java/llc/slacker/openime/data/UserPhraseRepository.kt index 46ceb480..ea39837f 100644 --- a/app/src/main/java/llc/slacker/openime/UserPhraseRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/UserPhraseRepository.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.data import android.content.Context import android.content.SharedPreferences diff --git a/app/src/main/java/llc/slacker/openime/EditorInfoAdapter.kt b/app/src/main/java/llc/slacker/openime/editor/EditorInfoAdapter.kt similarity index 98% rename from app/src/main/java/llc/slacker/openime/EditorInfoAdapter.kt rename to app/src/main/java/llc/slacker/openime/editor/EditorInfoAdapter.kt index 191749f4..f0e9db27 100644 --- a/app/src/main/java/llc/slacker/openime/EditorInfoAdapter.kt +++ b/app/src/main/java/llc/slacker/openime/editor/EditorInfoAdapter.kt @@ -1,7 +1,8 @@ -package llc.slacker.openime +package llc.slacker.openime.editor import android.text.InputType import android.view.inputmethod.EditorInfo +import llc.slacker.openime.core.KeyboardMode /** Pure mappings from [EditorInfo] to IME behavior; unit-testable. */ object EditorInfoAdapter { diff --git a/app/src/main/java/llc/slacker/openime/EnterActionPolicy.kt b/app/src/main/java/llc/slacker/openime/editor/EnterActionPolicy.kt similarity index 97% rename from app/src/main/java/llc/slacker/openime/EnterActionPolicy.kt rename to app/src/main/java/llc/slacker/openime/editor/EnterActionPolicy.kt index 56347693..4799a66a 100644 --- a/app/src/main/java/llc/slacker/openime/EnterActionPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/editor/EnterActionPolicy.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.editor import android.view.inputmethod.EditorInfo diff --git a/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt b/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt similarity index 99% rename from app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt rename to app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt index e584e14f..4dc24b75 100644 --- a/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt +++ b/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.editor import android.content.ClipData import android.content.ClipboardManager @@ -9,6 +9,9 @@ import android.view.inputmethod.ExtractedText import android.view.inputmethod.ExtractedTextRequest import android.view.inputmethod.InputConnection import android.view.inputmethod.InputContentInfo +import llc.slacker.openime.R +import llc.slacker.openime.core.nextCodePointUtf16Length +import llc.slacker.openime.core.previousCodePointUtf16Length internal fun relativeCursorKeyCode(delta: Int): Int? = when (delta) { -1 -> KeyEvent.KEYCODE_DPAD_LEFT diff --git a/app/src/main/java/llc/slacker/openime/InputMethodSubtypePolicy.kt b/app/src/main/java/llc/slacker/openime/editor/InputMethodSubtypePolicy.kt similarity index 94% rename from app/src/main/java/llc/slacker/openime/InputMethodSubtypePolicy.kt rename to app/src/main/java/llc/slacker/openime/editor/InputMethodSubtypePolicy.kt index 52c9950e..436c9a80 100644 --- a/app/src/main/java/llc/slacker/openime/InputMethodSubtypePolicy.kt +++ b/app/src/main/java/llc/slacker/openime/editor/InputMethodSubtypePolicy.kt @@ -1,4 +1,6 @@ -package llc.slacker.openime +package llc.slacker.openime.editor + +import llc.slacker.openime.core.KeyboardMode internal enum class ImeSubtypeLanguage { CHINESE, diff --git a/app/src/main/java/llc/slacker/openime/SelectionUpdatePolicy.kt b/app/src/main/java/llc/slacker/openime/editor/SelectionUpdatePolicy.kt similarity index 97% rename from app/src/main/java/llc/slacker/openime/SelectionUpdatePolicy.kt rename to app/src/main/java/llc/slacker/openime/editor/SelectionUpdatePolicy.kt index 283b199c..42f4aced 100644 --- a/app/src/main/java/llc/slacker/openime/SelectionUpdatePolicy.kt +++ b/app/src/main/java/llc/slacker/openime/editor/SelectionUpdatePolicy.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.editor /** * Decide whether a host/editor selection callback invalidates the IME's diff --git a/app/src/main/java/llc/slacker/openime/FloatingDragController.kt b/app/src/main/java/llc/slacker/openime/floating/FloatingDragController.kt similarity index 84% rename from app/src/main/java/llc/slacker/openime/FloatingDragController.kt rename to app/src/main/java/llc/slacker/openime/floating/FloatingDragController.kt index 8322018e..93e5ed47 100644 --- a/app/src/main/java/llc/slacker/openime/FloatingDragController.kt +++ b/app/src/main/java/llc/slacker/openime/floating/FloatingDragController.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.floating import android.view.MotionEvent import kotlin.math.abs @@ -20,6 +20,18 @@ internal class FloatingDragController( private var lastX = 0f private var lastY = 0f + /** + * Start a drag mid-gesture (long press on the top zone). The gesture is + * already a drag, so releasing it must not count as a dock tap. + */ + fun begin(event: MotionEvent) { + active = true + moved = true + pointerId = event.getPointerId(event.actionIndex) + lastX = event.rawX + lastY = event.rawY + } + fun onTouch(event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { diff --git a/app/src/main/java/llc/slacker/openime/FloatingKeyboardController.kt b/app/src/main/java/llc/slacker/openime/floating/FloatingKeyboardController.kt similarity index 93% rename from app/src/main/java/llc/slacker/openime/FloatingKeyboardController.kt rename to app/src/main/java/llc/slacker/openime/floating/FloatingKeyboardController.kt index fc931652..31965b0f 100644 --- a/app/src/main/java/llc/slacker/openime/FloatingKeyboardController.kt +++ b/app/src/main/java/llc/slacker/openime/floating/FloatingKeyboardController.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.floating import android.content.Context import android.graphics.Canvas @@ -8,6 +8,9 @@ import android.os.Build import android.view.MotionEvent import android.view.View import android.widget.LinearLayout +import llc.slacker.openime.theme.ImeDrawableFactory +import llc.slacker.openime.theme.ImeGeometryTokens +import llc.slacker.openime.theme.ImeTheme /** * Owns floating-keyboard chrome and drag-handle interaction. @@ -24,7 +27,7 @@ internal class FloatingKeyboardController( onDragBy: (Float, Float) -> Unit, private val onDock: () -> Unit, ) { - private val dragController = FloatingDragController( + val dragController = FloatingDragController( toPx = toPx, onDragBy = onDragBy, onDock = onDock, diff --git a/app/src/main/java/llc/slacker/openime/FloatingWindowController.kt b/app/src/main/java/llc/slacker/openime/floating/FloatingWindowController.kt similarity index 91% rename from app/src/main/java/llc/slacker/openime/FloatingWindowController.kt rename to app/src/main/java/llc/slacker/openime/floating/FloatingWindowController.kt index 1f80ec0c..fb8dfa0b 100644 --- a/app/src/main/java/llc/slacker/openime/FloatingWindowController.kt +++ b/app/src/main/java/llc/slacker/openime/floating/FloatingWindowController.kt @@ -1,6 +1,5 @@ -package llc.slacker.openime +package llc.slacker.openime.floating -import android.content.res.Configuration import android.os.Handler import android.view.Gravity import android.view.Window @@ -155,21 +154,19 @@ internal class FloatingWindowController( return intArrayOf(minX, maxX, minY, maxY) } + /** + * The floating keyboard is the same size in both orientations: its width + * follows the portrait width (the shorter screen side), so a landscape game + * screen gets a phone-sized keyboard instead of a stretched one. + */ private fun floatingWidth(screenWidth: Int): Int { - val landscape = - resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + val metrics = resources.displayMetrics + val portraitWidth = minOf(metrics.widthPixels, metrics.heightPixels) val percent = floatingWidthPercent().coerceIn(72, 100) - val preferred = (screenWidth * percent / 100f).toInt() - val maximum = dp( - if (landscape) ImeGeometryTokens.FLOATING_LANDSCAPE_WIDTH_DP else 420, - ) + val preferred = (portraitWidth * percent / 100f).toInt() val available = (screenWidth - dp(16)).coerceAtLeast(dp(1)) - val minimum = minOf(dp(if (landscape) 300 else 280), available) - return minOf( - preferred, - maximum, - available, - ).coerceAtLeast(minimum) + val minimum = minOf(dp(280), available) + return minOf(preferred, dp(420), available).coerceAtLeast(minimum) } private fun displaySize(): Pair { diff --git a/app/src/main/java/llc/slacker/openime/HandwritingPadView.kt b/app/src/main/java/llc/slacker/openime/handwriting/HandwritingPadView.kt similarity index 96% rename from app/src/main/java/llc/slacker/openime/HandwritingPadView.kt rename to app/src/main/java/llc/slacker/openime/handwriting/HandwritingPadView.kt index a2f51f36..9d5f656b 100644 --- a/app/src/main/java/llc/slacker/openime/HandwritingPadView.kt +++ b/app/src/main/java/llc/slacker/openime/handwriting/HandwritingPadView.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.handwriting import android.content.Context import android.graphics.Canvas @@ -8,6 +8,8 @@ import android.graphics.Paint import android.graphics.Path import android.view.MotionEvent import android.view.View +import llc.slacker.openime.data.ImeSettingsRepository +import llc.slacker.openime.theme.AccentPalette /** * Canvas handwriting pad matching the design's helper grid, smooth strokes, diff --git a/app/src/main/java/llc/slacker/openime/HandwritingProvider.kt b/app/src/main/java/llc/slacker/openime/handwriting/HandwritingProvider.kt similarity index 96% rename from app/src/main/java/llc/slacker/openime/HandwritingProvider.kt rename to app/src/main/java/llc/slacker/openime/handwriting/HandwritingProvider.kt index b73b1959..e12f6754 100644 --- a/app/src/main/java/llc/slacker/openime/HandwritingProvider.kt +++ b/app/src/main/java/llc/slacker/openime/handwriting/HandwritingProvider.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.handwriting /** * Handwriting recognition pipeline boundary. diff --git a/app/src/main/java/llc/slacker/openime/hotword/HomophoneCorrector.kt b/app/src/main/java/llc/slacker/openime/hotword/HomophoneCorrector.kt new file mode 100644 index 00000000..871e2499 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/hotword/HomophoneCorrector.kt @@ -0,0 +1,102 @@ +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 new file mode 100644 index 00000000..f882bc37 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/hotword/HotwordPack.kt @@ -0,0 +1,21 @@ +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 new file mode 100644 index 00000000..3575d8cf --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/hotword/HotwordPackStore.kt @@ -0,0 +1,144 @@ +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 new file mode 100644 index 00000000..90045518 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/hotword/HotwordPacksActivity.kt @@ -0,0 +1,215 @@ +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 new file mode 100644 index 00000000..d7600d45 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/hotword/HotwordParser.kt @@ -0,0 +1,92 @@ +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 new file mode 100644 index 00000000..bfab5edc --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/hotword/HotwordRuntime.kt @@ -0,0 +1,59 @@ +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 new file mode 100644 index 00000000..6a3f6d63 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/hotword/PinyinCandidateIndex.kt @@ -0,0 +1,74 @@ +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 new file mode 100644 index 00000000..2588ce1e --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/hotword/PinyinReadings.kt @@ -0,0 +1,55 @@ +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/BackspaceGestureController.kt b/app/src/main/java/llc/slacker/openime/keyboard/BackspaceGestureController.kt similarity index 99% rename from app/src/main/java/llc/slacker/openime/BackspaceGestureController.kt rename to app/src/main/java/llc/slacker/openime/keyboard/BackspaceGestureController.kt index 57880c7b..01bda39c 100644 --- a/app/src/main/java/llc/slacker/openime/BackspaceGestureController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/BackspaceGestureController.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.keyboard import android.os.Handler import android.os.Looper diff --git a/app/src/main/java/llc/slacker/openime/BackspaceKeyFactory.kt b/app/src/main/java/llc/slacker/openime/keyboard/BackspaceKeyFactory.kt similarity index 95% rename from app/src/main/java/llc/slacker/openime/BackspaceKeyFactory.kt rename to app/src/main/java/llc/slacker/openime/keyboard/BackspaceKeyFactory.kt index 1a591168..be8eb47c 100644 --- a/app/src/main/java/llc/slacker/openime/BackspaceKeyFactory.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/BackspaceKeyFactory.kt @@ -1,10 +1,13 @@ -package llc.slacker.openime +package llc.slacker.openime.keyboard import android.content.Context import android.util.Log import android.view.MotionEvent import android.view.View import android.view.accessibility.AccessibilityNodeInfo +import llc.slacker.openime.R +import llc.slacker.openime.theme.ImeTheme +import llc.slacker.openime.widget.ImeKeyView /** * Builds and binds the production backspace key. Gesture state and thresholds diff --git a/app/src/main/java/llc/slacker/openime/CandidateBarController.kt b/app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt similarity index 97% rename from app/src/main/java/llc/slacker/openime/CandidateBarController.kt rename to app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt index 6eb75e93..c35e6a6e 100644 --- a/app/src/main/java/llc/slacker/openime/CandidateBarController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.keyboard import android.app.AlertDialog import android.content.Context @@ -17,6 +17,13 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TextView +import llc.slacker.openime.setup.SetupUi +import llc.slacker.openime.theme.ImeGeometryTokens +import llc.slacker.openime.theme.ImeMotionTokens +import llc.slacker.openime.theme.ImeSurfacePolicy +import llc.slacker.openime.theme.ImeTheme +import llc.slacker.openime.theme.ImeTypographyTokens +import llc.slacker.openime.widget.ImeKeyView import kotlin.math.abs /** diff --git a/app/src/main/java/llc/slacker/openime/CandidateLayoutPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/CandidateLayoutPolicy.kt similarity index 95% rename from app/src/main/java/llc/slacker/openime/CandidateLayoutPolicy.kt rename to app/src/main/java/llc/slacker/openime/keyboard/CandidateLayoutPolicy.kt index c7833e58..ae21983a 100644 --- a/app/src/main/java/llc/slacker/openime/CandidateLayoutPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/CandidateLayoutPolicy.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.keyboard /** Reserve wider cells for phrases while keeping short candidates compact. */ internal fun candidateColumnSpan(text: String): Int = when { diff --git a/app/src/main/java/llc/slacker/openime/keyboard/EnglishShiftPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/EnglishShiftPolicy.kt new file mode 100644 index 00000000..7814b53e --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/keyboard/EnglishShiftPolicy.kt @@ -0,0 +1,21 @@ +package llc.slacker.openime.keyboard + +import android.text.InputType +import llc.slacker.openime.core.ShiftState + +/** + * Starting Shift state of the English 26-key surface. + * + * English mode never capitalizes on its own: sentence and word auto-capitals + * requested by the editor are ignored, and keys show lowercase letters. Only a + * field that demands capitals (TYPE_TEXT_FLAG_CAP_CHARACTERS) starts in Caps + * Lock; the user can still tap Shift for a capital. + */ +internal object EnglishShiftPolicy { + fun initial(inputType: Int, password: Boolean): ShiftState = + if (!password && inputType and InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS != 0) { + ShiftState.CAPS_LOCK + } else { + ShiftState.LOWERCASE + } +} diff --git a/app/src/main/java/llc/slacker/openime/HardwareKeyPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/HardwareKeyPolicy.kt similarity index 99% rename from app/src/main/java/llc/slacker/openime/HardwareKeyPolicy.kt rename to app/src/main/java/llc/slacker/openime/keyboard/HardwareKeyPolicy.kt index a019b810..f736a4d3 100644 --- a/app/src/main/java/llc/slacker/openime/HardwareKeyPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/HardwareKeyPolicy.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.keyboard import android.view.KeyEvent diff --git a/app/src/main/java/llc/slacker/openime/ImeBottomInsetPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/ImeBottomInsetPolicy.kt similarity index 94% rename from app/src/main/java/llc/slacker/openime/ImeBottomInsetPolicy.kt rename to app/src/main/java/llc/slacker/openime/keyboard/ImeBottomInsetPolicy.kt index 3426c634..719e6aad 100644 --- a/app/src/main/java/llc/slacker/openime/ImeBottomInsetPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeBottomInsetPolicy.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.keyboard internal object ImeBottomInsetPolicy { fun clampInset(reportedPx: Int, maxPx: Int): Int = diff --git a/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt similarity index 97% rename from app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt rename to app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt index 76988f79..e019c582 100644 --- a/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.keyboard import android.content.Context import android.content.Intent @@ -10,8 +10,8 @@ import android.os.Build import android.os.Handler import android.os.Looper import android.os.SystemClock -import android.util.Log import android.text.TextUtils +import android.util.Log import android.view.Gravity import android.view.HapticFeedbackConstants import android.view.MotionEvent @@ -20,10 +20,48 @@ import android.view.View import android.view.ViewGroup import android.view.WindowInsets import android.view.animation.DecelerateInterpolator -import android.widget.FrameLayout import android.widget.EditText +import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.TextView +import llc.slacker.openime.QuickPhraseEditActivity +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.core.ImeState +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.core.Panel +import llc.slacker.openime.core.ShiftState +import llc.slacker.openime.data.ImeHandedness +import llc.slacker.openime.data.ImeSettingsRepository +import llc.slacker.openime.data.QuickPhrase +import llc.slacker.openime.data.QuickPhraseRepository +import llc.slacker.openime.editor.EditorInfoAdapter +import llc.slacker.openime.editor.enterKeyPresentationFor +import llc.slacker.openime.floating.FloatingKeyboardController +import llc.slacker.openime.panel.ClipboardPanelController +import llc.slacker.openime.panel.EmojiCellFactory +import llc.slacker.openime.panel.ImePanelRenderer +import llc.slacker.openime.panel.PanelHeaderFactory +import llc.slacker.openime.panel.SettingsPanelController +import llc.slacker.openime.panel.TextEditorPanelController +import llc.slacker.openime.theme.AccentPalette +import llc.slacker.openime.theme.ImeAppearance +import llc.slacker.openime.theme.ImeDrawableFactory +import llc.slacker.openime.theme.ImeFocusRingPolicy +import llc.slacker.openime.theme.ImeGeometryTokens +import llc.slacker.openime.theme.ImeMotionTokens +import llc.slacker.openime.theme.ImeReferenceSizing +import llc.slacker.openime.theme.ImeSurfacePolicy +import llc.slacker.openime.theme.ImeTheme +import llc.slacker.openime.theme.ImeTypographyTokens +import llc.slacker.openime.voice.InlineVoicePresenter +import llc.slacker.openime.voice.VoicePanelController +import llc.slacker.openime.voice.VoiceSessionHost +import llc.slacker.openime.widget.ImeKeyView /** * Native IME top-level view. Visual baseline: the supplied preview.html prototype. @@ -37,7 +75,7 @@ open class ImeKeyboardView( private val standalonePanel: Boolean = false, ) : FrameLayout(context) { - interface Listener { + interface Listener : VoiceSessionHost { fun onModeChanged(mode: KeyboardMode) fun onPanelChanged(panel: Panel) fun onCharacter(char: String) @@ -55,19 +93,7 @@ open class ImeKeyboardView( fun onVoicePressChanged(pressed: Boolean) { if (pressed) onVoiceToggle() } - /** Kept for compatibility; production voice always commits on release of space. */ - fun onVoiceSessionStarted(autoCommitOnFinal: Boolean) {} - fun onVoicePartial(text: String) {} - fun onVoiceFinal(text: String) {} - fun onVoiceError(message: String) {} fun onVoiceCommit() {} - fun onVoiceCancel() {} - fun voiceModelState(): VoiceModelLifecycleState = VoiceModelLifecycleState.COLD - fun startVoiceRecognition(languageTag: String, events: VoiceRecognitionEvents) { - events.onError("本地语音服务未连接") - } - fun stopVoiceRecognition() {} - fun cancelVoiceRecognition() {} fun onEnter() fun onCompositionChanged(composition: String, candidates: List) fun onNineKeyCompositionChanged( @@ -241,11 +267,15 @@ open class ImeKeyboardView( private var floatingWidthPercent = ImeSettingsRepository.loadFloatingWidthPercent(context) private var floatingOpacityPercent = ImeSettingsRepository.loadFloatingOpacityPercent(context) private var referenceScale = ImeReferenceSizing.scale(context) - private var layoutMetrics = KeyboardLayoutMetrics( - landscape = appliedOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE, + private var floatingWindowMode = false + private var layoutMetrics = buildLayoutMetrics() + /** Landscape uses compact rows, except in floating mode, which keeps portrait size. */ + private fun buildLayoutMetrics() = KeyboardLayoutMetrics( + landscape = appliedOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE && + !floatingWindowMode, fontScale = appliedFontScale, heightPercent = keyboardHeightPercent, - availableWidthDp = resources.configuration.screenWidthDp, + availableWidthDp = resources.configuration.screenWidthDp, ) private var lastTextMode = KeyboardMode.PINYIN_26 private var preferredChineseMode = ImeSettingsRepository.loadPreferredChineseMode(context) @@ -406,6 +436,8 @@ open class ImeKeyboardView( onNineKey = ::onNineKey, onPinyinSegment = ::onPinyinSegment, onShowChoicePopup = ::showChoicePopup, + onShowChoiceRows = ::showChoiceRows, + swipeUpEnabled = { ImeSettingsRepository.loadSwipeUpDigits(context) }, onCommitCharacter = ::commitKeyboardCharacter, onShowSymbols = { showPanel(Panel.SYMBOLS) }, onDigits = { setMode(KeyboardMode.DIGITS) }, @@ -446,7 +478,6 @@ open class ImeKeyboardView( onEnter = listener::onEnter, ) } - private var floatingWindowMode = false private var systemBottomInsetPx = 0 private val maxContentWidthDp = 600 // Portrait row height follows the available screen width. Landscape stays @@ -590,6 +621,7 @@ open class ImeKeyboardView( currentSound = { soundEnabled }, currentHaptic = { hapticEnabled }, currentPopup = { popupEnabled }, + currentSwipeUpDigits = { ImeSettingsRepository.loadSwipeUpDigits(context) }, currentFuzzy = { fuzzyEnabled }, currentSkinOpacity = { skinOpacity }, currentSkinRadius = { skinRadius }, @@ -837,12 +869,7 @@ open class ImeKeyboardView( appliedOrientation = newConfig.orientation appliedFontScale = newConfig.fontScale appliedDensityDpi = newConfig.densityDpi - layoutMetrics = KeyboardLayoutMetrics( - landscape = appliedOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE, - fontScale = appliedFontScale, - heightPercent = keyboardHeightPercent, - availableWidthDp = resources.configuration.screenWidthDp, - ) + layoutMetrics = buildLayoutMetrics() if (!geometryChanged) return // Do not yank the user out of an open panel. applyDynamicHeights() @@ -985,7 +1012,7 @@ open class ImeKeyboardView( .coerceAtMost(measuredWidthPx) } - val nextScale = ImeReferenceSizing.scale(context, dockWidthPx) + val nextScale = ImeReferenceSizing.scale(context, dockWidthPx, landscapeCompact = !floatingWindowMode) // Configuration.screenWidthDp is a whole number while the measured width // is not (411dp vs 411.43dp on a 1080px / 420dpi screen), so the two // scales differ by up to 1/390 for the same window. That rounding noise @@ -1130,6 +1157,10 @@ open class ImeKeyboardView( onEmoji = { showPanel(Panel.EMOJI) }, onSymbols = { showPanel(Panel.SYMBOLS) }, onTextEditor = { showPanel(Panel.TEXT_EDITOR) }, + onQuickPhrases = { + clipboardPanelController.selectTab(1) + showPanel(Panel.CLIPBOARD) + }, onHideKeyboard = ::hideKeyboard, onTools = { showPanel(Panel.TOOLS) }, onExpandCandidates = { @@ -1314,7 +1345,13 @@ open class ImeKeyboardView( floatingWindowMode = enabled navigationBottomInsetPx = if (enabled) 0 else ImeBottomInsetPolicy.clampInset(systemBottomInsetPx, dp(32)) floatingKeyboardController.setEnabled(enabled) + topZone.setCompactToolbar(enabled) + layoutMetrics = buildLayoutMetrics() + renderedMode = null + topZone.setLongPressDrag(if (enabled) floatingKeyboardController.dragController else null) applyDynamicHeights() + // Floating landscape uses portrait rows, so the key surface must be rebuilt. + if (!standalonePanel && panel == Panel.NONE) renderModeBody() if (enabled) { contentInsetPx = dp(0) keyboardBody.setPadding(contentInsetPx, dp(6), contentInsetPx, dp(10)) @@ -1544,12 +1581,7 @@ open class ImeKeyboardView( ImeSettingsRepository.saveKeyboardHeightPercent(context, bounded) listener.onKeyboardHeightChanged(bounded) renderedMode = null - layoutMetrics = KeyboardLayoutMetrics( - landscape = appliedOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE, - fontScale = appliedFontScale, - heightPercent = keyboardHeightPercent, - availableWidthDp = resources.configuration.screenWidthDp, - ) + layoutMetrics = buildLayoutMetrics() applyDynamicHeights() if (!standalonePanel && panel == Panel.NONE) { renderModeBody() @@ -1601,12 +1633,7 @@ open class ImeKeyboardView( keyboardHandedness = persistedHandedness keyboardHeightPercent = persistedHeight if (heightChanged) renderedMode = null - layoutMetrics = KeyboardLayoutMetrics( - landscape = appliedOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE, - fontScale = appliedFontScale, - heightPercent = keyboardHeightPercent, - availableWidthDp = resources.configuration.screenWidthDp, - ) + layoutMetrics = buildLayoutMetrics() applyDynamicHeights() if (!standalonePanel && panel == Panel.NONE) { renderModeBody() @@ -2183,6 +2210,7 @@ open class ImeKeyboardView( "触感震动" -> hapticEnabled "模糊音纠错", "启用模糊音" -> fuzzyEnabled "按键气泡" -> popupEnabled + "上滑输入数字" -> ImeSettingsRepository.loadSwipeUpDigits(context) else -> true } @@ -2204,6 +2232,8 @@ open class ImeKeyboardView( popupEnabled = enabled listener.onPopupChanged(enabled) } + // Read at gesture time, so it needs no listener round trip. + "上滑输入数字" -> ImeSettingsRepository.saveSwipeUpDigits(context, enabled) } } @@ -2939,6 +2969,10 @@ open class ImeKeyboardView( keyPopupController.showChoices(anchor, choices) } + private fun showChoiceRows(anchor: View, rows: List>) { + keyPopupController.showChoiceRows(anchor, rows) + } + private fun hidePopup() { keyPopupController.hide() } diff --git a/app/src/main/java/llc/slacker/openime/ImeThemeApplier.kt b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt similarity index 98% rename from app/src/main/java/llc/slacker/openime/ImeThemeApplier.kt rename to app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt index 4e54cd9c..50ddb79c 100644 --- a/app/src/main/java/llc/slacker/openime/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.keyboard import android.content.res.ColorStateList import android.graphics.Color @@ -12,6 +12,14 @@ import android.widget.LinearLayout import android.widget.ScrollView import android.widget.SeekBar import android.widget.TextView +import llc.slacker.openime.R +import llc.slacker.openime.handwriting.HandwritingPadView +import llc.slacker.openime.theme.AccentPalette +import llc.slacker.openime.theme.ImeDrawableFactory +import llc.slacker.openime.theme.ImeGeometryTokens +import llc.slacker.openime.theme.ImeSurfacePolicy +import llc.slacker.openime.theme.ImeTheme +import llc.slacker.openime.widget.ImeKeyView /** * Applies the current IME design tokens to an already-built native view tree. diff --git a/app/src/main/java/llc/slacker/openime/ImeTopZone.kt b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt similarity index 79% rename from app/src/main/java/llc/slacker/openime/ImeTopZone.kt rename to app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt index e8f4b8c8..aab803e5 100644 --- a/app/src/main/java/llc/slacker/openime/ImeTopZone.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.keyboard import android.content.Context import android.content.res.ColorStateList @@ -8,13 +8,23 @@ import android.text.InputType import android.text.TextUtils import android.text.TextWatcher import android.view.Gravity +import android.view.HapticFeedbackConstants +import android.view.MotionEvent import android.view.View +import android.view.ViewConfiguration import android.view.ViewGroup import android.widget.EditText import android.widget.HorizontalScrollView import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import llc.slacker.openime.R +import llc.slacker.openime.floating.FloatingDragController +import llc.slacker.openime.theme.ImeDrawableFactory +import llc.slacker.openime.theme.ImeGeometryTokens +import llc.slacker.openime.theme.ImeSurfacePolicy +import llc.slacker.openime.theme.ImeTheme +import llc.slacker.openime.theme.ImeTypographyTokens internal enum class ImeTopZoneState { IDLE, @@ -40,6 +50,7 @@ internal class ImeTopZone( onEmoji: () -> Unit, onSymbols: () -> Unit, onTextEditor: () -> Unit, + onQuickPhrases: () -> Unit = {}, onHideKeyboard: () -> Unit, onTools: () -> Unit, onExpandCandidates: () -> Unit, @@ -66,6 +77,8 @@ internal class ImeTopZone( private val undoBannerLabel = TextView(context) private val undoBannerAction = TextView(context) private lateinit var toolbarIcons: List + private lateinit var compactToolbarIcons: List + private var compact = false private lateinit var keyboardHide: View private lateinit var associationScroll: HorizontalScrollView private var toolbarMode = ToolbarMode.NORMAL @@ -93,15 +106,20 @@ internal class ImeTopZone( setPadding(toPx(6), 0, toPx(6), 0) minimumHeight = toPx(ImeGeometryTokens.TOOLBAR_HEIGHT_DP) } - toolbarIcons = listOf( - toolbarIcon(R.drawable.ic_keyboard, "切换键盘", "keyboard-selector", onKeyboardSelect), - toolbarIcon(R.drawable.ic_clipboard, "剪贴板", "clipboard-toolbar", onClipboard), - toolbarIcon(R.drawable.ic_emoji, "表情", "toolbar", onEmoji), - toolbarIcon(R.drawable.ic_text_cursor, "文本编辑", "toolbar", onTextEditor), - toolbarIcon(R.drawable.ic_undo, "撤销", "undo-toolbar") { onUndoClear() }, - toolbarIcon(R.drawable.ic_grid, "更多", "toolbar", onTools), - ) - toolbarIcons.forEach { toolbarRow.addView(it, LinearLayout.LayoutParams(0, toPx(48), 1f)) } + val keyboardIcon = toolbarIcon(R.drawable.ic_keyboard, "切换键盘", "keyboard-selector", onKeyboardSelect) + val clipboardIcon = toolbarIcon(R.drawable.ic_clipboard, "剪贴板", "clipboard-toolbar", onClipboard) + val phraseIcon = toolbarIcon(R.drawable.ic_bubble, "常用语", "quick-phrase-toolbar", onQuickPhrases) + val emojiIcon = toolbarIcon(R.drawable.ic_emoji, "表情", "toolbar", onEmoji) + val textEditIcon = toolbarIcon(R.drawable.ic_text_cursor, "文本编辑", "toolbar", onTextEditor) + val undoIcon = toolbarIcon(R.drawable.ic_undo, "撤销", "undo-toolbar") { onUndoClear() } + val toolsIcon = toolbarIcon(R.drawable.ic_grid, "更多", "toolbar", onTools) + // Docked keeps the full toolbar. Floating (game) mode drops text editing + // and undo and puts quick phrases one tap away. + toolbarIcons = listOf(keyboardIcon, clipboardIcon, emojiIcon, textEditIcon, undoIcon, toolsIcon) + compactToolbarIcons = listOf(keyboardIcon, phraseIcon, emojiIcon, toolsIcon) + (toolbarIcons + phraseIcon).distinct().forEach { + toolbarRow.addView(it, LinearLayout.LayoutParams(0, toPx(48), 1f)) + } // Association state: "‹ words… ∨". The back control and the hide // control are fixed-width; the words take the rest of the row. @@ -385,6 +403,9 @@ internal class ImeTopZone( setMargins(toPx(8), toPx(8), toPx(8), toPx(8)) }, ) + // The quick-phrase icon exists for floating mode only; settle the + // initial docked state instead of waiting for the first toolbar change. + refreshToolbar() } fun showAssociations(show: Boolean) { @@ -414,10 +435,91 @@ internal class ImeTopZone( if (discardSnapshot && wasVisible) onUndoClearExpired() } + private var longPressDrag: FloatingDragController? = null + private var longPressArmed = false + private var longPressDragging = false + private var pressRawX = 0f + private var pressRawY = 0f + private val longPressRunnable = Runnable { + longPressArmed = true + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + } + + /** + * Floating mode: long-press anywhere on the top zone, then drag, to move the + * keyboard. Taps and scrolls on toolbar icons and candidates are untouched. + */ + fun setLongPressDrag(controller: FloatingDragController?) { + longPressDrag = controller + resetLongPress() + } + + private fun resetLongPress() { + removeCallbacks(longPressRunnable) + longPressArmed = false + longPressDragging = false + } + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + val drag = longPressDrag ?: return false + when (ev.actionMasked) { + MotionEvent.ACTION_DOWN -> { + resetLongPress() + pressRawX = ev.rawX + pressRawY = ev.rawY + postDelayed(longPressRunnable, ViewConfiguration.getLongPressTimeout().toLong()) + } + + MotionEvent.ACTION_MOVE -> { + if (longPressArmed) { + drag.begin(ev) + longPressDragging = true + return true + } + val slop = ViewConfiguration.get(context).scaledTouchSlop + if (kotlin.math.abs(ev.rawX - pressRawX) > slop || + kotlin.math.abs(ev.rawY - pressRawY) > slop + ) { + removeCallbacks(longPressRunnable) + } + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + // A long press that never moved is not a tap on the icon under it. + val swallow = longPressArmed + resetLongPress() + return swallow + } + } + return false + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + val drag = longPressDrag + if (drag == null || !longPressDragging) return super.onTouchEvent(event) + drag.onTouch(event) + if (event.actionMasked == MotionEvent.ACTION_UP || + event.actionMasked == MotionEvent.ACTION_CANCEL + ) { + resetLongPress() + } + return true + } + + /** Floating (game) mode: a slimmer toolbar without text editing and undo. */ + fun setCompactToolbar(value: Boolean) { + if (compact == value) return + compact = value + refreshToolbar() + } + private fun refreshToolbar() { val normal = toolbarMode == ToolbarMode.NORMAL val association = toolbarMode == ToolbarMode.ASSOCIATION - toolbarIcons.forEach { it.visibility = if (normal) View.VISIBLE else View.GONE } + val shown = if (compact) compactToolbarIcons else toolbarIcons + (toolbarIcons + compactToolbarIcons).distinct().forEach { + it.visibility = if (normal && it in shown) View.VISIBLE else View.GONE + } associationBack.visibility = if (association) View.VISIBLE else View.GONE associationScroll.visibility = if (association) View.VISIBLE else View.GONE undoBanner.visibility = if (toolbarMode == ToolbarMode.UNDO) View.VISIBLE else View.GONE diff --git a/app/src/main/java/llc/slacker/openime/KeyPopupController.kt b/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt similarity index 81% rename from app/src/main/java/llc/slacker/openime/KeyPopupController.kt rename to app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt index b205e0d2..398b36f7 100644 --- a/app/src/main/java/llc/slacker/openime/KeyPopupController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.keyboard import android.graphics.Color import android.graphics.drawable.Drawable @@ -9,6 +9,12 @@ import android.view.animation.DecelerateInterpolator import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.TextView +import llc.slacker.openime.theme.ImeDrawableFactory +import llc.slacker.openime.theme.ImeGeometryTokens +import llc.slacker.openime.theme.ImeMotionTokens +import llc.slacker.openime.theme.ImeSurfacePolicy +import llc.slacker.openime.theme.ImeTheme +import llc.slacker.openime.theme.ImeTypographyTokens /** * Owns transient key popups without owning keyboard state. @@ -194,14 +200,17 @@ internal class KeyPopupController( popupView = previewPopup } - fun showChoices(anchor: View, choices: List) { + fun showChoices(anchor: View, choices: List) = showChoiceRows(anchor, listOf(choices)) + + /** Long-press selector with one horizontal row per entry of [rows]. */ + fun showChoiceRows(anchor: View, rows: List>) { + if (rows.isEmpty() || rows.all { it.isEmpty() }) return hide() keepAfterKeyUp = true val t = tokens() - val row = LinearLayout(host.context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER + val column = LinearLayout(host.context).apply { + orientation = LinearLayout.VERTICAL setPadding(dp(5), dp(5), dp(5), dp(5)) background = ImeDrawableFactory.rounded( t.keyBackground, @@ -213,41 +222,55 @@ internal class KeyPopupController( contentDescription = "长按符号选择" } - choices.forEach { symbol -> - row.addView( - TextView(host.context).apply { - text = symbol - textSize = ImeTypographyTokens.BODY_SP - includeFontPadding = false - gravity = Gravity.CENTER - setTextColor(t.keyText) - background = statefulRounded( - Color.TRANSPARENT, - ImeSurfacePolicy.pressedSurface(t.keyBackground, t), - dp(ImeGeometryTokens.KEY_RADIUS_DP), - ) - isClickable = true - isFocusable = true - contentDescription = "输入$symbol" - setPadding(dp(11), 0, dp(11), 0) - setOnClickListener { - hide() - feedback() - onSymbolSelected(symbol) - } - }, + rows.forEach { choices -> + val row = LinearLayout(host.context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER + } + choices.forEach { symbol -> + row.addView( + TextView(host.context).apply { + text = symbol + textSize = ImeTypographyTokens.BODY_SP + includeFontPadding = false + gravity = Gravity.CENTER + setTextColor(t.keyText) + background = statefulRounded( + Color.TRANSPARENT, + ImeSurfacePolicy.pressedSurface(t.keyBackground, t), + dp(ImeGeometryTokens.KEY_RADIUS_DP), + ) + isClickable = true + isFocusable = true + contentDescription = "输入$symbol" + setPadding(dp(11), 0, dp(11), 0) + setOnClickListener { + hide() + feedback() + onSymbolSelected(symbol) + } + }, + LinearLayout.LayoutParams( + dp(ImeGeometryTokens.TOUCH_TARGET_DP), + dp(ImeGeometryTokens.TOUCH_TARGET_DP), + ), + ) + } + column.addView( + row, LinearLayout.LayoutParams( - dp(ImeGeometryTokens.TOUCH_TARGET_DP), + LinearLayout.LayoutParams.WRAP_CONTENT, dp(ImeGeometryTokens.TOUCH_TARGET_DP), ), ) } - val popupWidth = dp(48 * choices.size + 10) - val popupHeight = dp(58) - placeAbove(anchor, row, popupWidth, popupHeight) - popupView = row - animateIn(row, popupWidth, popupHeight) + val widest = rows.maxOf { it.size } + val popupWidth = dp(48 * widest + 10) + val popupHeight = dp(48 * rows.size + 10) + placeAbove(anchor, column, popupWidth, popupHeight) + popupView = column + animateIn(column, popupWidth, popupHeight) } fun hideIfOutside(x: Float, y: Float) { diff --git a/app/src/main/java/llc/slacker/openime/KeyboardGeometry.kt b/app/src/main/java/llc/slacker/openime/keyboard/KeyboardGeometry.kt similarity index 82% rename from app/src/main/java/llc/slacker/openime/KeyboardGeometry.kt rename to app/src/main/java/llc/slacker/openime/keyboard/KeyboardGeometry.kt index 5fbd4e9e..b0ec4206 100644 --- a/app/src/main/java/llc/slacker/openime/KeyboardGeometry.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeyboardGeometry.kt @@ -1,7 +1,8 @@ -package llc.slacker.openime +package llc.slacker.openime.keyboard import android.graphics.RectF import android.view.View +import llc.slacker.openime.theme.ImeGeometryTokens /** * Keyboard-local normalized coordinate system. @@ -96,15 +97,3 @@ internal data class KeyboardLayoutMetrics( } -/** All reference dimensions scale together from the PDF's 390-unit canvas. */ -internal object ImeReferenceSizing { - fun scale(context: android.content.Context, widthPx: Int = 0): Float { - val config = context.resources.configuration - val metrics = context.resources.displayMetrics - val widthDp = if (widthPx > 0) widthPx / metrics.density else config.screenWidthDp.toFloat() - val widthScale = widthDp.coerceAtMost(600f) / 390f - return if (config.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE) { - minOf(widthScale, config.screenHeightDp * 0.55f / 256f) - } else widthScale - } -} diff --git a/app/src/main/java/llc/slacker/openime/keyboard/NineKeyLongPressPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/NineKeyLongPressPolicy.kt new file mode 100644 index 00000000..95417164 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/keyboard/NineKeyLongPressPolicy.kt @@ -0,0 +1,19 @@ +package llc.slacker.openime.keyboard + +import llc.slacker.openime.core.ImeData + +/** + * What a long press on a nine-key digit key offers. + * + * Row one is the digit and the lowercase letters on the key, row two the same + * letters in uppercase, so a letter of either case or the digit is one tap away + * without leaving the nine-key surface. Keys with no letters (1, 0, *, #) keep + * their own behavior and return no rows. + */ +internal object NineKeyLongPressPolicy { + fun choiceRows(digit: String, keypad: Map> = ImeData.keypad9Map): List> { + val letters = keypad[digit].orEmpty().filter { it.length == 1 && it[0] in 'a'..'z' } + if (letters.isEmpty()) return emptyList() + return listOf(listOf(digit) + letters, letters.map { it.uppercase() }) + } +} diff --git a/app/src/main/java/llc/slacker/openime/NineKeySegmentRepairController.kt b/app/src/main/java/llc/slacker/openime/keyboard/NineKeySegmentRepairController.kt similarity index 93% rename from app/src/main/java/llc/slacker/openime/NineKeySegmentRepairController.kt rename to app/src/main/java/llc/slacker/openime/keyboard/NineKeySegmentRepairController.kt index 7bc19c23..95119ffb 100644 --- a/app/src/main/java/llc/slacker/openime/NineKeySegmentRepairController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/NineKeySegmentRepairController.kt @@ -1,10 +1,15 @@ -package llc.slacker.openime +package llc.slacker.openime.keyboard import android.content.Context import android.os.Bundle import android.view.View import android.view.accessibility.AccessibilityNodeInfo import android.widget.EditText +import llc.slacker.openime.candidate.CandidateResolver +import llc.slacker.openime.candidate.NineKeyLocalDecoder +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.data.ImeSettingsRepository +import llc.slacker.openime.widget.ImeKeyView /** * Repairs edits made inside an earlier Chinese 9-key segment. diff --git a/app/src/main/java/llc/slacker/openime/NineKeySymbolRailController.kt b/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt similarity index 97% rename from app/src/main/java/llc/slacker/openime/NineKeySymbolRailController.kt rename to app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt index 6655b2dc..55110f41 100644 --- a/app/src/main/java/llc/slacker/openime/NineKeySymbolRailController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.keyboard import android.content.Context import android.graphics.Typeface @@ -11,6 +11,11 @@ import android.widget.EditText import android.widget.LinearLayout import android.widget.ScrollView 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 /** * Owns the Chinese 9-key side rail. diff --git a/app/src/main/java/llc/slacker/openime/NumericKeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt similarity index 96% rename from app/src/main/java/llc/slacker/openime/NumericKeyboardRenderer.kt rename to app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt index b3b9c5c9..a2e4ddc1 100644 --- a/app/src/main/java/llc/slacker/openime/NumericKeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt @@ -1,9 +1,14 @@ -package llc.slacker.openime +package llc.slacker.openime.keyboard import android.content.Context import android.view.View import android.widget.LinearLayout import android.widget.ScrollView +import llc.slacker.openime.core.ImeData +import llc.slacker.openime.data.CustomSymbolRepository +import llc.slacker.openime.editor.EditorInfoAdapter +import llc.slacker.openime.theme.ImeTypographyTokens +import llc.slacker.openime.widget.ImeKeyView /** * Concrete first-frame renderer for number/decimal/phone keyboards. diff --git a/app/src/main/java/llc/slacker/openime/PhoneKeypadPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/PhoneKeypadPolicy.kt similarity index 88% rename from app/src/main/java/llc/slacker/openime/PhoneKeypadPolicy.kt rename to app/src/main/java/llc/slacker/openime/keyboard/PhoneKeypadPolicy.kt index 839c6cde..43728314 100644 --- a/app/src/main/java/llc/slacker/openime/PhoneKeypadPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/PhoneKeypadPolicy.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.keyboard /** Production remapping for TYPE_CLASS_PHONE without duplicating the numeric renderer. */ internal object PhoneKeypadPolicy { diff --git a/app/src/main/java/llc/slacker/openime/Pinyin26KeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt similarity index 84% rename from app/src/main/java/llc/slacker/openime/Pinyin26KeyboardRenderer.kt rename to app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt index 635f246f..5ac0a363 100644 --- a/app/src/main/java/llc/slacker/openime/Pinyin26KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt @@ -1,9 +1,13 @@ -package llc.slacker.openime +package llc.slacker.openime.keyboard import android.content.Context import android.view.Gravity import android.view.View import android.widget.LinearLayout +import llc.slacker.openime.R +import llc.slacker.openime.core.ShiftState +import llc.slacker.openime.theme.ImeTypographyTokens +import llc.slacker.openime.widget.ImeKeyView /** * Concrete renderer for the shared Chinese/English 26-key surface. @@ -72,10 +76,9 @@ internal class Pinyin26KeyboardRenderer( createKey("123", true, null, ImeTypographyTokens.BODY_SP, 0, onDigits), flexKeyParams(weights.leftOuter), ) - val punctuation = "中/英" bottom.addView( createKey( - punctuation, + "中/英", true, null, ImeTypographyTokens.BODY_SP, @@ -87,6 +90,7 @@ internal class Pinyin26KeyboardRenderer( createSpaceVoiceKey("空格", onSpace), flexKeyParams(weights.space), ) + bottom.addView(punctuationKey(english), flexKeyParams(weights.rightInner)) bottom.addView( createKey(enterLabel, true, null, ImeTypographyTokens.BODY_SP, 0, onEnter).apply { tag = "key-enter" @@ -96,6 +100,23 @@ internal class Pinyin26KeyboardRenderer( keyboardBody.addView(bottom, rowParams()) } + private fun punctuationKey(english: Boolean): ImeKeyView { + val spec = PunctuationKeyPolicy.spec(english) + // A character key, not a function key: only character keys carry corner hints. + return createKey(spec.comma, false, spec.period, ImeTypographyTokens.KEY_LETTER_COMPACT_SP, 0) { + onCommitCharacter(spec.comma) + }.apply { + tag = "key-punctuation" + contentDescription = "${spec.comma},长按输入${spec.period}" + setSecondaryVisible(true) + setSecondaryAlpha(1f) + setOnLongClickListener { + onCommitCharacter(spec.period) + true + } + } + } + private fun segmentKey(): ImeKeyView = createKey("分词", true, "@#/", ImeTypographyTokens.BODY_SP, 0, onPinyinSegment).apply { tag = "key-segment" @@ -135,7 +156,7 @@ internal class Pinyin26KeyboardRenderer( base } val secondary = if (english) null else DIGIT_HINTS[character] - return createKey(main, false, secondary, ImeTypographyTokens.KEY_LETTER_SP, 0) { + return createKey(main, false, secondary, ImeTypographyTokens.KEY_LETTER_COMPACT_SP, 0) { onLetter(base) }.apply { tag = "key:$base" diff --git a/app/src/main/java/llc/slacker/openime/Pinyin9KeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt similarity index 87% rename from app/src/main/java/llc/slacker/openime/Pinyin9KeyboardRenderer.kt rename to app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt index 6db5d6e0..25913cd4 100644 --- a/app/src/main/java/llc/slacker/openime/Pinyin9KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt @@ -1,9 +1,11 @@ -package llc.slacker.openime +package llc.slacker.openime.keyboard import android.content.Context import android.view.View import android.widget.LinearLayout import android.widget.ScrollView +import llc.slacker.openime.theme.ImeTypographyTokens +import llc.slacker.openime.widget.ImeKeyView /** * Concrete first-frame renderer for the Chinese 9-key surface. @@ -35,6 +37,8 @@ internal class Pinyin9KeyboardRenderer( private val onNineKey: (String) -> Unit, private val onPinyinSegment: () -> Unit, private val onShowChoicePopup: (View, List) -> Unit, + private val onShowChoiceRows: (View, List>) -> Unit, + private val swipeUpEnabled: () -> Boolean, private val onCommitCharacter: (String) -> Unit, private val onShowSymbols: () -> Unit, private val onDigits: () -> Unit, @@ -70,22 +74,23 @@ internal class Pinyin9KeyboardRenderer( val centerBottom = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } + val weights = ProductionKeyPolicy.nineKeyBottomRowWeights() centerBottom.addView( createKey("123", true, null, ImeTypographyTokens.BODY_SP, onDigits).apply { markSideKey(this) }, - flexKeyParams(), + flexKeyParams(weights.side), ) centerBottom.addView( createSpaceVoiceKey("空格", onSpace), - flexKeyParams(), + flexKeyParams(weights.space), ) centerBottom.addView( createKey("中/英", true, null, ImeTypographyTokens.BODY_SP, onModeSwitch).apply { tag = "key:mode" markSideKey(this) }, - flexKeyParams(), + flexKeyParams(weights.side), ) center.addView( centerBottom, @@ -151,14 +156,18 @@ internal class Pinyin9KeyboardRenderer( onShowChoicePopup(this, listOf("@", "#", "/")) true } - } else if (ImeData.keypad9Map[digit].orEmpty().any { - it.length == 1 && it[0] in 'a'..'z' - }) { - setOnLongClickListener { - onCommitCharacter(digit) - true + } else { + val rows = NineKeyLongPressPolicy.choiceRows(digit) + if (rows.isNotEmpty()) { + setOnLongClickListener { + onShowChoiceRows(this, rows) + true + } } } + // Swipe up types the digit without waiting for a long press. + onSwipeUp = { onCommitCharacter(digit) } + swipeUpEnabled = this@Pinyin9KeyboardRenderer.swipeUpEnabled } onDigitKeyCreated(digit, key) row.addView(key, flexKeyParams()) diff --git a/app/src/main/java/llc/slacker/openime/ProductionKeyPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt similarity index 63% rename from app/src/main/java/llc/slacker/openime/ProductionKeyPolicy.kt rename to app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt index 4566ef6b..e4b8ced1 100644 --- a/app/src/main/java/llc/slacker/openime/ProductionKeyPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.keyboard /** Pure geometry helpers for production key presentation. */ internal object ProductionKeyPolicy { @@ -15,9 +15,18 @@ internal object ProductionKeyPolicy { val rightOuter: Float, ) - /** Visual reference geometry for the shared Chinese/English 26-key bottom row. */ + /** Nine-key bottom row: 123 | 空格 | 中/英. The space bar is the wide key. */ + data class NineKeyBottomRowWeights(val side: Float, val space: Float) + + fun nineKeyBottomRowWeights(): NineKeyBottomRowWeights = NineKeyBottomRowWeights(side = 0.7f, space = 1.6f) + + /** + * Visual reference geometry for the shared Chinese/English 26-key bottom row: + * 123 | 中/英 | 空格 | ,。 | 确定. The row mirrors around the space key so the + * space bar sits on the keyboard's centre line. + */ fun twentySixKeyBottomRowWeights(): BottomRowWeights { - return BottomRowWeights(68f, 56f, 178f, 0f, 88f) + return BottomRowWeights(70f, 48f, 154f, 48f, 70f) } /** diff --git a/app/src/main/java/llc/slacker/openime/keyboard/PunctuationKeyPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/PunctuationKeyPolicy.kt new file mode 100644 index 00000000..48ba1db0 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/keyboard/PunctuationKeyPolicy.kt @@ -0,0 +1,19 @@ +package llc.slacker.openime.keyboard + +/** + * Content of the comma/period key beside the 26-key space bar. + * + * Tap commits [comma]; long press commits [period], which is also the corner + * hint. Chinese mode uses the full-width marks. + */ +internal object PunctuationKeyPolicy { + data class Spec( + val comma: String, + val period: String, + ) + + private val CHINESE = Spec(comma = ",", period = "。") + private val ENGLISH = Spec(comma = ",", period = ".") + + fun spec(english: Boolean): Spec = if (english) ENGLISH else CHINESE +} diff --git a/app/src/main/java/llc/slacker/openime/SpaceVoiceGestureController.kt b/app/src/main/java/llc/slacker/openime/keyboard/SpaceVoiceGestureController.kt similarity index 99% rename from app/src/main/java/llc/slacker/openime/SpaceVoiceGestureController.kt rename to app/src/main/java/llc/slacker/openime/keyboard/SpaceVoiceGestureController.kt index e69b4299..ab06c9f5 100644 --- a/app/src/main/java/llc/slacker/openime/SpaceVoiceGestureController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/SpaceVoiceGestureController.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.keyboard import android.os.Handler import android.os.Looper diff --git a/app/src/main/java/llc/slacker/openime/SpaceVoiceKeyFactory.kt b/app/src/main/java/llc/slacker/openime/keyboard/SpaceVoiceKeyFactory.kt similarity index 98% rename from app/src/main/java/llc/slacker/openime/SpaceVoiceKeyFactory.kt rename to app/src/main/java/llc/slacker/openime/keyboard/SpaceVoiceKeyFactory.kt index 3d6de4c8..a1efaa05 100644 --- a/app/src/main/java/llc/slacker/openime/SpaceVoiceKeyFactory.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/SpaceVoiceKeyFactory.kt @@ -1,6 +1,7 @@ -package llc.slacker.openime +package llc.slacker.openime.keyboard import android.view.MotionEvent +import llc.slacker.openime.widget.ImeKeyView /** * Builds the combined space/voice key and binds it to diff --git a/app/src/main/java/llc/slacker/openime/SymbolRailRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt similarity index 98% rename from app/src/main/java/llc/slacker/openime/SymbolRailRenderer.kt rename to app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt index a25a7233..eb49a9e5 100644 --- a/app/src/main/java/llc/slacker/openime/SymbolRailRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.keyboard import android.content.Context import android.graphics.Canvas @@ -9,6 +9,7 @@ import android.view.ViewGroup import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TextView +import llc.slacker.openime.theme.ImeGeometryTokens /** * First-frame builder for the vertical symbol rails used beside compact diff --git a/app/src/main/java/llc/slacker/openime/ClipboardPanelController.kt b/app/src/main/java/llc/slacker/openime/panel/ClipboardPanelController.kt similarity index 96% rename from app/src/main/java/llc/slacker/openime/ClipboardPanelController.kt rename to app/src/main/java/llc/slacker/openime/panel/ClipboardPanelController.kt index 7707fd39..06d3c70b 100644 --- a/app/src/main/java/llc/slacker/openime/ClipboardPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/ClipboardPanelController.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.panel import android.app.AlertDialog import android.content.ClipboardManager @@ -10,10 +10,19 @@ import android.view.Gravity import android.view.View import android.view.ViewGroup import android.widget.HorizontalScrollView +import android.widget.ImageView import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TextView -import android.widget.ImageView +import llc.slacker.openime.R +import llc.slacker.openime.data.ClipboardEntry +import llc.slacker.openime.data.ClipboardHistoryRepository +import llc.slacker.openime.data.QuickPhrase +import llc.slacker.openime.data.QuickPhraseRepository +import llc.slacker.openime.setup.SetupUi +import llc.slacker.openime.theme.ImeGeometryTokens +import llc.slacker.openime.theme.ImeTypographyTokens +import llc.slacker.openime.widget.ImeKeyView /** * Owns clipboard/quick-phrase panel presentation and transient load state. @@ -52,6 +61,11 @@ internal class ClipboardPanelController( private var tab = 0 private var loadGeneration = 0 + /** 0 = clipboard history, 1 = quick phrases; applied on the next [render]. */ + fun selectTab(index: Int) { + tab = index.coerceIn(0, 1) + } + fun invalidatePendingLoad() { loadGeneration++ } diff --git a/app/src/main/java/llc/slacker/openime/EmojiCellFactory.kt b/app/src/main/java/llc/slacker/openime/panel/EmojiCellFactory.kt similarity index 97% rename from app/src/main/java/llc/slacker/openime/EmojiCellFactory.kt rename to app/src/main/java/llc/slacker/openime/panel/EmojiCellFactory.kt index 8c6ce47e..10961a62 100644 --- a/app/src/main/java/llc/slacker/openime/EmojiCellFactory.kt +++ b/app/src/main/java/llc/slacker/openime/panel/EmojiCellFactory.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.panel import android.content.Context import android.graphics.Bitmap @@ -11,6 +11,8 @@ import android.view.View import android.widget.FrameLayout import android.widget.ImageView import android.widget.TextView +import llc.slacker.openime.data.FluentEmojiAssetRepository +import llc.slacker.openime.theme.ImeTypographyTokens import java.util.concurrent.Executors /** diff --git a/app/src/main/java/llc/slacker/openime/ImePanelRenderer.kt b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt similarity index 97% rename from app/src/main/java/llc/slacker/openime/ImePanelRenderer.kt rename to app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt index ed0ea93c..4da619ec 100644 --- a/app/src/main/java/llc/slacker/openime/ImePanelRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt @@ -1,21 +1,38 @@ -package llc.slacker.openime +package llc.slacker.openime.panel import android.content.Context import android.content.Intent -import android.graphics.Typeface import android.graphics.Canvas -import android.graphics.Paint import android.graphics.Color -import android.widget.FrameLayout +import android.graphics.Paint +import android.graphics.Typeface import android.os.Build import android.view.Gravity import android.view.View import android.view.ViewGroup +import android.widget.FrameLayout import android.widget.HorizontalScrollView import android.widget.ImageView import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TextView +import llc.slacker.openime.R +import llc.slacker.openime.SymbolManagerActivity +import llc.slacker.openime.core.ImeData +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.core.Panel +import llc.slacker.openime.data.CustomSymbolRepository +import llc.slacker.openime.data.EmojiRecentRepository +import llc.slacker.openime.data.ImeSettingsRepository +import llc.slacker.openime.handwriting.HandwritingPadView +import llc.slacker.openime.handwriting.HandwritingResult +import llc.slacker.openime.handwriting.UnavailableHandwritingProvider +import llc.slacker.openime.theme.AccentPalette +import llc.slacker.openime.theme.ImeGeometryTokens +import llc.slacker.openime.theme.ImeSurfacePolicy +import llc.slacker.openime.theme.ImeTheme +import llc.slacker.openime.theme.ImeTypographyTokens +import llc.slacker.openime.widget.ImeKeyView /** * Owns the low-state panel surfaces and their local presentation state. diff --git a/app/src/main/java/llc/slacker/openime/PanelHeaderFactory.kt b/app/src/main/java/llc/slacker/openime/panel/PanelHeaderFactory.kt similarity index 93% rename from app/src/main/java/llc/slacker/openime/PanelHeaderFactory.kt rename to app/src/main/java/llc/slacker/openime/panel/PanelHeaderFactory.kt index 20d6317b..1da46d80 100644 --- a/app/src/main/java/llc/slacker/openime/PanelHeaderFactory.kt +++ b/app/src/main/java/llc/slacker/openime/panel/PanelHeaderFactory.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.panel import android.content.Context import android.view.Gravity @@ -6,6 +6,11 @@ import android.view.MotionEvent import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import llc.slacker.openime.R +import llc.slacker.openime.core.Panel +import llc.slacker.openime.setup.SetupUi +import llc.slacker.openime.theme.ImeGeometryTokens +import llc.slacker.openime.theme.ImeTypographyTokens /** * Builds the shared panel header/back affordance used by every IME panel and diff --git a/app/src/main/java/llc/slacker/openime/SettingsPanelController.kt b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt similarity index 98% rename from app/src/main/java/llc/slacker/openime/SettingsPanelController.kt rename to app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt index b48f6c61..eeb9fc5a 100644 --- a/app/src/main/java/llc/slacker/openime/SettingsPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.panel import android.app.AlertDialog import android.content.Context @@ -23,6 +23,17 @@ import android.widget.LinearLayout import android.widget.ScrollView import android.widget.SeekBar import android.widget.TextView +import llc.slacker.openime.ImeSettingsActivity +import llc.slacker.openime.R +import llc.slacker.openime.data.ImeHandedness +import llc.slacker.openime.setup.SetupUi +import llc.slacker.openime.theme.AccentPalette +import llc.slacker.openime.theme.ImeAppearance +import llc.slacker.openime.theme.ImeDrawableFactory +import llc.slacker.openime.theme.ImeGeometryTokens +import llc.slacker.openime.theme.ImeMotionTokens +import llc.slacker.openime.theme.ImeTheme +import llc.slacker.openime.theme.ImeTypographyTokens /** * Concrete presentation owner for Settings and Fuzzy Settings. @@ -46,6 +57,7 @@ internal class SettingsPanelController( private val currentSound: () -> Boolean, private val currentHaptic: () -> Boolean, private val currentPopup: () -> Boolean, + private val currentSwipeUpDigits: () -> Boolean, private val currentFuzzy: () -> Boolean, private val currentSkinOpacity: () -> Int, private val currentSkinRadius: () -> Int, @@ -161,6 +173,7 @@ internal class SettingsPanelController( settingToggleRow("按键音效", "机械轴敲击反馈"), settingToggleRow("触感震动", "轻微触感反馈"), settingToggleRow("按键气泡", "按下时显示字母预览"), + settingToggleRow("上滑输入数字", "九键按键上滑直接输入数字"), ), groupParams(), ) @@ -565,6 +578,7 @@ internal class SettingsPanelController( "触感震动" -> currentHaptic() "模糊音纠错", "启用模糊音" -> currentFuzzy() "按键气泡" -> currentPopup() + "上滑输入数字" -> currentSwipeUpDigits() else -> true } diff --git a/app/src/main/java/llc/slacker/openime/TextEditControlPolicy.kt b/app/src/main/java/llc/slacker/openime/panel/TextEditControlPolicy.kt similarity index 96% rename from app/src/main/java/llc/slacker/openime/TextEditControlPolicy.kt rename to app/src/main/java/llc/slacker/openime/panel/TextEditControlPolicy.kt index 20ab4a03..baac19ec 100644 --- a/app/src/main/java/llc/slacker/openime/TextEditControlPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/panel/TextEditControlPolicy.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.panel /** * Controls that must remain unavailable for sensitive editors. Cursor movement diff --git a/app/src/main/java/llc/slacker/openime/TextEditorPanelController.kt b/app/src/main/java/llc/slacker/openime/panel/TextEditorPanelController.kt similarity index 96% rename from app/src/main/java/llc/slacker/openime/TextEditorPanelController.kt rename to app/src/main/java/llc/slacker/openime/panel/TextEditorPanelController.kt index a4065a08..0cdd6ac4 100644 --- a/app/src/main/java/llc/slacker/openime/TextEditorPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/TextEditorPanelController.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.panel import android.content.Context import android.os.Build @@ -6,6 +6,10 @@ import android.view.Gravity import android.view.View import android.widget.LinearLayout import android.widget.TextView +import llc.slacker.openime.theme.ImeGeometryTokens +import llc.slacker.openime.theme.ImeSurfacePolicy +import llc.slacker.openime.theme.ImeTypographyTokens +import llc.slacker.openime.widget.ImeKeyView /** * Owns the text-edit panel and direct references to its actionable controls. diff --git a/app/src/main/java/llc/slacker/openime/rime/NativeCandidateReference.kt b/app/src/main/java/llc/slacker/openime/rime/NativeCandidateReference.kt new file mode 100644 index 00000000..934e886e --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/rime/NativeCandidateReference.kt @@ -0,0 +1,33 @@ +package llc.slacker.openime.rime + +internal data class NativeCandidateReference( + val input: String, + val nativeIndex: Int, + /** Normalized-input characters the candidate spells; -1 when unknown. */ + val consumed: Int = -1, +) { + companion object { + private const val DEFERRED_PREFIX = "\u0000openime-deferred\u0000" + private const val SEP = '\u0000' + const val DEFERRED_INDEX = -1 + + /** + * Reference used before the async native snapshot exists. The editor can + * commit immediately; RimeEngine later resolves [candidate] by text on + * its mutation queue and learns the exact visible choice. + */ + fun deferred(input: String, candidate: String): NativeCandidateReference = + NativeCandidateReference( + input = DEFERRED_PREFIX + input + SEP + candidate, + nativeIndex = DEFERRED_INDEX, + ) + + fun decodeDeferred(encoded: String): Pair? { + if (!encoded.startsWith(DEFERRED_PREFIX)) return null + val payload = encoded.removePrefix(DEFERRED_PREFIX) + val split = payload.indexOf(SEP) + if (split <= 0 || split >= payload.lastIndex) return null + return payload.substring(0, split) to payload.substring(split + 1) + } + } +} diff --git a/app/src/main/java/llc/slacker/openime/RimeEngine.kt b/app/src/main/java/llc/slacker/openime/rime/RimeEngine.kt similarity index 98% rename from app/src/main/java/llc/slacker/openime/RimeEngine.kt rename to app/src/main/java/llc/slacker/openime/rime/RimeEngine.kt index 8270130d..192d0240 100644 --- a/app/src/main/java/llc/slacker/openime/RimeEngine.kt +++ b/app/src/main/java/llc/slacker/openime/rime/RimeEngine.kt @@ -1,10 +1,18 @@ -package llc.slacker.openime +package llc.slacker.openime.rime import android.content.Context import android.content.res.AssetManager import android.inputmethodservice.InputMethodService import android.os.Build import android.util.Log +import llc.slacker.openime.RimeNative +import llc.slacker.openime.core.CrashGuard +import llc.slacker.openime.core.RimeStartupRecovery +import llc.slacker.openime.data.ImeSettingsRepository +import llc.slacker.openime.data.PersonalizationPolicy +import llc.slacker.openime.data.PersonalizationRepository +import llc.slacker.openime.data.RimeUserDictionaryArchive +import llc.slacker.openime.data.UserDataArchiveCodec import java.io.File import java.util.concurrent.Executors import java.util.concurrent.TimeUnit diff --git a/app/src/main/java/llc/slacker/openime/RimeInputNormalizer.kt b/app/src/main/java/llc/slacker/openime/rime/RimeInputNormalizer.kt similarity index 88% rename from app/src/main/java/llc/slacker/openime/RimeInputNormalizer.kt rename to app/src/main/java/llc/slacker/openime/rime/RimeInputNormalizer.kt index 84f1c95b..24eddf1b 100644 --- a/app/src/main/java/llc/slacker/openime/RimeInputNormalizer.kt +++ b/app/src/main/java/llc/slacker/openime/rime/RimeInputNormalizer.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.rime /** Converts the editable openIME pre-edit string into Rime's input syntax. */ internal object RimeInputNormalizer { diff --git a/app/src/main/java/llc/slacker/openime/SetupUi.kt b/app/src/main/java/llc/slacker/openime/setup/SetupUi.kt similarity index 96% rename from app/src/main/java/llc/slacker/openime/SetupUi.kt rename to app/src/main/java/llc/slacker/openime/setup/SetupUi.kt index 78b7864e..be137867 100644 --- a/app/src/main/java/llc/slacker/openime/SetupUi.kt +++ b/app/src/main/java/llc/slacker/openime/setup/SetupUi.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.setup import android.app.AlertDialog import android.content.Context @@ -10,11 +10,22 @@ import android.os.Build import android.util.TypedValue import android.view.Gravity import android.view.HapticFeedbackConstants -import android.widget.ImageButton -import android.widget.LinearLayout import android.widget.Button import android.widget.EditText +import android.widget.ImageButton +import android.widget.LinearLayout import android.widget.TextView +import llc.slacker.openime.R +import llc.slacker.openime.data.ImeSettingsRepository +import llc.slacker.openime.theme.AccentPalette +import llc.slacker.openime.theme.ImeAppearance +import llc.slacker.openime.theme.ImeDrawableFactory +import llc.slacker.openime.theme.ImeGeometryTokens +import llc.slacker.openime.theme.ImeReferenceSizing +import llc.slacker.openime.theme.ImeSpacingTokens +import llc.slacker.openime.theme.ImeSurfacePolicy +import llc.slacker.openime.theme.ImeTheme +import llc.slacker.openime.theme.ImeTypographyTokens /** Shared visual primitives for the non-IME setup and editor screens. */ object SetupUi { diff --git a/app/src/main/java/llc/slacker/openime/theme/ImeAppearance.kt b/app/src/main/java/llc/slacker/openime/theme/ImeAppearance.kt new file mode 100644 index 00000000..bcf9c7b2 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/theme/ImeAppearance.kt @@ -0,0 +1,7 @@ +package llc.slacker.openime.theme + +enum class ImeAppearance(val label: String) { + SYSTEM("跟随系统"), + LIGHT("浅色"), + DARK("深色"), +} diff --git a/app/src/main/java/llc/slacker/openime/ImeDesignTokens.kt b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt similarity index 98% rename from app/src/main/java/llc/slacker/openime/ImeDesignTokens.kt rename to app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt index 2082a10e..fd186d3f 100644 --- a/app/src/main/java/llc/slacker/openime/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.theme import android.graphics.Color @@ -42,7 +42,6 @@ internal object ImeGeometryTokens { const val KEY_POPUP_WIDTH_SCALE = 1.08f const val KEY_POPUP_VERTICAL_GAP_DP = 8 const val LANDSCAPE_KEY_ROW_HEIGHT_DP = 40 - const val FLOATING_LANDSCAPE_WIDTH_DP = 368 // The toolbar itself is compact; the top zone still reserves the larger // composed height so typing never moves the keyboard window. const val TOOLBAR_HEIGHT_DP = TOUCH_TARGET_DP @@ -86,6 +85,8 @@ internal object ImeTypographyTokens { const val CANDIDATE_SP = 18f const val GLYPH_SP = 20f const val KEY_LETTER_SP = 21f + /** Letters on the 26-key surface, one step below [KEY_LETTER_SP]. */ + const val KEY_LETTER_COMPACT_SP = 19f const val SYMBOL_SP = 24f const val DISPLAY_SP = 28f diff --git a/app/src/main/java/llc/slacker/openime/ImeDrawableFactory.kt b/app/src/main/java/llc/slacker/openime/theme/ImeDrawableFactory.kt similarity index 98% rename from app/src/main/java/llc/slacker/openime/ImeDrawableFactory.kt rename to app/src/main/java/llc/slacker/openime/theme/ImeDrawableFactory.kt index 884525aa..191304c8 100644 --- a/app/src/main/java/llc/slacker/openime/ImeDrawableFactory.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDrawableFactory.kt @@ -1,8 +1,9 @@ -package llc.slacker.openime +package llc.slacker.openime.theme import android.graphics.Color import android.graphics.drawable.GradientDrawable import android.graphics.drawable.StateListDrawable +import llc.slacker.openime.R /** * Shared drawable/color primitives for the live IME and setup surfaces. diff --git a/app/src/main/java/llc/slacker/openime/theme/ImeReferenceSizing.kt b/app/src/main/java/llc/slacker/openime/theme/ImeReferenceSizing.kt new file mode 100644 index 00000000..cdffc88f --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/theme/ImeReferenceSizing.kt @@ -0,0 +1,19 @@ +package llc.slacker.openime.theme + +/** All reference dimensions scale together from the PDF's 390-unit canvas. */ +internal object ImeReferenceSizing { + fun scale( + context: android.content.Context, + widthPx: Int = 0, + // The floating window keeps portrait proportions in landscape too. + landscapeCompact: Boolean = true, + ): Float { + val config = context.resources.configuration + val metrics = context.resources.displayMetrics + val widthDp = if (widthPx > 0) widthPx / metrics.density else config.screenWidthDp.toFloat() + val widthScale = widthDp.coerceAtMost(600f) / 390f + return if (landscapeCompact && config.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE) { + minOf(widthScale, config.screenHeightDp * 0.55f / 256f) + } else widthScale + } +} diff --git a/app/src/main/java/llc/slacker/openime/InlineVoicePresenter.kt b/app/src/main/java/llc/slacker/openime/voice/InlineVoicePresenter.kt similarity index 96% rename from app/src/main/java/llc/slacker/openime/InlineVoicePresenter.kt rename to app/src/main/java/llc/slacker/openime/voice/InlineVoicePresenter.kt index 64ddbdc1..eff9b370 100644 --- a/app/src/main/java/llc/slacker/openime/InlineVoicePresenter.kt +++ b/app/src/main/java/llc/slacker/openime/voice/InlineVoicePresenter.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.voice import android.content.res.ColorStateList import android.os.Handler @@ -6,6 +6,9 @@ import android.view.View import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import llc.slacker.openime.theme.ImeDrawableFactory +import llc.slacker.openime.theme.ImeGeometryTokens +import llc.slacker.openime.theme.ImeTheme import kotlin.math.abs internal class InlineVoicePresenter( diff --git a/app/src/main/java/llc/slacker/openime/LocalAudioVoiceBackend.kt b/app/src/main/java/llc/slacker/openime/voice/LocalAudioVoiceBackend.kt similarity index 99% rename from app/src/main/java/llc/slacker/openime/LocalAudioVoiceBackend.kt rename to app/src/main/java/llc/slacker/openime/voice/LocalAudioVoiceBackend.kt index 05bd6cf1..45c8b9a2 100644 --- a/app/src/main/java/llc/slacker/openime/LocalAudioVoiceBackend.kt +++ b/app/src/main/java/llc/slacker/openime/voice/LocalAudioVoiceBackend.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.voice import android.Manifest import android.annotation.SuppressLint @@ -9,9 +9,10 @@ import android.media.AudioRecord import android.media.MediaRecorder import android.os.SystemClock import android.util.Log -import java.util.concurrent.atomic.AtomicBoolean +import llc.slacker.openime.hotword.HotwordRuntime import java.util.concurrent.Executors import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import kotlin.math.sqrt @@ -546,7 +547,7 @@ class LocalAudioVoiceBackend( ) VoicePerformanceTrace.markFinalAsr(session.traceToken) val punctuated = if (raw.isBlank()) raw else voiceSession.punctuate(raw) ?: raw - val final = VoiceCorrectionRepository.apply(punctuated) + val final = HotwordRuntime.apply(VoiceCorrectionRepository.apply(punctuated)) VoicePerformanceTrace.markPunctuationDone(session.traceToken) session.events.onFinal(final) VoicePerformanceTrace.finish(session.traceToken, session.ring.droppedSamples) diff --git a/app/src/main/java/llc/slacker/openime/SpeechRecognitionProvider.kt b/app/src/main/java/llc/slacker/openime/voice/SpeechRecognitionProvider.kt similarity index 99% rename from app/src/main/java/llc/slacker/openime/SpeechRecognitionProvider.kt rename to app/src/main/java/llc/slacker/openime/voice/SpeechRecognitionProvider.kt index af8f581c..9f739667 100644 --- a/app/src/main/java/llc/slacker/openime/SpeechRecognitionProvider.kt +++ b/app/src/main/java/llc/slacker/openime/voice/SpeechRecognitionProvider.kt @@ -1,8 +1,8 @@ -package llc.slacker.openime +package llc.slacker.openime.voice +import android.Manifest import android.content.Context import android.content.Intent -import android.Manifest import android.content.pm.PackageManager import android.speech.RecognitionListener import android.speech.RecognizerIntent diff --git a/app/src/main/java/llc/slacker/openime/VoiceAudioRouteManager.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceAudioRouteManager.kt similarity index 99% rename from app/src/main/java/llc/slacker/openime/VoiceAudioRouteManager.kt rename to app/src/main/java/llc/slacker/openime/voice/VoiceAudioRouteManager.kt index f920d545..fca366e5 100644 --- a/app/src/main/java/llc/slacker/openime/VoiceAudioRouteManager.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceAudioRouteManager.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.voice import android.annotation.TargetApi import android.content.Context diff --git a/app/src/main/java/llc/slacker/openime/VoiceCorrectionRange.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceCorrectionRange.kt similarity index 97% rename from app/src/main/java/llc/slacker/openime/VoiceCorrectionRange.kt rename to app/src/main/java/llc/slacker/openime/voice/VoiceCorrectionRange.kt index c7c0b23f..ebbaa5c8 100644 --- a/app/src/main/java/llc/slacker/openime/VoiceCorrectionRange.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceCorrectionRange.kt @@ -1,4 +1,6 @@ -package llc.slacker.openime +package llc.slacker.openime.voice + +import llc.slacker.openime.editor.InputConnectionGateway /** Absolute editor range used to track one committed ASR result across window shifts. */ internal data class VoiceCorrectionRange( diff --git a/app/src/main/java/llc/slacker/openime/VoiceCorrectionRepository.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceCorrectionRepository.kt similarity index 99% rename from app/src/main/java/llc/slacker/openime/VoiceCorrectionRepository.kt rename to app/src/main/java/llc/slacker/openime/voice/VoiceCorrectionRepository.kt index a9c13afa..fdd57b38 100644 --- a/app/src/main/java/llc/slacker/openime/VoiceCorrectionRepository.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceCorrectionRepository.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.voice import android.content.Context import org.json.JSONArray diff --git a/app/src/main/java/llc/slacker/openime/voice/VoiceEditorContext.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceEditorContext.kt new file mode 100644 index 00000000..c62bf365 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceEditorContext.kt @@ -0,0 +1,12 @@ +package llc.slacker.openime.voice + +/** + * What the voice layer needs to know about the active editor, supplied by the + * input method service. It keeps the recognition code from depending on the + * service or on editor classes. + */ +internal object VoiceEditorContext { + /** Whether voice text may get sentence punctuation added in the current editor. */ + @Volatile + var allowNaturalPunctuation: () -> Boolean = { true } +} diff --git a/app/src/main/java/llc/slacker/openime/VoiceFinalPolicy.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceFinalPolicy.kt similarity index 97% rename from app/src/main/java/llc/slacker/openime/VoiceFinalPolicy.kt rename to app/src/main/java/llc/slacker/openime/voice/VoiceFinalPolicy.kt index c143b3a9..5f8be63b 100644 --- a/app/src/main/java/llc/slacker/openime/VoiceFinalPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceFinalPolicy.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.voice internal data class VoiceFinalPlan( val setFinalText: Boolean, diff --git a/app/src/main/java/llc/slacker/openime/VoiceHotwordProvider.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceHotwordProvider.kt similarity index 91% rename from app/src/main/java/llc/slacker/openime/VoiceHotwordProvider.kt rename to app/src/main/java/llc/slacker/openime/voice/VoiceHotwordProvider.kt index 7a3edf3a..a8554e93 100644 --- a/app/src/main/java/llc/slacker/openime/VoiceHotwordProvider.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceHotwordProvider.kt @@ -1,4 +1,7 @@ -package llc.slacker.openime +package llc.slacker.openime.voice + +import llc.slacker.openime.data.PersonalizationRepository +import llc.slacker.openime.data.UserPhraseRepository /** Builds a bounded sherpa context string without logging personal phrases. */ object VoiceHotwordProvider { diff --git a/app/src/main/java/llc/slacker/openime/VoiceMediaMuteController.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceMediaMuteController.kt similarity index 99% rename from app/src/main/java/llc/slacker/openime/VoiceMediaMuteController.kt rename to app/src/main/java/llc/slacker/openime/voice/VoiceMediaMuteController.kt index 40561f4c..80fa9ab4 100644 --- a/app/src/main/java/llc/slacker/openime/VoiceMediaMuteController.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceMediaMuteController.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.voice import android.content.Context import android.media.AudioManager diff --git a/app/src/main/java/llc/slacker/openime/VoiceModelLifecycleManager.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceModelLifecycleManager.kt similarity index 99% rename from app/src/main/java/llc/slacker/openime/VoiceModelLifecycleManager.kt rename to app/src/main/java/llc/slacker/openime/voice/VoiceModelLifecycleManager.kt index bbcee55b..0112dfa6 100644 --- a/app/src/main/java/llc/slacker/openime/VoiceModelLifecycleManager.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceModelLifecycleManager.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.voice import android.app.ActivityManager import android.content.Context @@ -8,6 +8,7 @@ import android.os.Looper import android.os.SystemClock import android.util.Log import android.view.inputmethod.EditorInfo +import llc.slacker.openime.editor.EditorInfoAdapter import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicLong diff --git a/app/src/main/java/llc/slacker/openime/VoiceModelRepository.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceModelRepository.kt similarity index 99% rename from app/src/main/java/llc/slacker/openime/VoiceModelRepository.kt rename to app/src/main/java/llc/slacker/openime/voice/VoiceModelRepository.kt index 3823e5f1..34ae96fe 100644 --- a/app/src/main/java/llc/slacker/openime/VoiceModelRepository.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceModelRepository.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.voice import android.content.Context import android.util.Log diff --git a/app/src/main/java/llc/slacker/openime/VoicePanelController.kt b/app/src/main/java/llc/slacker/openime/voice/VoicePanelController.kt similarity index 98% rename from app/src/main/java/llc/slacker/openime/VoicePanelController.kt rename to app/src/main/java/llc/slacker/openime/voice/VoicePanelController.kt index 590aa98e..f5d68f02 100644 --- a/app/src/main/java/llc/slacker/openime/VoicePanelController.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoicePanelController.kt @@ -1,14 +1,16 @@ -package llc.slacker.openime +package llc.slacker.openime.voice import android.content.Context import android.widget.LinearLayout import android.widget.TextView +import llc.slacker.openime.R +import llc.slacker.openime.theme.ImeGeometryTokens import java.util.concurrent.atomic.AtomicBoolean /** * Owns the voice session's presentation-side state and VoiceRecognitionEvents. * - * Audio/model lifecycle remains behind ImeKeyboardView.Listener. Gesture + * Audio/model lifecycle remains behind VoiceSessionHost. Gesture * ownership remains in ImeKeyboardView/SpaceVoiceGestureController. */ internal class VoicePanelController( @@ -18,7 +20,7 @@ internal class VoicePanelController( private val panelBodyHeightPx: () -> Int, private val createHeader: (String) -> LinearLayout, private val createButton: (String, Float, Boolean) -> TextView, - private val listener: ImeKeyboardView.Listener, + private val listener: VoiceSessionHost, private val isGestureSessionActive: () -> Boolean, private val onInlineState: (String, Boolean, Boolean, Float?) -> Unit, private val onHideInlineLater: (Long) -> Unit, diff --git a/app/src/main/java/llc/slacker/openime/VoicePanelView.kt b/app/src/main/java/llc/slacker/openime/voice/VoicePanelView.kt similarity index 96% rename from app/src/main/java/llc/slacker/openime/VoicePanelView.kt rename to app/src/main/java/llc/slacker/openime/voice/VoicePanelView.kt index fb4ff360..a50bdd63 100644 --- a/app/src/main/java/llc/slacker/openime/VoicePanelView.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoicePanelView.kt @@ -1,16 +1,23 @@ -package llc.slacker.openime +package llc.slacker.openime.voice import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color import android.os.Build import android.text.TextUtils import android.view.Gravity import android.view.View -import android.widget.LinearLayout -import android.widget.TextView import android.widget.FrameLayout import android.widget.ImageView -import android.graphics.Color -import android.content.res.ColorStateList +import android.widget.LinearLayout +import android.widget.TextView +import llc.slacker.openime.R +import llc.slacker.openime.data.ImeSettingsRepository +import llc.slacker.openime.theme.AccentPalette +import llc.slacker.openime.theme.ImeDrawableFactory +import llc.slacker.openime.theme.ImeSurfacePolicy +import llc.slacker.openime.theme.ImeTheme +import llc.slacker.openime.theme.ImeTypographyTokens /** * Concrete visual surface for the voice panel. diff --git a/app/src/main/java/llc/slacker/openime/VoicePerformanceTrace.kt b/app/src/main/java/llc/slacker/openime/voice/VoicePerformanceTrace.kt similarity index 99% rename from app/src/main/java/llc/slacker/openime/VoicePerformanceTrace.kt rename to app/src/main/java/llc/slacker/openime/voice/VoicePerformanceTrace.kt index 9ee5eab4..8592d4f5 100644 --- a/app/src/main/java/llc/slacker/openime/VoicePerformanceTrace.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoicePerformanceTrace.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.voice import android.os.SystemClock import android.util.Log diff --git a/app/src/main/java/llc/slacker/openime/VoiceRecognitionBackend.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceRecognitionBackend.kt similarity index 99% rename from app/src/main/java/llc/slacker/openime/VoiceRecognitionBackend.kt rename to app/src/main/java/llc/slacker/openime/voice/VoiceRecognitionBackend.kt index a6b71cf3..46bad2db 100644 --- a/app/src/main/java/llc/slacker/openime/VoiceRecognitionBackend.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceRecognitionBackend.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.voice import android.content.Context import android.util.Log diff --git a/app/src/main/java/llc/slacker/openime/VoiceRuntimeModelSource.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceRuntimeModelSource.kt similarity index 98% rename from app/src/main/java/llc/slacker/openime/VoiceRuntimeModelSource.kt rename to app/src/main/java/llc/slacker/openime/voice/VoiceRuntimeModelSource.kt index 913807a8..f3b2abe4 100644 --- a/app/src/main/java/llc/slacker/openime/VoiceRuntimeModelSource.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceRuntimeModelSource.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.voice import java.io.File import java.nio.file.Files diff --git a/app/src/main/java/llc/slacker/openime/voice/VoiceSessionHost.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceSessionHost.kt new file mode 100644 index 00000000..681dac1b --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceSessionHost.kt @@ -0,0 +1,21 @@ +package llc.slacker.openime.voice + +/** + * What the voice presentation layer needs from whoever owns the editor and the + * recognizer. The keyboard's listener extends it, so voice code depends on this + * narrow contract instead of on the keyboard view. + */ +interface VoiceSessionHost { + /** Kept for compatibility; production voice always commits on release of space. */ + fun onVoiceSessionStarted(autoCommitOnFinal: Boolean) {} + fun onVoicePartial(text: String) {} + fun onVoiceFinal(text: String) {} + fun onVoiceError(message: String) {} + fun onVoiceCancel() {} + fun voiceModelState(): VoiceModelLifecycleState = VoiceModelLifecycleState.COLD + fun startVoiceRecognition(languageTag: String, events: VoiceRecognitionEvents) { + events.onError("本地语音服务未连接") + } + fun stopVoiceRecognition() {} + fun cancelVoiceRecognition() {} +} diff --git a/app/src/main/java/llc/slacker/openime/VoiceTextProcessor.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceTextProcessor.kt similarity index 94% rename from app/src/main/java/llc/slacker/openime/VoiceTextProcessor.kt rename to app/src/main/java/llc/slacker/openime/voice/VoiceTextProcessor.kt index 46c979e5..2f78c3e4 100644 --- a/app/src/main/java/llc/slacker/openime/VoiceTextProcessor.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceTextProcessor.kt @@ -1,4 +1,4 @@ -package llc.slacker.openime +package llc.slacker.openime.voice internal data class VoiceTextProcessingPolicy( val autoTerminalPunctuation: Boolean, @@ -40,11 +40,8 @@ object VoiceTextProcessor { ) fun process(raw: String, languageTag: String): String { - val service = LocalVoiceImeService.activeInstance val policy = VoiceTextProcessingPolicy( - autoTerminalPunctuation = service?.let { - EditorInfoAdapter.allowNaturalLanguageVoicePunctuation(it.currentInputEditorInfo) - } ?: true, + autoTerminalPunctuation = VoiceEditorContext.allowNaturalPunctuation(), ) return process(raw, languageTag, policy) } diff --git a/app/src/main/java/llc/slacker/openime/ImeKeyView.kt b/app/src/main/java/llc/slacker/openime/widget/ImeKeyView.kt similarity index 85% rename from app/src/main/java/llc/slacker/openime/ImeKeyView.kt rename to app/src/main/java/llc/slacker/openime/widget/ImeKeyView.kt index ce7a4360..d4c136f8 100644 --- a/app/src/main/java/llc/slacker/openime/ImeKeyView.kt +++ b/app/src/main/java/llc/slacker/openime/widget/ImeKeyView.kt @@ -1,18 +1,20 @@ -package llc.slacker.openime +package llc.slacker.openime.widget -import android.content.res.ColorStateList import android.content.Context +import android.content.res.ColorStateList import android.graphics.Color import android.text.TextUtils import android.util.TypedValue -import android.view.View -import android.view.MotionEvent import android.view.Gravity +import android.view.MotionEvent +import android.view.View import android.view.ViewGroup import android.view.animation.DecelerateInterpolator import android.widget.FrameLayout import android.widget.ImageView import android.widget.TextView +import llc.slacker.openime.R +import llc.slacker.openime.theme.ImeTypographyTokens /** * Compact native key view used by the IME. @@ -31,6 +33,50 @@ class ImeKeyView( fitMainText: Boolean = false, private val toPx: (Int) -> Int = { (it * context.resources.displayMetrics.density).toInt() }, ) : FrameLayout(context) { + /** + * Optional upward-swipe action (the nine-key digit shortcut). When it fires + * the press is cancelled, so neither the tap nor the long-press popup follows. + */ + var onSwipeUp: (() -> Unit)? = null + var swipeUpEnabled: () -> Boolean = { true } + private val swipeUp = SwipeUpDetector(thresholdPx = toPx(SWIPE_UP_THRESHOLD_DP).toFloat()) + private var swipeConsumed = false + private var cancellingForSwipe = false + + /** True when the swipe logic consumed [event]; the normal touch path then must not see it. */ + private fun interceptSwipeUp(event: MotionEvent): Boolean { + val action = onSwipeUp ?: return false + if (cancellingForSwipe) return false + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + swipeConsumed = false + swipeUp.down(event.rawX, event.rawY) + } + MotionEvent.ACTION_MOVE -> { + if (swipeConsumed) return true + if (swipeUpEnabled() && swipeUp.move(event.rawX, event.rawY)) { + swipeConsumed = true + // Cancel the press first: no tap, no long-press popup, pressed look released. + val cancel = MotionEvent.obtain(event).apply { this.action = MotionEvent.ACTION_CANCEL } + cancellingForSwipe = true + try { + dispatchTouchEvent(cancel) + } finally { + cancellingForSwipe = false + cancel.recycle() + } + action() + return true + } + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> if (swipeConsumed) { + swipeConsumed = false + return true + } + } + return false + } + private val density = resources.displayMetrics.density private var touchFeedbackPending = false @@ -48,6 +94,7 @@ class ImeKeyView( sp * density * minOf(resources.configuration.fontScale, MAX_LABEL_FONT_SCALE) override fun dispatchTouchEvent(event: MotionEvent): Boolean { + if (interceptSwipeUp(event)) return true when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { touchGeneration++ @@ -309,5 +356,6 @@ class ImeKeyView( private companion object { const val MAX_LABEL_FONT_SCALE = 1.3f + const val SWIPE_UP_THRESHOLD_DP = 22 } } diff --git a/app/src/main/java/llc/slacker/openime/widget/SwipeUpDetector.kt b/app/src/main/java/llc/slacker/openime/widget/SwipeUpDetector.kt new file mode 100644 index 00000000..7b248acc --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/widget/SwipeUpDetector.kt @@ -0,0 +1,31 @@ +package llc.slacker.openime.widget + +import kotlin.math.abs + +/** + * Recognizes an upward swipe that starts on a key. Pure: feed it raw pointer + * coordinates and it answers, once per gesture, whether the finger has moved + * far enough up (and more up than sideways) to count as the swipe. + */ +internal class SwipeUpDetector(private val thresholdPx: Float) { + private var downX = 0f + private var downY = 0f + private var fired = false + + fun down(x: Float, y: Float) { + downX = x + downY = y + fired = false + } + + /** True exactly once per gesture: the first move that qualifies as the swipe. */ + fun move(x: Float, y: Float): Boolean { + if (fired) return false + val up = downY - y + if (up >= thresholdPx && up > abs(x - downX)) { + fired = true + return true + } + return false + } +} diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index c1428fb0..30ec1db8 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -32,5 +32,6 @@ Preferences Quick phrases Custom symbols + Voice word lists About & data diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index a4e9a6c7..b4cb1001 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -32,5 +32,6 @@ 偏好设置 常用语 自定义符号 + 语音词表 关于与数据 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a4e9a6c7..b4cb1001 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,5 +32,6 @@ 偏好设置 常用语 自定义符号 + 语音词表 关于与数据 diff --git a/app/src/test/java/llc/slacker/openime/AccentPaletteTest.kt b/app/src/test/java/llc/slacker/openime/AccentPaletteTest.kt index 3dd9ec30..abbaaab3 100644 --- a/app/src/test/java/llc/slacker/openime/AccentPaletteTest.kt +++ b/app/src/test/java/llc/slacker/openime/AccentPaletteTest.kt @@ -1,5 +1,8 @@ package llc.slacker.openime +import llc.slacker.openime.theme.AccentPalette +import llc.slacker.openime.theme.ImeContrastPolicy +import llc.slacker.openime.theme.ImeFocusRingPolicy import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/ArchitectureLayeringTest.kt b/app/src/test/java/llc/slacker/openime/ArchitectureLayeringTest.kt new file mode 100644 index 00000000..9f2db437 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/ArchitectureLayeringTest.kt @@ -0,0 +1,130 @@ +package llc.slacker.openime + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +/** + * The package layout is the module map (see docs/ARCHITECTURE.md). This test + * pins the allowed dependency directions so the code cannot drift back into a + * tangle: a package may only import the packages listed for it here, and the + * listed edges must form a DAG. + * + * To add a new dependency, change it here deliberately and say why in the PR. + */ +class ArchitectureLayeringTest { + private val base = "llc.slacker.openime" + + /** package -> packages it may import. "app" is the root package (Android entry points). */ + private val allowed: Map> = mapOf( + "theme" to emptySet(), + "core" to setOf("theme"), + "editor" to setOf("core"), + "data" to setOf("core", "editor", "theme"), + "setup" to setOf("data", "theme"), + "widget" to setOf("theme"), + "floating" to setOf("theme"), + "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"), + "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", + "rime", "setup", "theme", "voice", + ), + ) + + /** + * Upward references to root-package Android components. Activities are + * launched by class, and RimeNative is the JNI boundary whose package name + * is part of the native symbol names. + */ + private val rootExceptions: Set> = setOf( + "rime" to "RimeNative", + "panel" to "ImeSettingsActivity", + "panel" to "SymbolManagerActivity", + "keyboard" to "QuickPhraseEditActivity", + ) + + private val sources: File = sequenceOf(File("."), File("..")) + .map { File(it, "app/src/main/java/llc/slacker/openime") } + .first { it.isDirectory } + + private data class Source(val file: File, val pkg: String, val imports: List) + + private fun scan(): List = sources.walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .map { file -> + val lines = file.readLines() + val dir = file.parentFile.relativeTo(sources).path + Source( + file = file, + pkg = if (dir.isEmpty()) "app" else dir, + imports = lines.filter { it.startsWith("import $base.") }.map { it.removePrefix("import $base.").trim() }, + ) + } + .toList() + + @Test + fun everyFileDeclaresThePackageOfItsDirectory() { + val wrong = scan().filter { source -> + val declared = source.file.useLines { lines -> lines.first { it.startsWith("package ") } }.removePrefix("package ").trim() + val expected = if (source.pkg == "app") base else "$base.${source.pkg}" + declared != expected + }.map { it.file.relativeTo(sources).path } + assertTrue("package/directory mismatch: $wrong", wrong.isEmpty()) + } + + @Test + fun everyPackageIsKnownToTheLayerMap() { + val unknown = scan().map { it.pkg }.toSet() - allowed.keys + assertTrue("new package needs a place in the layer map: $unknown", unknown.isEmpty()) + } + + @Test + fun importsOnlyFollowTheAllowedDirections() { + val violations = mutableListOf() + for (source in scan()) { + val permitted = allowed.getValue(source.pkg) + for (import in source.imports) { + val head = import.substringBefore('.') + val target = if (head.first().isLowerCase() && import.contains('.')) head else "app" + val symbol = if (target == "app") head else import.substringAfter('.').substringBefore('.') + if (target == "app" && symbol in setOf("R", "BuildConfig")) continue + if (target == source.pkg) continue + if (target == "app" && source.pkg != "app") { + if ((source.pkg to symbol) !in rootExceptions) { + violations += "${source.file.name}: ${source.pkg} must not use root class $symbol" + } + } else if (target !in permitted) { + violations += "${source.file.name}: ${source.pkg} must not import $target ($import)" + } + } + } + if (violations.isNotEmpty()) fail("Layering violations:\n" + violations.distinct().sorted().joinToString("\n")) + } + + @Test + fun theAllowedEdgesFormAnAcyclicGraph() { + val state = HashMap() // 1 = visiting, 2 = done + fun visit(node: String, path: List) { + when (state[node]) { + 2 -> return + 1 -> fail("dependency cycle: ${(path + node).joinToString(" -> ")}") + } + state[node] = 1 + allowed.getValue(node).forEach { visit(it, path + node) } + state[node] = 2 + } + allowed.keys.forEach { visit(it, emptyList()) } + assertEquals(allowed.keys, state.keys) + } +} diff --git a/app/src/test/java/llc/slacker/openime/CandidateEngineTest.kt b/app/src/test/java/llc/slacker/openime/CandidateEngineTest.kt index 68846fca..55c91974 100644 --- a/app/src/test/java/llc/slacker/openime/CandidateEngineTest.kt +++ b/app/src/test/java/llc/slacker/openime/CandidateEngineTest.kt @@ -1,5 +1,7 @@ package llc.slacker.openime +import llc.slacker.openime.candidate.CandidateEngine +import llc.slacker.openime.data.UserPhraseRepository import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/CandidateLayoutPolicyTest.kt b/app/src/test/java/llc/slacker/openime/CandidateLayoutPolicyTest.kt index 4dc94a17..29a0560a 100644 --- a/app/src/test/java/llc/slacker/openime/CandidateLayoutPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/CandidateLayoutPolicyTest.kt @@ -1,5 +1,7 @@ package llc.slacker.openime +import llc.slacker.openime.keyboard.candidateColumnSpan +import llc.slacker.openime.keyboard.expandedCandidateRows import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt b/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt index 4a100c90..37fd2cfb 100644 --- a/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt +++ b/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt @@ -1,5 +1,10 @@ package llc.slacker.openime +import llc.slacker.openime.candidate.CandidateEngine +import llc.slacker.openime.candidate.CandidatePipeline +import llc.slacker.openime.candidate.NineKeyLocalDecoder +import llc.slacker.openime.candidate.NineKeyPresets +import llc.slacker.openime.core.KeyboardMode import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull diff --git a/app/src/test/java/llc/slacker/openime/CandidateSnapshotTest.kt b/app/src/test/java/llc/slacker/openime/CandidateSnapshotTest.kt index 0ab426fa..fe17ae95 100644 --- a/app/src/test/java/llc/slacker/openime/CandidateSnapshotTest.kt +++ b/app/src/test/java/llc/slacker/openime/CandidateSnapshotTest.kt @@ -1,5 +1,8 @@ package llc.slacker.openime +import llc.slacker.openime.candidate.CandidateSnapshot +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.rime.NativeCandidateReference import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull diff --git a/app/src/test/java/llc/slacker/openime/ClipboardRetentionPolicyTest.kt b/app/src/test/java/llc/slacker/openime/ClipboardRetentionPolicyTest.kt index 1de88714..4a13d7c9 100644 --- a/app/src/test/java/llc/slacker/openime/ClipboardRetentionPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/ClipboardRetentionPolicyTest.kt @@ -1,5 +1,7 @@ package llc.slacker.openime +import llc.slacker.openime.data.ClipboardEntry +import llc.slacker.openime.data.ClipboardRetentionPolicy import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue diff --git a/app/src/test/java/llc/slacker/openime/ClipboardSensitivityPolicyTest.kt b/app/src/test/java/llc/slacker/openime/ClipboardSensitivityPolicyTest.kt index b8be1369..60ebda62 100644 --- a/app/src/test/java/llc/slacker/openime/ClipboardSensitivityPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/ClipboardSensitivityPolicyTest.kt @@ -1,6 +1,9 @@ package llc.slacker.openime import android.view.inputmethod.EditorInfo +import llc.slacker.openime.data.ClipboardPrivacyPolicy +import llc.slacker.openime.data.ClipboardSensitivityPolicy +import llc.slacker.openime.editor.EditorInfoAdapter import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/ContrastTest.kt b/app/src/test/java/llc/slacker/openime/ContrastTest.kt index c794ae33..0adc7b85 100644 --- a/app/src/test/java/llc/slacker/openime/ContrastTest.kt +++ b/app/src/test/java/llc/slacker/openime/ContrastTest.kt @@ -1,10 +1,10 @@ package llc.slacker.openime +import org.junit.Assert.assertTrue +import org.junit.Test import kotlin.math.max import kotlin.math.min import kotlin.math.pow -import org.junit.Assert.assertTrue -import org.junit.Test class ContrastTest { @Test diff --git a/app/src/test/java/llc/slacker/openime/CrashResilienceTest.kt b/app/src/test/java/llc/slacker/openime/CrashResilienceTest.kt index 7b5e5ac2..7771d634 100644 --- a/app/src/test/java/llc/slacker/openime/CrashResilienceTest.kt +++ b/app/src/test/java/llc/slacker/openime/CrashResilienceTest.kt @@ -1,11 +1,15 @@ package llc.slacker.openime -import java.io.File -import java.nio.file.Files +import llc.slacker.openime.core.CrashGuard +import llc.slacker.openime.core.RimeStartupRecovery +import llc.slacker.openime.editor.COMMIT_CHUNK_CHARS +import llc.slacker.openime.editor.chunksForCommit import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import java.io.File +import java.nio.file.Files class CrashResilienceTest { diff --git a/app/src/test/java/llc/slacker/openime/DictionaryAssetTest.kt b/app/src/test/java/llc/slacker/openime/DictionaryAssetTest.kt index 5e0a7e2f..5846e7bc 100644 --- a/app/src/test/java/llc/slacker/openime/DictionaryAssetTest.kt +++ b/app/src/test/java/llc/slacker/openime/DictionaryAssetTest.kt @@ -1,9 +1,9 @@ package llc.slacker.openime -import java.io.File import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import java.io.File class DictionaryAssetTest { diff --git a/app/src/test/java/llc/slacker/openime/EditorInfoAdapterTest.kt b/app/src/test/java/llc/slacker/openime/EditorInfoAdapterTest.kt index d3e1c3f5..a85440d5 100644 --- a/app/src/test/java/llc/slacker/openime/EditorInfoAdapterTest.kt +++ b/app/src/test/java/llc/slacker/openime/EditorInfoAdapterTest.kt @@ -2,6 +2,9 @@ package llc.slacker.openime import android.text.InputType import android.view.inputmethod.EditorInfo +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.editor.EditorInfoAdapter +import llc.slacker.openime.editor.InputMethodSubtypePolicy import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue diff --git a/app/src/test/java/llc/slacker/openime/EnglishCandidatePolicyTest.kt b/app/src/test/java/llc/slacker/openime/EnglishCandidatePolicyTest.kt index f729aa46..ee4764a3 100644 --- a/app/src/test/java/llc/slacker/openime/EnglishCandidatePolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/EnglishCandidatePolicyTest.kt @@ -1,5 +1,8 @@ package llc.slacker.openime +import llc.slacker.openime.candidate.CandidateEngine +import llc.slacker.openime.candidate.CandidatePipeline +import llc.slacker.openime.core.KeyboardMode import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/EnglishShiftPolicyTest.kt b/app/src/test/java/llc/slacker/openime/EnglishShiftPolicyTest.kt new file mode 100644 index 00000000..dd3a7120 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/EnglishShiftPolicyTest.kt @@ -0,0 +1,28 @@ +package llc.slacker.openime + +import android.text.InputType +import llc.slacker.openime.core.ShiftState +import llc.slacker.openime.keyboard.EnglishShiftPolicy +import org.junit.Assert.assertEquals +import org.junit.Test + +class EnglishShiftPolicyTest { + private val text = InputType.TYPE_CLASS_TEXT + + @Test + fun sentenceAndWordCapitalsRequestedByTheEditorAreIgnored() { + assertEquals(ShiftState.LOWERCASE, EnglishShiftPolicy.initial(text or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES, false)) + assertEquals(ShiftState.LOWERCASE, EnglishShiftPolicy.initial(text or InputType.TYPE_TEXT_FLAG_CAP_WORDS, false)) + assertEquals(ShiftState.LOWERCASE, EnglishShiftPolicy.initial(text, false)) + } + + @Test + fun aCapitalsOnlyFieldStillStartsInCapsLock() { + assertEquals(ShiftState.CAPS_LOCK, EnglishShiftPolicy.initial(text or InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS, false)) + } + + @Test + fun passwordFieldsAlwaysStartLowercase() { + assertEquals(ShiftState.LOWERCASE, EnglishShiftPolicy.initial(text or InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS, true)) + } +} diff --git a/app/src/test/java/llc/slacker/openime/EnterActionPolicyTest.kt b/app/src/test/java/llc/slacker/openime/EnterActionPolicyTest.kt index 618ac782..f0408615 100644 --- a/app/src/test/java/llc/slacker/openime/EnterActionPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/EnterActionPolicyTest.kt @@ -1,6 +1,8 @@ package llc.slacker.openime import android.view.inputmethod.EditorInfo +import llc.slacker.openime.editor.editorActionForEnter +import llc.slacker.openime.editor.enterKeyPresentationFor import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/FluentEmojiAssetRepositoryTest.kt b/app/src/test/java/llc/slacker/openime/FluentEmojiAssetRepositoryTest.kt index 8c458fa5..fe3f09d2 100644 --- a/app/src/test/java/llc/slacker/openime/FluentEmojiAssetRepositoryTest.kt +++ b/app/src/test/java/llc/slacker/openime/FluentEmojiAssetRepositoryTest.kt @@ -1,5 +1,6 @@ package llc.slacker.openime +import llc.slacker.openime.data.FluentEmojiAssetRepository import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/HandwritingFeaturePolicyTest.kt b/app/src/test/java/llc/slacker/openime/HandwritingFeaturePolicyTest.kt index a268b7f8..53a5e046 100644 --- a/app/src/test/java/llc/slacker/openime/HandwritingFeaturePolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/HandwritingFeaturePolicyTest.kt @@ -1,5 +1,10 @@ package llc.slacker.openime +import llc.slacker.openime.handwriting.HandwritingFeaturePolicy +import llc.slacker.openime.handwriting.HandwritingProvider +import llc.slacker.openime.handwriting.HandwritingResult +import llc.slacker.openime.handwriting.Stroke +import llc.slacker.openime.handwriting.UnavailableHandwritingProvider import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/HardwareKeyPolicyTest.kt b/app/src/test/java/llc/slacker/openime/HardwareKeyPolicyTest.kt index 01310c33..c76672ba 100644 --- a/app/src/test/java/llc/slacker/openime/HardwareKeyPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/HardwareKeyPolicyTest.kt @@ -1,6 +1,10 @@ package llc.slacker.openime import android.view.KeyEvent +import llc.slacker.openime.keyboard.HardwareContext +import llc.slacker.openime.keyboard.HardwareKey +import llc.slacker.openime.keyboard.HardwareKeyAction +import llc.slacker.openime.keyboard.HardwareKeyPolicy import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/IconLintTest.kt b/app/src/test/java/llc/slacker/openime/IconLintTest.kt index 3b05ab5a..220eb60c 100644 --- a/app/src/test/java/llc/slacker/openime/IconLintTest.kt +++ b/app/src/test/java/llc/slacker/openime/IconLintTest.kt @@ -1,8 +1,8 @@ package llc.slacker.openime -import java.io.File import org.junit.Assert.fail import org.junit.Test +import java.io.File class IconLintTest { @Test diff --git a/app/src/test/java/llc/slacker/openime/ImeBottomInsetPolicyTest.kt b/app/src/test/java/llc/slacker/openime/ImeBottomInsetPolicyTest.kt index 02d4f36c..3f8f984a 100644 --- a/app/src/test/java/llc/slacker/openime/ImeBottomInsetPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/ImeBottomInsetPolicyTest.kt @@ -1,6 +1,7 @@ package llc.slacker.openime import android.view.View +import llc.slacker.openime.keyboard.ImeBottomInsetPolicy import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/ImeDataEmojiCatalogTest.kt b/app/src/test/java/llc/slacker/openime/ImeDataEmojiCatalogTest.kt index 035380d5..65d5cc88 100644 --- a/app/src/test/java/llc/slacker/openime/ImeDataEmojiCatalogTest.kt +++ b/app/src/test/java/llc/slacker/openime/ImeDataEmojiCatalogTest.kt @@ -1,5 +1,6 @@ package llc.slacker.openime +import llc.slacker.openime.core.ImeData import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/ImeStateTest.kt b/app/src/test/java/llc/slacker/openime/ImeStateTest.kt index 2bb3d2e0..44c0cfc2 100644 --- a/app/src/test/java/llc/slacker/openime/ImeStateTest.kt +++ b/app/src/test/java/llc/slacker/openime/ImeStateTest.kt @@ -1,5 +1,7 @@ package llc.slacker.openime +import llc.slacker.openime.core.ImeState +import llc.slacker.openime.core.ShiftState import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/ImeThemePersistenceTest.kt b/app/src/test/java/llc/slacker/openime/ImeThemePersistenceTest.kt index 64704ab2..aaf4bada 100644 --- a/app/src/test/java/llc/slacker/openime/ImeThemePersistenceTest.kt +++ b/app/src/test/java/llc/slacker/openime/ImeThemePersistenceTest.kt @@ -1,5 +1,7 @@ package llc.slacker.openime +import llc.slacker.openime.data.ImeSettingsRepository +import llc.slacker.openime.theme.ImeTheme import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt b/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt index 7d55156f..336f4d3a 100644 --- a/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt +++ b/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt @@ -9,6 +9,8 @@ import android.view.inputmethod.ExtractedText import android.view.inputmethod.ExtractedTextRequest import android.view.inputmethod.InputConnection import android.view.inputmethod.InputContentInfo +import llc.slacker.openime.editor.InputConnectionGateway +import llc.slacker.openime.editor.relativeCursorKeyCode import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull diff --git a/app/src/test/java/llc/slacker/openime/InputMethodSubtypeMetadataTest.kt b/app/src/test/java/llc/slacker/openime/InputMethodSubtypeMetadataTest.kt index 0a82b123..a8a727ff 100644 --- a/app/src/test/java/llc/slacker/openime/InputMethodSubtypeMetadataTest.kt +++ b/app/src/test/java/llc/slacker/openime/InputMethodSubtypeMetadataTest.kt @@ -1,9 +1,9 @@ package llc.slacker.openime -import java.io.File import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import java.io.File class InputMethodSubtypeMetadataTest { diff --git a/app/src/test/java/llc/slacker/openime/InputMethodSubtypePolicyTest.kt b/app/src/test/java/llc/slacker/openime/InputMethodSubtypePolicyTest.kt index 0a2cf2c3..0bd4bb06 100644 --- a/app/src/test/java/llc/slacker/openime/InputMethodSubtypePolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/InputMethodSubtypePolicyTest.kt @@ -1,5 +1,8 @@ package llc.slacker.openime +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.editor.EditorInfoAdapter +import llc.slacker.openime.editor.InputMethodSubtypePolicy import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt b/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt index b12b0d5f..9c580099 100644 --- a/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt +++ b/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt @@ -1,5 +1,7 @@ package llc.slacker.openime +import llc.slacker.openime.keyboard.KeyboardLayoutMetrics +import llc.slacker.openime.theme.ImeGeometryTokens import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/LocalAudioVoiceBackendTest.kt b/app/src/test/java/llc/slacker/openime/LocalAudioVoiceBackendTest.kt index 06499480..fbeca63d 100644 --- a/app/src/test/java/llc/slacker/openime/LocalAudioVoiceBackendTest.kt +++ b/app/src/test/java/llc/slacker/openime/LocalAudioVoiceBackendTest.kt @@ -1,5 +1,9 @@ package llc.slacker.openime +import llc.slacker.openime.voice.LocalVoiceAudioSpec +import llc.slacker.openime.voice.PcmRingBuffer +import llc.slacker.openime.voice.VoiceStreamLease +import llc.slacker.openime.voice.pcm16ToFloat import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/app/src/test/java/llc/slacker/openime/NativeCandidatePipelineTest.kt b/app/src/test/java/llc/slacker/openime/NativeCandidatePipelineTest.kt index aceefa18..e105dfae 100644 --- a/app/src/test/java/llc/slacker/openime/NativeCandidatePipelineTest.kt +++ b/app/src/test/java/llc/slacker/openime/NativeCandidatePipelineTest.kt @@ -1,5 +1,9 @@ package llc.slacker.openime +import llc.slacker.openime.candidate.NativeCandidatePipeline +import llc.slacker.openime.candidate.NineKeyFallbackRegistry +import llc.slacker.openime.rime.NativeCandidateReference +import llc.slacker.openime.rime.RimeCandidateEntry import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue diff --git a/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt b/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt index 39629931..77110022 100644 --- a/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt +++ b/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt @@ -1,5 +1,8 @@ package llc.slacker.openime +import llc.slacker.openime.candidate.CandidateEngine +import llc.slacker.openime.candidate.CandidatePipeline +import llc.slacker.openime.candidate.NineKeyLocalDecoder import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue diff --git a/app/src/test/java/llc/slacker/openime/NineKeyLongPressPolicyTest.kt b/app/src/test/java/llc/slacker/openime/NineKeyLongPressPolicyTest.kt new file mode 100644 index 00000000..d2c1091a --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/NineKeyLongPressPolicyTest.kt @@ -0,0 +1,36 @@ +package llc.slacker.openime + +import llc.slacker.openime.keyboard.NineKeyLongPressPolicy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class NineKeyLongPressPolicyTest { + @Test + fun offersTheDigitAndBothCasesOfTheLetters() { + assertEquals( + listOf(listOf("2", "a", "b", "c"), listOf("A", "B", "C")), + NineKeyLongPressPolicy.choiceRows("2"), + ) + assertEquals( + listOf(listOf("9", "w", "x", "y", "z"), listOf("W", "X", "Y", "Z")), + NineKeyLongPressPolicy.choiceRows("9"), + ) + } + + @Test + fun everyRowFitsTheLongestPopup() { + ('2'..'9').forEach { digit -> + NineKeyLongPressPolicy.choiceRows(digit.toString()).forEach { row -> + assertTrue("row for $digit is too wide: $row", row.size <= 5) + } + } + } + + @Test + fun keysWithoutLettersKeepTheirOwnBehavior() { + listOf("1", "0", "*", "#").forEach { + assertTrue(NineKeyLongPressPolicy.choiceRows(it).isEmpty()) + } + } +} diff --git a/app/src/test/java/llc/slacker/openime/NineKeyUiStateTest.kt b/app/src/test/java/llc/slacker/openime/NineKeyUiStateTest.kt index 25ed5a78..d242b141 100644 --- a/app/src/test/java/llc/slacker/openime/NineKeyUiStateTest.kt +++ b/app/src/test/java/llc/slacker/openime/NineKeyUiStateTest.kt @@ -1,5 +1,7 @@ package llc.slacker.openime +import llc.slacker.openime.candidate.CandidateEngine +import llc.slacker.openime.candidate.CandidatePipeline import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/NoDecorativeGlyphTest.kt b/app/src/test/java/llc/slacker/openime/NoDecorativeGlyphTest.kt index 7de71821..9b230ba3 100644 --- a/app/src/test/java/llc/slacker/openime/NoDecorativeGlyphTest.kt +++ b/app/src/test/java/llc/slacker/openime/NoDecorativeGlyphTest.kt @@ -1,8 +1,8 @@ package llc.slacker.openime -import java.io.File import org.junit.Assert.fail import org.junit.Test +import java.io.File class NoDecorativeGlyphTest { @Test diff --git a/app/src/test/java/llc/slacker/openime/NoRawColorTest.kt b/app/src/test/java/llc/slacker/openime/NoRawColorTest.kt index 8b61d3de..940efc8c 100644 --- a/app/src/test/java/llc/slacker/openime/NoRawColorTest.kt +++ b/app/src/test/java/llc/slacker/openime/NoRawColorTest.kt @@ -1,8 +1,8 @@ package llc.slacker.openime -import java.io.File import org.junit.Assert.fail import org.junit.Test +import java.io.File class NoRawColorTest { @Test diff --git a/app/src/test/java/llc/slacker/openime/NoRawTextSizeTest.kt b/app/src/test/java/llc/slacker/openime/NoRawTextSizeTest.kt index 9b595bca..e94e4303 100644 --- a/app/src/test/java/llc/slacker/openime/NoRawTextSizeTest.kt +++ b/app/src/test/java/llc/slacker/openime/NoRawTextSizeTest.kt @@ -1,8 +1,8 @@ package llc.slacker.openime -import java.io.File import org.junit.Assert.fail import org.junit.Test +import java.io.File class NoRawTextSizeTest { @Test diff --git a/app/src/test/java/llc/slacker/openime/PersonalizationPolicyTest.kt b/app/src/test/java/llc/slacker/openime/PersonalizationPolicyTest.kt index 5d6cbef9..8986adc0 100644 --- a/app/src/test/java/llc/slacker/openime/PersonalizationPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/PersonalizationPolicyTest.kt @@ -2,6 +2,7 @@ package llc.slacker.openime import android.text.InputType import android.view.inputmethod.EditorInfo +import llc.slacker.openime.data.PersonalizationPolicy import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/PersonalizationRepositoryTest.kt b/app/src/test/java/llc/slacker/openime/PersonalizationRepositoryTest.kt index 3aa5f13e..dc2465f8 100644 --- a/app/src/test/java/llc/slacker/openime/PersonalizationRepositoryTest.kt +++ b/app/src/test/java/llc/slacker/openime/PersonalizationRepositoryTest.kt @@ -1,5 +1,9 @@ package llc.slacker.openime +import llc.slacker.openime.data.PersonalizationRepository +import llc.slacker.openime.data.UserPhraseRepository +import llc.slacker.openime.voice.VoiceCorrectionRepository +import llc.slacker.openime.voice.VoiceHotwordProvider import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/app/src/test/java/llc/slacker/openime/PersonalizedLearningPolicyTest.kt b/app/src/test/java/llc/slacker/openime/PersonalizedLearningPolicyTest.kt index 61505509..8b65eac0 100644 --- a/app/src/test/java/llc/slacker/openime/PersonalizedLearningPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/PersonalizedLearningPolicyTest.kt @@ -1,6 +1,7 @@ package llc.slacker.openime import android.view.inputmethod.EditorInfo +import llc.slacker.openime.candidate.personalizedLearningAllowed import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/PhoneKeypadPolicyTest.kt b/app/src/test/java/llc/slacker/openime/PhoneKeypadPolicyTest.kt index 5b67a96b..e7c11f12 100644 --- a/app/src/test/java/llc/slacker/openime/PhoneKeypadPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/PhoneKeypadPolicyTest.kt @@ -1,5 +1,6 @@ package llc.slacker.openime +import llc.slacker.openime.keyboard.PhoneKeypadPolicy import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue diff --git a/app/src/test/java/llc/slacker/openime/PinyinFuzzyFallbackTest.kt b/app/src/test/java/llc/slacker/openime/PinyinFuzzyFallbackTest.kt index b6cab429..3eb9570d 100644 --- a/app/src/test/java/llc/slacker/openime/PinyinFuzzyFallbackTest.kt +++ b/app/src/test/java/llc/slacker/openime/PinyinFuzzyFallbackTest.kt @@ -1,5 +1,7 @@ package llc.slacker.openime +import llc.slacker.openime.candidate.CandidateEngine +import llc.slacker.openime.candidate.pinyinFuzzyVariants import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/PinyinFuzzyRulesTest.kt b/app/src/test/java/llc/slacker/openime/PinyinFuzzyRulesTest.kt index 18786b73..a04ad25f 100644 --- a/app/src/test/java/llc/slacker/openime/PinyinFuzzyRulesTest.kt +++ b/app/src/test/java/llc/slacker/openime/PinyinFuzzyRulesTest.kt @@ -1,5 +1,6 @@ package llc.slacker.openime +import llc.slacker.openime.candidate.pinyinFuzzyVariants import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue diff --git a/app/src/test/java/llc/slacker/openime/PinyinLexiconCharWeightTest.kt b/app/src/test/java/llc/slacker/openime/PinyinLexiconCharWeightTest.kt new file mode 100644 index 00000000..5da30cf3 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/PinyinLexiconCharWeightTest.kt @@ -0,0 +1,30 @@ +package llc.slacker.openime + +import java.io.File +import llc.slacker.openime.candidate.PinyinLexicon +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PinyinLexiconCharWeightTest { + private val dictionary: File = 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 } + + @Test + fun parsesRowsAfterTheHeaderOnly() { + val rows = PinyinLexicon.charWeights( + sequenceOf("# comment", "你\tni\t1", "---", "...", "你\tni\t1422192", "伱\tni\t12", "ab\tni\t5", "尼\tni\tx", "好"), + ) + assertEquals(listOf(Triple("ni", "你", 1422192), Triple("ni", "伱", 12)), rows) + } + + @Test + fun theMostCommonCharacterOutranksRareVariantsForASyllable() { + val rows = PinyinLexicon.charWeights(dictionary.readLines(Charsets.UTF_8).asSequence()) + fun top(pinyin: String) = rows.filter { it.first == pinyin }.maxByOrNull { it.third }!!.second + assertEquals("你", top("ni")) + assertTrue("rare variant must rank far below", rows.none { it.first == "ni" && it.second == "伱" && it.third > 1000 }) + } +} diff --git a/app/src/test/java/llc/slacker/openime/PrefixRangeIndexTest.kt b/app/src/test/java/llc/slacker/openime/PrefixRangeIndexTest.kt index beb30b0e..c26df7fc 100644 --- a/app/src/test/java/llc/slacker/openime/PrefixRangeIndexTest.kt +++ b/app/src/test/java/llc/slacker/openime/PrefixRangeIndexTest.kt @@ -1,5 +1,6 @@ package llc.slacker.openime +import llc.slacker.openime.candidate.PrefixRangeIndex import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt b/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt index 3535f4fd..2e70a203 100644 --- a/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt @@ -1,5 +1,6 @@ package llc.slacker.openime +import llc.slacker.openime.keyboard.ProductionKeyPolicy import org.junit.Assert.assertEquals import org.junit.Test @@ -8,12 +9,12 @@ class ProductionKeyPolicyTest { @Test fun twentySixKeyBottomRowFollowsTheReferenceCanvas() { val weights = ProductionKeyPolicy.twentySixKeyBottomRowWeights() - // 123 | 中/英 | 空格 | (no right inner key) | 确定, in the design's 390 units. - assertEquals(68f, weights.leftOuter, 0.0001f) - assertEquals(56f, weights.leftInner, 0.0001f) - assertEquals(178f, weights.space, 0.0001f) - assertEquals(0f, weights.rightInner, 0.0001f) - assertEquals(88f, weights.rightOuter, 0.0001f) + // 123 | 中/英 | 空格 | ,。 | 确定, in the design's 390 units. + assertEquals(70f, weights.leftOuter, 0.0001f) + assertEquals(48f, weights.leftInner, 0.0001f) + assertEquals(154f, weights.space, 0.0001f) + assertEquals(48f, weights.rightInner, 0.0001f) + assertEquals(70f, weights.rightOuter, 0.0001f) assertEquals( 390f, weights.leftOuter + weights.leftInner + weights.space + weights.rightInner + weights.rightOuter, @@ -21,6 +22,22 @@ class ProductionKeyPolicyTest { ) } + @Test + fun twentySixKeyBottomRowIsSymmetricAroundTheSpaceKey() { + val weights = ProductionKeyPolicy.twentySixKeyBottomRowWeights() + assertEquals(weights.leftOuter, weights.rightOuter, 0.0001f) + assertEquals(weights.leftInner, weights.rightInner, 0.0001f) + } + + @Test + fun nineKeyBottomRowGivesTheSpaceBarMoreRoomThanTheSideKeys() { + val weights = ProductionKeyPolicy.nineKeyBottomRowWeights() + assertEquals(0.7f, weights.side, 0.0001f) + assertEquals(1.6f, weights.space, 0.0001f) + // Same total as the former 1:1:1 row, so the grid above is unaffected. + assertEquals(3f, weights.side * 2 + weights.space, 0.0001f) + } + @Test fun balancesNineKeyBottomRowWithoutChangingSpaceWeight() { val balanced = ProductionKeyPolicy.balancedOuterWeights( diff --git a/app/src/test/java/llc/slacker/openime/PunctuationKeyPolicyTest.kt b/app/src/test/java/llc/slacker/openime/PunctuationKeyPolicyTest.kt new file mode 100644 index 00000000..5a9c0876 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/PunctuationKeyPolicyTest.kt @@ -0,0 +1,21 @@ +package llc.slacker.openime + +import llc.slacker.openime.keyboard.PunctuationKeyPolicy +import org.junit.Assert.assertEquals +import org.junit.Test + +class PunctuationKeyPolicyTest { + @Test + fun chineseModeUsesFullWidthCommaAndPeriod() { + val spec = PunctuationKeyPolicy.spec(english = false) + assertEquals(",", spec.comma) + assertEquals("。", spec.period) + } + + @Test + fun englishModeUsesAsciiCommaAndPeriod() { + val spec = PunctuationKeyPolicy.spec(english = true) + assertEquals(",", spec.comma) + assertEquals(".", spec.period) + } +} diff --git a/app/src/test/java/llc/slacker/openime/RimeFuzzySchemaTest.kt b/app/src/test/java/llc/slacker/openime/RimeFuzzySchemaTest.kt index ae59dc80..a57c9259 100644 --- a/app/src/test/java/llc/slacker/openime/RimeFuzzySchemaTest.kt +++ b/app/src/test/java/llc/slacker/openime/RimeFuzzySchemaTest.kt @@ -1,9 +1,10 @@ package llc.slacker.openime -import java.io.File +import llc.slacker.openime.rime.rimeSchemaId import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import java.io.File class RimeFuzzySchemaTest { diff --git a/app/src/test/java/llc/slacker/openime/RimeInputNormalizerTest.kt b/app/src/test/java/llc/slacker/openime/RimeInputNormalizerTest.kt index 851716f6..b2262aa8 100644 --- a/app/src/test/java/llc/slacker/openime/RimeInputNormalizerTest.kt +++ b/app/src/test/java/llc/slacker/openime/RimeInputNormalizerTest.kt @@ -1,5 +1,6 @@ package llc.slacker.openime +import llc.slacker.openime.rime.RimeInputNormalizer import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/RimeMutationQueueTest.kt b/app/src/test/java/llc/slacker/openime/RimeMutationQueueTest.kt index f124de7a..38977056 100644 --- a/app/src/test/java/llc/slacker/openime/RimeMutationQueueTest.kt +++ b/app/src/test/java/llc/slacker/openime/RimeMutationQueueTest.kt @@ -1,11 +1,12 @@ package llc.slacker.openime -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit +import llc.slacker.openime.rime.RimeMutationQueue import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit class RimeMutationQueueTest { diff --git a/app/src/test/java/llc/slacker/openime/RimeStartupGateTest.kt b/app/src/test/java/llc/slacker/openime/RimeStartupGateTest.kt index d058bfbc..41d48b7b 100644 --- a/app/src/test/java/llc/slacker/openime/RimeStartupGateTest.kt +++ b/app/src/test/java/llc/slacker/openime/RimeStartupGateTest.kt @@ -1,5 +1,8 @@ package llc.slacker.openime +import llc.slacker.openime.rime.RimeStartupGate +import llc.slacker.openime.rime.rimeDataRevision +import llc.slacker.openime.rime.rimeProbeHasCandidate import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull diff --git a/app/src/test/java/llc/slacker/openime/SelectionArrowPolicyTest.kt b/app/src/test/java/llc/slacker/openime/SelectionArrowPolicyTest.kt index 616fa3b0..9a7c15a7 100644 --- a/app/src/test/java/llc/slacker/openime/SelectionArrowPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/SelectionArrowPolicyTest.kt @@ -1,5 +1,6 @@ package llc.slacker.openime +import llc.slacker.openime.editor.collapseSelectionForAdjacentArrow import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/SelectionUpdatePolicyTest.kt b/app/src/test/java/llc/slacker/openime/SelectionUpdatePolicyTest.kt index f8a22923..62cc92d0 100644 --- a/app/src/test/java/llc/slacker/openime/SelectionUpdatePolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/SelectionUpdatePolicyTest.kt @@ -1,5 +1,6 @@ package llc.slacker.openime +import llc.slacker.openime.editor.shouldClearCompositionForSelectionUpdate import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/SwipeUpDetectorTest.kt b/app/src/test/java/llc/slacker/openime/SwipeUpDetectorTest.kt new file mode 100644 index 00000000..329301d1 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/SwipeUpDetectorTest.kt @@ -0,0 +1,34 @@ +package llc.slacker.openime + +import llc.slacker.openime.widget.SwipeUpDetector +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SwipeUpDetectorTest { + private fun detector() = SwipeUpDetector(thresholdPx = 50f).also { it.down(100f, 300f) } + + @Test + fun firesOnceWhenTheFingerTravelsFarEnoughUp() { + val d = detector() + assertFalse(d.move(100f, 280f)) + assertTrue(d.move(102f, 240f)) + assertFalse("must not fire twice in one gesture", d.move(102f, 200f)) + } + + @Test + fun ignoresSidewaysAndDownwardMovement() { + assertFalse(detector().move(220f, 290f)) + assertFalse(detector().move(100f, 400f)) + // Far up, but even further sideways: a drag, not a swipe up. + assertFalse(detector().move(300f, 240f)) + } + + @Test + fun aNewDownStartsANewGesture() { + val d = detector() + assertTrue(d.move(100f, 240f)) + d.down(100f, 300f) + assertTrue(d.move(100f, 240f)) + } +} diff --git a/app/src/test/java/llc/slacker/openime/TextEditControlPolicyTest.kt b/app/src/test/java/llc/slacker/openime/TextEditControlPolicyTest.kt index 84ad90b6..bfd3abcd 100644 --- a/app/src/test/java/llc/slacker/openime/TextEditControlPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/TextEditControlPolicyTest.kt @@ -1,5 +1,6 @@ package llc.slacker.openime +import llc.slacker.openime.panel.TextEditControlPolicy import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue diff --git a/app/src/test/java/llc/slacker/openime/TokenDriftTest.kt b/app/src/test/java/llc/slacker/openime/TokenDriftTest.kt index 904ffcb2..c43a003b 100644 --- a/app/src/test/java/llc/slacker/openime/TokenDriftTest.kt +++ b/app/src/test/java/llc/slacker/openime/TokenDriftTest.kt @@ -1,21 +1,22 @@ package llc.slacker.openime -import java.io.File -import org.junit.Assert.assertFalse import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import java.io.File class TokenDriftTest { @Test fun canonicalTokenScaleDoesNotDrift() { - val source = source("app/src/main/java/llc/slacker/openime/ImeDesignTokens.kt") + val source = source("app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt") listOf( "const val CAPTION_SP = 11f", "const val BODY_SP = 14f", "const val TITLE_SP = 16f", "const val CANDIDATE_SP = 18f", "const val KEY_LETTER_SP = 21f", + "const val KEY_LETTER_COMPACT_SP = 19f", "const val DISPLAY_SP = 28f", "const val XXS_DP = 2", "const val XS_DP = 4", diff --git a/app/src/test/java/llc/slacker/openime/UnicodeTextTest.kt b/app/src/test/java/llc/slacker/openime/UnicodeTextTest.kt index 7917d2c2..bc41c8e2 100644 --- a/app/src/test/java/llc/slacker/openime/UnicodeTextTest.kt +++ b/app/src/test/java/llc/slacker/openime/UnicodeTextTest.kt @@ -1,5 +1,8 @@ package llc.slacker.openime +import llc.slacker.openime.core.dropLastCodePointSafe +import llc.slacker.openime.core.nextCodePointUtf16Length +import llc.slacker.openime.core.previousCodePointUtf16Length import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/UserDataArchiveTest.kt b/app/src/test/java/llc/slacker/openime/UserDataArchiveTest.kt index 48eeee74..421b1501 100644 --- a/app/src/test/java/llc/slacker/openime/UserDataArchiveTest.kt +++ b/app/src/test/java/llc/slacker/openime/UserDataArchiveTest.kt @@ -1,5 +1,17 @@ package llc.slacker.openime +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.data.ArchiveCustomSymbol +import llc.slacker.openime.data.ArchiveQuickPhrase +import llc.slacker.openime.data.ArchiveSettings +import llc.slacker.openime.data.ArchiveUserPhrase +import llc.slacker.openime.data.ImeHandedness +import llc.slacker.openime.data.RimeUserDictionaryArchive +import llc.slacker.openime.data.UserDataArchive +import llc.slacker.openime.data.UserDataArchiveCodec +import llc.slacker.openime.data.UserDataArchiveMerger +import llc.slacker.openime.theme.ImeAppearance +import llc.slacker.openime.theme.ImeTheme import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -13,6 +25,7 @@ class UserDataArchiveTest { haptic = false, popup = true, fuzzy = false, + swipeUpDigits = true, skinOpacity = 92, skinRadius = 10, skinFont = 18, diff --git a/app/src/test/java/llc/slacker/openime/UserPhraseRepositoryTest.kt b/app/src/test/java/llc/slacker/openime/UserPhraseRepositoryTest.kt index 402beb47..7febbbda 100644 --- a/app/src/test/java/llc/slacker/openime/UserPhraseRepositoryTest.kt +++ b/app/src/test/java/llc/slacker/openime/UserPhraseRepositoryTest.kt @@ -1,8 +1,9 @@ package llc.slacker.openime +import llc.slacker.openime.data.UserPhraseRepository +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue -import org.junit.After import org.junit.Test class UserPhraseRepositoryTest { diff --git a/app/src/test/java/llc/slacker/openime/VoiceAudioRouteManagerTest.kt b/app/src/test/java/llc/slacker/openime/VoiceAudioRouteManagerTest.kt index 619ba52c..96a07957 100644 --- a/app/src/test/java/llc/slacker/openime/VoiceAudioRouteManagerTest.kt +++ b/app/src/test/java/llc/slacker/openime/VoiceAudioRouteManagerTest.kt @@ -1,5 +1,6 @@ package llc.slacker.openime +import llc.slacker.openime.voice.VoiceRouteOwnership import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue diff --git a/app/src/test/java/llc/slacker/openime/VoiceAutoPreloadPolicyTest.kt b/app/src/test/java/llc/slacker/openime/VoiceAutoPreloadPolicyTest.kt index 85c3e3e1..2ead3c05 100644 --- a/app/src/test/java/llc/slacker/openime/VoiceAutoPreloadPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/VoiceAutoPreloadPolicyTest.kt @@ -1,6 +1,8 @@ package llc.slacker.openime import android.view.inputmethod.EditorInfo +import llc.slacker.openime.editor.EditorInfoAdapter +import llc.slacker.openime.voice.VoiceAutoPreloadPolicy import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/VoiceCorrectionRangeTest.kt b/app/src/test/java/llc/slacker/openime/VoiceCorrectionRangeTest.kt index 2b593f8d..2374391e 100644 --- a/app/src/test/java/llc/slacker/openime/VoiceCorrectionRangeTest.kt +++ b/app/src/test/java/llc/slacker/openime/VoiceCorrectionRangeTest.kt @@ -1,5 +1,9 @@ package llc.slacker.openime +import llc.slacker.openime.editor.InputConnectionGateway +import llc.slacker.openime.voice.VoiceCorrectionRange +import llc.slacker.openime.voice.correctedVoiceText +import llc.slacker.openime.voice.voiceCorrectionRange import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/VoiceCorrectionRepositoryTest.kt b/app/src/test/java/llc/slacker/openime/VoiceCorrectionRepositoryTest.kt index 20777d29..9975d1ef 100644 --- a/app/src/test/java/llc/slacker/openime/VoiceCorrectionRepositoryTest.kt +++ b/app/src/test/java/llc/slacker/openime/VoiceCorrectionRepositoryTest.kt @@ -1,5 +1,6 @@ package llc.slacker.openime +import llc.slacker.openime.voice.VoiceCorrectionRepository import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/app/src/test/java/llc/slacker/openime/VoiceCorrectionTrackerTest.kt b/app/src/test/java/llc/slacker/openime/VoiceCorrectionTrackerTest.kt index ff8f2ceb..7ceb9870 100644 --- a/app/src/test/java/llc/slacker/openime/VoiceCorrectionTrackerTest.kt +++ b/app/src/test/java/llc/slacker/openime/VoiceCorrectionTrackerTest.kt @@ -1,5 +1,7 @@ package llc.slacker.openime +import llc.slacker.openime.editor.InputConnectionGateway +import llc.slacker.openime.voice.VoiceCorrectionTracker import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/VoiceFinalPolicyTest.kt b/app/src/test/java/llc/slacker/openime/VoiceFinalPolicyTest.kt index 9d67df2f..e6001a4a 100644 --- a/app/src/test/java/llc/slacker/openime/VoiceFinalPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/VoiceFinalPolicyTest.kt @@ -1,5 +1,7 @@ package llc.slacker.openime +import llc.slacker.openime.voice.VoiceFinalPlan +import llc.slacker.openime.voice.VoiceFinalPolicy import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/VoiceHotwordProviderTest.kt b/app/src/test/java/llc/slacker/openime/VoiceHotwordProviderTest.kt index e3e79933..c7590efd 100644 --- a/app/src/test/java/llc/slacker/openime/VoiceHotwordProviderTest.kt +++ b/app/src/test/java/llc/slacker/openime/VoiceHotwordProviderTest.kt @@ -1,5 +1,6 @@ package llc.slacker.openime +import llc.slacker.openime.voice.VoiceHotwordProvider import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue diff --git a/app/src/test/java/llc/slacker/openime/VoiceLifecycleOwnershipStressTest.kt b/app/src/test/java/llc/slacker/openime/VoiceLifecycleOwnershipStressTest.kt index 1abdfca0..6b410678 100644 --- a/app/src/test/java/llc/slacker/openime/VoiceLifecycleOwnershipStressTest.kt +++ b/app/src/test/java/llc/slacker/openime/VoiceLifecycleOwnershipStressTest.kt @@ -1,11 +1,15 @@ package llc.slacker.openime -import java.util.concurrent.atomic.AtomicLong +import llc.slacker.openime.voice.LocalVoiceAudioSpec +import llc.slacker.openime.voice.PcmRingBuffer +import llc.slacker.openime.voice.VoicePerformanceTrace +import llc.slacker.openime.voice.VoiceRouteOwnership import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import java.util.concurrent.atomic.AtomicLong /** Deterministic high-frequency ownership races; no microphone or device is required. */ class VoiceLifecycleOwnershipStressTest { diff --git a/app/src/test/java/llc/slacker/openime/VoiceMemoryAdmissionPolicyTest.kt b/app/src/test/java/llc/slacker/openime/VoiceMemoryAdmissionPolicyTest.kt index e3f3e540..c71d40ea 100644 --- a/app/src/test/java/llc/slacker/openime/VoiceMemoryAdmissionPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/VoiceMemoryAdmissionPolicyTest.kt @@ -1,6 +1,10 @@ package llc.slacker.openime import android.view.inputmethod.EditorInfo +import llc.slacker.openime.editor.EditorInfoAdapter +import llc.slacker.openime.voice.VoiceAutoPreloadPolicy +import llc.slacker.openime.voice.VoiceMemoryAdmissionPolicy +import llc.slacker.openime.voice.VoiceMemorySnapshot import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/test/java/llc/slacker/openime/VoiceModelLifecycleRetryPolicyTest.kt b/app/src/test/java/llc/slacker/openime/VoiceModelLifecycleRetryPolicyTest.kt index e55989c9..17c8928c 100644 --- a/app/src/test/java/llc/slacker/openime/VoiceModelLifecycleRetryPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/VoiceModelLifecycleRetryPolicyTest.kt @@ -1,5 +1,6 @@ package llc.slacker.openime +import llc.slacker.openime.voice.VoicePreloadRetryPolicy import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue diff --git a/app/src/test/java/llc/slacker/openime/VoiceModelPathPolicyTest.kt b/app/src/test/java/llc/slacker/openime/VoiceModelPathPolicyTest.kt index f4b44632..da750fc5 100644 --- a/app/src/test/java/llc/slacker/openime/VoiceModelPathPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/VoiceModelPathPolicyTest.kt @@ -1,10 +1,13 @@ package llc.slacker.openime -import java.nio.file.Files +import llc.slacker.openime.voice.isSafeVoiceModelId +import llc.slacker.openime.voice.isSafeVoiceModelRelativePath +import llc.slacker.openime.voice.resolveContainedVoiceModelFile import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import java.nio.file.Files class VoiceModelPathPolicyTest { @Test diff --git a/app/src/test/java/llc/slacker/openime/VoiceModelTrustPolicyTest.kt b/app/src/test/java/llc/slacker/openime/VoiceModelTrustPolicyTest.kt index df69d4f6..90668f31 100644 --- a/app/src/test/java/llc/slacker/openime/VoiceModelTrustPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/VoiceModelTrustPolicyTest.kt @@ -1,14 +1,24 @@ package llc.slacker.openime -import java.io.File -import java.nio.file.Files +import llc.slacker.openime.voice.SHERPA_DECODER +import llc.slacker.openime.voice.SHERPA_ENCODER +import llc.slacker.openime.voice.SHERPA_TOKENS +import llc.slacker.openime.voice.TrustedVoiceModelCatalog +import llc.slacker.openime.voice.VoiceModelManifest +import llc.slacker.openime.voice.downloadedPackageCacheSignature +import llc.slacker.openime.voice.isSafeVoiceModelId +import llc.slacker.openime.voice.isSafeVoiceModelRelativePath +import llc.slacker.openime.voice.resolveContainedVoiceModelFile +import llc.slacker.openime.voice.trustFingerprint import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Assume.assumeTrue import org.junit.Test +import java.io.File +import java.nio.file.Files class VoiceModelTrustPolicyTest { diff --git a/app/src/test/java/llc/slacker/openime/VoicePerformanceTraceGenerationTest.kt b/app/src/test/java/llc/slacker/openime/VoicePerformanceTraceGenerationTest.kt index 58862a37..81688274 100644 --- a/app/src/test/java/llc/slacker/openime/VoicePerformanceTraceGenerationTest.kt +++ b/app/src/test/java/llc/slacker/openime/VoicePerformanceTraceGenerationTest.kt @@ -1,11 +1,12 @@ package llc.slacker.openime -import java.util.Collections -import java.util.concurrent.atomic.AtomicLong +import llc.slacker.openime.voice.VoicePerformanceTrace import org.junit.After import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import java.util.Collections +import java.util.concurrent.atomic.AtomicLong class VoicePerformanceTraceGenerationTest { diff --git a/app/src/test/java/llc/slacker/openime/VoiceRuntimeModelSourceTest.kt b/app/src/test/java/llc/slacker/openime/VoiceRuntimeModelSourceTest.kt index f26beed8..4c2c440a 100644 --- a/app/src/test/java/llc/slacker/openime/VoiceRuntimeModelSourceTest.kt +++ b/app/src/test/java/llc/slacker/openime/VoiceRuntimeModelSourceTest.kt @@ -1,13 +1,21 @@ package llc.slacker.openime -import java.io.File -import java.nio.file.Files +import llc.slacker.openime.voice.SHERPA_DECODER +import llc.slacker.openime.voice.SHERPA_ENCODER +import llc.slacker.openime.voice.SHERPA_TOKENS +import llc.slacker.openime.voice.SherpaRuntimeStorage +import llc.slacker.openime.voice.VoiceModelManifest +import llc.slacker.openime.voice.VoiceModelSelection +import llc.slacker.openime.voice.resolveSherpaRuntimeModelFiles +import llc.slacker.openime.voice.runtimeIdentity import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import java.io.File +import java.nio.file.Files class VoiceRuntimeModelSourceTest { diff --git a/app/src/test/java/llc/slacker/openime/VoiceTextProcessorContextTest.kt b/app/src/test/java/llc/slacker/openime/VoiceTextProcessorContextTest.kt index 2652a11b..b6add3d0 100644 --- a/app/src/test/java/llc/slacker/openime/VoiceTextProcessorContextTest.kt +++ b/app/src/test/java/llc/slacker/openime/VoiceTextProcessorContextTest.kt @@ -2,6 +2,9 @@ package llc.slacker.openime import android.text.InputType import android.view.inputmethod.EditorInfo +import llc.slacker.openime.editor.EditorInfoAdapter +import llc.slacker.openime.voice.VoiceTextProcessingPolicy +import llc.slacker.openime.voice.VoiceTextProcessor import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue diff --git a/app/src/test/java/llc/slacker/openime/hotword/BundledHotwordPacksTest.kt b/app/src/test/java/llc/slacker/openime/hotword/BundledHotwordPacksTest.kt new file mode 100644 index 00000000..917eeb8b --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/hotword/BundledHotwordPacksTest.kt @@ -0,0 +1,45 @@ +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 new file mode 100644 index 00000000..d1aa8bf8 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/hotword/HomophoneCorrectorTest.kt @@ -0,0 +1,70 @@ +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 new file mode 100644 index 00000000..10a39c40 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/hotword/HotwordModuleBoundaryTest.kt @@ -0,0 +1,40 @@ +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 new file mode 100644 index 00000000..2c9f85c1 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/hotword/HotwordParserTest.kt @@ -0,0 +1,56 @@ +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 new file mode 100644 index 00000000..95f2fc8b --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/hotword/PinyinCandidateIndexTest.kt @@ -0,0 +1,58 @@ +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/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 349469ce..43601f8f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -85,6 +85,81 @@ LocalVoiceImeService `VoiceModelLifecycleManager` 是 ASR runtime 的唯一 owner。模型校验、预热、构建和释放不在 IME 主线程执行。`VoicePanelController` 只持有 presentation-side session/generation,`InlineVoicePresenter` 只负责顶部内联状态,`SpaceVoiceGestureController` 只负责长按/上滑取消手势。长按判定跟随 Android 配置的 touch-and-hold timeout;松手、取消和旧 session 回调必须保持 generation 隔离。 +## 模块与分包 + +代码按功能分成子包,依赖只能自下而上;方向由 `ArchitectureLayeringTest` 固定,新增依赖必须先改那张表。 + +```text + app(根包:Service、Activity、RimeNative) + │ + keyboard ────────────────┐ + ┌──────────┬──────────┼──────────┬────────┤ + panel voice candidate floating hotword + │ │ │ │ │ + widget handwriting │ rime │ setup + └──────────┴──────────┴────┬─────┴─────────┘ + data + editor + core + theme +``` + +| 包 | 职责 | 依赖 | +|---|---|---| +| `theme` | 设计 token、主题、绘制工具、外观枚举、参考尺寸 | 无 | +| `core` | `ImeState`、键盘模式、静态数据、崩溃保护 | theme | +| `editor` | 目标编辑器边界:`InputConnectionGateway`、`EditorInfoAdapter`、Enter/选区策略 | core | +| `data` | SharedPreferences 和文件仓库、用户数据导入导出 | core、editor、theme | +| `setup` | Activity 页面共用的 UI 工具 `SetupUi` | data、theme | +| `widget` | 可复用控件:`ImeKeyView`(按键)、`SwipeUpDetector` | theme | +| `floating` | 浮动键盘窗口、拖动、卡片外观 | theme | +| `handwriting` | 手写板 | data、theme | +| `rime` | librime 引擎封装、输入规范化、native 候选引用 | core、data(及 JNI 类 `RimeNative`) | +| `candidate` | 候选管线、快照、九键本地解码、模糊音、拼音词典 | core、rime | +| `hotword` | 语音词表:解析、同音纠正、打字候选加权、管理界面 | setup、theme | +| `voice` | 语音识别、模型生命周期、语音面板、识别后处理 | data、editor、hotword、theme | +| `panel` | 工具、剪贴板、设置、文本编辑等面板 | core、data、handwriting、setup、theme、widget | +| `keyboard` | `ImeKeyboardView` 编排、26 键/九键/数字键盘、顶部区、手势、弹窗 | 以上除 app 外的全部 | +| `app`(根包) | Manifest 里的 Service 和 Activity、JNI 类 | 全部 | + +规则: + +- 低层包不能引用高层包。唯一的例外写在测试里:`panel` 和 `keyboard` 按类名启动几个 Activity,`rime` 调用 JNI 类 + `RimeNative`(它的包名和方法名绑定 native 符号,不能移动)。 +- 根包只放 Manifest、JNI 和测试脚本按名字引用的入口类,别的东西不要放进来。 +- 语音层通过窄接口回到界面:`VoiceSessionHost`(键盘监听器继承它)和 `VoiceEditorContext`(Service 提供编辑器信息), + 不直接依赖 `ImeKeyboardView` 或 Service。 +- 新功能自成一个包,按“纯逻辑 / Android 边界 / 唯一入口”拆分,样板是 `hotword`:纯逻辑文件不 import `android.*`, + 包外只通过 `HotwordRuntime` 和管理 Activity 使用,由 `HotwordModuleBoundaryTest` 检查。 +- 可见性默认 `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/COORDINATE_SYSTEM.md b/docs/COORDINATE_SYSTEM.md index c9cdb4e5..33eb138f 100644 --- a/docs/COORDINATE_SYSTEM.md +++ b/docs/COORDINATE_SYSTEM.md @@ -20,7 +20,7 @@ ## 实现 -`app/src/main/java/llc/slacker/openime/KeyboardGeometry.kt` +`app/src/main/java/llc/slacker/openime/keyboard/KeyboardGeometry.kt` - `NormalizedBounds(left, top, right, bottom)`:归一化矩形。 - `NormalizedBounds.fromView(view, root)`:从真实 `View` 测量值生成归一化坐标。 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5d9ba25f..78ce811b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -9,3 +9,5 @@ - 2026-09-29|九键首帧与 Rime 刷新|候选按压或滚动期间暂缓应用异步 Rime 结果;同时记录从请求到结果可应用的端到端延迟|防止手指下的候选列表重排;是否在 Rime 就绪时跳过首帧回退,等实测 P95 是否低于 40ms 后再定。 - 2026-09-29|Rime 用户词库导出|vendored librime 1.17.0 已编入 levers 模块,`UserDictManager::Export/Import` 可在关闭用户库会话后导出/合并 UTF-8 快照|用户数据 JSON 在 Rime 会话已加载时包含自动学习词库;不可用时必须明确提示,不静默遗漏。 - 2026-09-29|流式语音模型|默认模型切换为 `sherpa-onnx-streaming-paraformer-bilingual-zh-en` 的 INT8 encoder/decoder;运行时使用 `OnlineParaformerModelConfig` + `greedy_search`,结束时补 300ms 静音;不再向 Paraformer stream 传 transducer-only 动态 hotwords|优先降低模型体积和保持中英流式识别,同时遵循 sherpa-onnx v1.13.6 官方 Paraformer 配置。 +- 2026-10-03|语音词表|内置词表随版本发布(`assets/hotwords/`),用户可导入文本词表;二者都不联网,不新增 `INTERNET` 权限,不做在线定期更新|Paraformer 不能把热词传进解码器,所以词表走识别后的“同音替换”:读音(取自 `8105.dict.yaml`,含多音字)相同而字不同的片段改成词表写法。游戏词表默认关闭,避免日常聊天被误改。 +- 2026-10-03|整体分包|99 个平铺文件按功能分进 theme/core/editor/data/setup/widget/floating/handwriting/rime/candidate/hotword/voice/panel/keyboard,根包只留 Service、Activity 和 JNI 类;依赖方向由 `ArchitectureLayeringTest` 固定|包名只影响组织和可见性,不改 Manifest、native 符号和测试脚本引用的名字;Gradle 多模块暂不做,因为 `keyboard` 与 `panel`、`voice` 仍通过大接口耦合,需要先拆 `ImeKeyboardView.Listener`。 diff --git a/docs/LOCAL_VOICE_MODEL.md b/docs/LOCAL_VOICE_MODEL.md index e20b7882..356c610a 100644 --- a/docs/LOCAL_VOICE_MODEL.md +++ b/docs/LOCAL_VOICE_MODEL.md @@ -86,6 +86,9 @@ 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 模型,目前没有调用方。 - 用户在语音上屏后立即删除并改正的文本会形成私有 `VoiceCorrectionRepository` 对; 后续相同 ASR 原结果先应用本地纠正,改正目标也会回流动态热词。 - 密码框可以使用语音,但只在结束时一次性上屏最终结果,不显示中间结果,也不进入热词或纠错学习;日志不记录 PCM、转写、热词、纠错内容。 diff --git a/scripts/core_regression.sh b/scripts/core_regression.sh index 04ae7eb9..41916cb3 100755 --- a/scripts/core_regression.sh +++ b/scripts/core_regression.sh @@ -125,6 +125,22 @@ mode() { return 1 } +# The first start after an install or data clear copies and compiles the Rime +# dictionaries, which takes a while; until then candidates come from the Kotlin +# fallback and steps that need native candidates (partial picks) cannot pass. +wait_rime_ready() { + local attempt + for attempt in $(seq 1 180); do + send state + sleep 1 + if adb_do logcat -d -s OpenImeE2E:I | grep -q 'rimeReady=true'; then + return 0 + fi + done + say 'WARN: librime did not become ready in time; candidate steps use the Kotlin fallback' >&2 + return 1 +} + start_real() { adb_do logcat -c >/dev/null 2>&1 adb_do shell am force-stop "$PKG" >/dev/null 2>&1 @@ -132,6 +148,7 @@ start_real() { sleep 3 focus_editor || say 'WARN: test_input was not found for focus' sleep 1 + wait_rime_ready } check() {