From b0f3139328ddb3b467904226a2366ac0872d9682 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:23:49 +0800 Subject: [PATCH 01/13] fix: keep the keyboard usable in landscape, on tablets and with large system fonts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by running the live keyboard through a display matrix instead of only the default portrait phone: - Landscape no longer enters fullscreen "extract" mode (onEvaluateFullscreenMode = false). By default the framework hid the app's own editor behind an unthemed copy of the field; chat, search and form typing disappeared. - Content-box geometry (paddings, row widths) is now applied in onMeasure, before the children are measured. It used to be applied from onSizeChanged, i.e. to views that are laid out later in the same pass; their own requestLayout() calls are dropped because each view clears its force-layout flag when it finishes laying out, so the rows kept the full window width inside a clamped content box and were clipped (landscape, tablets, foldables). A deferred relayout flags the changed containers for late callers. - Key labels follow the system font only up to 1.3x and the autosize of function labels survives applyMainTextScale. At 200% "m" became an ellipsis and "中/英" lost a glyph. Tests: DisplayEnvironmentInstrumentedTest (content box and label fit across layouts and font scales; both fail without these fixes), and scripts/display_matrix_regression.py, which drives the real IME through 11 environments and asserts bottom-panel mode plus every key inside the window. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 6 + .../DisplayEnvironmentInstrumentedTest.kt | 155 +++++++++++++ .../java/llc/slacker/openime/ImeKeyView.kt | 53 ++++- .../llc/slacker/openime/ImeKeyboardView.kt | 62 +++++- .../slacker/openime/LocalVoiceImeService.kt | 9 + docs/TEST_SOP.md | 10 + scripts/README.md | 9 + scripts/display_matrix_regression.py | 205 ++++++++++++++++++ 8 files changed, 491 insertions(+), 18 deletions(-) create mode 100644 app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt create mode 100755 scripts/display_matrix_regression.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d2cc5d11..7800aa6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,12 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 强调色可选可自定义,界面里的强调元素统一从它推导;设置页分段控件、剪贴板清除按钮等可点区域保持 48dp 触控目标;安装引导页的卡片对读屏软件是一个整体,不再逐个朗读装饰元素。 - 手写目前只保留笔迹采集界面,识别引擎尚未接入,入口默认隐藏。 +### 兼容性 +- 横屏不再进入全屏「提取」模式:应用自己的输入框不会被盖住,键盘始终是底部面板。 +- 横屏、平板和折叠屏上按键行宽度现在跟随内容区,不会再被裁掉一部分;布局在测量前就按可用宽度计算。 +- 系统字体放大到 200% 时按键字符不再变成省略号,「中/英」不再缺字:按键标签最多跟随系统字体放大到 1.3 倍,功能键标签会自动缩小以放入按键。 +- 竖屏、横屏、字体 130% / 200%、深色、小屏、窄屏、平板(竖 / 横)和折叠屏内屏都由 `scripts/display_matrix_regression.py` 逐一验证。 + ### 隐私与安全 - 不声明 `INTERNET` 权限;`allowBackup=false`。 - 密码输入框不写入候选、剪贴板历史或日志(允许从剪贴板粘贴);语音 PCM 只在当前会话的内存缓冲区中处理,结束、取消或失败时清空。 diff --git a/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt new file mode 100644 index 00000000..2c5d59c9 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt @@ -0,0 +1,155 @@ +package llc.slacker.openime + +import android.content.Context +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 org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The keyboard has to stay usable in every display environment, not just the + * default portrait phone: landscape (content box narrower than the window), + * large system fonts, and every layout. + */ +@RunWith(AndroidJUnit4::class) +class DisplayEnvironmentInstrumentedTest { + + private val listener: ImeKeyboardView.Listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, _ -> + when (method.returnType) { + java.lang.Boolean.TYPE -> false + java.lang.Integer.TYPE -> 0 + else -> null + } + } as ImeKeyboardView.Listener + + private fun configured(base: Context, change: Configuration.() -> Unit): Context = + base.createConfigurationContext(Configuration(base.resources.configuration).apply(change)) + + private fun allViews(root: View): Sequence = sequence { + yield(root) + if (root is ViewGroup) { + for (index in 0 until root.childCount) yieldAll(allViews(root.getChildAt(index))) + } + } + + /** Show a keyboard built from [contextFor] and hand it to [check] once the first layout is done. */ + private fun withKeyboard( + mode: KeyboardMode, + contextFor: (Context) -> Context, + check: (ImeKeyboardView) -> Unit, + ) { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val keyboard = harness.awaitMain { activity -> + ImeKeyboardView(contextFor(activity), listener).also { + activity.findViewById(android.R.id.content).addView( + it, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + } + } + try { + harness.awaitMain { + keyboard.setMode(mode, notifyListener = false) + true + } + // First layout done: some visible key has a size. + harness.awaitMain { + val shown = allViews(keyboard).filterIsInstance().filter { it.isShown }.toList() + if (keyboard.width > 0 && shown.isNotEmpty() && shown.all { it.width > 0 }) true else null + } + // Geometry corrections are applied around the first layout; give + // any deferred relayout time to run before looking at sizes. + Thread.sleep(600) + harness.awaitMain { + check(keyboard) + true + } + } finally { + harness.awaitMain { + keyboard.shutdown() + (keyboard.parent as? ViewGroup)?.removeView(keyboard) + true + } + } + } + } + + @Test + fun landscapeContentBoxKeepsEveryRowInsideIt() { + // A landscape configuration makes the reference scale follow the height, + // so the content box (padding on both sides) is narrower than the window. + // Rows used to keep the width they were measured with before that padding + // was applied: as wide as the window, clipped to the box. + for (mode in listOf(KeyboardMode.PINYIN_26, KeyboardMode.ENGLISH_26, KeyboardMode.PINYIN_9, KeyboardMode.DIGITS)) { + withKeyboard( + mode = mode, + contextFor = { base -> + configured(base) { + orientation = Configuration.ORIENTATION_LANDSCAPE + screenWidthDp = 914 + screenHeightDp = 411 + } + }, + ) { keyboard -> + val body = keyboard.findViewWithTag("keyboard-body") + assertNotNull("keyboard body", body) + val content = body.width - body.paddingLeft - body.paddingRight + assertTrue("$mode must be laid out in a clamped content box (padding ${body.paddingLeft})", body.paddingLeft > 0) + for (index in 0 until body.childCount) { + val row = body.getChildAt(index) + assertTrue( + "$mode row $index is ${row.width}px wide inside a ${content}px content box", + row.width <= content + 1, + ) + } + val toolbar = keyboard.findViewWithTag("toolbar-row") + if (toolbar != null) { + val toolbarContent = toolbar.width - toolbar.paddingLeft - toolbar.paddingRight + var used = 0 + for (index in 0 until toolbar.childCount) { + val child = toolbar.getChildAt(index) + if (child.visibility == View.VISIBLE) used += child.width + } + assertTrue( + "$mode toolbar items need ${used}px but the content box is ${toolbarContent}px", + used <= toolbarContent + 1, + ) + } + } + } + } + + @Test + fun keyLabelsStayReadableAtEverySystemFontSize() { + // Key labels follow the system font only up to 1.3x; beyond that a key + // cannot grow, and "m" turned into an ellipsis while "中/英" lost a glyph. + for (fontScale in listOf(1.0f, 1.3f, 1.5f, 2.0f)) { + for (mode in listOf(KeyboardMode.PINYIN_26, KeyboardMode.ENGLISH_26, KeyboardMode.PINYIN_9, KeyboardMode.DIGITS)) { + withKeyboard( + mode = mode, + contextFor = { base -> configured(base) { this.fontScale = fontScale } }, + ) { keyboard -> + val clipped = allViews(keyboard) + .filterIsInstance() + .filter { !it.mainLabelFits() } + .map { "'${it.currentMainText}'" } + .toList() + assertEquals("labels cut off at font scale $fontScale in $mode", emptyList(), clipped) + } + } + } + } +} diff --git a/app/src/main/java/llc/slacker/openime/ImeKeyView.kt b/app/src/main/java/llc/slacker/openime/ImeKeyView.kt index 1d01df88..ce7a4360 100644 --- a/app/src/main/java/llc/slacker/openime/ImeKeyView.kt +++ b/app/src/main/java/llc/slacker/openime/ImeKeyView.kt @@ -36,6 +36,16 @@ class ImeKeyView( private var touchFeedbackPending = false private var touchGeneration = 0L private val baseMainTextSize = mainTextSize + private val fitMain = fitMainText + + /** + * Key labels follow the system font size only up to [MAX_LABEL_FONT_SCALE]. + * A key has a fixed width, so at 200% a plain "m" no longer fits and became + * "…", and "中/英" lost its last glyph. Everything else (candidates, panels, + * settings) still follows the system setting in full. + */ + private fun labelPx(sp: Float): Float = + sp * density * minOf(resources.configuration.fontScale, MAX_LABEL_FONT_SCALE) override fun dispatchTouchEvent(event: MotionEvent): Boolean { when (event.actionMasked) { @@ -126,7 +136,7 @@ class ImeKeyView( } }.apply { this.text = text - textSize = mainTextSize + setTextSize(TypedValue.COMPLEX_UNIT_PX, labelPx(mainTextSize)) gravity = Gravity.CENTER isAllCaps = false includeFontPadding = false @@ -136,10 +146,10 @@ class ImeKeyView( // scales. Shrink within a controlled range instead of // replacing the action with an ellipsis such as “中/…”. setAutoSizeTextTypeUniformWithConfiguration( - (mainTextSize * 0.68f).toInt().coerceAtLeast(10), - mainTextSize.toInt().coerceAtLeast(12), + labelPx((mainTextSize * 0.68f).coerceAtLeast(10f)).toInt(), + labelPx(mainTextSize.coerceAtLeast(12f)).toInt(), 1, - TypedValue.COMPLEX_UNIT_SP, + TypedValue.COMPLEX_UNIT_PX, ) ellipsize = null } else { @@ -155,7 +165,7 @@ class ImeKeyView( secondaryTextView = secondary?.takeIf { it.isNotEmpty() }?.let { sub -> TextView(context).apply { this.text = sub - textSize = ImeTypographyTokens.CAPTION_SP + setTextSize(TypedValue.COMPLEX_UNIT_PX, labelPx(ImeTypographyTokens.CAPTION_SP)) gravity = Gravity.CENTER isAllCaps = false includeFontPadding = false @@ -239,11 +249,34 @@ class ImeKeyView( /** Update key typography without rebuilding the keyboard hierarchy. */ fun applyMainTextScale(scale: Float) { + val sp = baseMainTextSize * scale.coerceAtLeast(0.4f) mainTextView?.apply { - setAutoSizeTextTypeWithDefaults(TextView.AUTO_SIZE_TEXT_TYPE_NONE) - textSize = baseMainTextSize * scale.coerceAtLeast(0.4f) + if (fitMain) { + // Function labels ("中/英", "完成") must keep shrinking to fit their key. + setAutoSizeTextTypeUniformWithConfiguration( + labelPx((sp * 0.68f).coerceAtLeast(10f)).toInt(), + labelPx(sp.coerceAtLeast(12f)).toInt(), + 1, + TypedValue.COMPLEX_UNIT_PX, + ) + } else { + setAutoSizeTextTypeWithDefaults(TextView.AUTO_SIZE_TEXT_TYPE_NONE) + setTextSize(TypedValue.COMPLEX_UNIT_PX, labelPx(sp)) + } } - secondaryTextView?.textSize = ImeTypographyTokens.CAPTION_SP * (toPx(100) / (100f * density)) + secondaryTextView?.setTextSize( + TypedValue.COMPLEX_UNIT_PX, + labelPx(ImeTypographyTokens.CAPTION_SP) * (toPx(100) / (100f * density)), + ) + } + + /** True when the main label is fully visible (no ellipsis, nothing clipped). Used by tests. */ + internal fun mainLabelFits(): Boolean { + val view = mainTextView ?: return true + if (view.visibility != View.VISIBLE || view.width == 0) return true + val layout = view.layout ?: return false + val available = view.width - view.paddingLeft - view.paddingRight + return layout.getEllipsisCount(0) == 0 && layout.getLineWidth(0) <= available + 0.5f } /** @@ -273,4 +306,8 @@ class ImeKeyView( } private fun dp(value: Int): Int = toPx(value) + + private companion object { + const val MAX_LABEL_FONT_SCALE = 1.3f + } } diff --git a/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt b/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt index cbed6a9b..9b1a4ae2 100644 --- a/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt @@ -896,6 +896,19 @@ open class ImeKeyboardView( super.onMeasure(widthMeasureSpec, heightMeasureSpec) return } + // The content box (padding, row widths) follows the width the parent + // offers. Apply it here, before the children are measured: doing it from + // onSizeChanged changes paddings of views laid out later in the same + // pass and the framework then keeps their stale measurements. + val offeredWidth = MeasureSpec.getSize(widthMeasureSpec) + if (MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.UNSPECIFIED && offeredWidth > 0) { + applyingGeometryInMeasure = true + try { + updateResponsiveGeometry(offeredWidth) + } finally { + applyingGeometryInMeasure = false + } + } val desiredHeight = dp(imeHeightDp()) val mode = MeasureSpec.getMode(heightMeasureSpec) val size = MeasureSpec.getSize(heightMeasureSpec) @@ -990,14 +1003,9 @@ open class ImeKeyboardView( if (panel == Panel.NONE) renderModeBody() else renderPanel(panel) } applyTheme() - // This runs from onSizeChanged, i.e. in the middle of a layout pass. - // Rows rebuilt there are added after their parent was measured and - // would stay at 0x0 (a blank keyboard on first show) until something - // else happened to request a layout. Ask for a fresh pass. - post { - requestLayout() - invalidate() - } + // Rows rebuilt here are added after their parent was measured and + // would stay at 0x0 (a blank keyboard on first show) otherwise. + if (!applyingGeometryInMeasure) scheduleRelayout() } (mainDock.layoutParams as? FrameLayout.LayoutParams)?.let { params -> @@ -1035,7 +1043,10 @@ open class ImeKeyboardView( expandedPanel.setPadding(contentInsetPx, 0, contentInsetPx, 0) candidateOverlay.setPadding(contentInsetPx, 0, contentInsetPx, 0) topZone.setContentInset(contentInsetPx) - requestLayout() + if (!applyingGeometryInMeasure) { + requestLayout() + scheduleRelayout() + } return } val minimumInset = dp(0) @@ -1059,7 +1070,38 @@ open class ImeKeyboardView( expandedPanel.setPadding(contentInsetPx, 0, contentInsetPx, 0) candidateOverlay.setPadding(contentInsetPx, 0, contentInsetPx, 0) topZone.setContentInset(contentInsetPx) - requestLayout() + if (!applyingGeometryInMeasure) { + requestLayout() + scheduleRelayout() + } + } + + private var relayoutPosted = false + private var applyingGeometryInMeasure = false + + /** + * updateResponsiveGeometry runs from onSizeChanged, i.e. inside a layout + * pass, and changes paddings and row widths of views that are laid out later + * in that same pass. Their own requestLayout() calls are lost: each view + * clears its force-layout flag when it finishes laying out, so the framework + * sees no pending request and never re-measures them (the landscape keyboard + * kept rows as wide as the whole window inside a clamped content box). + * requestLayout() on the root is not enough either, it marks only the root + * and its ancestors. So once the pass is over, flag the containers that + * were changed. + */ + private fun scheduleRelayout() { + if (relayoutPosted) return + relayoutPosted = true + post { + relayoutPosted = false + keyboardBody.requestLayout() + if (::topZone.isInitialized) topZone.requestLayout() + expandedPanel.requestLayout() + candidateOverlay.requestLayout() + requestLayout() + invalidate() + } } private fun rescaleTopZone(view: View, ratio: Float) { diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index b984ad29..36169a81 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -191,6 +191,15 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can return true } + override fun onEvaluateFullscreenMode(): Boolean { + // The framework default turns the whole screen into the IME in landscape: + // the app's editor is hidden behind an unthemed copy of the field (the + // extract view) and the conversation, search results or form the user is + // typing into disappear. Every mainstream keyboard stays a bottom panel + // in every orientation and lets the app resize or pan, so do the same. + return false + } + private fun ensureInputViewAfterFinish() { if (keyboardView != null) return // InputMethodService keeps the old view instance after diff --git a/docs/TEST_SOP.md b/docs/TEST_SOP.md index 530df926..ae674602 100644 --- a/docs/TEST_SOP.md +++ b/docs/TEST_SOP.md @@ -79,6 +79,16 @@ L2 至少覆盖 320、360、390、412、432、600dp。390×296 仅是设计基 - 字体 100%、130%、150%,默认和放大显示。 - 横屏、竖屏、分屏、前后台、锁屏恢复、进程被杀后恢复。 +显示环境矩阵由脚本自动检查,不要手工代替: + +```bash +python3 scripts/display_matrix_regression.py --serial +``` + +它在竖屏、横屏、字体 130% / 200%、深色、小屏、窄屏、平板(竖 / 横)、折叠屏内屏之间切换, +断言键盘是底部面板(不是全屏提取模式),且每个按键和工具栏按钮都有尺寸、都在窗口内, +并保存每个环境的截图供人工复核;运行结束会还原所有系统设置。 + debug APK 提供 `ImeTestLabActivity`,包含普通、多行、密码、数字、电话、邮箱、URL、 搜索、聊天发送、Next/Done、已有 1 万字和预选文字替换输入框。release APK 不包含或导出 任何测试 Activity/Receiver。 diff --git a/scripts/README.md b/scripts/README.md index dbd3f3be..f325644f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -15,6 +15,15 @@ bash scripts/setup_release_signing.sh # 一次性:生成发布密钥 bash scripts/apply_repo_settings.sh --dry-run # 查看将要应用的仓库规则(见 docs/REPOSITORY.md) ``` +## 显示环境矩阵 + +需要一台装了 debug 版 openIME 并已设为默认输入法的设备或模拟器;脚本会改动并还原旋转、字体、 +分辨率和深色模式: + +```bash +python3 scripts/display_matrix_regression.py --serial [用例名 ...] +``` + ## 常用命令 ```powershell diff --git a/scripts/display_matrix_regression.py b/scripts/display_matrix_regression.py new file mode 100755 index 00000000..a9f5a33b --- /dev/null +++ b/scripts/display_matrix_regression.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Display-environment matrix for the live keyboard. + +For each environment (orientation, system font size, dark mode, phone / tablet / +foldable / narrow windows) the debug input lab is opened, the keyboard is shown, +and the script checks that + + * the keyboard is a bottom panel, never the fullscreen "extract" mode; + * every key and toolbar button has a size and lies inside the keyboard window. + +It needs a debug build of openIME selected as the default input method on one +device or emulator. Every setting it changes is reset afterwards, also on error. +Screenshots go to --out so a human can look at them. + + python3 scripts/display_matrix_regression.py [--serial SERIAL] [--out DIR] [case ...] +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +import time +from pathlib import Path + +PKG = "llc.slacker.openime" +LAB = f"{PKG}/.ImeTestLabActivity" +RECEIVER = f"{PKG}/.E2ETestReceiver" +ACTION = f"{PKG}.TEST_COMMAND" + +# name -> (wm size, wm density, user_rotation, font_scale, night) +CASES: dict[str, tuple] = { + "portrait": (None, None, 0, "1.0", False), + "landscape": (None, None, 1, "1.0", False), + "portrait_font1.3": (None, None, 0, "1.3", False), + "portrait_font2.0": (None, None, 0, "2.0", False), + "landscape_font1.3": (None, None, 1, "1.3", False), + "dark": (None, None, 0, "1.0", True), + "small_phone_720x1280_d320": ("720x1280", "320", 0, "1.0", False), + "narrow_540x1200_d420": ("540x1200", "420", 0, "1.0", False), + "tablet_1600x2560_d280": ("1600x2560", "280", 0, "1.0", False), + "tablet_landscape": ("1600x2560", "280", 1, "1.0", False), + "foldable_inner_1840x2208_d420": ("1840x2208", "420", 0, "1.0", False), +} + +KEYBOARD_ITEMS = ("key", "toolbar", "keyboard-selector", "clipboard-toolbar", "undo-toolbar", "keyboard-hide") + + +class Device: + def __init__(self, serial: str | None): + self.base = ["adb"] + (["-s", serial] if serial else []) + + def run(self, *args: str, binary: bool = False): + result = subprocess.run(self.base + list(args), capture_output=True) + return result.stdout if binary else result.stdout.decode("utf-8", "replace") + + def shell(self, *args: str) -> str: + return self.run("shell", *args) + + +def settle(seconds: float) -> None: + time.sleep(seconds) + + +def reset(dev: Device) -> None: + dev.shell("wm", "size", "reset") + dev.shell("wm", "density", "reset") + dev.shell("settings", "put", "system", "font_scale", "1.0") + dev.shell("settings", "put", "system", "accelerometer_rotation", "0") + dev.shell("settings", "put", "system", "user_rotation", "0") + dev.shell("cmd", "uimode", "night", "no") + settle(2) + + +def apply(dev: Device, case: tuple) -> None: + size, density, rotation, font, night = case + if size: + dev.shell("wm", "size", size) + if density: + dev.shell("wm", "density", density) + dev.shell("settings", "put", "system", "accelerometer_rotation", "0") + dev.shell("settings", "put", "system", "user_rotation", str(rotation)) + dev.shell("settings", "put", "system", "font_scale", font) + dev.shell("cmd", "uimode", "night", "yes" if night else "no") + settle(2) + + +def edit_fields(dev: Device) -> list[tuple[int, int]]: + dev.shell("uiautomator", "dump", "/sdcard/display_matrix.xml") + xml = dev.shell("cat", "/sdcard/display_matrix.xml") + pattern = r']*class="android.widget.EditText"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"' + centers = [] + for match in re.finditer(pattern, xml): + x0, y0, x1, y1 = map(int, match.groups()) + if x1 > x0 and y1 > y0: + centers.append(((x0 + x1) // 2, (y0 + y1) // 2)) + return centers + + +def ime_shown(dev: Device) -> bool: + return "mInputShown=true" in dev.shell("dumpsys", "input_method") + + +def ensure_default_ime(dev: Device) -> None: + service = f"{PKG}/.LocalVoiceImeService" + if dev.shell("settings", "get", "secure", "default_input_method").strip() != service: + dev.shell("ime", "enable", "--user", "0", service) + dev.shell("ime", "set", "--user", "0", service) + settle(1) + + +def show_ime(dev: Device) -> bool: + ensure_default_ime(dev) + # The lab and the IME service share one process: restart only the activity, + # a force-stop would kill the keyboard and the system may fall back to another IME. + # 0x10008000 = FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TASK + dev.shell("am", "start", "-n", LAB, "--es", "focus_id", "lab_single", "-f", "0x10008000") + settle(3) + if "ImeTestLabActivity" not in dev.shell("dumpsys", "activity", "activities").split("topResumedActivity=")[-1][:200]: + return False # something else is in front; the checks below would measure the wrong app + for _ in range(4): + if ime_shown(dev): + break + fields = edit_fields(dev) + if len(fields) >= 2: + # Moving the focus away and back makes the client request the IME again. + dev.shell("input", "tap", str(fields[1][0]), str(fields[1][1])) + settle(1) + dev.shell("input", "tap", str(fields[0][0]), str(fields[0][1])) + elif fields: + dev.shell("input", "tap", str(fields[0][0]), str(fields[0][1])) + settle(2) + settle(1.5) + return ime_shown(dev) + + +def bounds(dev: Device) -> list[tuple[str, float, float, float, float]]: + dev.run("logcat", "-c") + dev.shell("am", "broadcast", "-n", RECEIVER, "-a", ACTION, "--es", "cmd", "bounds") + settle(1) + log = dev.run("logcat", "-d", "-s", "OpenIme:I") + items = [] + for match in re.finditer(r"tag=([^|]*)\|desc=([^|]*)\|([-0-9.eE]+),([-0-9.eE]+),([-0-9.eE]+),([-0-9.eE]+)", log): + x, y, w, h = (float(value) for value in match.groups()[2:]) + items.append((match.group(1), x, y, w, h)) + return items + + +def check(dev: Device, name: str, out: Path) -> bool: + problems: list[str] = [] + if not show_ime(dev): + problems.append("the keyboard did not show") + else: + # The first inFullscreenMode= in the dump is the service's current state; + # the ones after it are history entries. + fullscreen = re.search(r"\binFullscreenMode=(true|false)", dev.shell("dumpsys", "input_method")) + if fullscreen and fullscreen.group(1) == "true": + problems.append("the keyboard is in fullscreen (extract) mode") + items = bounds(dev) + if not items: + problems.append("no bounds were reported") + for tag, x, y, w, h in items: + if not tag.startswith(KEYBOARD_ITEMS): + continue + if w <= 0.005 or h <= 0.005: + problems.append(f"{tag} has no size ({w:.3f}x{h:.3f})") + elif x < -0.002 or y < -0.002 or x + w > 1.002 or y + h > 1.002: + problems.append(f"{tag} leaves the window (x={x:.3f} w={w:.3f} y={y:.3f} h={h:.3f})") + out.mkdir(parents=True, exist_ok=True) + (out / f"{name}.png").write_bytes(dev.run("exec-out", "screencap", "-p", binary=True)) + verdict = "FAIL" if problems else "PASS" + detail = f" :: {'; '.join(problems[:4])}" if problems else f" ({len(items)} items)" + print(f"{verdict} {name}{detail}") + return not problems + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--serial") + parser.add_argument("--out", type=Path, default=Path(".local/test-runs/display-matrix")) + parser.add_argument("cases", nargs="*", help=f"subset of: {', '.join(CASES)}") + args = parser.parse_args() + unknown = [name for name in args.cases if name not in CASES] + if unknown: + parser.error(f"unknown case(s): {', '.join(unknown)}") + + dev = Device(args.serial) + results: dict[str, bool] = {} + try: + for name, case in CASES.items(): + if args.cases and name not in args.cases: + continue + reset(dev) + apply(dev, case) + results[name] = check(dev, name, args.out) + finally: + reset(dev) + failed = [name for name, ok in results.items() if not ok] + print(f"SUMMARY {len(results) - len(failed)} passed, {len(failed)} failed" + (f": {failed}" if failed else "")) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) From e183cdd6c6ea2cd5039da4a26b68ec0044e5ee09 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:09:01 +0800 Subject: [PATCH 02/13] feat(nine-key): the left rail lists one character's pinyin at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commercial and open-source nine-key keyboards (Baidu, rime-t9-shiyin, iOS) let the user choose the pinyin of one character at a time: the left column holds the syllables for the next character, a tap fixes it and the list moves on. The rail listed whole readings (ni'hao) for short input, which made one tap decide the spelling of several characters. It now always lists first syllables. Unit tests follow; core_regression case 037 types 64426, picks ni, then hao, then space and expects 你好. docs/NINE_KEY_REFERENCE.md and the 1.0.0 notes describe the new behaviour. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- .../slacker/openime/NineKeyLocalDecoder.kt | 33 +++++-------------- .../slacker/openime/CandidatePipelineTest.kt | 24 +++++++++----- docs/NINE_KEY_REFERENCE.md | 9 ++--- scripts/core_regression.sh | 8 +++++ 5 files changed, 39 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7800aa6b..6c28a175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ### 输入 - 键盘:26 键拼音、九键拼音、英文 26 键、数字与符号;不提供英文九键。 - 拼音引擎:基于 librime 的全拼、简拼、显式分词、候选读取(上限 96 项)、用户学习和 OpenCC 简繁转换;内置约 90 万条 Rime Ice 词典记录(固定版本,含 8105 字表)。首次部署完整词典期间,高频快速词库即时提供候选。 -- 九键:输入时左栏变成「读法列表」——短输入列整条读法(`ni'hao`),长输入列首音节;点选即锁定,锁定的音节在继续输入时保持不变,退格先解锁。预编辑跟随首选词的读法(`9694264244326` → `wo'xiang'chi'fan`「我想吃饭」),数字刚好拼得出的词排在预测词之前。做法与取舍见 [九键参考](https://github.com/Slacker-LLC/openIME/blob/main/docs/NINE_KEY_REFERENCE.md)。 +- 九键:输入时左栏变成拼音列表,一个字一个拼音——每项是下一个字的一个音节(`ni`、`mi`),点选即锁定,列表随即移到下一个字;锁定的音节在继续输入时保持不变,退格先解锁。预编辑跟随首选词的读法(`9694264244326` → `wo'xiang'chi'fan`「我想吃饭」),数字刚好拼得出的词排在预测词之前。做法与取舍见 [九键参考](https://github.com/Slacker-LLC/openIME/blob/main/docs/NINE_KEY_REFERENCE.md)。 - 选词只覆盖一部分输入时只上屏该词,剩余输入继续作为预编辑;不会把没选过的整句写进用户词库。 - 空格提交首选候选,回车(确定)提交已输入的拼音原文;提交后统一清除拼音、候选与 Rime composition,随后删除键只处理目标输入框。上屏后的联想栏为「‹ 联想词 ∨」。 - 删除键:长按连续删除;上滑 ≥ 32dp 松手清空,清空后 5 秒内下滑撤回。清空与撤回在自绘、Compose、Web 等没有「全选」也没有完整 ExtractedText 的输入框里同样可用,提示统一为一个气泡,清空后顶栏显示「已清空 · 撤销」。 diff --git a/app/src/main/java/llc/slacker/openime/NineKeyLocalDecoder.kt b/app/src/main/java/llc/slacker/openime/NineKeyLocalDecoder.kt index 603880ff..a224ade3 100644 --- a/app/src/main/java/llc/slacker/openime/NineKeyLocalDecoder.kt +++ b/app/src/main/java/llc/slacker/openime/NineKeyLocalDecoder.kt @@ -41,9 +41,8 @@ internal class NineKeyLocalDecoder( } /** - * One way to read the open digits as pinyin. [syllables] are in order; - * [coversAll] tells whether they spell every digit (a whole reading) or only - * the start of them (a first-syllable choice). + * One way to read the start of the open digits as pinyin. [coversAll] tells + * whether the syllable spells every open digit or only the first of them. */ data class Reading( val syllables: List, @@ -261,14 +260,12 @@ internal class NineKeyLocalDecoder( } /** - * The readings offered in the left rail, best first. - * - * Short input lists whole readings (`ni'hao`, `mi'hao`, `ni'gao`), the way - * the design shows them. Once a whole reading no longer fits the rail it - * lists first syllables (`zhong`, `xiong`), and fixing one moves the list on - * to the next position (the Baidu / rime-t9-shiyin behaviour). Either way a - * tap fixes exactly what the item shows. Choices that would leave digits no - * syllable can read are never offered. + * The readings offered in the left rail, best first: one syllable per item, + * for the next character only (`ni`, `mi`, ...). Fixing one moves the list on + * to the following character, the way Baidu and rime-t9-shiyin do it, so the + * user chooses the pinyin of one character at a time and never has to pick + * a whole phrase's spelling. A tap fixes exactly what the item shows, and + * choices that would leave digits no syllable can read are never offered. */ @Synchronized fun readingOptions(digits: String, preferred: String?, limit: Int = MAX_SYLLABLE_OPTIONS): List { @@ -281,19 +278,8 @@ internal class NineKeyLocalDecoder( // when nothing else exists. val all = syllablePaths(bounded, READING_BEAM) val paths = all.filter { path -> path.syllables.none { it.length == 1 } }.ifEmpty { all } - val best = paths.firstOrNull() - if (best != null && best.syllables.joinToString("'").length <= WHOLE_READING_MAX_CHARS) { - val ranked = paths.sortedWith( - compareByDescending { it.syllables.joinToString("") == lead } - .thenByDescending { it.score }, - ) - return ranked - .map { Reading(it.syllables, coversAll = true) } - .distinctBy { it.display } - .take(limit) - } - // Long input: first syllables, each only if the rest can still be read. + // First syllables, each only if the rest can still be read. val firsts = LinkedHashMap() paths.forEach { path -> firsts.putIfAbsent(path.syllables.first(), path.score) } syllableOptions(bounded, preferred, limit * 2).forEach { syllable -> @@ -611,7 +597,6 @@ internal class NineKeyLocalDecoder( const val MAX_DIGITS = 64 const val MAX_SYLLABLE_OPTIONS = 12 private const val READING_BEAM = 24 - private const val WHOLE_READING_MAX_CHARS = 14 private const val MAX_PHRASE_SYLLABLES = 6 private const val MAX_SYLLABLE_LENGTH = 6 private const val MAX_PATHS = 12 diff --git a/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt b/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt index f99ac6f5..4a100c90 100644 --- a/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt +++ b/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt @@ -157,21 +157,29 @@ class CandidatePipelineTest { } @Test - fun shortInputOffersWholeReadingsAndTheWordReadingLeads() { + fun everyRailItemIsOneCharactersPinyinAndTheWordReadingLeads() { val readings = pipeline.nineKeyReadingsFor("64426", null) - assertEquals("ni'hao", readings.first().display) - assertTrue(readings.all { it.coversAll }) - assertTrue("mi'hao" in readings.map { it.display }) - assertTrue("ni'gao" in readings.map { it.display }) + assertTrue(readings.all { it.syllables.size == 1 }) + assertEquals("ni", readings.first().display) + assertTrue("mi" in readings.map { it.display }) + // ni spells two of the five digits: choosing it moves on to the next character. + assertTrue(readings.none { it.coversAll }) // A lone vowel between syllables is a digit-grid artefact, not a reading. assertTrue(readings.none { reading -> reading.syllables.any { it.length == 1 } }) assertEquals(readings.map { it.display }.distinct(), readings.map { it.display }) } @Test - fun readingPreviewLeadsWhenItIsOneOfTheReadings() { - val readings = pipeline.nineKeyReadingsFor("64426", "migao") - assertEquals("mi'gao", readings.first().display) + fun theSyllableOfTheShownPreviewLeads() { + assertEquals("mi", pipeline.nineKeyReadingsFor("64426", "migao").first().display) + assertEquals("ni", pipeline.nineKeyReadingsFor("64426", "nihao").first().display) + } + + @Test + fun aSyllableThatSpellsAllTheDigitsCoversThem() { + val readings = pipeline.nineKeyReadingsFor("64", null) + assertTrue(readings.map { it.display }.containsAll(listOf("ni", "mi"))) + assertTrue(readings.filter { it.display in setOf("ni", "mi") }.all { it.coversAll }) } @Test diff --git a/docs/NINE_KEY_REFERENCE.md b/docs/NINE_KEY_REFERENCE.md index 7f50892f..71282a2b 100644 --- a/docs/NINE_KEY_REFERENCE.md +++ b/docs/NINE_KEY_REFERENCE.md @@ -12,15 +12,16 @@ | [百度输入法九键说明](https://jingyan.baidu.com/article/19020a0a7ee4ab529c284246.html) | 官方使用说明 | 拼音键左侧是精确拼音;可上下滑动拼音列表更改拼音组成;按 1 手动分词 | | [搜狗输入法帮助](https://shouji.sogou.com/wap/feedback/faqdetail?id=2004148&click_fr=3&platform=Android) | 官方帮助 | 直接上滑删除键清空、直接下滑撤回;多次清空只保留最后一次 | | iOS 九宫格(「简体拼音十键」) | 系统输入法 | 拼音编码区 + 文字候选区分开;放弃单独的分词键 | -| 《openIME 界面重构稿》设计稿 | 本项目设计依据 | 输入中左栏是整块面板,列整条读法(`ni'hao` / `mi'hao` / `ni'gao`),选中项为强调色胶囊;联想态为「‹ 词 … ∨」 | +| 《openIME 界面重构稿》设计稿 | 本项目设计依据 | 输入中左栏是整块面板,选中项为强调色胶囊(版式沿用;每项的内容改为一个字的拼音,见下);联想态为「‹ 词 … ∨」 | (豆包输入法、搜狗、微信键盘等闭源产品没有可读的实现,只能依据其公开使用说明。) ## 行为约定(openIME 的实现) -1. **点选即锁定,所见即所锁。** 左栏列出「读法」:短输入列整条读法(`ni'hao`), - 当整条读法放不下左栏时(> 14 个字符)改列首音节(`zhong`、`xiong`),锁定一个后列表移到下一位。 - 不提供会让剩余数字无法拼读的选项;孤立的 `a/o/e`、无元音的 `ng/m` 不当读法。 +1. **一个字一个拼音:左栏每项是下一个字的一个音节**(`ni`、`mi`),点选即锁定,所见即所锁;锁定后列表移到下一个字 + (百度输入法、rime-t9-shiyin 的做法,也是九键「先选拼音再选字」的通行流程)。用户不用为整句选拼音, + 整词的读法在预编辑里看,要换字就从候选里选。不提供会让剩余数字无法拼读的选项;孤立的 `a/o/e`、无元音的 `ng/m` + 不当读法。早先按设计稿列整条读法(`ni'hao`),与「一个字一个拼音」冲突,已改掉。 2. **锁定的音节发给 Rime 时保持字母**(`xiong'486`)。luna_pinyin 方案同时接受字母和 2–9 数字, 所以 zhong / xiong 这类同数字的读法不会被重新混在一起,候选与所选读法一致。 3. **锁定的音节在继续打字时保持锁定**(用边界封住),退格先解锁最近锁定的音节 / 分词边界。 diff --git a/scripts/core_regression.sh b/scripts/core_regression.sh index b8c9fd48..7ae4d79f 100755 --- a/scripts/core_regression.sh +++ b/scripts/core_regression.sh @@ -212,6 +212,14 @@ check "033 nine-key partial pick keeps the rest composing -> 你hao'ma" "你hao' tap key-space check '034 nine-key rest then space -> 你好吗' '你好吗' "$(editor_text)" +start_real +mode PINYIN_9 || say 'WARN: PINYIN_9 not reached' +for key in 6 4 4 2 6; do tap "$key"; done +tap '九键拼音ni' +tap '九键拼音hao' +tap key-space +check '037 nine-key one pinyin per character: ni, then hao, then space -> 你好' '你好' "$(editor_text)" + start_real mode PINYIN_26 || say 'WARN: PINYIN_26 not reached' for key in n i h a o m a; do tap "$key"; done From a5a88cc5e299ac0d5ed7b0980f1e20125121710d Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:13:13 +0800 Subject: [PATCH 03/13] perf: build the lexicon and nine-key decoder off the main thread Both were built in onCreate on the main thread (about 0.3 s on a fast host) at every cold start. The service starts with an empty pipeline and swaps in the real one when the background thread is done; Rime supplies candidates meanwhile. Co-Authored-By: Claude Sonnet 5.5 --- .../slacker/openime/LocalVoiceImeService.kt | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index 36169a81..3890fb1d 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -38,7 +38,15 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can private var keyboardView: ImeKeyboardView? = null private lateinit var gateway: InputConnectionGateway - private lateinit var candidatePipeline: CandidatePipeline + /** + * Starts as an empty pipeline and is replaced by the real one once the lexicon + * and the nine-key decoder are built on a background thread (about 0.3 s on a + * fast host, several times that on a mid-range phone). Building them in + * onCreate froze the main thread at every cold start; until the swap, Rime + * alone supplies candidates. + */ + @Volatile + private var candidatePipeline: CandidatePipeline = CandidatePipeline(CandidateEngine(linkedMapOf())) private lateinit var candidateQueries: CandidateQueryCoordinator private lateinit var rime: RimeEngine private lateinit var voiceLifecycle: VoiceModelLifecycleManager @@ -87,12 +95,16 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can UserPhraseRepository.configure(this) VoiceCorrectionRepository.configure(this) voiceLifecycle = VoiceModelLifecycleManager(this) - candidatePipeline = CandidatePipeline(CandidateEngine(PinyinLexicon.load(this))) + Thread({ + runCatching { CandidatePipeline(CandidateEngine(PinyinLexicon.load(this))) } + .onSuccess { candidatePipeline = it } + .onFailure { Log.e(TAG, "lexicon/decoder initialisation failed; running on Rime only", it) } + }, "openime-lexicon").apply { isDaemon = true; start() } rime = RimeEngine(this).also { it.start() } candidateQueries = CandidateQueryCoordinator( rime = rime, mainHandler = mainHandler, - fallbackCandidatesFor = candidatePipeline::nineKeyFallbackCandidatesFor, + fallbackCandidatesFor = { candidatePipeline.nineKeyFallbackCandidatesFor(it) }, maxInputLength = MAX_RIME_INPUT_LENGTH, maxNineKeyPaths = MAX_RIME_NINE_KEY_PATHS, maxCandidates = MAX_CANDIDATES, From 2473c2d97d916b8a60b7570e57f890a436ab6fed Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:36:24 +0800 Subject: [PATCH 04/13] feat: survive crashes, freezes and conflicts with other software - CrashGuard records crashes locally (types and frames only, never typed text), folds native crashes and ANRs from Android's exit history into a crash loop counter, and three in ten minutes put the next start in safe mode (no librime, no voice preload) so the user can keep typing. - A failing typing handler is contained: it is recorded, the half-finished composition is dropped and the keyboard carries on instead of being replaced by another keyboard. Debug command fail-next injects one (core_regression 038). - Librime startup leaves a marker until the engine proves itself. A marker left by a start that died natively escalates: clear compiled data, set the user dictionary aside, then skip the native engine. A start merely killed by the user or system is not counted (Android 11+ exit reasons). - Media volume muted for voice input is persisted and restored on the next start if the process dies mid-recording, and a watchdog restores it after two minutes (instrumented test). - Huge commits (pasting megabytes) are chunked below the Binder limit. - The lexicon and nine-key decoder are built off the main thread (they cost about 0.3 s on every cold start). Co-Authored-By: Claude Sonnet 5.5 --- .../VoiceMediaMuteRecoveryInstrumentedTest.kt | 45 ++++ .../java/llc/slacker/openime/CrashGuard.kt | 233 ++++++++++++++++++ .../slacker/openime/InputConnectionGateway.kt | 22 +- .../slacker/openime/LocalVoiceImeService.kt | 68 +++-- .../java/llc/slacker/openime/RimeEngine.kt | 63 +++++ .../openime/VoiceMediaMuteController.kt | 36 +++ .../slacker/openime/CrashResilienceTest.kt | 99 ++++++++ scripts/core_regression.sh | 8 + 8 files changed, 559 insertions(+), 15 deletions(-) create mode 100644 app/src/androidTest/java/llc/slacker/openime/VoiceMediaMuteRecoveryInstrumentedTest.kt create mode 100644 app/src/main/java/llc/slacker/openime/CrashGuard.kt create mode 100644 app/src/test/java/llc/slacker/openime/CrashResilienceTest.kt diff --git a/app/src/androidTest/java/llc/slacker/openime/VoiceMediaMuteRecoveryInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/VoiceMediaMuteRecoveryInstrumentedTest.kt new file mode 100644 index 00000000..d3272c39 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/VoiceMediaMuteRecoveryInstrumentedTest.kt @@ -0,0 +1,45 @@ +package llc.slacker.openime + +import android.content.Context +import android.media.AudioManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class VoiceMediaMuteRecoveryInstrumentedTest { + + @Test + fun mediaVolumeComesBackAfterTheKeyboardProcessDiesWhileRecording() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val audio = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val stream = AudioManager.STREAM_MUSIC + val originalVolume = audio.getStreamVolume(stream) + val originalMuted = audio.isStreamMute(stream) + try { + if (originalMuted) audio.adjustStreamVolume(stream, AudioManager.ADJUST_UNMUTE, 0) + val volume = audio.getStreamMaxVolume(stream).coerceAtMost(6).coerceAtLeast(2) + audio.setStreamVolume(stream, volume, 0) + + assertTrue(VoiceMediaMuteController(context).mute()) + assertTrue("media must be silent while recording", audio.isStreamMute(stream) || audio.getStreamVolume(stream) == 0) + + // The process dies here: the controller above is never asked to restore(). + VoiceMediaMuteController.recoverAfterCrash(context) + + assertFalse("media must not stay muted after an unclean exit", audio.isStreamMute(stream)) + assertEquals(volume, audio.getStreamVolume(stream)) + // The recovery is one-shot: a second start must not touch the volume again. + audio.setStreamVolume(stream, 1, 0) + VoiceMediaMuteController.recoverAfterCrash(context) + assertEquals(1, audio.getStreamVolume(stream)) + } finally { + audio.setStreamVolume(stream, originalVolume, 0) + if (originalMuted) audio.adjustStreamVolume(stream, AudioManager.ADJUST_MUTE, 0) + } + } +} diff --git a/app/src/main/java/llc/slacker/openime/CrashGuard.kt b/app/src/main/java/llc/slacker/openime/CrashGuard.kt new file mode 100644 index 00000000..27a75d09 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/CrashGuard.kt @@ -0,0 +1,233 @@ +package llc.slacker.openime + +import android.content.Context +import android.os.Build +import android.util.Log +import java.io.File + +/** + * Crash resilience for a keyboard that has to keep working inside other apps. + * + * An input method that dies is replaced by the system with another keyboard, so + * the goals are: remember what happened (locally; the app has no INTERNET + * permission), never turn one bad key press into a crash, and stop a crash loop + * from locking the user out of typing. + * + * What is stored never contains typed text: only the time, the app version, the + * exception types and stack frames. + */ +internal object CrashGuard { + private const val TAG = "OpenImeCrash" + private const val PREFS = "openime_crash_guard" + private const val KEY_TIMES = "crash_times" + private const val KEY_LAST = "last_report" + private const val KEY_HANDLED = "handled_count" + private const val KEY_EXIT_SEEN = "exit_seen" + private const val MAX_TIMES = 10 + private const val MAX_FRAMES = 14 + + /** This many crashes within [LOOP_WINDOW_MS] switch the next start to safe mode. */ + internal const val LOOP_CRASHES = 3 + internal const val LOOP_WINDOW_MS = 10 * 60 * 1000L + + @Volatile + private var installed = false + + /** Idempotent; chains to whatever handler was installed before. */ + fun install(context: Context) { + if (installed) return + installed = true + val app = context.applicationContext + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + runCatching { recordCrash(app, thread.name, throwable) } + previous?.uncaughtException(thread, throwable) + } + } + + internal fun recordCrash(context: Context, threadName: String, throwable: Throwable, now: Long = System.currentTimeMillis()) { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val times = parseTimes(prefs.getString(KEY_TIMES, null)) + now + prefs.edit() + .putString(KEY_TIMES, formatTimes(times.takeLast(MAX_TIMES))) + .putString(KEY_LAST, report(context, "crash", threadName, throwable, now)) + .commit() // the process is about to die: no async apply() + } + + /** An exception that was caught and survived; kept for diagnostics, not counted as a crash. */ + fun recordHandled(context: Context, where: String, throwable: Throwable, now: Long = System.currentTimeMillis()) { + runCatching { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + prefs.edit() + .putInt(KEY_HANDLED, prefs.getInt(KEY_HANDLED, 0) + 1) + .putString(KEY_LAST, report(context, "handled in $where", Thread.currentThread().name, throwable, now)) + .apply() + } + } + + /** A native startup that never came back counts as a crash, even though no Java handler ran. */ + fun recordNativeStartupCrash(context: Context, now: Long = System.currentTimeMillis()) { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val times = parseTimes(prefs.getString(KEY_TIMES, null)) + now + prefs.edit() + .putString(KEY_TIMES, formatTimes(times.takeLast(MAX_TIMES))) + .putString(KEY_LAST, "${header(context, "native crash while starting librime", "local-rime-startup", now)}\n(no Java stack: the process died inside native code)") + .commit() + } + + /** + * Android 11+ remembers why earlier processes died. Native crashes (no Java + * handler ever runs) and ANRs (the keyboard froze) of this app are folded + * into the crash history here, once each, so a freeze loop also ends in safe mode. + */ + fun ingestProcessExitReasons(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return + runCatching { + val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val seen = prefs.getLong(KEY_EXIT_SEEN, 0L) + val bad = manager.getHistoricalProcessExitReasons(context.packageName, 0, 8).filter { + it.timestamp > seen && it.reason in countedExitReasons + } + if (bad.isEmpty()) return + val times = parseTimes(prefs.getString(KEY_TIMES, null)) + bad.map { it.timestamp } + val newest = bad.maxBy { it.timestamp } + prefs.edit() + .putString(KEY_TIMES, formatTimes(times.sorted().takeLast(MAX_TIMES))) + .putLong(KEY_EXIT_SEEN, newest.timestamp) + .putString(KEY_LAST, "${header(context, "previous process ended: ${exitReasonName(newest.reason)}", "process", newest.timestamp)}\n(reported by Android; no stack)") + .commit() + } + } + + /** + * Whether the process before this one ended in a native crash. null when + * Android cannot say (before Android 11). A marker left by a startup that was + * merely killed (force stop, update, low memory) must not count as a crash. + */ + fun previousExitWasNativeCrash(context: Context): Boolean? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null + return runCatching { + val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager + val latest = manager.getHistoricalProcessExitReasons(context.packageName, 0, 1).firstOrNull() + ?: return@runCatching null + latest.reason == android.app.ApplicationExitInfo.REASON_CRASH_NATIVE + }.getOrNull() + } + + /** Leave safe mode now, e.g. from the About screen. */ + fun clearHistory(context: Context) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().remove(KEY_TIMES).commit() + } + + private val countedExitReasons: Set by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + setOf( + android.app.ApplicationExitInfo.REASON_CRASH, + android.app.ApplicationExitInfo.REASON_CRASH_NATIVE, + android.app.ApplicationExitInfo.REASON_ANR, + android.app.ApplicationExitInfo.REASON_INITIALIZATION_FAILURE, + ) + } else { + emptySet() + } + } + + @android.annotation.SuppressLint("NewApi") + private fun exitReasonName(reason: Int): String = when (reason) { + android.app.ApplicationExitInfo.REASON_CRASH -> "crash" + android.app.ApplicationExitInfo.REASON_CRASH_NATIVE -> "native crash" + android.app.ApplicationExitInfo.REASON_ANR -> "not responding (ANR)" + android.app.ApplicationExitInfo.REASON_INITIALIZATION_FAILURE -> "initialisation failure" + else -> "reason $reason" + } + + fun isSafeMode(context: Context, now: Long = System.currentTimeMillis()): Boolean = + inCrashLoop(parseTimes(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_TIMES, null)), now) + + fun lastReport(context: Context): String? = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_LAST, null) + + internal fun inCrashLoop(times: List, now: Long): Boolean = + times.count { now - it in 0..LOOP_WINDOW_MS } >= LOOP_CRASHES + + internal fun parseTimes(raw: String?): List = + raw.orEmpty().split(',').mapNotNull { it.trim().toLongOrNull() } + + internal fun formatTimes(times: List): String = times.joinToString(",") + + private fun header(context: Context, kind: String, thread: String, now: Long): String { + val version = runCatching { + context.packageManager.getPackageInfo(context.packageName, 0).versionName + }.getOrNull().orEmpty() + return "openIME $version | $kind | thread=$thread | at=$now | android=${Build.VERSION.SDK_INT} | device=${Build.MANUFACTURER} ${Build.MODEL}" + } + + /** Exception types and stack frames only. Messages are left out on purpose: they can quote user text. */ + internal fun report(context: Context, kind: String, thread: String, throwable: Throwable, now: Long): String = + buildString { + append(header(context, kind, thread, now)).append('\n') + var current: Throwable? = throwable + var depth = 0 + while (current != null && depth < 4) { + append(if (depth == 0) "" else "caused by ").append(current.javaClass.name).append('\n') + current.stackTrace.take(MAX_FRAMES).forEach { append(" at ").append(it).append('\n') } + current = current.cause?.takeIf { it !== current } + depth++ + } + } + + internal fun log(message: String, throwable: Throwable? = null) { + runCatching { Log.e(TAG, message, throwable) } + } +} + +/** + * Librime lives in native code: a segmentation fault there cannot be caught and + * kills the whole keyboard process, and with a damaged compiled dictionary or + * user database it would do so on every start. A marker file is written before + * native startup and removed once the engine proves itself, so a marker that is + * still there at the next start means the last start died natively. Each such + * death escalates: clear the compiled data, then set the user database aside, + * then run without the native engine. + */ +internal class RimeStartupRecovery(private val stateDir: File) { + enum class Action { NORMAL, CLEAN_BUILD, RESET_USER_DATA, SKIP_NATIVE } + + private val marker = File(stateDir, ".rime-startup-pending") + private val failuresFile = File(stateDir, ".rime-startup-failures") + + /** Call before native startup. Returns what to do and arms the marker unless native is skipped. */ + fun begin(): Action { + stateDir.mkdirs() + if (marker.exists()) writeFailures(failures() + 1) + val action = when (failures()) { + 0 -> Action.NORMAL + 1 -> Action.CLEAN_BUILD + 2 -> Action.RESET_USER_DATA + else -> Action.SKIP_NATIVE + } + if (action == Action.SKIP_NATIVE) marker.delete() else marker.writeText("pending\n") + return action + } + + /** The engine started and passed its health probe. */ + fun succeeded() { + marker.delete() + failuresFile.delete() + } + + /** Startup ended with a Java exception: not a crash, so it is not counted. */ + fun failedWithoutCrash() { + marker.delete() + } + + /** True when the previous native startup never finished. */ + fun lastStartupDiedNatively(): Boolean = marker.exists() + + fun failures(): Int = runCatching { failuresFile.readText().trim().toInt() }.getOrDefault(0) + + private fun writeFailures(value: Int) { + failuresFile.writeText("$value\n") + } +} diff --git a/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt b/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt index a7aec094..b14148bc 100644 --- a/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt +++ b/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt @@ -96,7 +96,11 @@ class InputConnectionGateway( fun commitText(text: String) { if (text.isEmpty()) return invalidateClearUndo() - connection()?.commitText(text, 1) + val ic = connection() ?: return + // One Binder transaction carries about 1 MB: committing a huge paste or + // transcript in one call throws TransactionTooLargeException and takes the + // keyboard down with it. Chunk it, never splitting a surrogate pair. + chunksForCommit(text).forEach { chunk -> ic.commitText(chunk, 1) } } /** @@ -812,3 +816,19 @@ class InputConnectionGateway( const val MAX_SURROUNDING_ROUNDS = 8 } } + +internal const val COMMIT_CHUNK_CHARS = 32_000 + +/** Pieces of at most [COMMIT_CHUNK_CHARS] UTF-16 units that never end between a surrogate pair. */ +internal fun chunksForCommit(text: String): List { + if (text.length <= COMMIT_CHUNK_CHARS) return listOf(text) + val chunks = ArrayList(text.length / COMMIT_CHUNK_CHARS + 1) + var start = 0 + while (start < text.length) { + var end = minOf(start + COMMIT_CHUNK_CHARS, text.length) + if (end < text.length && Character.isHighSurrogate(text[end - 1])) end-- + chunks += text.substring(start, end) + start = end + } + return chunks +} diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index 3890fb1d..d76a7464 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -89,8 +89,43 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can (applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 } + /** + * One bad key press must not take the keyboard down: the system would swap + * in another keyboard and the user loses their place. A failure in a typing + * handler is recorded (no typed text), the half-finished composition is + * dropped, and the keyboard carries on. + */ + @Volatile + private var injectedFailureForTest = false + + private inline fun guarded(name: String, block: () -> Unit) { + try { + if (injectedFailureForTest) { + injectedFailureForTest = false + throw IllegalStateException("injected failure for test") + } + block() + } catch (failure: Exception) { + recoverFromHandledFailure(name, failure) + } catch (failure: StackOverflowError) { + recoverFromHandledFailure(name, failure) + } + } + + private fun recoverFromHandledFailure(name: String, failure: Throwable) { + CrashGuard.log("handled failure in $name", failure) + CrashGuard.recordHandled(this, name, failure) + runCatching { + clearImeCompositionState(render = true) + gateway.cancelComposing() + } + } + override fun onCreate() { super.onCreate() + CrashGuard.install(this) + CrashGuard.ingestProcessExitReasons(this) + VoiceMediaMuteController.recoverAfterCrash(this) activeInstance = this UserPhraseRepository.configure(this) VoiceCorrectionRepository.configure(this) @@ -386,7 +421,8 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can floatingWindow.restore() } keyboardView?.refreshAuxiliaryContent() - voiceLifecycle.onStartInputView() + // Safe mode (repeated crashes or freezes): do not preload the voice model. + if (!CrashGuard.isSafeMode(this)) voiceLifecycle.onStartInputView() } /** Re-render panels whose data may have been edited in a full-screen Activity. */ @@ -428,7 +464,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can candidatesEnd: Int, ) { gateway.updateSelection(newSelStart, newSelEnd) - refreshTextEditControls() + guarded("onUpdateSelection") { refreshTextEditControls() } super.onUpdateSelection( oldSelStart, oldSelEnd, @@ -578,6 +614,10 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can ) text.isNotBlank() && QuickPhraseRepository.load(this).any { it.text == text } }.getOrDefault(false) + command == "fail-next" -> { + injectedFailureForTest = true + true + } command == "bounds" -> { Log.i(TAG, "BOUNDS\n${keyboardView?.normalizedBoundsReport().orEmpty()}") true @@ -662,7 +702,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can ) } - override fun onCharacter(char: String) { + override fun onCharacter(char: String) = guarded("onCharacter") { prepareForManualInput() voiceCorrectionTracker.noteReplacementInput() commitPendingComposition() @@ -670,7 +710,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can gateway.commitText(char) } - override fun onBackspace() { + override fun onBackspace() = guarded("onBackspace") { prepareForManualInput() voiceCorrectionTracker.noteBackspace() if (keyboardView?.deleteInlineEditorChar() == true) return @@ -698,7 +738,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } } - override fun onClearAll() { + override fun onClearAll() = guarded("onClearAll") { prepareForManualInput() // Invalidate every pending candidate/Rime path before touching the // editor. Otherwise a late native result can restore the just-cleared @@ -762,7 +802,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can if (floatingWindow.enabled) floatingWindow.reapply() } - override fun onSpace() { + override fun onSpace() = guarded("onSpace") { prepareForManualInput() if (keyboardView?.insertIntoInlineEditor(" ") == true) return if (state.passwordField) { @@ -913,7 +953,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can ) } - override fun onEnter() { + override fun onEnter() = guarded("onEnter") { prepareForManualInput() if (lastComposition.isNotEmpty()) { // Space picks the first word; Enter ("确定") keeps what was typed, @@ -931,7 +971,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } } - override fun onCompositionChanged(composition: String, candidates: List) { + override fun onCompositionChanged(composition: String, candidates: List) = guarded("onCompositionChanged") { prepareForManualInput() if (composition.isNotEmpty()) voiceCorrectionTracker.noteReplacementInput() handleCompositionChanged( @@ -946,7 +986,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can digitBuffer: String, pinyinPaths: List, candidates: List, - ) { + ) = guarded("onNineKeyCompositionChanged") { prepareForManualInput() if (composition.isNotEmpty()) voiceCorrectionTracker.noteReplacementInput() if (state.keyboardMode != KeyboardMode.PINYIN_9 || digitBuffer.isEmpty()) { @@ -1006,7 +1046,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can keyboardView?.renderState(state) } - override fun onCandidateSelected(candidate: String) { + override fun onCandidateSelected(candidate: String) = guarded("onCandidateSelected") { prepareForManualInput() if (state.passwordField) return selectCandidate(candidate) @@ -1099,7 +1139,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can * directly and chain to the next association set so a user can keep * tapping: 你好 -> 呀 -> ! */ - override fun onAssociationSelected(text: String) { + override fun onAssociationSelected(text: String) = guarded("onAssociationSelected") { prepareForManualInput() if (state.passwordField || text.isEmpty()) return commitPendingComposition() @@ -1165,14 +1205,14 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can state = state.copy(panel = if (open) Panel.CANDIDATE_EXPANDED else Panel.NONE) } - override fun onSymbolSelected(symbol: String) { + override fun onSymbolSelected(symbol: String) = guarded("onSymbolSelected") { prepareForManualInput() commitPendingComposition() keyboardView?.clearAssociationCandidates() gateway.commitText(symbol) } - override fun onEmojiSelected(emoji: String) { + override fun onEmojiSelected(emoji: String) = guarded("onEmojiSelected") { prepareForManualInput() commitPendingComposition() keyboardView?.clearAssociationCandidates() @@ -1180,7 +1220,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can gateway.commitText(emoji) } - override fun onTextEdit(action: String) { + override fun onTextEdit(action: String) = guarded("onTextEdit") { if (action in setOf("select-all", "cut", "paste", "left", "right")) prepareForManualInput() when (action) { "select-all" -> if (!gateway.selectAll()) { diff --git a/app/src/main/java/llc/slacker/openime/RimeEngine.kt b/app/src/main/java/llc/slacker/openime/RimeEngine.kt index c020903e..8270130d 100644 --- a/app/src/main/java/llc/slacker/openime/RimeEngine.kt +++ b/app/src/main/java/llc/slacker/openime/RimeEngine.kt @@ -146,6 +146,41 @@ class RimeEngine( copyAssetsIfNeeded(sharedDir) if (!startupGate.isCurrent(generation)) return@execute + // A previous start that died inside native code leaves its marker + // behind; escalate (clear compiled data, set the user database + // aside, finally skip librime) instead of crashing in a loop. + val recovery = RimeStartupRecovery(File(context.filesDir, "$dataDirName-startup")) + if (recovery.lastStartupDiedNatively()) { + when (CrashGuard.previousExitWasNativeCrash(context)) { + // Killed by the user or the system while starting, not by librime. + false -> recovery.failedWithoutCrash() + // Android 11+ already recorded it in the exit history. + true -> Unit + // Older Android cannot say: assume the worst. + null -> CrashGuard.recordNativeStartupCrash(context) + } + } + val action = if (CrashGuard.isSafeMode(context)) { + recovery.failedWithoutCrash() // already counted above; do not count it every session + RimeStartupRecovery.Action.SKIP_NATIVE + } else { + recovery.begin() + } + when (action) { + RimeStartupRecovery.Action.NORMAL -> Unit + RimeStartupRecovery.Action.CLEAN_BUILD -> clearCompiledData(sharedDir, userDir) + RimeStartupRecovery.Action.RESET_USER_DATA -> { + clearCompiledData(sharedDir, userDir) + setUserDataAside(userDir) + } + RimeStartupRecovery.Action.SKIP_NATIVE -> { + errorMessage = "librime is off after repeated crashes" + startupGate.fail(generation) + Log.w(TAG, "librime skipped (safe mode); using the Kotlin fallback") + return@execute + } + } + // nativeStartup is internally serialized. Even if destroy races // this call, nativeShutdown will either run after it or this // stale worker will perform the same idempotent cleanup below. @@ -179,10 +214,15 @@ class RimeEngine( cleanupNative() return@execute } + recovery.succeeded() errorMessage = "" isReady = true Log.i(TAG, "librime ready schema=$activeSchemaId") } catch (throwable: Throwable) { + // A Java exception is not a native crash: lift the marker so it is not counted as one. + runCatching { + RimeStartupRecovery(File(context.filesDir, "${assetRoot.replace('/', '_')}-startup")).failedWithoutCrash() + } if (nativeStartupReturned) cleanupNative() if (startupGate.fail(generation)) { isReady = false @@ -573,6 +613,29 @@ class RimeEngine( } } + private fun clearCompiledData(sharedDir: File, userDir: File) { + listOf(File(sharedDir, "build"), File(userDir, "build")).forEach { build -> + if (build.isDirectory) { + deleteChildren(build) + build.delete() + } + } + Log.w(TAG, "cleared compiled librime data after a native startup failure") + } + + /** Keep one backup of a user database that may be damaged and start with an empty one. */ + private fun setUserDataAside(userDir: File) { + val backup = File(userDir.parentFile, "${userDir.name}.corrupt") + if (backup.exists()) { + deleteChildren(backup) + backup.delete() + } + if (userDir.renameTo(backup)) { + userDir.mkdirs() + Log.w(TAG, "user dictionary set aside as ${backup.name}") + } + } + private fun deleteChildren(directory: File) { directory.listFiles().orEmpty().forEach { child -> if (child.isDirectory) deleteChildren(child) diff --git a/app/src/main/java/llc/slacker/openime/VoiceMediaMuteController.kt b/app/src/main/java/llc/slacker/openime/VoiceMediaMuteController.kt index dd2ccc9a..40561f4c 100644 --- a/app/src/main/java/llc/slacker/openime/VoiceMediaMuteController.kt +++ b/app/src/main/java/llc/slacker/openime/VoiceMediaMuteController.kt @@ -3,6 +3,8 @@ package llc.slacker.openime import android.content.Context import android.media.AudioManager import android.os.Build +import android.os.Handler +import android.os.Looper import android.util.Log /** @@ -16,6 +18,27 @@ import android.util.Log internal class VoiceMediaMuteController(context: Context) { companion object { private const val TAG = "OpenImeVoiceMedia" + private const val PREFS = "openime_voice_media_mute" + + /** No recording session lasts this long; if nobody restored the volume by then, do it. */ + private const val MAX_MUTE_MS = 2 * 60 * 1000L + + /** + * The original volume is also written to disk while media is muted. If the + * keyboard process dies mid-recording (crash, low-memory kill, force stop) + * nothing in memory can undo the mute, and the user's music and video stay + * silent until they notice. The next start puts the volume back. + */ + fun recoverAfterCrash(context: Context) { + val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + if (!prefs.getBoolean("pending", false)) return + val controller = VoiceMediaMuteController(context) + controller.snapshot = Snapshot(prefs.getInt("volume", -1), prefs.getBoolean("muted", false)) + .takeIf { it.volume >= 0 } + controller.restore() + prefs.edit().clear().commit() + Log.w(TAG, "restored media volume after an unclean exit") + } } private data class Snapshot( @@ -25,6 +48,10 @@ internal class VoiceMediaMuteController(context: Context) { private val audioManager = context.applicationContext .getSystemService(Context.AUDIO_SERVICE) as? AudioManager + private val appContext = context.applicationContext + private val prefs = appContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private val handler = Handler(Looper.getMainLooper()) + private val watchdog = Runnable { restore() } private var snapshot: Snapshot? = null @Synchronized @@ -45,6 +72,13 @@ internal class VoiceMediaMuteController(context: Context) { return false } snapshot = baseline + prefs.edit() + .putBoolean("pending", true) + .putInt("volume", baseline.volume) + .putBoolean("muted", baseline.muted) + .commit() + handler.removeCallbacks(watchdog) + handler.postDelayed(watchdog, MAX_MUTE_MS) return runCatching { if (!baseline.muted) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -73,6 +107,8 @@ internal class VoiceMediaMuteController(context: Context) { fun restore() { val baseline = snapshot ?: return snapshot = null + handler.removeCallbacks(watchdog) + prefs.edit().clear().commit() val manager = audioManager ?: return runCatching { // Restore the numeric volume without producing a volume beep, then diff --git a/app/src/test/java/llc/slacker/openime/CrashResilienceTest.kt b/app/src/test/java/llc/slacker/openime/CrashResilienceTest.kt new file mode 100644 index 00000000..7b5e5ac2 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/CrashResilienceTest.kt @@ -0,0 +1,99 @@ +package llc.slacker.openime + +import java.io.File +import java.nio.file.Files +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CrashResilienceTest { + + @Test + fun threeCrashesInTenMinutesIsACrashLoop() { + val now = 1_000_000_000L + val minute = 60_000L + assertFalse(CrashGuard.inCrashLoop(emptyList(), now)) + assertFalse(CrashGuard.inCrashLoop(listOf(now - minute, now - 2 * minute), now)) + assertTrue(CrashGuard.inCrashLoop(listOf(now - minute, now - 2 * minute, now - 3 * minute), now)) + } + + @Test + fun oldCrashesAgeOutSoSafeModeEnds() { + val now = 1_000_000_000L + val old = now - CrashGuard.LOOP_WINDOW_MS - 1 + assertFalse(CrashGuard.inCrashLoop(listOf(old, old - 1, old - 2, now - 1), now)) + } + + @Test + fun crashTimesRoundTripAndIgnoreGarbage() { + assertEquals(listOf(1L, 22L, 333L), CrashGuard.parseTimes(CrashGuard.formatTimes(listOf(1L, 22L, 333L)))) + assertEquals(listOf(5L), CrashGuard.parseTimes("x,5,,y")) + assertEquals(emptyList(), CrashGuard.parseTimes(null)) + } + + private fun tempDir(): File = Files.createTempDirectory("rime-recovery").toFile().also { it.deleteOnExit() } + + @Test + fun aCleanStartupLeavesNoTraceAndNeverEscalates() { + val recovery = RimeStartupRecovery(tempDir()) + repeat(5) { + assertEquals(RimeStartupRecovery.Action.NORMAL, recovery.begin()) + recovery.succeeded() + } + assertEquals(0, recovery.failures()) + assertFalse(recovery.lastStartupDiedNatively()) + } + + @Test + fun everyNativeDeathEscalatesUntilNativeIsSkipped() { + val dir = tempDir() + // Each begin() without succeeded() is a start that died inside native code. + assertEquals(RimeStartupRecovery.Action.NORMAL, RimeStartupRecovery(dir).begin()) + assertEquals(RimeStartupRecovery.Action.CLEAN_BUILD, RimeStartupRecovery(dir).begin()) + assertEquals(RimeStartupRecovery.Action.RESET_USER_DATA, RimeStartupRecovery(dir).begin()) + assertEquals(RimeStartupRecovery.Action.SKIP_NATIVE, RimeStartupRecovery(dir).begin()) + // Skipping disarms the marker, so skipped sessions are not counted again. + assertFalse(RimeStartupRecovery(dir).lastStartupDiedNatively()) + } + + @Test + fun aSuccessfulStartAfterTroubleResetsTheEscalation() { + val dir = tempDir() + RimeStartupRecovery(dir).begin() + assertEquals(RimeStartupRecovery.Action.CLEAN_BUILD, RimeStartupRecovery(dir).begin()) + RimeStartupRecovery(dir).succeeded() + assertEquals(RimeStartupRecovery.Action.NORMAL, RimeStartupRecovery(dir).begin()) + } + + @Test + fun aJavaExceptionDuringStartupIsNotCountedAsACrash() { + val dir = tempDir() + val recovery = RimeStartupRecovery(dir) + recovery.begin() + recovery.failedWithoutCrash() + assertEquals(RimeStartupRecovery.Action.NORMAL, RimeStartupRecovery(dir).begin()) + } + + @Test + fun smallTextIsCommittedInOnePiece() { + assertEquals(listOf("你好"), chunksForCommit("你好")) + val limit = "a".repeat(COMMIT_CHUNK_CHARS) + assertEquals(listOf(limit), chunksForCommit(limit)) + } + + @Test + fun hugeTextIsChunkedLosslesslyAndNeverInsideASurrogatePair() { + val emoji = "😀" // one surrogate pair + // Put a pair across the first boundary: the high surrogate would be the last unit of a full chunk. + val text = "a".repeat(COMMIT_CHUNK_CHARS - 1) + emoji + "b".repeat(COMMIT_CHUNK_CHARS * 2) + val chunks = chunksForCommit(text) + assertEquals(text, chunks.joinToString("")) + assertTrue(chunks.all { it.length <= COMMIT_CHUNK_CHARS }) + chunks.forEach { chunk -> + assertFalse("chunk ends with a lone high surrogate", Character.isHighSurrogate(chunk.last())) + assertFalse("chunk starts with a lone low surrogate", Character.isLowSurrogate(chunk.first())) + } + assertTrue(chunks.size >= 3) + } +} diff --git a/scripts/core_regression.sh b/scripts/core_regression.sh index 7ae4d79f..916e0363 100755 --- a/scripts/core_regression.sh +++ b/scripts/core_regression.sh @@ -228,6 +228,14 @@ check '035 26-key partial pick keeps the rest composing -> 你haoma' '你haoma' tap key-space check '036 26-key rest then space -> 你好吗' '你好吗' "$(editor_text)" +start_real +mode PINYIN_26 || say 'WARN: PINYIN_26 not reached' +send fail-next +tap n +for key in n i h a o; do tap "$key"; done +tap candidate-first-row +check '038 a failure inside a key handler is contained, typing carries on -> 你好' '你好' "$(editor_text)" + start_real mode DIGITS || say 'WARN: DIGITS not reached' for key in 1 2 3; do tap "$key"; done From 0b3b021e8da12131801ba49080c0002b8ab31c5f Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:38:35 +0800 Subject: [PATCH 05/13] feat: diagnostics card (copy report, leave safe mode) and crash handler in the entry activities Co-Authored-By: Claude Sonnet 5.5 --- .../llc/slacker/openime/AboutDataActivity.kt | 41 +++++++++++++++++++ .../java/llc/slacker/openime/MainActivity.kt | 1 + 2 files changed, 42 insertions(+) diff --git a/app/src/main/java/llc/slacker/openime/AboutDataActivity.kt b/app/src/main/java/llc/slacker/openime/AboutDataActivity.kt index d0719b57..df19b8f7 100644 --- a/app/src/main/java/llc/slacker/openime/AboutDataActivity.kt +++ b/app/src/main/java/llc/slacker/openime/AboutDataActivity.kt @@ -20,6 +20,7 @@ class AboutDataActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + CrashGuard.install(this) val content = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL @@ -89,6 +90,46 @@ class AboutDataActivity : Activity() { ), wrap().apply { topMargin = dp(ImeSpacingTokens.LG_DP) }, ) + val safeMode = CrashGuard.isSafeMode(this@AboutDataActivity) + val lastReport = CrashGuard.lastReport(this@AboutDataActivity) + val diagnostics = infoCard( + title = "诊断", + body = when { + safeMode -> "输入法刚才多次异常退出,已临时关闭原生词库和语音预加载;约 10 分钟后自动恢复,也可以现在退出。" + lastReport != null -> "最近一次异常:" + lastReport.lineSequence().first().substringAfter("| ").substringBefore(" | thread") + else -> "没有异常记录。" + } + "\n诊断信息只含异常类型和代码位置,不含任何输入内容;只有你点“复制”才会离开这里。", + ) + addView(diagnostics, wrap().apply { topMargin = dp(ImeSpacingTokens.LG_DP) }) + if (lastReport != null || safeMode) { + val row = LinearLayout(this@AboutDataActivity).apply { + orientation = LinearLayout.HORIZONTAL + if (lastReport != null) { + addView( + SetupUi.secondaryButton(this@AboutDataActivity, "复制诊断信息") { + val clipboard = getSystemService(android.content.ClipboardManager::class.java) + clipboard.setPrimaryClip(android.content.ClipData.newPlainText("openIME diagnostics", lastReport)) + Toast.makeText(this@AboutDataActivity, "已复制", Toast.LENGTH_SHORT).show() + }, + LinearLayout.LayoutParams(0, dp(44), 1f).apply { marginEnd = dp(ImeSpacingTokens.SM_DP) }, + ) + } + if (safeMode) { + addView( + SetupUi.primaryButton(this@AboutDataActivity, "退出安全模式") { + CrashGuard.clearHistory(this@AboutDataActivity) + Toast.makeText(this@AboutDataActivity, "下次打开键盘时恢复完整功能", Toast.LENGTH_SHORT).show() + recreate() + }, + LinearLayout.LayoutParams(0, dp(44), 1f), + ) + } + } + diagnostics.addView( + row, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(44)).apply { topMargin = dp(12); marginStart = dp(44) }, + ) + } addView(TextView(this@AboutDataActivity).apply { text = "openIME · 版本 " + versionName(); textSize = ImeTypographyTokens.SMALL_SP; gravity = android.view.Gravity.CENTER setTextColor(getColor(R.color.setup_body)) diff --git a/app/src/main/java/llc/slacker/openime/MainActivity.kt b/app/src/main/java/llc/slacker/openime/MainActivity.kt index bef88017..0509ffe1 100644 --- a/app/src/main/java/llc/slacker/openime/MainActivity.kt +++ b/app/src/main/java/llc/slacker/openime/MainActivity.kt @@ -38,6 +38,7 @@ class MainActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { appliedAppearance = ImeSettingsRepository.loadAppearance(this) super.onCreate(savedInstanceState) + CrashGuard.install(this) setContentView(R.layout.activity_main) findViewById(R.id.main_scroll).setOnApplyWindowInsetsListener { view, insets -> if (Build.VERSION.SDK_INT >= 30) { From a9e3887ca2017ebd92aec77d75b7652c6a4f9503 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:00:00 +0800 Subject: [PATCH 06/13] feat: terminals, games and date fields get the right keyboard and key handling - TYPE_NULL editors (terminals, games, remote desktops) start in English, receive each letter as it is typed, and are edited with key events: their InputConnection is normally BaseInputConnection in dummy mode, where deleteSurroundingText answers true and removes nothing, so Backspace did nothing there. Pinyin still composes if the user switches to it. - Date and time fields (TYPE_CLASS_DATETIME) start on digits. Unit tests cover the editor kinds and the key-event paths. Co-Authored-By: Claude Sonnet 5.5 --- .../llc/slacker/openime/EditorInfoAdapter.kt | 12 ++++++ .../slacker/openime/InputConnectionGateway.kt | 31 +++++++++++++ .../openime/InputMethodSubtypePolicy.kt | 2 + .../slacker/openime/LocalVoiceImeService.kt | 10 ++++- .../slacker/openime/EditorInfoAdapterTest.kt | 23 ++++++++++ .../openime/InputConnectionGatewayTest.kt | 43 +++++++++++++++++++ 6 files changed, 120 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/llc/slacker/openime/EditorInfoAdapter.kt b/app/src/main/java/llc/slacker/openime/EditorInfoAdapter.kt index 15d70de5..191749f4 100644 --- a/app/src/main/java/llc/slacker/openime/EditorInfoAdapter.kt +++ b/app/src/main/java/llc/slacker/openime/EditorInfoAdapter.kt @@ -15,6 +15,12 @@ object EditorInfoAdapter { URL, PASSWORD, MULTILINE, + + /** Date / time fields: digits and separators. */ + DATETIME, + + /** TYPE_NULL: terminals, games, remote desktops. They take key events, not text editing. */ + RAW_KEYS, UNKNOWN, } @@ -23,6 +29,8 @@ object EditorInfoAdapter { val cls = t and InputType.TYPE_MASK_CLASS val variation = t and InputType.TYPE_MASK_VARIATION return when { + info != null && t == InputType.TYPE_NULL -> EditorKind.RAW_KEYS + cls == InputType.TYPE_CLASS_DATETIME -> EditorKind.DATETIME cls == InputType.TYPE_CLASS_TEXT && variation in setOf( InputType.TYPE_TEXT_VARIATION_PASSWORD, InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD, @@ -52,10 +60,12 @@ object EditorInfoAdapter { EditorKind.NUMBER, EditorKind.DECIMAL, EditorKind.PHONE, + EditorKind.DATETIME, -> KeyboardMode.DIGITS EditorKind.EMAIL, EditorKind.URL, EditorKind.PASSWORD, + EditorKind.RAW_KEYS, -> KeyboardMode.ENGLISH_26 else -> KeyboardMode.PINYIN_26 } @@ -75,6 +85,8 @@ object EditorInfoAdapter { EditorKind.NUMBER, EditorKind.DECIMAL, EditorKind.PHONE, + EditorKind.DATETIME, + EditorKind.RAW_KEYS, EditorKind.UNKNOWN, -> return false EditorKind.TEXT, diff --git a/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt b/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt index b14148bc..2f3705e9 100644 --- a/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt +++ b/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt @@ -43,6 +43,14 @@ class InputConnectionGateway( private val connection: () -> InputConnection?, private val isPassword: () -> Boolean = { false }, private val nowMs: () -> Long = { System.nanoTime() / 1_000_000L }, + /** + * Editors with no text type (terminals, games, remote desktops: TYPE_NULL). + * Their InputConnection is usually Android's BaseInputConnection in dummy + * mode, which edits a private buffer: deleteSurroundingText "succeeds" and does + * nothing. They only understand key events, so typing and deleting use those. + */ + private val isRawKeys: () -> Boolean = { false }, + private val keyEventsFor: (String) -> Array? = ::keyEventsForText, ) { data class CursorSnapshot( @@ -97,6 +105,7 @@ class InputConnectionGateway( if (text.isEmpty()) return invalidateClearUndo() val ic = connection() ?: return + if (isRawKeys() && typeAsKeyEvents(ic, text)) return // One Binder transaction carries about 1 MB: committing a huge paste or // transcript in one call throws TransactionTooLargeException and takes the // keyboard down with it. Chunk it, never splitting a surrogate pair. @@ -159,9 +168,20 @@ class InputConnectionGateway( knownSelectionEnd = end } + private fun typeAsKeyEvents(ic: InputConnection, text: String): Boolean { + if (text.length > RAW_KEY_TEXT_MAX) return false + val events = keyEventsFor(text) ?: return false + events.forEach { ic.sendKeyEvent(it) } + return true + } + fun deleteBackwards() { invalidateClearUndo() val ic = connection() ?: return + if (isRawKeys()) { + sendKeyDownUp(ic, KeyEvent.KEYCODE_DEL) + return + } if (deleteSelection()) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @@ -244,6 +264,10 @@ class InputConnectionGateway( fun deleteForwards() { invalidateClearUndo() val ic = connection() ?: return + if (isRawKeys()) { + sendKeyDownUp(ic, KeyEvent.KEYCODE_FORWARD_DEL) + return + } if (deleteSelection()) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val deleted = runCatching { ic.deleteSurroundingTextInCodePoints(0, 1) }.getOrDefault(false) @@ -832,3 +856,10 @@ internal fun chunksForCommit(text: String): List { } return chunks } + +private const val RAW_KEY_TEXT_MAX = 64 + +/** Real key events for [text] when the virtual keyboard can type all of it (ASCII), else null. */ +internal fun keyEventsForText(text: String): Array? = + runCatching { android.view.KeyCharacterMap.load(android.view.KeyCharacterMap.VIRTUAL_KEYBOARD)?.getEvents(text.toCharArray()) } + .getOrNull() diff --git a/app/src/main/java/llc/slacker/openime/InputMethodSubtypePolicy.kt b/app/src/main/java/llc/slacker/openime/InputMethodSubtypePolicy.kt index 91917932..52c9950e 100644 --- a/app/src/main/java/llc/slacker/openime/InputMethodSubtypePolicy.kt +++ b/app/src/main/java/llc/slacker/openime/InputMethodSubtypePolicy.kt @@ -23,11 +23,13 @@ internal object InputMethodSubtypePolicy { EditorInfoAdapter.EditorKind.NUMBER, EditorInfoAdapter.EditorKind.DECIMAL, EditorInfoAdapter.EditorKind.PHONE, + EditorInfoAdapter.EditorKind.DATETIME, -> KeyboardMode.DIGITS EditorInfoAdapter.EditorKind.EMAIL, EditorInfoAdapter.EditorKind.URL, EditorInfoAdapter.EditorKind.PASSWORD, + EditorInfoAdapter.EditorKind.RAW_KEYS, -> KeyboardMode.ENGLISH_26 else -> when (language(subtypeLocale)) { diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index d76a7464..e01ba5cd 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -148,6 +148,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can context = this, connection = { currentInputConnection }, isPassword = { state.passwordField }, + isRawKeys = { EditorInfoAdapter.kind(state.editorInfo) == EditorInfoAdapter.EditorKind.RAW_KEYS }, ) state = ImeState( theme = ImeSettingsRepository.loadTheme(this), @@ -1005,7 +1006,14 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can candidates: List, rimeInputs: List, ) { - if (state.passwordField) { + val directCommit = state.passwordField || ( + // Terminals and games need each letter as it is typed; composing English + // there shows nothing until the word ends. Pinyin still composes. + EditorInfoAdapter.kind(state.editorInfo) == EditorInfoAdapter.EditorKind.RAW_KEYS && + state.keyboardMode != KeyboardMode.PINYIN_26 && + state.keyboardMode != KeyboardMode.PINYIN_9 + ) + if (directCommit) { // Password fields never receive composing text, so the view's // buffer is the only holder of pending input and renderState() // empties it on every report. The buffer therefore contains diff --git a/app/src/test/java/llc/slacker/openime/EditorInfoAdapterTest.kt b/app/src/test/java/llc/slacker/openime/EditorInfoAdapterTest.kt index 44ee904b..d3e1c3f5 100644 --- a/app/src/test/java/llc/slacker/openime/EditorInfoAdapterTest.kt +++ b/app/src/test/java/llc/slacker/openime/EditorInfoAdapterTest.kt @@ -105,4 +105,27 @@ class EditorInfoAdapterTest { assertTrue(EditorInfoAdapter.isPassword(numberPassword)) assertFalse(EditorInfoAdapter.allowCandidates(numberPassword)) } + + @Test + fun editorsWithoutATextTypeAreRawKeyEditorsAndStartInEnglish() { + val kind = EditorInfoAdapter.kind(info(InputType.TYPE_NULL)) + assertEquals(EditorInfoAdapter.EditorKind.RAW_KEYS, kind) + assertEquals(KeyboardMode.ENGLISH_26, EditorInfoAdapter.defaultKeyboardMode(kind)) + assertEquals(KeyboardMode.ENGLISH_26, InputMethodSubtypePolicy.defaultKeyboardMode(kind, "zh_CN")) + assertFalse(EditorInfoAdapter.isPassword(kind)) + assertFalse(EditorInfoAdapter.allowCandidates(kind).not()) + } + + @Test + fun noEditorAtAllIsStillUnknownNotRawKeys() { + assertEquals(EditorInfoAdapter.EditorKind.UNKNOWN, EditorInfoAdapter.kind(null)) + } + + @Test + fun dateAndTimeFieldsStartWithDigits() { + val kind = EditorInfoAdapter.kind(info(InputType.TYPE_CLASS_DATETIME or InputType.TYPE_DATETIME_VARIATION_DATE)) + assertEquals(EditorInfoAdapter.EditorKind.DATETIME, kind) + assertEquals(KeyboardMode.DIGITS, EditorInfoAdapter.defaultKeyboardMode(kind)) + assertEquals(KeyboardMode.DIGITS, InputMethodSubtypePolicy.defaultKeyboardMode(kind, "zh_CN")) + } } diff --git a/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt b/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt index 36484c73..501ad232 100644 --- a/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt +++ b/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt @@ -668,4 +668,47 @@ class InputConnectionGatewayTest { assertTrue(gateway.clearAllText()) assertFalse(gateway.hasClearUndo()) } + + // --- terminals, games and remote desktops: TYPE_NULL, key events only --- + + private fun rawKeyGateway(fake: FakeInputConnection) = InputConnectionGateway( + context = null, + connection = { fake }, + isRawKeys = { true }, + // The JVM has no key character map; one synthetic event per character is enough to observe. + keyEventsFor = { text -> if (text.all { it.code < 0x80 }) Array(text.length) { KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_A) } else null }, + ) + + @Test + fun rawKeyEditorsDeleteWithKeyEventsNotWithTheDummyConnectionsPrivateBuffer() { + // BaseInputConnection in dummy mode answers true to deleteSurroundingText and removes nothing. + val fake = FakeInputConnection(deleteSurroundingResult = true) + rawKeyGateway(fake).deleteBackwards() + assertEquals(listOf("key", "key"), fake.events) // DEL down + up, no delete* call + } + + @Test + fun rawKeyEditorsForwardDeleteWithKeyEventsToo() { + val fake = FakeInputConnection() + rawKeyGateway(fake).deleteForwards() + assertEquals(listOf("key", "key"), fake.events) + } + + @Test + fun rawKeyEditorsReceiveAsciiAsKeyEventsAndOtherTextAsCommit() { + val fake = FakeInputConnection() + val gateway = rawKeyGateway(fake) + gateway.commitText("ls") + assertEquals(listOf("key", "key"), fake.events) + fake.events.clear() + gateway.commitText("你好") + assertEquals(listOf("commit:你好"), fake.events) + } + + @Test + fun ordinaryEditorsAreNotAffectedByTheRawKeyPath() { + val fake = FakeInputConnection() + InputConnectionGateway(null, { fake }).commitText("ls") + assertEquals(listOf("commit:ls"), fake.events) + } } From 07b7603d3d45458e6ea828ea64328195061f45a3 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:09:05 +0800 Subject: [PATCH 07/13] feat: type Chinese from a physical keyboard On tablets, foldables with a keyboard cover, Chromebooks, desktop mode and the emulator, letters used to reach the app as plain Latin text. In the 26-key Chinese mode they now compose pinyin: space picks the first candidate, 1-9 pick a candidate, Enter keeps the typed pinyin, Esc cancels, ' separates syllables, and , . ? ! ; : ( ) give full-width punctuation (after a digit , . : stay ASCII). Ctrl/Alt/Meta shortcuts, capitals and every other key go to the app unchanged. HardwareKeyPolicy is pure and unit-tested; core_regression 040-043 drive real key events on the emulator. docs/COMPATIBILITY.md lists every environment the keyboard handles, how, and how it is verified, and what is still not covered. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 9 ++ .../llc/slacker/openime/HardwareKeyPolicy.kt | 107 ++++++++++++++++++ .../llc/slacker/openime/ImeKeyboardView.kt | 21 ++++ .../slacker/openime/InputConnectionGateway.kt | 6 + .../slacker/openime/LocalVoiceImeService.kt | 73 ++++++++++++ .../slacker/openime/HardwareKeyPolicyTest.kt | 105 +++++++++++++++++ docs/COMPATIBILITY.md | 51 +++++++++ docs/README.md | 1 + scripts/core_regression.sh | 27 +++++ 9 files changed, 400 insertions(+) create mode 100644 app/src/main/java/llc/slacker/openime/HardwareKeyPolicy.kt create mode 100644 app/src/test/java/llc/slacker/openime/HardwareKeyPolicyTest.kt create mode 100644 docs/COMPATIBILITY.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c28a175..828c2307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,11 +32,20 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 手写目前只保留笔迹采集界面,识别引擎尚未接入,入口默认隐藏。 ### 兼容性 +- 物理键盘(平板、折叠屏键盘套、Chromebook、桌面模式)可以打中文:字母组成拼音,空格选首选,1–9 选候选,回车保留拼音,Esc 取消,`,` `.` 等输出全角标点,数字后的 `,` `.` `:` 保持 ASCII;快捷键、大写字母原样交给应用。 +- 终端、游戏、远程桌面这类没有文本类型(TYPE_NULL)的输入框:默认英文,字母立即送出,退格改用按键事件(此前什么也删不掉);日期时间输入框默认数字键盘。 +- 一次提交超大文本(大段粘贴)会分块,不会因超过 Binder 事务上限而崩溃。 - 横屏不再进入全屏「提取」模式:应用自己的输入框不会被盖住,键盘始终是底部面板。 - 横屏、平板和折叠屏上按键行宽度现在跟随内容区,不会再被裁掉一部分;布局在测量前就按可用宽度计算。 - 系统字体放大到 200% 时按键字符不再变成省略号,「中/英」不再缺字:按键标签最多跟随系统字体放大到 1.3 倍,功能键标签会自动缩小以放入按键。 - 竖屏、横屏、字体 130% / 200%、深色、小屏、窄屏、平板(竖 / 横)和折叠屏内屏都由 `scripts/display_matrix_regression.py` 逐一验证。 +### 稳定性 +- 一次按键处理失败不会让键盘退出;崩溃、原生崩溃和卡死(ANR)会记录在本机(只含异常类型和代码位置,不含输入内容),10 分钟内三次进入安全模式(关闭原生词库与语音预加载),「关于与数据」里可以复制诊断信息或退出安全模式。 +- librime 启动时进程原生崩溃会逐级自愈:清理编译产物、备份并重建用户词库、最后不再启动原生引擎。 +- 语音输入静音媒体音量期间进程意外退出,下次启动会恢复音量,并带两分钟看门狗。 +- 词库和九键解码器改在后台线程构建,冷启动不再卡住主线程。 + ### 隐私与安全 - 不声明 `INTERNET` 权限;`allowBackup=false`。 - 密码输入框不写入候选、剪贴板历史或日志(允许从剪贴板粘贴);语音 PCM 只在当前会话的内存缓冲区中处理,结束、取消或失败时清空。 diff --git a/app/src/main/java/llc/slacker/openime/HardwareKeyPolicy.kt b/app/src/main/java/llc/slacker/openime/HardwareKeyPolicy.kt new file mode 100644 index 00000000..a019b810 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/HardwareKeyPolicy.kt @@ -0,0 +1,107 @@ +package llc.slacker.openime + +import android.view.KeyEvent + +/** What a physical key should do while the keyboard is in Chinese pinyin mode. */ +internal sealed interface HardwareKeyAction { + /** Leave the key to the app. */ + data object PassThrough : HardwareKeyAction + + /** The key is ours and does nothing (e.g. a digit that selects no candidate). */ + data object Consume : HardwareKeyAction + + /** Commit what is being composed, then let the key reach the app. */ + data object FinishCompositionThenPassThrough : HardwareKeyAction + + data class Letter(val char: Char) : HardwareKeyAction + data object Backspace : HardwareKeyAction + data object Space : HardwareKeyAction + data object Enter : HardwareKeyAction + data object Cancel : HardwareKeyAction + data object Apostrophe : HardwareKeyAction + data class SelectCandidate(val index: Int) : HardwareKeyAction + + /** Full-width punctuation; the first candidate is committed first when a composition is open. */ + data class Punctuation(val text: String, val commitFirstCandidate: Boolean) : HardwareKeyAction +} + +internal data class HardwareKey( + val keyCode: Int, + /** The character the key produces with the current layout and modifiers (0 for none). */ + val unicode: Int, + val shift: Boolean = false, + val ctrl: Boolean = false, + val alt: Boolean = false, + val meta: Boolean = false, + val capsLock: Boolean = false, + val repeat: Boolean = false, +) + +internal data class HardwareContext( + /** The keyboard is in the Chinese 26-key mode and the editor accepts composing. */ + val pinyinMode: Boolean, + val composing: Boolean, + val candidateCount: Int, + /** The character before the cursor, when known: "3.14" must keep its ASCII dot. */ + val charBeforeCursor: Char?, +) + +/** + * Physical-keyboard typing for Chinese: letters compose pinyin, space picks the + * first candidate, 1-9 pick a candidate, Enter keeps the typed pinyin, Esc + * cancels. Everything else, and every shortcut, belongs to the app. Pure, so + * it can be tested without a device. + */ +internal object HardwareKeyPolicy { + private val modifierKeys = setOf( + KeyEvent.KEYCODE_SHIFT_LEFT, KeyEvent.KEYCODE_SHIFT_RIGHT, + KeyEvent.KEYCODE_CTRL_LEFT, KeyEvent.KEYCODE_CTRL_RIGHT, + KeyEvent.KEYCODE_ALT_LEFT, KeyEvent.KEYCODE_ALT_RIGHT, + KeyEvent.KEYCODE_META_LEFT, KeyEvent.KEYCODE_META_RIGHT, + KeyEvent.KEYCODE_CAPS_LOCK, KeyEvent.KEYCODE_NUM_LOCK, KeyEvent.KEYCODE_FUNCTION, + ) + + private val fullWidth = mapOf( + ',' to ",", '.' to "。", '?' to "?", '!' to "!", ';' to ";", ':' to ":", + '(' to "(", ')' to ")", + ) + + /** These keep their ASCII form right after a digit (3.14, 12:30, 1,000). */ + private val asciiAfterDigit = setOf(',', '.', ':') + + fun decide(key: HardwareKey, context: HardwareContext): HardwareKeyAction { + if (!context.pinyinMode) return HardwareKeyAction.PassThrough + if (key.keyCode in modifierKeys) return HardwareKeyAction.PassThrough + if (key.ctrl || key.alt || key.meta) return HardwareKeyAction.PassThrough + + val composing = context.composing + when (key.keyCode) { + KeyEvent.KEYCODE_DEL -> return if (composing) HardwareKeyAction.Backspace else HardwareKeyAction.PassThrough + KeyEvent.KEYCODE_SPACE -> + return if (!composing) HardwareKeyAction.PassThrough + else if (key.repeat) HardwareKeyAction.Consume else HardwareKeyAction.Space + KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> + return if (!composing) HardwareKeyAction.PassThrough + else if (key.repeat) HardwareKeyAction.Consume else HardwareKeyAction.Enter + KeyEvent.KEYCODE_ESCAPE -> + return if (composing && !key.repeat) HardwareKeyAction.Cancel else HardwareKeyAction.PassThrough + } + + val char = key.unicode.takeIf { it in 0x20..0x7e }?.toChar() ?: return HardwareKeyAction.PassThrough + return when { + char in 'a'..'z' && !key.shift && !key.capsLock -> HardwareKeyAction.Letter(char) + // A capital letter is English: close the composition, then type it. + char in 'A'..'Z' -> if (composing) HardwareKeyAction.FinishCompositionThenPassThrough else HardwareKeyAction.PassThrough + char in '1'..'9' && composing -> + if (char - '1' < context.candidateCount) HardwareKeyAction.SelectCandidate(char - '1') else HardwareKeyAction.Consume + char == '\'' && composing -> HardwareKeyAction.Apostrophe + char in fullWidth -> { + val numeric = char in asciiAfterDigit && context.charBeforeCursor?.let { it in '0'..'9' } == true && !composing + if (numeric) HardwareKeyAction.PassThrough + else HardwareKeyAction.Punctuation(fullWidth.getValue(char), commitFirstCandidate = composing) + } + composing -> HardwareKeyAction.FinishCompositionThenPassThrough + else -> HardwareKeyAction.PassThrough + } + } +} diff --git a/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt b/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt index 9b1a4ae2..d5d1713b 100644 --- a/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt @@ -2987,6 +2987,27 @@ open class ImeKeyboardView( keyPopupController.hide() } + // --- physical keyboard: the same entry points a tap on the soft key reaches --- + + /** Pinyin typing from a physical keyboard needs the plain 26-key surface: no panel, no voice. */ + internal fun hardwareAccepts(): Boolean = + mode == KeyboardMode.PINYIN_26 && panel == Panel.NONE && !standalonePanel && + !voicePanelController.active && !voiceGestureSession + + internal fun hardwareIsComposing(): Boolean = composition.text.isNotEmpty() + internal fun hardwareCandidateCount(): Int = currentCandidates.size + internal fun hardwareLetter(char: Char) = onKeyTapped(char.toString()) + internal fun hardwareBackspace() = performBackspaceOnce() + internal fun hardwareSpace() = commitFirstCandidateOrSpace() + internal fun hardwareApostrophe() = onPinyinSegment() + internal fun hardwareCancelComposition() = publishComposition("", emptyList()) + + internal fun hardwareSelectCandidate(index: Int): Boolean { + val candidate = currentCandidates.getOrNull(index) ?: return false + listener.onCandidateSelected(candidate) + return true + } + /** * The key preview is a permanent child that only toggles visibility, so * tests cannot detect it by counting children. diff --git a/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt b/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt index 2f3705e9..6024f57a 100644 --- a/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt +++ b/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt @@ -139,6 +139,12 @@ class InputConnectionGateway( } } + /** The character before the cursor, or null when unknown (and always in password fields). */ + fun charBeforeCursor(): Char? { + if (isPassword()) return null + return runCatching { connection()?.getTextBeforeCursor(1, 0)?.lastOrNull() }.getOrNull() + } + fun finishComposing() { connection()?.finishComposingText() } diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index e01ba5cd..8bdebe04 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -1284,9 +1284,79 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can if (keyCode == KeyEvent.KEYCODE_BACK && keyboardView?.closePanelToKeyboard() == true) { return true } + if (event != null && handleHardwareKey(event)) { + hardwareConsumed += keyCode + return true + } return super.onKeyDown(keyCode, event) } + override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { + // The matching up of a key we consumed must not reach the app on its own. + if (hardwareConsumed.remove(keyCode)) return true + return super.onKeyUp(keyCode, event) + } + + private val hardwareConsumed = HashSet() + + /** + * Pinyin typing from a physical keyboard (tablets, foldables with a keyboard + * cover, Chromebooks, desktop mode, emulators). Returns true when the key was + * ours; anything else, and every shortcut, goes on to the app unchanged. + */ + private fun handleHardwareKey(event: KeyEvent): Boolean { + val view = keyboardView ?: return false + if (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD != 0) return false + val kind = EditorInfoAdapter.kind(state.editorInfo) + val pinyinMode = isInputViewShown && view.hardwareAccepts() && !state.passwordField && + EditorInfoAdapter.allowCandidates(kind) && kind != EditorInfoAdapter.EditorKind.RAW_KEYS + if (!pinyinMode) return false + val composing = view.hardwareIsComposing() + val unicode = event.unicodeChar + val needsCharBefore = !composing && unicode in HARDWARE_ASCII_AFTER_DIGIT + val action = HardwareKeyPolicy.decide( + HardwareKey( + keyCode = event.keyCode, + unicode = unicode, + shift = event.isShiftPressed, + ctrl = event.isCtrlPressed, + alt = event.isAltPressed, + meta = event.isMetaPressed, + capsLock = event.isCapsLockOn, + repeat = event.repeatCount > 0, + ), + HardwareContext( + pinyinMode = true, + composing = composing, + candidateCount = view.hardwareCandidateCount(), + charBeforeCursor = if (needsCharBefore) gateway.charBeforeCursor() else null, + ), + ) + var consumed = true + guarded("hardwareKey") { + when (action) { + HardwareKeyAction.PassThrough -> consumed = false + HardwareKeyAction.Consume -> Unit + HardwareKeyAction.FinishCompositionThenPassThrough -> { + commitPendingComposition() + consumed = false + } + is HardwareKeyAction.Letter -> view.hardwareLetter(action.char) + HardwareKeyAction.Backspace -> view.hardwareBackspace() + HardwareKeyAction.Space -> view.hardwareSpace() + HardwareKeyAction.Enter -> onEnter() + HardwareKeyAction.Cancel -> view.hardwareCancelComposition() + HardwareKeyAction.Apostrophe -> view.hardwareApostrophe() + is HardwareKeyAction.SelectCandidate -> consumed = view.hardwareSelectCandidate(action.index) + is HardwareKeyAction.Punctuation -> { + if (action.commitFirstCandidate) commitFirstCandidate() + onCharacter(action.text) + } + } + } + return consumed + } + private fun updateComposition(next: String, candidates: List) { lastComposition = next state = state.copy(composition = next, candidates = candidates) @@ -1681,3 +1751,6 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can var activeInstance: LocalVoiceImeService? = null } } + +/** Punctuation that stays ASCII right after a digit (3.14, 12:30, 1,000). */ +private val HARDWARE_ASCII_AFTER_DIGIT = setOf(','.code, '.'.code, ':'.code) diff --git a/app/src/test/java/llc/slacker/openime/HardwareKeyPolicyTest.kt b/app/src/test/java/llc/slacker/openime/HardwareKeyPolicyTest.kt new file mode 100644 index 00000000..01310c33 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/HardwareKeyPolicyTest.kt @@ -0,0 +1,105 @@ +package llc.slacker.openime + +import android.view.KeyEvent +import org.junit.Assert.assertEquals +import org.junit.Test + +class HardwareKeyPolicyTest { + private val typing = HardwareContext(pinyinMode = true, composing = true, candidateCount = 5, charBeforeCursor = null) + private val idle = typing.copy(composing = false, candidateCount = 0) + + private fun key(code: Int, char: Char? = null, shift: Boolean = false, ctrl: Boolean = false, caps: Boolean = false, repeat: Boolean = false) = + HardwareKey(code, char?.code ?: 0, shift = shift, ctrl = ctrl, capsLock = caps, repeat = repeat) + + private fun letter(c: Char) = key(KeyEvent.KEYCODE_A + (c - 'a'), c) + + @Test + fun lettersComposePinyinWhetherOrNotAnythingIsOpen() { + assertEquals(HardwareKeyAction.Letter('n'), HardwareKeyPolicy.decide(letter('n'), idle)) + assertEquals(HardwareKeyAction.Letter('i'), HardwareKeyPolicy.decide(letter('i'), typing)) + } + + @Test + fun otherModesAndShortcutsBelongToTheApp() { + assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(letter('a'), typing.copy(pinyinMode = false))) + assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_C, 'c', ctrl = true), typing)) + assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_SHIFT_LEFT), typing)) + } + + @Test + fun capitalLettersAreEnglishAndCloseTheComposition() { + val a = key(KeyEvent.KEYCODE_A, 'A', shift = true) + assertEquals(HardwareKeyAction.FinishCompositionThenPassThrough, HardwareKeyPolicy.decide(a, typing)) + assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(a, idle)) + // Caps Lock types lower case keys as capitals too. + assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_A, 'A', caps = true), idle)) + } + + @Test + fun spaceEnterBackspaceAndEscapeOnlyActWhileComposing() { + for ((code, expected) in listOf( + KeyEvent.KEYCODE_SPACE to HardwareKeyAction.Space, + KeyEvent.KEYCODE_ENTER to HardwareKeyAction.Enter, + KeyEvent.KEYCODE_DEL to HardwareKeyAction.Backspace, + KeyEvent.KEYCODE_ESCAPE to HardwareKeyAction.Cancel, + )) { + assertEquals(expected, HardwareKeyPolicy.decide(key(code), typing)) + assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(key(code), idle)) + } + } + + @Test + fun aHeldSpaceOrEnterDoesNotCommitRepeatedly() { + assertEquals(HardwareKeyAction.Consume, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_SPACE, repeat = true), typing)) + assertEquals(HardwareKeyAction.Consume, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_ENTER, repeat = true), typing)) + } + + @Test + fun digitsPickCandidatesOnlyWhileComposing() { + assertEquals(HardwareKeyAction.SelectCandidate(0), HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_1, '1'), typing)) + assertEquals(HardwareKeyAction.SelectCandidate(4), HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_5, '5'), typing)) + assertEquals(HardwareKeyAction.Consume, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_9, '9'), typing)) + assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_1, '1'), idle)) + } + + @Test + fun apostropheSeparatesSyllablesOnlyWhileComposing() { + assertEquals(HardwareKeyAction.Apostrophe, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_APOSTROPHE, '\''), typing)) + assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_APOSTROPHE, '\''), idle)) + } + + @Test + fun punctuationIsFullWidthAndCommitsTheFirstCandidateWhenComposing() { + assertEquals( + HardwareKeyAction.Punctuation(",", commitFirstCandidate = true), + HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_COMMA, ','), typing), + ) + assertEquals( + HardwareKeyAction.Punctuation("。", commitFirstCandidate = false), + HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_PERIOD, '.'), idle), + ) + assertEquals( + HardwareKeyAction.Punctuation("?", commitFirstCandidate = false), + HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_SLASH, '?', shift = true), idle), + ) + } + + @Test + fun numbersKeepTheirAsciiPunctuation() { + val afterDigit = idle.copy(charBeforeCursor = '3') + assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_PERIOD, '.'), afterDigit)) + assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_COMMA, ','), afterDigit)) + // A question mark after a digit is still a question mark. + assertEquals( + HardwareKeyAction.Punctuation("?", commitFirstCandidate = false), + HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_SLASH, '?', shift = true), afterDigit), + ) + } + + @Test + fun anyOtherPrintableKeyClosesTheCompositionFirst() { + assertEquals(HardwareKeyAction.FinishCompositionThenPassThrough, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_MINUS, '-'), typing)) + assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_MINUS, '-'), idle)) + assertEquals(HardwareKeyAction.PassThrough, HardwareKeyPolicy.decide(key(KeyEvent.KEYCODE_DPAD_LEFT), typing)) + } +} diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md new file mode 100644 index 00000000..a4e6e03f --- /dev/null +++ b/docs/COMPATIBILITY.md @@ -0,0 +1,51 @@ +# 输入环境兼容性 + +键盘要在别人的应用里工作,所以「哪些环境、怎么处理、怎么验证」写在这里。改输入链路之前先看一遍。 + +## 编辑器 + +| 环境 | 处理 | 验证 | +|---|---|---| +| 普通 / 多行 / 搜索 / 聊天(EditText、WebView、Compose) | 拼音预编辑 + 候选;回车按 IME action 或原始回车 | `core_regression.sh`、`ImeTestLabActivity` | +| 自绘 / Compose / Web,没有「全选」也没有 ExtractedText | 清空 / 撤回改用光标前后文本,答案长度等于请求长度时拒绝删除 | `InputConnectionGatewayTest`,`CustomEditorTestActivity` | +| 密码(含可见密码、网页密码、数字密码) | 不组合、不学习、不进剪贴板历史、禁用语音 | `security_regression.ps1` | +| 数字 / 电话 / 日期时间 | 起始键盘为数字 | `EditorInfoAdapterTest` | +| 邮箱 / URL | 起始键盘为英文 | `EditorInfoAdapterTest` | +| TYPE_NULL(终端、游戏、远程桌面) | 起始英文;每个字母立即以真实按键事件送出;退格 / 前删用按键事件(它们的 InputConnection 多半是 dummy 模式的 BaseInputConnection,`deleteSurroundingText` 返回 true 却什么也没删) | `InputConnectionGatewayTest` | +| 无个性化学习标志(隐身模式) | 不学习、不记录剪贴板 | `PersonalizedLearningPolicy` | +| 一次提交几十万字(大段粘贴、长语音) | 分块提交,每块不超过 32000 个字符且不拆代理对,避免超过 Binder 单次事务上限 | `CrashResilienceTest` | + +## 物理键盘(平板、折叠屏键盘套、Chromebook、桌面模式、模拟器) + +中文 26 键模式下:字母组成拼音,空格选首选,1–9 选候选,回车保留已输入拼音,Esc 取消,`'` 分词,退格删拼音; +`, . ? ! ; : ( )` 输出全角标点(数字后的 `, . :` 保持 ASCII,3.14 不会变成 3。14);Ctrl / Alt / Meta 组合键、 +大写字母和其他按键原样交给应用(大写会先结束当前预编辑)。英文 / 九键 / 数字模式、密码框、TYPE_NULL 编辑器不接管。 +需要键盘面板可见(候选显示在面板上)。验证:`HardwareKeyPolicyTest`,`core_regression.sh` 040–043。 + +## 显示环境 + +横屏(不进入全屏提取模式,键盘是底部面板)、字体 130% / 200%(按键标签最多放大到 1.3 倍,功能键标签自动缩小)、 +深色、小屏、窄屏、平板竖 / 横、折叠屏内屏。验证:`scripts/display_matrix_regression.py`(断言底部面板且每个键都在窗口内)、 +`DisplayEnvironmentInstrumentedTest`。 + +## Android 版本 + +`minSdk` 26;CI 在 API 29 和 31 上运行全部仪器测试,本地另在 API 36 上运行。 + +## 崩溃、卡死与冲突 + +- 一次按键处理失败不会让键盘进程退出:记录(只含异常类型和代码位置,不含输入内容)、丢弃半成品预编辑、继续工作。 + 验证:`core_regression.sh` 038(调试命令 `fail-next` 注入一次失败)。 +- 崩溃历史:Java 崩溃、原生崩溃和 ANR(Android 11+ 的进程退出记录)。10 分钟内 3 次进入**安全模式**: + 关闭 librime 和语音预加载,用内置词库继续输入,「设置 → 关于与数据 → 诊断」可复制诊断信息或退出安全模式。 +- librime 启动前写标记,通过健康检查后清除。留下标记且上个进程确实是原生崩溃时逐级处理:清理编译产物 → + 把用户词库改名备份并重建 → 不再启动原生引擎。被用户或系统强停的启动不计为崩溃。 +- 语音输入静音媒体音量时,原音量同时写入磁盘并有两分钟看门狗;进程在录音中途死掉,下次启动恢复,音乐 / 视频不会一直没声。 + 验证:`VoiceMediaMuteRecoveryInstrumentedTest`。 +- 词库与九键解码器在后台线程构建,不再占用主线程(冷启动曾多占约 0.3 秒)。 + +## 尚未覆盖 + +- 九键模式下的物理键盘(字母直接交给应用); +- 物理键盘用户隐藏键盘面板后的候选显示(需要独立的候选窗口); +- 真机上的 OEM 差异(小米、OPPO、三星):目前只有模拟器与 CI 模拟器的结果。 diff --git a/docs/README.md b/docs/README.md index 986a2b2b..5de16fa3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,7 @@ - [TEST_ARCHITECTURE.md](TEST_ARCHITECTURE.md):自动化层级、debug harness 和 CI 门禁。 - [TEST_SOP.md](TEST_SOP.md):L0~L3 正式测试流程。 - [TEST_SOP_CHECKLIST.md](TEST_SOP_CHECKLIST.md):多设备与人工交互验收清单。 +- [COMPATIBILITY.md](COMPATIBILITY.md):输入环境兼容性:编辑器类型、物理键盘、显示环境、崩溃 / 卡死 / 冲突的处理与验证。 - [LICENSING.md](LICENSING.md):主项目与第三方组件许可证边界。 - [RELEASE.md](RELEASE.md):版本号规则(`VERSION`)、CHANGELOG、固定签名、arm64 正式包、标签发布、演练与回滚。 - [REPOSITORY.md](REPOSITORY.md):分支、合并、`main` 与标签保护、安全与依赖更新,以及如何重新应用这些设置。 diff --git a/scripts/core_regression.sh b/scripts/core_regression.sh index 916e0363..04ae7eb9 100755 --- a/scripts/core_regression.sh +++ b/scripts/core_regression.sh @@ -236,6 +236,33 @@ for key in n i h a o; do tap "$key"; done tap candidate-first-row check '038 a failure inside a key handler is contained, typing carries on -> 你好' '你好' "$(editor_text)" +# Physical keyboard (KEYCODE_N=42 I=37 H=36 A=29 O=43 SPACE=62 1=8 COMMA=55 ENTER=66) +hwkey() { adb_do shell input keyevent "$@" >/dev/null 2>&1; sleep 0.35; } + +start_real +mode PINYIN_26 || say 'WARN: PINYIN_26 not reached' +for code in 42 37 36 29 43; do hwkey "$code"; done +hwkey 62 +check '040 physical keyboard: nihao + space -> 你好' '你好' "$(editor_text)" + +start_real +mode PINYIN_26 || say 'WARN: PINYIN_26 not reached' +for code in 42 37; do hwkey "$code"; done +hwkey 8 +check '041 physical keyboard: ni + 1 picks the first candidate -> 你' '你' "$(editor_text)" + +start_real +mode PINYIN_26 || say 'WARN: PINYIN_26 not reached' +for code in 42 37 36 29 43; do hwkey "$code"; done +hwkey 55 +check '042 physical keyboard: nihao + comma commits 你好 then a full-width comma' '你好,' "$(editor_text)" + +start_real +mode PINYIN_26 || say 'WARN: PINYIN_26 not reached' +hwkey 8 +hwkey 55 +check '043 physical keyboard: with nothing composing, 1 and comma stay ASCII' '1,' "$(editor_text)" + start_real mode DIGITS || say 'WARN: DIGITS not reached' for key in 1 2 3; do tap "$key"; done From c6a106d4d8ad98c6e16b6e987cb46f2333ad921f Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:09:22 +0800 Subject: [PATCH 08/13] ci: also run the instrumented suite on API 26 (minSdk) and 34 Informational like the others; the release gate still requires 29 and 31. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/android.yml | 2 +- CONTRIBUTING.md | 2 +- docs/COMPATIBILITY.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index e7712ab3..7f5943ba 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -113,7 +113,7 @@ jobs: strategy: fail-fast: false matrix: - api-level: [29, 31] + api-level: [26, 29, 31, 34] steps: - name: Enable KVM group perms diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9cdffe0d..aeebf175 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,4 +55,4 @@ PR 描述应包含:改动目的、影响范围、测试命令和结果、已 密码、剪贴板或录音。 `main` 受保护:只能通过 PR 合并,且 **Build and verify** 必须通过;PR 一律 squash 合并, -合并后分支自动删除。API 29 / 31 兼容测试同样会在 PR 上运行,红了请先修再合并。 +合并后分支自动删除。API 26 / 29 / 31 / 34 兼容测试同样会在 PR 上运行,红了请先修再合并。 diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index a4e6e03f..b10750d7 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -30,7 +30,7 @@ ## Android 版本 -`minSdk` 26;CI 在 API 29 和 31 上运行全部仪器测试,本地另在 API 36 上运行。 +`minSdk` 26;CI 在 API 26(minSdk)、29、31、34 上运行全部仪器测试,本地另在 API 36 上运行;发布前必须通过的是 API 29 和 31。 ## 崩溃、卡死与冲突 From 954ef735eac3de91c2a194333a85b2ba72bda0cb Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:28:10 +0800 Subject: [PATCH 09/13] perf: Backspace skips the selection round trips when the cursor is known collapsed Deleting asked the app for the selected text and the extracted text before every single-character delete: three synchronous Binder calls, each of which waits as long as a slow or stuck app takes. Once the editor itself has reported a collapsed cursor (onUpdateSelection, not the start-up values) there is no selection to ask about. Co-Authored-By: Claude Sonnet 5.5 --- .../slacker/openime/InputConnectionGateway.kt | 11 ++++++- .../slacker/openime/LocalVoiceImeService.kt | 2 +- .../openime/InputConnectionGatewayTest.kt | 29 ++++++++++++++++++- docs/COMPATIBILITY.md | 2 ++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt b/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt index 6024f57a..e584e14f 100644 --- a/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt +++ b/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt @@ -169,9 +169,14 @@ class InputConnectionGateway( @Volatile private var knownSelectionEnd: Int = -1 - fun updateSelection(start: Int, end: Int) { + /** True once the editor itself has reported a selection (onUpdateSelection), not just its start-up values. */ + @Volatile + private var selectionReportedByEditor = false + + fun updateSelection(start: Int, end: Int, reportedByEditor: Boolean = false) { knownSelectionStart = start knownSelectionEnd = end + selectionReportedByEditor = reportedByEditor } private fun typeAsKeyEvents(ic: InputConnection, text: String): Boolean { @@ -227,6 +232,10 @@ class InputConnectionGateway( } return false } + // Every call below is a synchronous Binder round trip into the app; a slow + // or stuck app makes each one wait, and Backspace used to make three. When + // the editor has told us the cursor is collapsed there is no selection to ask about. + if (selectionReportedByEditor && knownSelectionStart >= 0 && knownSelectionStart == knownSelectionEnd) return false val selected = runCatching { ic.getSelectedText(0)?.toString().orEmpty() }.getOrDefault("") if (selected.isNotEmpty()) { if (knownSelectionStart >= 0 && knownSelectionEnd >= 0) { diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index 8bdebe04..664cbbf5 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -464,7 +464,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can candidatesStart: Int, candidatesEnd: Int, ) { - gateway.updateSelection(newSelStart, newSelEnd) + gateway.updateSelection(newSelStart, newSelEnd, reportedByEditor = true) guarded("onUpdateSelection") { refreshTextEditControls() } super.onUpdateSelection( oldSelStart, diff --git a/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt b/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt index 501ad232..7d55156f 100644 --- a/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt +++ b/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt @@ -62,7 +62,11 @@ class InputConnectionGatewayTest { override fun getExtractedText(request: ExtractedTextRequest?, flags: Int): ExtractedText? = extractedText override fun getHandler(): Handler? = null - override fun getSelectedText(flags: Int): CharSequence? = selectedText + var selectedTextCalls = 0 + override fun getSelectedText(flags: Int): CharSequence? { + selectedTextCalls++ + return selectedText + } override fun getTextAfterCursor(length: Int, flags: Int): CharSequence? = afterText.take(length) override fun getTextBeforeCursor(length: Int, flags: Int): CharSequence? = beforeText.takeLast(length) override fun performContextMenuAction(id: Int): Boolean { @@ -711,4 +715,27 @@ class InputConnectionGatewayTest { InputConnectionGateway(null, { fake }).commitText("ls") assertEquals(listOf("commit:ls"), fake.events) } + + @Test + fun backspaceDoesNotQueryTheAppWhenTheEditorReportedACollapsedCursor() { + val fake = FakeInputConnection(beforeText = "abc") + val gateway = InputConnectionGateway(null, { fake }) + gateway.updateSelection(3, 3, reportedByEditor = true) + gateway.deleteBackwards() + assertEquals(0, fake.selectedTextCalls) + // One delete call (which flavour depends on the SDK level); nothing else was sent. + assertEquals(1, fake.events.size) + assertTrue(fake.events.single().startsWith("delete")) + } + + @Test + fun backspaceStillAsksWhenOnlyTheStartUpSelectionIsKnown() { + // initialSelStart/End can be stale; only the editor's own reports are trusted. + val fake = FakeInputConnection(selectedText = "bc") + val gateway = InputConnectionGateway(null, { fake }) + gateway.updateSelection(3, 3, reportedByEditor = false) + gateway.deleteBackwards() + assertEquals(1, fake.selectedTextCalls) + assertEquals(listOf("commit:"), fake.events) + } } diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index b10750d7..f30460b8 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -43,6 +43,8 @@ - 语音输入静音媒体音量时,原音量同时写入磁盘并有两分钟看门狗;进程在录音中途死掉,下次启动恢复,音乐 / 视频不会一直没声。 验证:`VoiceMediaMuteRecoveryInstrumentedTest`。 - 词库与九键解码器在后台线程构建,不再占用主线程(冷启动曾多占约 0.3 秒)。 +- 退格不再每次向应用发起三次同步 Binder 调用:编辑器已经报告光标是收起状态时,不再去问「选中了什么」。 + 应用卡住时,每次调用都会让键盘跟着等。 ## 尚未覆盖 From 5a8c5edc4c7398d1912df1d761159e4d3e1b3865 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:50:21 +0800 Subject: [PATCH 10/13] release: record the signing certificate fingerprint for 1.0.0 The first release is verified against the key in the repository secrets too: the build fails if the signing certificate differs from this SHA-256. Co-Authored-By: Claude Sonnet 5.5 --- docs/release-cert.sha256 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-cert.sha256 b/docs/release-cert.sha256 index 4eb25b6e..4a73baa5 100644 --- a/docs/release-cert.sha256 +++ b/docs/release-cert.sha256 @@ -1 +1 @@ -unset +22592ecc7eadcd168649e438f98b0d78563d098af99a1cc735c69d9cb7b1fe28 From d839f2307ac3c9ef04185d661d4b1f2ddde00ad2 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:11:06 +0800 Subject: [PATCH 11/13] docs: license openIME under GPL-3.0-only Decided by the owner. The APK ships Rime Ice dictionaries that are GPL-3.0-only; licensing the project the same way leaves no question about how the dictionary data and the program relate when the APK is distributed, and every other bundled component (BSD, Apache-2.0, MIT) is compatible. LICENSE is the official text. Co-Authored-By: Claude Sonnet 5.5 --- LICENSE | 675 +++++++++++++++++++++++++++++++++++++++++ README.md | 4 +- THIRD_PARTY_NOTICES.md | 4 +- docs/LICENSING.md | 9 +- docs/RELEASE.md | 2 +- 5 files changed, 686 insertions(+), 8 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..53d1f3d0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,675 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. + diff --git a/README.md b/README.md index 451192c7..28b1f958 100644 --- a/README.md +++ b/README.md @@ -192,5 +192,5 @@ bash scripts/verify_linux.sh emulator-5554 ## 许可证 -主项目许可证尚未单独声明;公开仓库不等同于授予再分发或商业使用许可。第三方 -组件的许可证保留在各自目录中,详见 [docs/LICENSING.md](docs/LICENSING.md)。 +openIME 以 [GPL-3.0-only](LICENSE) 发布。第三方组件的许可证保留在各自目录中, +详见 [docs/LICENSING.md](docs/LICENSING.md) 和 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 09dba908..7311c725 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # 第三方资源与分发核对 -本文件记录 openIME 实际随源码或 APK 分发的主要第三方代码、数据和模型。主项目许可证仍由项目所有者决定,本文件不为 openIME 本身授予许可证。 +本文件记录 openIME 实际随源码或 APK 分发的主要第三方代码、数据和模型。openIME 本身的许可证见仓库根目录 `LICENSE`(GPL-3.0-only),本文件只记录第三方组件。 | 组件 | 仓库内位置 | 上游/来源 | 许可证 | 分发核对 | |---|---|---|---|---| @@ -19,7 +19,7 @@ - `app/src/main/assets/licenses/` 下的第三方许可文本必须继续随 APK 打包,包括 Rime Ice、librime、OpenCC、Snappy、sherpa-onnx、Paraformer 模型和 Fluent Emoji。 - 语音 runtime 与模型升级时,重新核对**具体版本/模型**的许可证,不要只沿用本文件旧结论。 - 内置词库来源或固定提交变化时,同步更新本文件、`docs/LICENSING.md` 和 APK 内许可证文件。 -- 主项目 `LICENSE` 在项目所有者决定前保持缺失;README 对主项目许可状态的现有表述保持不变。 +- 主项目 `LICENSE`(GPL-3.0-only)与 README、`docs/LICENSING.md` 保持一致;更换主项目许可证前先核对 Rime Ice 词典的 GPL-3.0-only 义务。 ## 备注 diff --git a/docs/LICENSING.md b/docs/LICENSING.md index 5a73bb59..b02bd2af 100644 --- a/docs/LICENSING.md +++ b/docs/LICENSING.md @@ -2,9 +2,12 @@ ## 主项目 -`openIME` 当前尚未选择主项目许可证。仓库公开可见,但在添加明确许可证前,不能 -把“公开”理解为允许任意复制、再分发或商业使用。后续由项目所有者选择许可证后, -应在仓库根目录增加标准 `LICENSE` 文件,并同步更新本页和 README。 +`openIME` 以 **GPL-3.0-only** 发布,全文见仓库根目录 `LICENSE`。 + +选择它的原因:APK 内置的 Rime Ice 词典按 GPL-3.0-only 使用(见下),主项目采用同一份许可证, +分发 APK 时整体的许可证状况没有歧义——不用争论词典数据与程序是「聚合」还是「衍生」。 +其余组件(librime、OpenCC、Snappy、sherpa-onnx、Paraformer 模型、Fluent Emoji)均为 BSD / Apache-2.0 / MIT, +与 GPL-3.0 兼容。想改用别的许可证需要先去掉或替换 Rime Ice 词典。 ## 已随仓库提供的第三方组件 diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 52b8f5a6..187cb7e1 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -132,7 +132,7 @@ OPENIME_REHEARSAL=1 OPENIME_SKIP_TESTS=1 scripts/release_build.sh # 需要上 - `android:allowBackup="false"` 保持不变。 - `THIRD_PARTY_NOTICES.md` 与 `app/src/main/assets/licenses/` 同步。 - 语音模型、词库或第三方 runtime 版本变化时重新核对对应许可证(见 [LICENSING.md](LICENSING.md))。 -- 主项目许可证:仓库尚未选择,见 LICENSING.md;公开发布前需要所有者决定。 +- 主项目许可证:`LICENSE`(GPL-3.0-only),说明见 LICENSING.md。 ## 社交预览 From 36e7d54bd50a601ce6f22396b5d63266aea49819 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:59:50 +0800 Subject: [PATCH 12/13] docs: mention the license in the 1.0.0 notes Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 828c2307..57e16297 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 密码输入框不写入候选、剪贴板历史或日志(允许从剪贴板粘贴);语音 PCM 只在当前会话的内存缓冲区中处理,结束、取消或失败时清空。 - 「设置 → 关于与数据」可导出 / 合并导入用户数据,剪贴板历史不导出;卸载会清除本机全部数据,包括学习的用户词库。 - 测试用 Activity 与 E2E 接收器只存在于 debug 变体,release APK 不导出。 +- 以 GPL-3.0-only 许可证发布,见仓库根目录 `LICENSE`。 ### 工程 - 版本号单一来源(根目录 `VERSION`),`versionCode = 主 × 10000 + 次 × 100 + 修订`。 From b28abfea43e25e6be78856fb0ea4238ea51e12c7 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:15:23 +0800 Subject: [PATCH 13/13] test: make the compatibility tests valid on API 26 Found by the new API 26 job: the two "P+" code-point deletion tests asserted unconditionally although the gateway correctly falls back to UTF-16 deletion below API 28, and the clipboard test created a ClipboardManager on a thread without a Looper, which API 27 and below refuse. No product defect. Co-Authored-By: Claude Sonnet 5.5 --- .../slacker/openime/CompatibilityApiInstrumentedTest.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/java/llc/slacker/openime/CompatibilityApiInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/CompatibilityApiInstrumentedTest.kt index a839b0ee..fc049b15 100644 --- a/app/src/androidTest/java/llc/slacker/openime/CompatibilityApiInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/CompatibilityApiInstrumentedTest.kt @@ -5,6 +5,7 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.pm.PackageManager +import android.os.Build import android.os.PersistableBundle import android.view.View import android.view.inputmethod.BaseInputConnection @@ -22,6 +23,7 @@ class CompatibilityApiInstrumentedTest { @Test fun pPlusBackspaceUsesCodePointDeletionForUnicodeSafety() { + assumeTrue("code-point deletion needs API 28+; older levels use UTF-16 deletion", Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) val context = InstrumentationRegistry.getInstrumentation().targetContext val connection = RecordingInputConnection(View(context)) val gateway = InputConnectionGateway(context, { connection }) @@ -34,6 +36,7 @@ class CompatibilityApiInstrumentedTest { @Test fun pPlusForwardDeleteUsesCodePointDeletionForUnicodeSafety() { + assumeTrue("code-point deletion needs API 28+; older levels use UTF-16 deletion", Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) val context = InstrumentationRegistry.getInstrumentation().targetContext val connection = RecordingInputConnection(View(context)) val gateway = InputConnectionGateway(context, { connection }) @@ -48,7 +51,11 @@ class CompatibilityApiInstrumentedTest { fun sensitiveClipboardIsNeverCapturedIntoPersistentHistory() { val context = InstrumentationRegistry.getInstrumentation().targetContext ClipboardHistoryRepository.clearAll(context) - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + // Before API 28 a ClipboardManager can only be created on a thread with a Looper. + lateinit var clipboard: ClipboardManager + InstrumentationRegistry.getInstrumentation().runOnMainSync { + clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + } val clip = ClipData.newPlainText("secret", "compat-secret") clip.description.extras = PersistableBundle().apply { putBoolean(ClipboardSensitivityPolicy.SENSITIVE_KEY, true)