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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

- 更新离线拼音词库,补充字词与读音。
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.1.0
1.2.0
116 changes: 85 additions & 31 deletions app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -650,6 +659,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can

override fun onFinishInput() {
inlineSuggestions = emptyList()
hardwareTypingWhileHidden = false
invalidateCandidateQueries()
voiceCorrectionTracker.finalizeIfNeeded()
voiceMediaMute.restore()
Expand Down Expand Up @@ -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.
Expand All @@ -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()
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand All @@ -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") {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<String>) {
lastComposition = next
state = state.copy(composition = next, candidates = candidates)
Expand Down Expand Up @@ -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),
)
Expand All @@ -1818,7 +1863,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
*/
private fun commitPendingComposition() {
if (lastComposition.isEmpty()) return
commitFirstCandidate()
commitFirstCandidate(appendEnglishSpace = false)
}

private fun fallbackCandidatesFor(
Expand Down Expand Up @@ -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
Expand All @@ -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. */
Expand All @@ -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 = ""
Expand All @@ -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) {
Expand All @@ -2071,7 +2123,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
} else {
""
}
finishCandidateCommit(composition, nativeCommit.ifBlank { entry.text })
finishCandidateCommit(composition, nativeCommit.ifBlank { entry.text }, appendEnglishSpace)
}

/**
Expand Down Expand Up @@ -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
Expand All @@ -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 = ""
Expand Down Expand Up @@ -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)
25 changes: 24 additions & 1 deletion app/src/main/java/llc/slacker/openime/core/CrashGuard.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
}
Expand Down Expand Up @@ -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()
}
Expand Down
Loading
Loading