From 61224530742205880d84c6de0b0d7fcc8f407a1b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 11:11:07 +0000 Subject: [PATCH] =?UTF-8?q?fix(android):=20=E4=BA=91=E7=AC=94=E8=AE=B0?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E5=A4=B1=E8=B4=A5=E6=97=B6=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=8F=A3=E8=BF=B0=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core 在 webhook 返回前就删除云笔记的历史和录音。地址未配置、网络失败或 HTTP 错误时,识别文本只存在于完成事件里,原来的错误提示会把它丢掉。失败时改写入键盘剪贴板,成功提交仍不留本地副本。 Co-authored-by: lubaiqing9 --- .../app/android/kotlin/CloudNoteDelivery.kt | 39 +++++++++++++++ .../app/android/kotlin/OpenLessImeService.kt | 46 ++++++++++++++++-- .../kotlin/test/CloudNoteDeliveryTest.kt | 39 +++++++++++++++ ...roid-cloud-note-delivery-contract.test.mjs | 47 +++++++++++++++++++ .../app/scripts/copy-android-scaffolding.mjs | 2 + 5 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 openless-all/app/android/kotlin/CloudNoteDelivery.kt create mode 100644 openless-all/app/android/kotlin/test/CloudNoteDeliveryTest.kt create mode 100644 openless-all/app/scripts/android-cloud-note-delivery-contract.test.mjs diff --git a/openless-all/app/android/kotlin/CloudNoteDelivery.kt b/openless-all/app/android/kotlin/CloudNoteDelivery.kt new file mode 100644 index 000000000..750321030 --- /dev/null +++ b/openless-all/app/android/kotlin/CloudNoteDelivery.kt @@ -0,0 +1,39 @@ +package com.openless.app + +/** + * What to do with a cloud-note transcript the webhook did not accept. + * + * The backend deletes the recording and history before the IME learns whether + * the webhook succeeded. A failed submit therefore has to keep the text the + * completion event already delivered, or the dictation is gone. + */ +internal enum class CloudNoteFailure { + MissingDestination, + Http, + Network, +} + +internal fun cloudNoteShouldRetain(text: String): Boolean = text.isNotBlank() + +internal fun cloudNoteFailureDetail(failure: CloudNoteFailure, httpCode: Int = 0): Pair { + return when (failure) { + CloudNoteFailure.MissingDestination -> + "请先在设置中填写云笔记的地址和 Token" to "Add the cloud note URL and token in Settings" + CloudNoteFailure.Http -> + "云笔记提交失败($httpCode)" to "Cloud note submit failed ($httpCode)" + CloudNoteFailure.Network -> + "云笔记提交失败,请检查网络" to "Cloud note submit failed. Check the network" + } +} + +internal fun cloudNoteFailureMessage( + detail: Pair, + retained: Boolean, +): Pair { + val (zh, en) = detail + return if (retained) { + "$zh,内容已保存在键盘剪贴板" to "$en. The text was saved in the keyboard clipboard." + } else { + zh to en + } +} diff --git a/openless-all/app/android/kotlin/OpenLessImeService.kt b/openless-all/app/android/kotlin/OpenLessImeService.kt index be1543462..222cf5502 100644 --- a/openless-all/app/android/kotlin/OpenLessImeService.kt +++ b/openless-all/app/android/kotlin/OpenLessImeService.kt @@ -3844,7 +3844,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt recording = false processing = false if (url.isEmpty() || token.isEmpty()) { - setState("error", "请先在设置中填写云笔记的地址/Token") + reportCloudNoteFailure(requestId, text, CloudNoteFailure.MissingDestination) return } setState("thinking", "正在提交云笔记") @@ -3867,22 +3867,60 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } val code = connection.responseCode mainHandler.post { - if (requestId != imeRequestId) return@post if (code in 200..299) { + if (requestId != imeRequestId) return@post setState("done", "已提交云笔记", QUICK_NOTE_CONFIRMATION_DELAY_MS) } else { - setState("error", "云笔记提交失败($code)") + reportCloudNoteFailure(requestId, text, CloudNoteFailure.Http, code) } } } catch (error: Throwable) { android.util.Log.w("OpenLessImeService", "cloud note webhook submit failed: ${error.javaClass.simpleName}") mainHandler.post { - if (requestId == imeRequestId) setState("error", "云笔记提交失败,请检查网络") + reportCloudNoteFailure(requestId, text, CloudNoteFailure.Network) } } finally { connection?.disconnect() } }.start() } + /** + * Keeps a transcript the webhook did not accept. Core already removed the + * recording and history, so the completion text is the only remaining copy. + * A newer recording may own the status line; the clipboard entry still has + * to be written, and a toast reports it without touching that session. + */ + private fun reportCloudNoteFailure( + requestId: Long, + text: String, + failure: CloudNoteFailure, + httpCode: Int = 0, + ) { + val retained = if (cloudNoteShouldRetain(text)) { + try { + OpenLessClipboardHistory.recordCopy(this, text) + true + } catch (error: Throwable) { + android.util.Log.w( + "OpenLessImeService", + "cloud note clipboard retain failed: ${error.javaClass.simpleName}", + ) + false + } + } else { + false + } + val (messageZh, messageEn) = cloudNoteFailureMessage( + cloudNoteFailureDetail(failure, httpCode), + retained, + ) + val message = ui(messageZh, messageEn) + if (requestId == imeRequestId) { + setState("error", message) + } else { + Toast.makeText(this, message, Toast.LENGTH_LONG).show() + } + } + private fun commitImeText(text: String) { if (awaitingEditReplacement) { finishEditWithSpokenReplacement(text) diff --git a/openless-all/app/android/kotlin/test/CloudNoteDeliveryTest.kt b/openless-all/app/android/kotlin/test/CloudNoteDeliveryTest.kt new file mode 100644 index 000000000..d5c00fc07 --- /dev/null +++ b/openless-all/app/android/kotlin/test/CloudNoteDeliveryTest.kt @@ -0,0 +1,39 @@ +package com.openless.app +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CloudNoteDeliveryTest { + @Test fun blankTranscriptIsNotRetained() { + assertFalse(cloudNoteShouldRetain("")) + assertFalse(cloudNoteShouldRetain(" ")) + } + + @Test fun failedSubmitKeepsTheTranscriptInTheMessage() { + assertTrue(cloudNoteShouldRetain("明天开会")) + val message = cloudNoteFailureMessage( + cloudNoteFailureDetail(CloudNoteFailure.Http, 500), + retained = true, + ) + assertEquals("云笔记提交失败(500),内容已保存在键盘剪贴板", message.first) + assertEquals( + "Cloud note submit failed (500). The text was saved in the keyboard clipboard.", + message.second, + ) + } + + @Test fun missingDestinationAndNetworkFailuresAlsoSayTheTextWasKept() { + val missing = cloudNoteFailureMessage( + cloudNoteFailureDetail(CloudNoteFailure.MissingDestination), + retained = true, + ) + assertEquals("请先在设置中填写云笔记的地址和 Token,内容已保存在键盘剪贴板", missing.first) + val network = cloudNoteFailureMessage( + cloudNoteFailureDetail(CloudNoteFailure.Network), + retained = false, + ) + assertEquals("云笔记提交失败,请检查网络", network.first) + assertFalse(network.second.contains("clipboard")) + } +} diff --git a/openless-all/app/scripts/android-cloud-note-delivery-contract.test.mjs b/openless-all/app/scripts/android-cloud-note-delivery-contract.test.mjs new file mode 100644 index 000000000..33e917926 --- /dev/null +++ b/openless-all/app/scripts/android-cloud-note-delivery-contract.test.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const servicePath = fileURLToPath( + new URL('../android/kotlin/OpenLessImeService.kt', import.meta.url), +); +const copyScriptPath = fileURLToPath(new URL('./copy-android-scaffolding.mjs', import.meta.url)); +const service = readFileSync(servicePath, 'utf8'); +const copyScript = readFileSync(copyScriptPath, 'utf8'); + +function functionBody(source, signature) { + const signatureIndex = source.indexOf(signature); + assert.notEqual(signatureIndex, -1, `missing function: ${signature}`); + const openBrace = source.indexOf('{', signatureIndex); + assert.notEqual(openBrace, -1, `missing opening brace: ${signature}`); + let depth = 0; + for (let index = openBrace; index < source.length; index += 1) { + if (source[index] === '{') depth += 1; + if (source[index] === '}') depth -= 1; + if (depth === 0) return source.slice(openBrace + 1, index); + } + assert.fail(`missing closing brace: ${signature}`); +} + +const submit = functionBody(service, 'private fun submitCloudNoteText'); +const report = functionBody(service, 'private fun reportCloudNoteFailure'); +const successStart = submit.indexOf('if (code in 200..299)'); +const successElse = submit.indexOf('} else {', successStart); +assert.ok(successStart !== -1 && successElse > successStart, 'missing webhook success branch'); +const successBranch = submit.slice(successStart, successElse); + +assert.match(submit, /CloudNoteFailure\.MissingDestination/); +assert.match(submit, /CloudNoteFailure\.Http/); +assert.match(submit, /CloudNoteFailure\.Network/); +assert.match(report, /OpenLessClipboardHistory\.recordCopy/); +assert.match(report, /cloudNoteShouldRetain\(text\)/); +assert.doesNotMatch( + successBranch, + /recordCopy|reportCloudNoteFailure|cloudNoteShouldRetain/, + 'a successful webhook submit must not keep a local copy', +); +assert.match(copyScript, /'CloudNoteDelivery\.kt'/); +assert.match(copyScript, /'CloudNoteDeliveryTest\.kt'/); + +console.log('android cloud note delivery contract passed'); diff --git a/openless-all/app/scripts/copy-android-scaffolding.mjs b/openless-all/app/scripts/copy-android-scaffolding.mjs index 1378c307d..0e5f121c4 100644 --- a/openless-all/app/scripts/copy-android-scaffolding.mjs +++ b/openless-all/app/scripts/copy-android-scaffolding.mjs @@ -74,6 +74,7 @@ const KOTLIN_FILES = [ 'OpenLessRuntimeService.kt', 'OpenLessBackendWarmupActivity.kt', 'OpenLessClipboardHistory.kt', + 'CloudNoteDelivery.kt', 'OpenLessKeyboardSettingsActivity.kt', 'OpenLessProcessRestartStats.kt', 'OpenLessBuildInfo.kt', @@ -81,6 +82,7 @@ const KOTLIN_FILES = [ const KOTLIN_TEST_FILES = [ 'ImeLearningPolicyTest.kt', + 'CloudNoteDeliveryTest.kt', 'OpenLessContentReaderTest.kt', 'OpenLessCredentialCipherTest.kt', 'OpenLessShizukuBridgeTest.kt',