From 69431d43ac4a5ea8fdaaa30a9c85b264e1d3982f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Oct 2026 11:14:20 +0000 Subject: [PATCH] =?UTF-8?q?fix(android):=20=E5=90=AC=E5=86=99=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=A4=B1=E8=B4=A5=E5=90=8E=E4=B8=8D=E5=86=8D=E8=AF=AF?= =?UTF-8?q?=E5=88=A0=E4=B8=8B=E4=B8=80=E6=AC=A1=E4=B8=8A=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 编辑面板关闭后若识别失败,awaitingEditReplacement 仍会把下一次普通听写送进按长度删除的整段替换。失败和主麦克风上的新录音都会释放这个状态。 Co-authored-by: lubaiqing9 --- .../app/android/kotlin/OpenLessImeService.kt | 36 +++++++++++++++- .../android/kotlin/PendingEditReplacement.kt | 26 ++++++++++++ .../kotlin/test/PendingEditReplacementTest.kt | 42 +++++++++++++++++++ .../app/scripts/copy-android-scaffolding.mjs | 2 + 4 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 openless-all/app/android/kotlin/PendingEditReplacement.kt create mode 100644 openless-all/app/android/kotlin/test/PendingEditReplacementTest.kt diff --git a/openless-all/app/android/kotlin/OpenLessImeService.kt b/openless-all/app/android/kotlin/OpenLessImeService.kt index be1543462..724c66ecf 100644 --- a/openless-all/app/android/kotlin/OpenLessImeService.kt +++ b/openless-all/app/android/kotlin/OpenLessImeService.kt @@ -83,7 +83,11 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt private fun stopImeSession() { if (imeSessionId == null) pendingImeStop = true - else if (!sendImeCommand("stop")) { recording = false; processing = false } + else if (!sendImeCommand("stop")) { + recording = false + processing = false + releaseDismissedEditReplacement() + } } private fun onImeSessionEvent(json: String) { @@ -118,6 +122,10 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt imeSessionId = null pendingImeStop = false pendingCloudArm = false + // The edit panel was already dismissed when recording stopped. + // Leaving the replacement armed makes the next successful + // dictation delete the old span by length. + releaseDismissedEditReplacement() setState("error", ui("听写失败,请重试", "Dictation failed; please retry")) } } @@ -311,6 +319,9 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt // polish animation plays there, not on the compact edit view), while // this flag keeps commitImeText() routing the eventual result to // finishEditWithSpokenReplacement() instead of a normal commit. + // A failed session, or a later tap on the main mic, must release it — + // otherwise that next utterance is still treated as the correction and + // deletes original.length characters before the cursor. private var awaitingEditReplacement = false // The exact span being replaced by the edit flow's "speak the correct // word" mic: either whatever the user selected in the real input field, @@ -3511,6 +3522,10 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt rawModeArmed = false quickNoteArmed = false cloudNoteArmed = false + // A tap on the main mic (edit panel already gone) is a new + // utterance. Drop a correction that failed or was superseded + // before its result arrived, so this session cannot inherit it. + releaseDismissedEditReplacement() // The actual start of a new recording attempt — reset the // silence watch and fire the start haptic here, not in // onCapsuleStateChanged's "recording" branch: that branch only @@ -4026,6 +4041,25 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt toggleDictation() } + /** + * Drops a spoken correction that is no longer on screen. The in-flight + * result of an open edit panel must keep its replacement; once that panel + * is gone, the next commit is an ordinary insert. + */ + private fun releaseDismissedEditReplacement() { + val next = PendingEditReplacement( + awaiting = awaitingEditReplacement, + panelVisible = editingDictationResult, + original = editingOriginalText, + replacesWhole = editingReplacesWholeResult, + addToDictionary = addToDictionaryForEdit, + ).releaseIfPanelDismissed() + awaitingEditReplacement = next.awaiting + editingOriginalText = next.original + editingReplacesWholeResult = next.replacesWhole + addToDictionaryForEdit = next.addToDictionary + } + /** Backs out of the edit sub-view without applying any correction. */ private fun closeEditDictationResult() { if (recording) { diff --git a/openless-all/app/android/kotlin/PendingEditReplacement.kt b/openless-all/app/android/kotlin/PendingEditReplacement.kt new file mode 100644 index 000000000..d3066cead --- /dev/null +++ b/openless-all/app/android/kotlin/PendingEditReplacement.kt @@ -0,0 +1,26 @@ +package com.openless.app + +/** + * Spoken edit replacement stays armed after the edit panel closes so the + * in-flight result can still replace the original span. That arming must not + * survive a failed session or a later tap on the main mic: both look like a + * fresh dictation, and [OpenLessImeService] would otherwise delete by the old + * span's length. + */ +internal data class PendingEditReplacement( + val awaiting: Boolean, + val panelVisible: Boolean, + val original: String?, + val replacesWhole: Boolean, + val addToDictionary: Boolean, +) { + fun releaseIfPanelDismissed(): PendingEditReplacement { + if (!awaiting || panelVisible) return this + return copy( + awaiting = false, + original = null, + replacesWhole = false, + addToDictionary = false, + ) + } +} diff --git a/openless-all/app/android/kotlin/test/PendingEditReplacementTest.kt b/openless-all/app/android/kotlin/test/PendingEditReplacementTest.kt new file mode 100644 index 000000000..e75b8cb06 --- /dev/null +++ b/openless-all/app/android/kotlin/test/PendingEditReplacementTest.kt @@ -0,0 +1,42 @@ +package com.openless.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test + +class PendingEditReplacementTest { + private fun armed(panelVisible: Boolean) = PendingEditReplacement( + awaiting = true, + panelVisible = panelVisible, + original = "hello world", + replacesWhole = true, + addToDictionary = true, + ) + + @Test + fun failureAfterTheEditPanelClosesDropsThePendingReplacement() { + val released = armed(panelVisible = false).releaseIfPanelDismissed() + assertFalse(released.awaiting) + assertNull(released.original) + assertFalse(released.replacesWhole) + assertFalse(released.addToDictionary) + } + + @Test + fun failureWhileTheEditPanelIsOpenKeepsTheRetryArmed() { + assertEquals(armed(panelVisible = true), armed(panelVisible = true).releaseIfPanelDismissed()) + } + + @Test + fun idleDictationIsUnchanged() { + val idle = PendingEditReplacement( + awaiting = false, + panelVisible = false, + original = null, + replacesWhole = false, + addToDictionary = false, + ) + assertEquals(idle, idle.releaseIfPanelDismissed()) + } +} diff --git a/openless-all/app/scripts/copy-android-scaffolding.mjs b/openless-all/app/scripts/copy-android-scaffolding.mjs index 1378c307d..468aaa12e 100644 --- a/openless-all/app/scripts/copy-android-scaffolding.mjs +++ b/openless-all/app/scripts/copy-android-scaffolding.mjs @@ -59,6 +59,7 @@ const KOTLIN_FILES = [ 'OpenLessContentReader.kt', 'OpenLessContentWriter.kt', 'OpenLessImeService.kt', + 'PendingEditReplacement.kt', 'ImeLearningPolicy.kt', 'StrokeInputController.kt', 'StrokeInput.kt', @@ -81,6 +82,7 @@ const KOTLIN_FILES = [ const KOTLIN_TEST_FILES = [ 'ImeLearningPolicyTest.kt', + 'PendingEditReplacementTest.kt', 'OpenLessContentReaderTest.kt', 'OpenLessCredentialCipherTest.kt', 'OpenLessShizukuBridgeTest.kt',