diff --git a/openless-all/app/android/kotlin/CloudNoteDelivery.kt b/openless-all/app/android/kotlin/CloudNoteDelivery.kt new file mode 100644 index 000000000..58c15a160 --- /dev/null +++ b/openless-all/app/android/kotlin/CloudNoteDelivery.kt @@ -0,0 +1,41 @@ +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, + persisted: Boolean, +): Pair { + val (zh, en) = detail + return when { + persisted -> "$zh,内容已保存在键盘剪贴板" to "$en. The text was saved in the keyboard clipboard." + retained -> "$zh,内容仅暂存在键盘剪贴板,应用重启后会丢失,请及时复制" to + "$en. The text is only in memory in the keyboard clipboard. Copy it before the app restarts." + else -> zh to en + } +} diff --git a/openless-all/app/android/kotlin/OpenLessClipboardHistory.kt b/openless-all/app/android/kotlin/OpenLessClipboardHistory.kt index a9133feba..a9abbfb82 100644 --- a/openless-all/app/android/kotlin/OpenLessClipboardHistory.kt +++ b/openless-all/app/android/kotlin/OpenLessClipboardHistory.kt @@ -4,6 +4,9 @@ import android.content.Context import org.json.JSONArray import org.json.JSONObject import java.io.File +import java.io.FileOutputStream +import java.nio.file.Files +import java.nio.file.StandardCopyOption /** One remembered clipboard entry. */ data class ClipboardEntry(val text: String, val timestamp: Long, val favorite: Boolean = false) @@ -18,6 +21,8 @@ data class ClipboardEntry(val text: String, val timestamp: Long, val favorite: B object OpenLessClipboardHistory { private const val FILE_NAME = "openless_clipboard_history.json" private const val MAX_ENTRIES = 200 + // A failed disk write must leave the text available in the clipboard panel until retry or process exit. + private var unsavedEntries: List? = null enum class Category { ALL, RECENT, TEXT, NUMBER, LINK } @@ -43,6 +48,7 @@ object OpenLessClipboardHistory { @Synchronized fun load(context: Context): List { + unsavedEntries?.let { return it } return try { val target = file(context) if (!target.exists()) return emptyList() @@ -58,8 +64,10 @@ object OpenLessClipboardHistory { } @Synchronized - private fun save(context: Context, entries: List) { - try { + private fun save(context: Context, entries: List): Boolean { + val target = file(context) + val temporary = File(target.parentFile, "$FILE_NAME.tmp") + return try { val array = JSONArray() entries.forEach { entry -> array.put( @@ -70,9 +78,19 @@ object OpenLessClipboardHistory { }, ) } - file(context).writeText(array.toString()) + FileOutputStream(temporary).use { output -> + output.write(array.toString().toByteArray(Charsets.UTF_8)) + output.fd.sync() + } + Files.move(temporary.toPath(), target.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + unsavedEntries = null + true } catch (error: Exception) { + unsavedEntries = entries android.util.Log.w("OpenLessClipboardHistory", "failed to save history", error) + false + } finally { + temporary.delete() } } @@ -82,11 +100,12 @@ object OpenLessClipboardHistory { * instead of creating a second row). Also used to bump an existing * history entry to the front when the user pastes it from the history * browser, per the "after a selected clip is committed, move it to the front of the - * clipboard history" requirement. + * clipboard history" requirement. Returns true only after the entry is persisted; + * on failure, load() still exposes the in-memory copy. */ @Synchronized - fun recordCopy(context: Context, text: String) { - if (text.isBlank()) return + fun recordCopy(context: Context, text: String): Boolean { + if (text.isBlank()) return false val current = load(context).toMutableList() // Preserve an existing favorite flag rather than silently clearing // it just because the same text got copied again. @@ -94,7 +113,7 @@ object OpenLessClipboardHistory { current.removeAll { it.text == text } current.add(0, ClipboardEntry(text, System.currentTimeMillis(), wasFavorite)) while (current.size > MAX_ENTRIES) current.removeAt(current.lastIndex) - save(context, current) + return save(context, current) } /** Flips one entry's favorite flag by text — swipe-right on a clipboard history row. Preserves list order. */ diff --git a/openless-all/app/android/kotlin/OpenLessImeService.kt b/openless-all/app/android/kotlin/OpenLessImeService.kt index 7da42c194..3eb130dd0 100644 --- a/openless-all/app/android/kotlin/OpenLessImeService.kt +++ b/openless-all/app/android/kotlin/OpenLessImeService.kt @@ -3856,8 +3856,8 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt recording = false processing = false if (url.isEmpty() || token.isEmpty()) { - setState("error", "请先在设置中填写云笔记的地址/Token") - onFinished?.invoke(false, "请先在设置中填写云笔记的地址/Token") + val message = reportCloudNoteFailure(requestId, text, CloudNoteFailure.MissingDestination) + onFinished?.invoke(false, message) return } cloudNoteSubmitting = true @@ -3882,22 +3882,54 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt val code = connection.responseCode mainHandler.post { val success = code in 200..299 - val message = if (success) "已提交云笔记" else "云笔记提交失败($code)" - if (requestId == imeRequestId) { - if (success) setState("done", message, QUICK_NOTE_CONFIRMATION_DELAY_MS) else setState("error", message) + val message = if (success) { + if (requestId == imeRequestId) { + setState("done", "已提交云笔记", QUICK_NOTE_CONFIRMATION_DELAY_MS) + } + "已提交云笔记" + } else { + reportCloudNoteFailure(requestId, text, CloudNoteFailure.Http, code) } onFinished?.invoke(success, message) } } catch (error: Throwable) { android.util.Log.w("OpenLessImeService", "cloud note webhook submit failed: ${error.javaClass.simpleName}") mainHandler.post { - if (requestId == imeRequestId) setState("error", "云笔记提交失败,请检查网络") - onFinished?.invoke(false, "云笔记提交失败,请检查网络") + val message = reportCloudNoteFailure(requestId, text, CloudNoteFailure.Network) + onFinished?.invoke(false, message) } } 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, + ): String { + val retained = cloudNoteShouldRetain(text) + val persisted = retained && OpenLessClipboardHistory.recordCopy(this, text) + val (messageZh, messageEn) = cloudNoteFailureMessage( + cloudNoteFailureDetail(failure, httpCode), + retained, + persisted, + ) + val message = ui(messageZh, messageEn) + if (requestId == imeRequestId) { + setState("error", message) + } else { + Toast.makeText(this, message, Toast.LENGTH_LONG).show() + } + return message + } + /** What Confirm does with the text in the draft bar. */ private enum class DraftPurpose { /** Post it to the Cloud notes webhook. */ diff --git a/openless-all/app/android/kotlin/androidTest/OpenLessClipboardHistoryInstrumentedTest.kt b/openless-all/app/android/kotlin/androidTest/OpenLessClipboardHistoryInstrumentedTest.kt new file mode 100644 index 000000000..0afbadd8d --- /dev/null +++ b/openless-all/app/android/kotlin/androidTest/OpenLessClipboardHistoryInstrumentedTest.kt @@ -0,0 +1,111 @@ +package com.openless.app + +import android.content.ContextWrapper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.io.File +import org.json.JSONArray +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class OpenLessClipboardHistoryInstrumentedTest { + @get:Rule val temporaryFolder = TemporaryFolder() + private lateinit var directory: File + private lateinit var context: ContextWrapper + private val history get() = File(directory, "openless_clipboard_history.json") + private val staging get() = File(directory, "openless_clipboard_history.json.tmp") + + @Before fun setUp() { + directory = temporaryFolder.newFolder("clipboard") + context = object : ContextWrapper(null) { + override fun getFilesDir(): File = directory + } + } + + @After fun tearDown() { + unblock(staging) + unblock(history) + // A successful save clears the singleton's temporary copy before the next test. + OpenLessClipboardHistory.clearNonFavorites(context) + } + + private fun block(path: File) { + assertTrue(path.mkdir()) + File(path, "blocker").writeText("keep the directory nonempty") + } + + private fun unblock(path: File) { + if (!path.isDirectory) return + assertTrue(File(path, "blocker").delete()) + assertTrue(path.delete()) + } + + private fun texts() = OpenLessClipboardHistory.load(context).map { it.text } + + @Test fun blankTextDoesNotClaimPersistence() { + assertFalse(OpenLessClipboardHistory.recordCopy(context, " ")) + assertFalse(history.exists()) + assertTrue(texts().isEmpty()) + } + + @Test fun successfulWritesPreserveFavoriteAndDeduplicate() { + assertTrue(OpenLessClipboardHistory.recordCopy(context, "favorite")) + OpenLessClipboardHistory.toggleFavorite(context, "favorite") + assertTrue(OpenLessClipboardHistory.recordCopy(context, "favorite")) + val entries = OpenLessClipboardHistory.load(context) + assertEquals(listOf("favorite"), entries.map { it.text }) + assertTrue(entries.single().favorite) + assertTrue(JSONArray(history.readText()).getJSONObject(0).getBoolean("favorite")) + } + + @Test fun failedWritesKeepBothNotesAndLeaveThePreviousFileIntact() { + assertTrue(OpenLessClipboardHistory.recordCopy(context, "saved")) + val before = history.readText() + block(staging) + + assertFalse(OpenLessClipboardHistory.recordCopy(context, "first failed note")) + assertFalse(OpenLessClipboardHistory.recordCopy(context, "second failed note")) + assertEquals(listOf("second failed note", "first failed note", "saved"), texts()) + assertEquals(before, history.readText()) + + unblock(staging) + assertTrue(OpenLessClipboardHistory.recordCopy(context, "second failed note")) + assertEquals(listOf("second failed note", "first failed note", "saved"), texts()) + val persisted = JSONArray(history.readText()) + assertEquals(3, persisted.length()) + assertEquals("first failed note", persisted.getJSONObject(1).getString("text")) + assertFalse(staging.exists()) + } + + @Test fun failedReplacementAlsoRetainsTheText() { + block(history) + assertFalse(OpenLessClipboardHistory.recordCopy(context, "not replaced")) + assertEquals(listOf("not replaced"), texts()) + assertTrue(File(history, "blocker").isFile) + assertFalse(staging.exists()) + + unblock(history) + assertTrue(OpenLessClipboardHistory.recordCopy(context, "retry")) + assertEquals(listOf("retry", "not replaced"), texts()) + assertEquals(2, JSONArray(history.readText()).length()) + } + + @Test fun temporaryHistoryStillSupportsFavoriteClearAndDelete() { + block(staging) + assertFalse(OpenLessClipboardHistory.recordCopy(context, "keep")) + assertFalse(OpenLessClipboardHistory.recordCopy(context, "discard")) + OpenLessClipboardHistory.toggleFavorite(context, "keep") + OpenLessClipboardHistory.clearNonFavorites(context) + assertEquals(listOf("keep"), texts()) + assertTrue(OpenLessClipboardHistory.load(context).single().favorite) + OpenLessClipboardHistory.delete(context, "keep") + assertTrue(texts().isEmpty()) + } +} 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..e79fed4a3 --- /dev/null +++ b/openless-all/app/android/kotlin/test/CloudNoteDeliveryTest.kt @@ -0,0 +1,57 @@ +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, + persisted = 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, + persisted = true, + ) + assertEquals("请先在设置中填写云笔记的地址和 Token,内容已保存在键盘剪贴板", missing.first) + val network = cloudNoteFailureMessage( + cloudNoteFailureDetail(CloudNoteFailure.Network), + retained = false, + persisted = false, + ) + assertEquals("云笔记提交失败,请检查网络", network.first) + assertFalse(network.second.contains("clipboard")) + } + + @Test fun failedDiskWriteWarnsThatTheClipboardCopyIsTemporary() { + val message = cloudNoteFailureMessage( + cloudNoteFailureDetail(CloudNoteFailure.Http, 503), + retained = true, + persisted = false, + ) + assertEquals("云笔记提交失败(503),内容仅暂存在键盘剪贴板,应用重启后会丢失,请及时复制", message.first) + assertEquals( + "Cloud note submit failed (503). The text is only in memory in the keyboard clipboard. Copy it before the app restarts.", + message.second, + ) + assertFalse(message.first.contains("已保存")) + assertFalse(message.second.contains("was saved")) + } +} 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..58149cd98 --- /dev/null +++ b/openless-all/app/scripts/android-cloud-note-delivery-contract.test.mjs @@ -0,0 +1,73 @@ +#!/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 (success)'); +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, /val success = code in 200\.\.299/); +assert.match(submit, /CloudNoteFailure\.MissingDestination/); +assert.match(submit, /CloudNoteFailure\.Http/); +assert.match(submit, /CloudNoteFailure\.Network/); +// The draft workflow must receive a completion even when a newer request owns the status line. +assert.match( + submit, + /val message = reportCloudNoteFailure\(requestId, text, CloudNoteFailure\.MissingDestination\)\s+onFinished\?\.invoke\(false, message\)/, +); +assert.match( + submit, + /val message = reportCloudNoteFailure\(requestId, text, CloudNoteFailure\.Network\)\s+onFinished\?\.invoke\(false, message\)/, +); +assert.match( + submit, + /reportCloudNoteFailure\(requestId, text, CloudNoteFailure\.Http, code\)\s+}\s+onFinished\?\.invoke\(success, message\)/, +); +assert.match(successBranch, /if \(requestId == imeRequestId\)/); +assert.doesNotMatch( + submit, + /return@post/, + 'stale submissions must still finish their draft callback', +); +assert.match(report, /return message/); +assert.match( + report, + /val persisted = retained && OpenLessClipboardHistory\.recordCopy\(this, text\)/, +); +assert.match(report, /cloudNoteFailureMessage\([\s\S]*?retained,\s+persisted,/); +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'/); +assert.match(copyScript, /'OpenLessClipboardHistoryInstrumentedTest\.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 ebf977828..bfe5677c8 100644 --- a/openless-all/app/scripts/copy-android-scaffolding.mjs +++ b/openless-all/app/scripts/copy-android-scaffolding.mjs @@ -80,6 +80,7 @@ const KOTLIN_FILES = [ 'OpenLessRuntimeService.kt', 'OpenLessBackendWarmupActivity.kt', 'OpenLessClipboardHistory.kt', + 'CloudNoteDelivery.kt', 'OpenLessKeyboardSettingsActivity.kt', 'OpenLessLocalMigrationActivity.kt', 'OpenLessProcessRestartStats.kt', @@ -88,6 +89,7 @@ const KOTLIN_FILES = [ const KOTLIN_TEST_FILES = [ 'ImeLearningPolicyTest.kt', + 'CloudNoteDeliveryTest.kt', 'DictationSpanTest.kt', 'ImePrivacyPolicyTest.kt', 'ImeDraftReplacementTest.kt', @@ -104,6 +106,7 @@ const KOTLIN_TEST_FILES = [ 'OpenLessAccessibilityComponentIdsTest.kt', ]; const KOTLIN_ANDROID_TEST_FILES = [ + 'OpenLessClipboardHistoryInstrumentedTest.kt', 'OpenLessCredentialVaultInstrumentedTest.kt', 'ImeLearningMigrationTest.kt', 'OpenLessMigrationBundleInstrumentedTest.kt',