diff --git a/CHANGELOG.md b/CHANGELOG.md index a2f0d7a9..a05b29a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +## [1.2.0] - 2026-10-18 + +- 输入英文后直接点击句号、逗号、斜杠等符号,不再自动插入多余空格;主动点击空格或候选时仍保留正常分词。 +- 修复快速连续输入时偶尔丢字、候选意外清空,以及旧的光标事件取消正在输入的拼音。 +- 外接键盘在软键盘隐藏后也能输入中文:拼音写进输入框,空格选首选,数字键按序号选词;候选列表仍需打开键盘面板查看。 +- 部分应用自定义的回车动作标签和动作可以正确显示并执行。 +- 从复制的短信中识别验证码:补充繁体中文、一次性密码、2FA、MFA 等写法;超长文本直接放弃;未来日期的短信和复制内容不再提示。 +- 语音输入在蓝牙、USB、有线耳机切换时,改为给录音器指定输入设备;设备拒绝切换时沿用系统默认麦克风。 +- 加强语音模型归档导入的大小和完整性检查,压缩包被截断时报错而不是留下不完整文件。 +- 修复异常恢复过程中同一次崩溃被重复计数,减少输入法过早进入安全模式的情况。 + ## [1.1.0] - 2026-10-17 - 更新离线拼音词库,补充字词与读音。 diff --git a/VERSION b/VERSION index 9084fa2f..26aaba0e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0 +1.2.0 diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index 2a5072de..375c91d9 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -487,7 +487,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can // what the user was mid-way through typing; only a genuinely new // editor resets the composition. val sameEditorKind = kind == EditorInfoAdapter.kind(state.editorInfo) - val preserve = restarting && sameEditorKind && !EditorInfoAdapter.isPassword(kind) && state.composition.isNotEmpty() + var preserve = restarting && sameEditorKind && !EditorInfoAdapter.isPassword(kind) && state.composition.isNotEmpty() val policyMode = if (restarting && sameEditorKind) { state.keyboardMode } else { @@ -525,6 +525,14 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can ) if (!preserve) lastComposition = "" attribute?.let { gateway.updateSelection(it.initialSelStart, it.initialSelEnd) } + if (preserve && (!state.keyboardMode.isChineseLayout || composesInEditor()) && + !gateway.restoreComposition(state.composition)) { + // If the restarted editor no longer exposes that exact text, keep + // its document intact and stop presenting a phantom pre-edit. + preserve = false + lastComposition = "" + state = state.copy(composition = "", candidates = emptyList()) + } rime.clear() keyboardView?.clearAssociationCandidates() keyboardView?.setShiftState(initialShiftState) @@ -630,6 +638,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can override fun onStartInputView(attribute: EditorInfo?, restarting: Boolean) { super.onStartInputView(attribute, restarting) + hardwareTypingWhileHidden = false ensureInputViewAfterFinish() applyLandscapeLayout() if (floatingWindow.enabled) { @@ -650,6 +659,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can override fun onFinishInput() { inlineSuggestions = emptyList() + hardwareTypingWhileHidden = false invalidateCandidateQueries() voiceCorrectionTracker.finalizeIfNeeded() voiceMediaMute.restore() @@ -705,6 +715,15 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can ) ) return + // A queued selection can describe an edit before the current word. + // Never cancel that new composing span based on an obsolete cursor. + gateway.absoluteCursorSnapshot()?.let { current -> + if (newSelStart != current.cursorAbsolute || newSelEnd != current.cursorAbsolute) { + gateway.updateSelection(current.cursorAbsolute, current.cursorAbsolute, reportedByEditor = true) + return + } + } + // Drop IME-side state before touching the editor so any callback caused // by cancelComposing() observes an already-empty composition and cannot // recursively invalidate a new one. @@ -723,6 +742,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can Thread { if (clip != null) runCatching { ClipboardHistoryRepository.captureCopied(this, clip) } mainHandler.post { + if (activeInstance !== this@LocalVoiceImeService) return@post if (state.composition.isEmpty()) refreshIdleCandidates() keyboardView?.refreshClipboardContent() } @@ -986,9 +1006,11 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can override fun onCharacter(char: String) = guarded("onCharacter") { prepareForManualInput() voiceCorrectionTracker.noteReplacementInput() - commitPendingComposition() - keyboardView?.clearAssociationCandidates() - gateway.commitText(char) + gateway.batchEdit { + commitPendingComposition() + keyboardView?.clearAssociationCandidates() + gateway.commitText(char) + } // 算式出结果: the digit that finishes an expression brings its result. refreshIdleCandidates() refreshEnglishAutoCapitalization() @@ -1247,7 +1269,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can return } voiceCorrectionTracker.finalizeIfNeeded() - val action = state.editorInfo?.imeOptions?.let(::editorActionForEnter) + val action = state.editorInfo?.let(::editorActionForEnter) if (action != null) { gateway.performEditorAction(action) } else { @@ -1631,10 +1653,12 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can override fun onSymbolSelected(symbol: String) = guarded("onSymbolSelected") { prepareForManualInput() - commitPendingComposition() - keyboardView?.clearAssociationCandidates() + gateway.batchEdit { + commitPendingComposition() + keyboardView?.clearAssociationCandidates() + gateway.commitText(symbol) + } SymbolRecentRepository.record(this, symbol) - gateway.commitText(symbol) // 算式出结果: 「)」 or a full-width figure can finish the expression. refreshIdleCandidates() // A 「.」「!」「?」 (or their full-width forms) starts the next sentence. @@ -1643,10 +1667,12 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can override fun onEmojiSelected(emoji: String) = guarded("onEmojiSelected") { prepareForManualInput() - commitPendingComposition() - keyboardView?.clearAssociationCandidates() + gateway.batchEdit { + commitPendingComposition() + keyboardView?.clearAssociationCandidates() + gateway.commitText(emoji) + } EmojiRecentRepository.record(this, emoji) - gateway.commitText(emoji) } override fun onTextEdit(action: String) = guarded("onTextEdit") { @@ -1726,12 +1752,21 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can * 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 + if (state.passwordField || !EditorInfoAdapter.allowCandidates(kind) || + kind == EditorInfoAdapter.EditorKind.RAW_KEYS || currentInputConnection == null) return false + // With the on-screen keyboard hidden the view is gone. Only a key that + // starts or continues a Chinese composition is worth building it for. + if (keyboardView == null) { + val plainLetter = event.unicodeChar in 'a'.code..'z'.code && + !event.isCtrlPressed && !event.isAltPressed && !event.isMetaPressed + if (!plainLetter || state.keyboardMode !in HARDWARE_CHINESE_MODES) return false + ensureInputViewAfterFinish() + } + val view = keyboardView ?: return false + if (!view.hardwareAccepts()) return false + hardwareTypingWhileHidden = !isShowInputRequested val composing = view.hardwareIsComposing() val unicode = event.unicodeChar val needsCharBefore = !composing && unicode in HARDWARE_ASCII_AFTER_DIGIT @@ -1770,14 +1805,24 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can HardwareKeyAction.Apostrophe -> view.hardwareApostrophe() is HardwareKeyAction.SelectCandidate -> consumed = view.hardwareSelectCandidate(action.index) is HardwareKeyAction.Punctuation -> { - if (action.commitFirstCandidate) commitFirstCandidate() - onCharacter(action.text) + gateway.batchEdit { + if (action.commitFirstCandidate) commitFirstCandidate() + onCharacter(action.text) + } } } } return consumed } + /** + * True while a physical keyboard types with the on-screen keyboard hidden. + * The keyboard's own pre-edit row is gone then, and the system does not let + * an IME show a candidates-only window without a show request, so the + * pinyin is written into the field where the user can see it. + */ + private var hardwareTypingWhileHidden = false + private fun updateComposition(next: String, candidates: List) { lastComposition = next state = state.copy(composition = next, candidates = candidates) @@ -1806,7 +1851,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can */ private fun composesInEditor(): Boolean = ComposingTextPolicy.composesInEditor( - location = state.pinyinLocation, + location = if (hardwareTypingWhileHidden) PinyinLocation.INPUT_BOX else state.pinyinLocation, passwordField = state.passwordField, kind = EditorInfoAdapter.kind(state.editorInfo), ) @@ -1818,7 +1863,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can */ private fun commitPendingComposition() { if (lastComposition.isEmpty()) return - commitFirstCandidate() + commitFirstCandidate(appendEnglishSpace = false) } private fun fallbackCandidatesFor( @@ -2006,7 +2051,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } /** Commit only the first candidate that belongs to the currently rendered snapshot. */ - private fun commitFirstCandidate() { + private fun commitFirstCandidate(appendEnglishSpace: Boolean = true) { val composition = lastComposition if (composition.isEmpty()) return val mode = state.keyboardMode @@ -2016,7 +2061,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can currentMode = mode, ) ?: return invalidateCandidateQueries() - commitSnapshotEntry(composition, mode, entry) + commitSnapshotEntry(composition, mode, entry, appendEnglishSpace) } /** Commit the pre-edit pinyin as typed (separators dropped); no word is chosen or learned. */ @@ -2027,8 +2072,10 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can .filterNot { it == ' ' || it == '\'' || it == '|' } .ifEmpty { composition } invalidateCandidateQueries() - gateway.commitText(raw) - gateway.finishComposing() + gateway.batchEdit { + gateway.commitText(raw) + gateway.finishComposing() + } voiceCorrectionTracker.finalizeIfNeeded() rime.clear() lastComposition = "" @@ -2053,7 +2100,12 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } /** Commit one rendered candidate; a partial match keeps the rest of the input composing. */ - private fun commitSnapshotEntry(composition: String, mode: KeyboardMode, entry: CandidateSnapshotEntry) { + private fun commitSnapshotEntry( + composition: String, + mode: KeyboardMode, + entry: CandidateSnapshotEntry, + appendEnglishSpace: Boolean = true, + ) { val reference = entry.nativeReference val remaining = partialRemainder(reference, mode) if (remaining != null) { @@ -2071,7 +2123,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } else { "" } - finishCandidateCommit(composition, nativeCommit.ifBlank { entry.text }) + finishCandidateCommit(composition, nativeCommit.ifBlank { entry.text }, appendEnglishSpace) } /** @@ -2123,7 +2175,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } } - private fun finishCandidateCommit(composition: String, committed: String) { + private fun finishCandidateCommit(composition: String, committed: String, appendEnglishSpace: Boolean) { if (committed.isEmpty()) return // librime owns learning wherever its own order is the visible one, and // there the local repository stays the offline fallback; never run two @@ -2137,11 +2189,12 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } else if (allowsPersonalizedLearning() && (!rime.isReady || engineOwnsPinyinOrder())) { UserPhraseRepository.record(composition, committed) } - // An English word is followed by a space, as space and a suggestion - // tap do on Gboard and Sogou: otherwise "hello world" comes out as - // "helloworld". - gateway.commitText(if (state.keyboardMode == KeyboardMode.ENGLISH_26) "$committed " else committed) - gateway.finishComposing() + // Only an explicit Space or candidate pick separates English words. + // Implicit confirmation before punctuation must keep URLs/emails intact. + gateway.batchEdit { + gateway.commitText(if (state.keyboardMode == KeyboardMode.ENGLISH_26 && appendEnglishSpace) "$committed " else committed) + gateway.finishComposing() + } voiceCorrectionTracker.finalizeIfNeeded() rime.clear() lastComposition = "" @@ -2289,4 +2342,5 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } /** Punctuation that stays ASCII right after a digit (3.14, 12:30, 1,000). */ +private val HARDWARE_CHINESE_MODES = setOf(KeyboardMode.PINYIN_26, KeyboardMode.WUBI, KeyboardMode.DOUBLE_PINYIN) private val HARDWARE_ASCII_AFTER_DIGIT = setOf(','.code, '.'.code, ':'.code) diff --git a/app/src/main/java/llc/slacker/openime/core/CrashGuard.kt b/app/src/main/java/llc/slacker/openime/core/CrashGuard.kt index 1949e6a9..d73c20af 100644 --- a/app/src/main/java/llc/slacker/openime/core/CrashGuard.kt +++ b/app/src/main/java/llc/slacker/openime/core/CrashGuard.kt @@ -24,6 +24,7 @@ internal object CrashGuard { 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 KEY_LOCAL_JAVA_EXITS = "local_java_exits" private const val MAX_TIMES = 10 private const val MAX_FRAMES = 14 @@ -49,8 +50,11 @@ internal object CrashGuard { 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 + val localExits = prefs.getString(KEY_LOCAL_JAVA_EXITS, "").orEmpty() + .split(',').filter { it.isNotEmpty() } + "${android.os.Process.myPid()}:$now" prefs.edit() .putString(KEY_TIMES, formatTimes(times.takeLast(MAX_TIMES))) + .putString(KEY_LOCAL_JAVA_EXITS, localExits.takeLast(MAX_TIMES).joinToString(",")) .putString(KEY_LAST, report(context, "crash", threadName, throwable, now)) .commit() // the process is about to die: no async apply() } @@ -91,11 +95,30 @@ internal object CrashGuard { it.timestamp > seen && it.reason in countedExitReasons } if (bad.isEmpty()) return - val times = parseTimes(prefs.getString(KEY_TIMES, null)) + bad.map { it.timestamp } + // Match by process as well as time: two real crashes close together + // must still count separately. The Java handler already persisted + // the matching process's crash before Android recorded its exit. + val localExits = prefs.getString(KEY_LOCAL_JAVA_EXITS, "").orEmpty() + .split(',').mapNotNull { entry -> + val parts = entry.split(':') + val pid = parts.getOrNull(0)?.toIntOrNull() ?: return@mapNotNull null + val at = parts.getOrNull(1)?.toLongOrNull() ?: return@mapNotNull null + pid to at + }.toMutableList() + val uncounted = bad.sortedBy { it.timestamp }.filter { exit -> + val match = if (exit.reason == android.app.ApplicationExitInfo.REASON_CRASH) { + localExits.indexOfLast { (pid, at) -> pid == exit.pid && at <= exit.timestamp } + } else -1 + if (match >= 0) localExits.removeAt(match) + match < 0 + } + val times = parseTimes(prefs.getString(KEY_TIMES, null)) + uncounted.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_LOCAL_JAVA_EXITS, localExits.filter { it.second > newest.timestamp } + .joinToString(",") { "${it.first}:${it.second}" }) .putString(KEY_LAST, "${header(context, "previous process ended: ${exitReasonName(newest.reason)}", "process", newest.timestamp)}\n(reported by Android; no stack)") .commit() } diff --git a/app/src/main/java/llc/slacker/openime/editor/EnterActionPolicy.kt b/app/src/main/java/llc/slacker/openime/editor/EnterActionPolicy.kt index 5c68b206..4a12b907 100644 --- a/app/src/main/java/llc/slacker/openime/editor/EnterActionPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/editor/EnterActionPolicy.kt @@ -12,10 +12,13 @@ internal data class EnterKeyPresentation( * Resolve both the label and behavior from the same EditorInfo bits so the key * never promises “发送” while the service is actually going to emit raw Enter. */ -internal fun enterKeyPresentationFor(imeOptions: Int): EnterKeyPresentation { +internal fun enterKeyPresentationFor(imeOptions: Int, editorInfo: EditorInfo? = null): EnterKeyPresentation { if ((imeOptions and EditorInfo.IME_FLAG_NO_ENTER_ACTION) != 0) { return EnterKeyPresentation(label = "换行", editorAction = null) } + editorInfo?.actionLabel?.takeIf { it.isNotEmpty() }?.let { + return EnterKeyPresentation(it.toString(), editorInfo.actionId) + } return when (val action = imeOptions and EditorInfo.IME_MASK_ACTION) { EditorInfo.IME_ACTION_SEND -> EnterKeyPresentation("发送", action) EditorInfo.IME_ACTION_SEARCH -> EnterKeyPresentation("搜索", action) @@ -31,9 +34,9 @@ internal fun enterKeyPresentationFor(imeOptions: Int): EnterKeyPresentation { } /** The label drawn on the key: 确定 while composing, the action word when the editor asked for one, otherwise ↵. */ -internal fun enterKeyFaceFor(imeOptions: Int?, composing: Boolean): String { +internal fun enterKeyFaceFor(imeOptions: Int?, composing: Boolean, editorInfo: EditorInfo? = null): String { if (composing) return "确定" - val presentation = imeOptions?.let(::enterKeyPresentationFor) ?: return "↵" + val presentation = imeOptions?.let { enterKeyPresentationFor(it, editorInfo) } ?: return "↵" return if (presentation.editorAction != null) presentation.label else "↵" } @@ -41,6 +44,9 @@ internal fun enterKeyFaceFor(imeOptions: Int?, composing: Boolean): String { internal fun editorActionForEnter(imeOptions: Int): Int? = enterKeyPresentationFor(imeOptions).editorAction +internal fun editorActionForEnter(editorInfo: EditorInfo): Int? = + enterKeyPresentationFor(editorInfo.imeOptions, editorInfo).editorAction + /** What a long press on the Enter key does, when it does anything. */ internal enum class EnterKeyLongPress { Newline } @@ -52,8 +58,8 @@ internal enum class EnterKeyLongPress { Newline } * only that face takes one here; a composition reads 确定 on the key, not 发送, and * keeps its press-only behaviour. */ -internal fun enterKeyLongPressFor(imeOptions: Int, composing: Boolean): EnterKeyLongPress? { - if (composing) return null +internal fun enterKeyLongPressFor(imeOptions: Int, composing: Boolean, editorInfo: EditorInfo? = null): EnterKeyLongPress? { + if (composing || !editorInfo?.actionLabel.isNullOrEmpty()) return null return if (enterKeyPresentationFor(imeOptions).editorAction == EditorInfo.IME_ACTION_SEND) { EnterKeyLongPress.Newline } else { diff --git a/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt b/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt index 4492ba65..66692ba3 100644 --- a/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt +++ b/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt @@ -136,12 +136,23 @@ class InputConnectionGateway( @Volatile private var composingInEditor = false - /** A new editor (or a restart) starts without any span of ours. */ + /** A restarted InputConnection can finish the editor's old composing span. */ fun onEditorStarted() { composingInEditor = false ownEditPending = false } + /** Reclaim only verified text immediately before a collapsed cursor. */ + fun restoreComposition(text: String): Boolean { + if (text.isEmpty() || isPassword()) return false + val ic = connection() ?: return false + val end = knownSelectionEnd + if (knownSelectionStart != end || end < text.length) return false + if (runCatching { ic.getTextBeforeCursor(text.length, 0)?.toString() }.getOrNull() != text) return false + composingInEditor = runCatching { ic.setComposingRegion(end - text.length, end) }.getOrDefault(false) + return composingInEditor + } + /** * Run [block] as one atomic editor edit. The editor then reports a single * final selection/composing state; without it a commit followed by a new diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt index 7b0f698b..55edf0ab 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -1951,14 +1951,15 @@ open class ImeKeyboardView( val options = imeOptions ?: return val enter = findViewWithTag("key-enter") ?: return val composing = composition.text?.isNotEmpty() == true - val longPress = enterKeyLongPressFor(options, composing) - enter.setMainText(enterKeyFaceFor(options, composing)) + val info = currentEditorInfo() + val longPress = enterKeyLongPressFor(options, composing, info) + enter.setMainText(enterKeyFaceFor(options, composing, info)) enter.applyMainTextScale(referenceScale) enter.contentDescription = when { composing -> "确定" // 长按按参考自己的说法换行,跟着键面一起念出来(同「,上滑或长按输入X」的写法)。 - longPress == EnterKeyLongPress.Newline -> "${enterKeyPresentationFor(options).label},长按换行" - else -> enterKeyPresentationFor(options).label + longPress == EnterKeyLongPress.Newline -> "${enterKeyPresentationFor(options, info).label},长按换行" + else -> enterKeyPresentationFor(options, info).label } themeApplier.apply(enter, currentThemeTokens()) enter.setOnLongClickListener( @@ -2612,7 +2613,7 @@ open class ImeKeyboardView( /** One rule for every keyboard: 确定 composing, the action word for a real action, otherwise ↵. */ private fun enterKeyLabel(): String { - return enterKeyFaceFor(currentImeOptions(), composition.text?.isNotEmpty() == true) + return enterKeyFaceFor(currentImeOptions(), composition.text?.isNotEmpty() == true, currentEditorInfo()) } private fun renderPinyin9() { diff --git a/app/src/main/java/llc/slacker/openime/keyboard/SmsCodeExtractor.kt b/app/src/main/java/llc/slacker/openime/keyboard/SmsCodeExtractor.kt index 548f9e07..7efdff75 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/SmsCodeExtractor.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/SmsCodeExtractor.kt @@ -3,8 +3,9 @@ package llc.slacker.openime.keyboard /** Local, conservative extraction: a code must be directly attached to a verification label. */ internal object SmsCodeExtractor { private val KEYWORDS = Regex( - "验证码|校验码|动态码|确认码|安全码|验证代码|驗證碼|驗證代碼|動態密碼|动态密码|随机码|登录码|登入碼|授权码|" + - "\\b(?:(?:verification|authentication|security|login|sign[ -]?in|confirmation)\\s+code|" + + "验证码|校验码|校驗碼|动态码|動態碼|确认码|確認碼|安全码|安全碼|验证代码|驗證碼|驗證代碼|" + + "動態密碼|动态密码|一次性密码|一次性密碼|短信口令|简讯口令|簡訊口令|随机码|隨機碼|登录码|登錄碼|登入碼|授权码|授權碼|" + + "\\b(?:(?:verification|authentication|security|login|sign[ -]?in|confirmation|2fa|mfa)\\s+code|" + "one[ -]time\\s+(?:code|password|passcode)|otp|passcode|code)\\b", RegexOption.IGNORE_CASE, ) @@ -41,11 +42,12 @@ internal object SmsCodeExtractor { ) fun extract(body: String): String? { + if (body.length > 16_384) return null // SMS senders also use full-width digits/letters and punctuation. Keep letter case. val text = body.map { char -> when (char) { in '!'..'~' -> (char.code - 0xFEE0).toChar() - ' ' -> ' ' + ' ', '\u00a0', '\u202f' -> ' ' else -> char } }.joinToString("") @@ -61,12 +63,14 @@ internal object SmsCodeExtractor { val attached = labels.any { label -> when { label.range.last < token.range.first -> { - val gap = text.substring(label.range.last + 1, token.range.first) - gap.length <= 32 && AFTER_LABEL.matches(gap) + val start = label.range.last + 1 + token.range.first - start <= 32 && + AFTER_LABEL.matches(text.substring(start, token.range.first)) } token.range.last < label.range.first -> { - val gap = text.substring(token.range.last + 1, label.range.first) - gap.length <= 32 && BEFORE_LABEL.matches(gap) + val start = token.range.last + 1 + label.range.first - start <= 32 && + BEFORE_LABEL.matches(text.substring(start, label.range.first)) } else -> false } diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ToolbarSuggestions.kt b/app/src/main/java/llc/slacker/openime/keyboard/ToolbarSuggestions.kt index 5fb49954..2acba94f 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ToolbarSuggestions.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ToolbarSuggestions.kt @@ -31,17 +31,18 @@ internal object ToolbarSuggestions { private fun smsCode(context: Context): ToolbarSuggestion? { if (context.checkSelfPermission(Manifest.permission.READ_SMS) != PackageManager.PERMISSION_GRANTED) return null return runCatching { - val since = System.currentTimeMillis() - SMS_WINDOW_MS + val now = System.currentTimeMillis() + val since = now - SMS_WINDOW_MS context.contentResolver.query( Uri.parse("content://sms/inbox"), arrayOf("_id", "date", "body"), - "date > ?", - arrayOf(since.toString()), + "date > ? AND date <= ?", + arrayOf(since.toString(), now.toString()), "date DESC", )?.use { cursor -> var found: ToolbarSuggestion? = null var seen = 0 - while (found == null && seen < 5 && cursor.moveToNext()) { + while (found == null && seen < 50 && cursor.moveToNext()) { found = SmsCodeExtractor.extract(cursor.getString(2).orEmpty())?.let { code -> ToolbarSuggestion(ToolbarSuggestion.Kind.SMS_CODE, "验证码 $code", code, "sms:${cursor.getLong(0)}:${cursor.getLong(1)}") @@ -61,9 +62,15 @@ internal object ToolbarSuggestions { } ?: false if (sensitive) return null val stamp = clip.description.timestamp - if (stamp <= 0L || System.currentTimeMillis() - stamp > CLIP_WINDOW_MS) return null + val now = System.currentTimeMillis() + if (stamp <= 0L || stamp > now || now - stamp >= CLIP_WINDOW_MS) return null val text = runCatching { - clip.takeIf { it.itemCount > 0 }?.getItemAt(0)?.coerceToText(context)?.toString().orEmpty() + // coerceToText can read a remote provider. Plain links need no I/O; + // file contents belong to the clipboard panel. + val item = clip.takeIf { it.itemCount > 0 }?.getItemAt(0) + item?.text?.toString() ?: item?.uri?.takeIf { + it.scheme?.lowercase() in setOf("http", "https", "mailto") + }?.toString().orEmpty() }.getOrDefault("") return text.takeIf { it.isNotBlank() }?.let { val preview = it.take(CLIP_PREVIEW_CHARS).trimEnd { char -> char.isHighSurrogate() } diff --git a/app/src/main/java/llc/slacker/openime/voice/LocalAudioVoiceBackend.kt b/app/src/main/java/llc/slacker/openime/voice/LocalAudioVoiceBackend.kt index 626849c0..2df5c35f 100644 --- a/app/src/main/java/llc/slacker/openime/voice/LocalAudioVoiceBackend.kt +++ b/app/src/main/java/llc/slacker/openime/voice/LocalAudioVoiceBackend.kt @@ -249,9 +249,8 @@ class LocalAudioVoiceBackend( } // 使用耳机麦克风录音: read per session, so a change takes effect at once. - val routeSession = routeManager.beginSession( - preferHeadsetMic = ImeSettingsRepository.loadBluetoothHeadsetMic(context), - ) + val preferHeadsetMic = ImeSettingsRepository.loadBluetoothHeadsetMic(context) + val routeSession = routeManager.beginSession(preferHeadsetMic) val record = runCatching { AudioRecord.Builder() .setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION) @@ -334,10 +333,12 @@ class LocalAudioVoiceBackend( // Capture first. The expensive first recognizer mapping happens // below while PCM accumulates in the ring, so no spoken prefix is // lost and the waveform can follow the finger immediately. + routeManager.preferInput(record, preferHeadsetMic) record.startRecording() if (record.recordingState != AudioRecord.RECORDSTATE_RECORDING) { throw IllegalStateException("AudioRecord 未进入录音状态") } + routeManager.logRecordingRoute(record) VoicePerformanceTrace.markAudioRecordStart(session.traceToken) val canRun = synchronized(lock) { if (startGeneration.get() != generation || this.session !== session) { diff --git a/app/src/main/java/llc/slacker/openime/voice/VoiceAudioRouteManager.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceAudioRouteManager.kt index 69e89220..b0a2e43e 100644 --- a/app/src/main/java/llc/slacker/openime/voice/VoiceAudioRouteManager.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceAudioRouteManager.kt @@ -4,6 +4,7 @@ import android.annotation.TargetApi import android.content.Context import android.media.AudioDeviceInfo import android.media.AudioManager +import android.media.AudioRecord import android.os.Build import android.util.Log import java.util.concurrent.atomic.AtomicBoolean @@ -56,6 +57,20 @@ class VoiceAudioRouteManager(context: Context) { .getSystemService(Context.AUDIO_SERVICE) as AudioManager private val ownership = VoiceRouteOwnership() + /** Record-level input selection also works on Android 8–11. */ + fun preferInput(record: AudioRecord, preferHeadsetMic: Boolean) { + val inputs = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS).filter { it.isSource } + val type = VoiceInputRoutePolicy.preferredInputType(inputs.map { it.type }, preferHeadsetMic) + ?: return + val device = inputs.first { it.type == type } + // A refused preference leaves the system default route, which still records. + if (!record.setPreferredDevice(device)) Log.w(TAG, "preferInput refused type=$type") + } + + fun logRecordingRoute(record: AudioRecord) { + Log.i(TAG, "recordingRoute requestedType=${record.preferredDevice?.type ?: 0} actualType=${record.routedDevice?.type ?: 0}") + } + fun beginSession(preferHeadsetMic: Boolean = true): Session { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { // VOICE_RECOGNITION follows the system-selected wired/SCO route on @@ -85,7 +100,8 @@ class VoiceAudioRouteManager(context: Context) { val previousMode = audioManager.mode val previousDevice = runCatching { audioManager.communicationDevice }.getOrNull() val previousType = previousDevice?.type - val devices = runCatching { audioManager.availableCommunicationDevices }.getOrDefault(emptyList()) + val devices = runCatching { audioManager.availableCommunicationDevices.filter { it.isSink } } + .getOrDefault(emptyList()) val preferred = VoiceInputRoutePolicy .preferredType(devices.map { it.type }, previousType, preferHeadsetMic) ?.let { type -> devices.firstOrNull { it.type == type } } diff --git a/app/src/main/java/llc/slacker/openime/voice/VoiceInputRoutePolicy.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceInputRoutePolicy.kt index 6a5a1c34..3fa29422 100644 --- a/app/src/main/java/llc/slacker/openime/voice/VoiceInputRoutePolicy.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceInputRoutePolicy.kt @@ -23,9 +23,9 @@ internal object VoiceInputRoutePolicy { ) /** - * The device type this session should record from, or null to leave the - * route as the system has it. [available] holds the types the platform - * offers for communication, [current] the type in use right now. + * Phone/headset type among the supplied devices, or null to keep the route. + * A source list has BUILTIN_MIC; a communication sink list instead has + * an earpiece/speaker. Never require a microphone in a sink-only list. * * With the switch on, the best available headset wins. With it off the * phone's own microphone wins, but only when something else had routed @@ -36,6 +36,14 @@ internal object VoiceInputRoutePolicy { return HEADSET_TYPES.firstOrNull { it in available } } if (current == null || current !in HEADSET_TYPES) return null + return listOf(AudioDeviceInfo.TYPE_BUILTIN_MIC, + AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) + .firstOrNull { it in available } + } + + /** AudioRecord takes source devices, unlike communication-device routing. */ + fun preferredInputType(available: List, preferHeadsetMic: Boolean): Int? { + if (preferHeadsetMic) return HEADSET_TYPES.firstOrNull { it in available } return AudioDeviceInfo.TYPE_BUILTIN_MIC.takeIf { it in available } } } diff --git a/app/src/main/java/llc/slacker/openime/voice/VoiceModelArchive.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceModelArchive.kt index b716f393..677778b6 100644 --- a/app/src/main/java/llc/slacker/openime/voice/VoiceModelArchive.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceModelArchive.kt @@ -4,6 +4,7 @@ import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream import java.io.BufferedInputStream import java.io.File import java.io.InputStream +import java.io.EOFException import java.util.zip.GZIPInputStream import java.util.zip.ZipInputStream @@ -93,8 +94,10 @@ internal object VoiceModelArchive { private fun extractZip(raw: InputStream, root: File, maxBytes: Long): Long { var total = 0L ZipInputStream(BufferedInputStream(raw)).use { zip -> + var entries = 0 while (true) { val entry = zip.nextEntry ?: break + require(++entries <= MAX_ENTRIES) { "模型包文件数量过多" } val target = targetFor(root, entry.name) if (entry.isDirectory) { target.mkdirs() @@ -113,24 +116,27 @@ internal object VoiceModelArchive { var total = 0L var pendingName: String? = null val header = ByteArray(TAR_HEADER) + var entries = 0 while (true) { if (!readFully(input, header)) break if (header.all { it.toInt() == 0 }) break + require(++entries <= MAX_ENTRIES) { "模型包文件数量过多" } var name = readName(header) val size = readOctal(header, SIZE_OFFSET, SIZE_LENGTH) + require(size <= maxBytes - total) { "模型包解压大小超过上限" } val type = header[TYPE_OFFSET].toInt().toChar() when (type) { 'L' -> { // GNU long name: the payload is the next entry's name. - val longName = ByteArray(size.toInt().coerceAtLeast(0)) - readFully(input, longName) + val longName = readMetadata(input, size) + total += size skipPadding(input, size) pendingName = longName.toString(Charsets.UTF_8).trimEnd('\u0000') continue } 'x', 'g' -> { - val data = ByteArray(size.toInt().coerceAtLeast(0)) - readFully(input, data) + val data = readMetadata(input, size) + total += size skipPadding(input, size) parsePaxPath(data)?.let { pendingName = it } continue @@ -138,6 +144,7 @@ internal object VoiceModelArchive { '0', '\u0000', '5' -> Unit else -> { skipFully(input, size) + total += size skipPadding(input, size) pendingName = null continue @@ -151,6 +158,7 @@ internal object VoiceModelArchive { if (type == '5') { target.mkdirs() skipFully(input, size) + total += size } else { target.parentFile?.mkdirs() total += copyBounded(input, target, maxBytes, total, size) @@ -172,21 +180,47 @@ internal object VoiceModelArchive { require(already + written <= maxBytes) { "模型包解压后超过 ${maxBytes / 1_048_576} MB 上限" } out.write(buffer, 0, n) } + if (limit != Long.MAX_VALUE && written != limit) throw EOFException("模型包文件内容不完整") } return written } private fun parsePaxPath(data: ByteArray): String? { - val text = data.toString(Charsets.UTF_8) - val line = text.lineSequence().firstOrNull { it.startsWith("path=") } ?: return null - return line.removePrefix("path=").trimEnd('\n') + var offset = 0 + var path: String? = null + while (offset < data.size) { + var space = offset + while (space < data.size && data[space] != ' '.code.toByte()) space++ + require(space < data.size) { "PAX 记录长度无效" } + val length = String(data, offset, space - offset, Charsets.US_ASCII).toIntOrNull() + ?: error("PAX 记录长度无效") + require(length > space - offset + 1 && length <= data.size - offset) { "PAX 记录越界" } + val end = offset + length + require(data[end - 1] == '\n'.code.toByte()) { "PAX 记录不完整" } + val record = String(data, space + 1, end - space - 2, Charsets.UTF_8) + if (record.startsWith("path=")) path = record.removePrefix("path=") + offset = end + } + return path + } + + private fun readMetadata(input: InputStream, size: Long): ByteArray { + // Bound before converting to Int or allocating: file data is streamed, + // but GNU/PAX headers previously bypassed the extraction budget. + require(size in 1..MAX_METADATA_BYTES) { "模型包扩展头大小无效" } + val data = ByteArray(size.toInt()) + if (!readFully(input, data)) throw EOFException("模型包扩展头不完整") + return data } private fun readFully(input: InputStream, buffer: ByteArray): Boolean { var offset = 0 while (offset < buffer.size) { val n = input.read(buffer, offset, buffer.size - offset) - if (n < 0) return offset == 0 + if (n < 0) { + if (offset == 0) return false + throw EOFException("模型包文件头不完整") + } offset += n } return true @@ -197,7 +231,7 @@ internal object VoiceModelArchive { while (remaining > 0) { val skipped = input.skip(remaining) if (skipped <= 0) { - if (input.read() < 0) return + if (input.read() < 0) throw EOFException("模型包内容不完整") remaining-- } else { remaining -= skipped @@ -228,12 +262,15 @@ internal object VoiceModelArchive { val byte = bytes[index].toInt() and 0xFF if (byte == 0 || byte == ' '.code) continue val digit = byte - '0'.code - if (digit in 0..7) value = value * 8 + digit + require(digit in 0..7 && value <= (Long.MAX_VALUE - digit) / 8) { "TAR 文件大小无效" } + value = value * 8 + digit } return value } private const val TAR_BLOCK = 512 + private const val MAX_METADATA_BYTES = 1_048_576L + private const val MAX_ENTRIES = 10_000 private const val TAR_HEADER = 512 private const val NAME_OFFSET = 0 private const val NAME_LENGTH = 100 diff --git a/app/src/main/java/llc/slacker/openime/voice/VoiceModelLifecycleManager.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceModelLifecycleManager.kt index ebaf5c37..25f99289 100644 --- a/app/src/main/java/llc/slacker/openime/voice/VoiceModelLifecycleManager.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceModelLifecycleManager.kt @@ -304,6 +304,9 @@ class VoiceModelLifecycleManager( if (!accepted) { created.release() } else { + // The hidden-view timer may have fired while preload was + // still running and there was no runtime to release yet. + scheduleUnloadIfHidden() Log.i( TAG, "preloadComplete verifyMs=$verifyMs modelCreateMs=$modelCreateMs " + diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 80565a1c..8df166e2 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -67,7 +67,10 @@ In 26-key Chinese mode: A capital letter first ends the current composition. openIME does not handle the physical keyboard in English, nine-key and digit modes, in password fields and in `TYPE_NULL` editors. -The keyboard panel must be visible, because the candidates appear on the panel. +The candidates appear on the keyboard panel. +When the user hides the panel, a letter key still starts a Chinese composition. +openIME then writes the pinyin into the field, whatever the pinyin location setting says. +Space picks the first candidate and keys 1 to 9 pick by number, but the candidate list is not visible. Verification: `HardwareKeyPolicyTest`, `core_regression.sh` cases 040 to 043. @@ -140,5 +143,5 @@ It never starts an emulator or selects a connected phone. ## Not covered - A physical keyboard in nine-key mode (letters go to the app). -- Candidates when the user hides the keyboard panel on a physical keyboard. This needs a separate candidate window. +- A candidate list while the keyboard panel is hidden on a physical keyboard. Android 11 and later do not let an input method show a candidates-only window without a show request. - Vendor differences on real devices (Xiaomi, OPPO, Samsung). We have results only from emulators and CI emulators.