Skip to content

Commit fb2a56e

Browse files
cursor[bot]cursoragentlubaiqing9H-Chris233
authored
fix(android): 云笔记提交失败时保留口述内容 (#1148)
* fix(android): 云笔记提交失败时保留口述内容 Core 在 webhook 返回前就删除云笔记的历史和录音。地址未配置、网络失败或 HTTP 错误时,识别文本只存在于完成事件里,原来的错误提示会把它丢掉。失败时改写入键盘剪贴板,成功提交仍不留本地副本。 Co-authored-by: lubaiqing9 <lubaiqing9@gmail.com> * fix(android): retain cloud notes when clipboard persistence fails --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: lubaiqing9 <lubaiqing9@gmail.com> Co-authored-by: Chris233 <h-chris233@outlook.com>
1 parent 30988e2 commit fb2a56e

7 files changed

Lines changed: 350 additions & 14 deletions
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.openless.app
2+
3+
/**
4+
* What to do with a cloud-note transcript the webhook did not accept.
5+
*
6+
* The backend deletes the recording and history before the IME learns whether
7+
* the webhook succeeded. A failed submit therefore has to keep the text the
8+
* completion event already delivered, or the dictation is gone.
9+
*/
10+
internal enum class CloudNoteFailure {
11+
MissingDestination,
12+
Http,
13+
Network,
14+
}
15+
16+
internal fun cloudNoteShouldRetain(text: String): Boolean = text.isNotBlank()
17+
18+
internal fun cloudNoteFailureDetail(failure: CloudNoteFailure, httpCode: Int = 0): Pair<String, String> {
19+
return when (failure) {
20+
CloudNoteFailure.MissingDestination ->
21+
"请先在设置中填写云笔记的地址和 Token" to "Add the cloud note URL and token in Settings"
22+
CloudNoteFailure.Http ->
23+
"云笔记提交失败($httpCode)" to "Cloud note submit failed ($httpCode)"
24+
CloudNoteFailure.Network ->
25+
"云笔记提交失败,请检查网络" to "Cloud note submit failed. Check the network"
26+
}
27+
}
28+
29+
internal fun cloudNoteFailureMessage(
30+
detail: Pair<String, String>,
31+
retained: Boolean,
32+
persisted: Boolean,
33+
): Pair<String, String> {
34+
val (zh, en) = detail
35+
return when {
36+
persisted -> "$zh,内容已保存在键盘剪贴板" to "$en. The text was saved in the keyboard clipboard."
37+
retained -> "$zh,内容仅暂存在键盘剪贴板,应用重启后会丢失,请及时复制" to
38+
"$en. The text is only in memory in the keyboard clipboard. Copy it before the app restarts."
39+
else -> zh to en
40+
}
41+
}

‎openless-all/app/android/kotlin/OpenLessClipboardHistory.kt‎

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import android.content.Context
44
import org.json.JSONArray
55
import org.json.JSONObject
66
import java.io.File
7+
import java.io.FileOutputStream
8+
import java.nio.file.Files
9+
import java.nio.file.StandardCopyOption
710

811
/** One remembered clipboard entry. */
912
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
1821
object OpenLessClipboardHistory {
1922
private const val FILE_NAME = "openless_clipboard_history.json"
2023
private const val MAX_ENTRIES = 200
24+
// A failed disk write must leave the text available in the clipboard panel until retry or process exit.
25+
private var unsavedEntries: List<ClipboardEntry>? = null
2126

2227
enum class Category { ALL, RECENT, TEXT, NUMBER, LINK }
2328

@@ -43,6 +48,7 @@ object OpenLessClipboardHistory {
4348

4449
@Synchronized
4550
fun load(context: Context): List<ClipboardEntry> {
51+
unsavedEntries?.let { return it }
4652
return try {
4753
val target = file(context)
4854
if (!target.exists()) return emptyList()
@@ -58,8 +64,10 @@ object OpenLessClipboardHistory {
5864
}
5965

6066
@Synchronized
61-
private fun save(context: Context, entries: List<ClipboardEntry>) {
62-
try {
67+
private fun save(context: Context, entries: List<ClipboardEntry>): Boolean {
68+
val target = file(context)
69+
val temporary = File(target.parentFile, "$FILE_NAME.tmp")
70+
return try {
6371
val array = JSONArray()
6472
entries.forEach { entry ->
6573
array.put(
@@ -70,9 +78,19 @@ object OpenLessClipboardHistory {
7078
},
7179
)
7280
}
73-
file(context).writeText(array.toString())
81+
FileOutputStream(temporary).use { output ->
82+
output.write(array.toString().toByteArray(Charsets.UTF_8))
83+
output.fd.sync()
84+
}
85+
Files.move(temporary.toPath(), target.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
86+
unsavedEntries = null
87+
true
7488
} catch (error: Exception) {
89+
unsavedEntries = entries
7590
android.util.Log.w("OpenLessClipboardHistory", "failed to save history", error)
91+
false
92+
} finally {
93+
temporary.delete()
7694
}
7795
}
7896

@@ -82,19 +100,20 @@ object OpenLessClipboardHistory {
82100
* instead of creating a second row). Also used to bump an existing
83101
* history entry to the front when the user pastes it from the history
84102
* browser, per the "after a selected clip is committed, move it to the front of the
85-
* clipboard history" requirement.
103+
* clipboard history" requirement. Returns true only after the entry is persisted;
104+
* on failure, load() still exposes the in-memory copy.
86105
*/
87106
@Synchronized
88-
fun recordCopy(context: Context, text: String) {
89-
if (text.isBlank()) return
107+
fun recordCopy(context: Context, text: String): Boolean {
108+
if (text.isBlank()) return false
90109
val current = load(context).toMutableList()
91110
// Preserve an existing favorite flag rather than silently clearing
92111
// it just because the same text got copied again.
93112
val wasFavorite = current.any { it.text == text && it.favorite }
94113
current.removeAll { it.text == text }
95114
current.add(0, ClipboardEntry(text, System.currentTimeMillis(), wasFavorite))
96115
while (current.size > MAX_ENTRIES) current.removeAt(current.lastIndex)
97-
save(context, current)
116+
return save(context, current)
98117
}
99118

100119
/** Flips one entry's favorite flag by text — swipe-right on a clipboard history row. Preserves list order. */

‎openless-all/app/android/kotlin/OpenLessImeService.kt‎

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3856,8 +3856,8 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt
38563856
recording = false
38573857
processing = false
38583858
if (url.isEmpty() || token.isEmpty()) {
3859-
setState("error", "请先在设置中填写云笔记的地址/Token")
3860-
onFinished?.invoke(false, "请先在设置中填写云笔记的地址/Token")
3859+
val message = reportCloudNoteFailure(requestId, text, CloudNoteFailure.MissingDestination)
3860+
onFinished?.invoke(false, message)
38613861
return
38623862
}
38633863
cloudNoteSubmitting = true
@@ -3882,22 +3882,54 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt
38823882
val code = connection.responseCode
38833883
mainHandler.post {
38843884
val success = code in 200..299
3885-
val message = if (success) "已提交云笔记" else "云笔记提交失败($code)"
3886-
if (requestId == imeRequestId) {
3887-
if (success) setState("done", message, QUICK_NOTE_CONFIRMATION_DELAY_MS) else setState("error", message)
3885+
val message = if (success) {
3886+
if (requestId == imeRequestId) {
3887+
setState("done", "已提交云笔记", QUICK_NOTE_CONFIRMATION_DELAY_MS)
3888+
}
3889+
"已提交云笔记"
3890+
} else {
3891+
reportCloudNoteFailure(requestId, text, CloudNoteFailure.Http, code)
38883892
}
38893893
onFinished?.invoke(success, message)
38903894
}
38913895
} catch (error: Throwable) {
38923896
android.util.Log.w("OpenLessImeService", "cloud note webhook submit failed: ${error.javaClass.simpleName}")
38933897
mainHandler.post {
3894-
if (requestId == imeRequestId) setState("error", "云笔记提交失败,请检查网络")
3895-
onFinished?.invoke(false, "云笔记提交失败,请检查网络")
3898+
val message = reportCloudNoteFailure(requestId, text, CloudNoteFailure.Network)
3899+
onFinished?.invoke(false, message)
38963900
}
38973901
} finally { connection?.disconnect() }
38983902
}.start()
38993903
}
39003904

3905+
/**
3906+
* Keeps a transcript the webhook did not accept. Core already removed the
3907+
* recording and history, so the completion text is the only remaining copy.
3908+
* A newer recording may own the status line; the clipboard entry still has
3909+
* to be written, and a toast reports it without touching that session.
3910+
*/
3911+
private fun reportCloudNoteFailure(
3912+
requestId: Long,
3913+
text: String,
3914+
failure: CloudNoteFailure,
3915+
httpCode: Int = 0,
3916+
): String {
3917+
val retained = cloudNoteShouldRetain(text)
3918+
val persisted = retained && OpenLessClipboardHistory.recordCopy(this, text)
3919+
val (messageZh, messageEn) = cloudNoteFailureMessage(
3920+
cloudNoteFailureDetail(failure, httpCode),
3921+
retained,
3922+
persisted,
3923+
)
3924+
val message = ui(messageZh, messageEn)
3925+
if (requestId == imeRequestId) {
3926+
setState("error", message)
3927+
} else {
3928+
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
3929+
}
3930+
return message
3931+
}
3932+
39013933
/** What Confirm does with the text in the draft bar. */
39023934
private enum class DraftPurpose {
39033935
/** Post it to the Cloud notes webhook. */
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package com.openless.app
2+
3+
import android.content.ContextWrapper
4+
import androidx.test.ext.junit.runners.AndroidJUnit4
5+
import java.io.File
6+
import org.json.JSONArray
7+
import org.junit.After
8+
import org.junit.Assert.assertEquals
9+
import org.junit.Assert.assertFalse
10+
import org.junit.Assert.assertTrue
11+
import org.junit.Before
12+
import org.junit.Rule
13+
import org.junit.Test
14+
import org.junit.rules.TemporaryFolder
15+
import org.junit.runner.RunWith
16+
17+
@RunWith(AndroidJUnit4::class)
18+
class OpenLessClipboardHistoryInstrumentedTest {
19+
@get:Rule val temporaryFolder = TemporaryFolder()
20+
private lateinit var directory: File
21+
private lateinit var context: ContextWrapper
22+
private val history get() = File(directory, "openless_clipboard_history.json")
23+
private val staging get() = File(directory, "openless_clipboard_history.json.tmp")
24+
25+
@Before fun setUp() {
26+
directory = temporaryFolder.newFolder("clipboard")
27+
context = object : ContextWrapper(null) {
28+
override fun getFilesDir(): File = directory
29+
}
30+
}
31+
32+
@After fun tearDown() {
33+
unblock(staging)
34+
unblock(history)
35+
// A successful save clears the singleton's temporary copy before the next test.
36+
OpenLessClipboardHistory.clearNonFavorites(context)
37+
}
38+
39+
private fun block(path: File) {
40+
assertTrue(path.mkdir())
41+
File(path, "blocker").writeText("keep the directory nonempty")
42+
}
43+
44+
private fun unblock(path: File) {
45+
if (!path.isDirectory) return
46+
assertTrue(File(path, "blocker").delete())
47+
assertTrue(path.delete())
48+
}
49+
50+
private fun texts() = OpenLessClipboardHistory.load(context).map { it.text }
51+
52+
@Test fun blankTextDoesNotClaimPersistence() {
53+
assertFalse(OpenLessClipboardHistory.recordCopy(context, " "))
54+
assertFalse(history.exists())
55+
assertTrue(texts().isEmpty())
56+
}
57+
58+
@Test fun successfulWritesPreserveFavoriteAndDeduplicate() {
59+
assertTrue(OpenLessClipboardHistory.recordCopy(context, "favorite"))
60+
OpenLessClipboardHistory.toggleFavorite(context, "favorite")
61+
assertTrue(OpenLessClipboardHistory.recordCopy(context, "favorite"))
62+
val entries = OpenLessClipboardHistory.load(context)
63+
assertEquals(listOf("favorite"), entries.map { it.text })
64+
assertTrue(entries.single().favorite)
65+
assertTrue(JSONArray(history.readText()).getJSONObject(0).getBoolean("favorite"))
66+
}
67+
68+
@Test fun failedWritesKeepBothNotesAndLeaveThePreviousFileIntact() {
69+
assertTrue(OpenLessClipboardHistory.recordCopy(context, "saved"))
70+
val before = history.readText()
71+
block(staging)
72+
73+
assertFalse(OpenLessClipboardHistory.recordCopy(context, "first failed note"))
74+
assertFalse(OpenLessClipboardHistory.recordCopy(context, "second failed note"))
75+
assertEquals(listOf("second failed note", "first failed note", "saved"), texts())
76+
assertEquals(before, history.readText())
77+
78+
unblock(staging)
79+
assertTrue(OpenLessClipboardHistory.recordCopy(context, "second failed note"))
80+
assertEquals(listOf("second failed note", "first failed note", "saved"), texts())
81+
val persisted = JSONArray(history.readText())
82+
assertEquals(3, persisted.length())
83+
assertEquals("first failed note", persisted.getJSONObject(1).getString("text"))
84+
assertFalse(staging.exists())
85+
}
86+
87+
@Test fun failedReplacementAlsoRetainsTheText() {
88+
block(history)
89+
assertFalse(OpenLessClipboardHistory.recordCopy(context, "not replaced"))
90+
assertEquals(listOf("not replaced"), texts())
91+
assertTrue(File(history, "blocker").isFile)
92+
assertFalse(staging.exists())
93+
94+
unblock(history)
95+
assertTrue(OpenLessClipboardHistory.recordCopy(context, "retry"))
96+
assertEquals(listOf("retry", "not replaced"), texts())
97+
assertEquals(2, JSONArray(history.readText()).length())
98+
}
99+
100+
@Test fun temporaryHistoryStillSupportsFavoriteClearAndDelete() {
101+
block(staging)
102+
assertFalse(OpenLessClipboardHistory.recordCopy(context, "keep"))
103+
assertFalse(OpenLessClipboardHistory.recordCopy(context, "discard"))
104+
OpenLessClipboardHistory.toggleFavorite(context, "keep")
105+
OpenLessClipboardHistory.clearNonFavorites(context)
106+
assertEquals(listOf("keep"), texts())
107+
assertTrue(OpenLessClipboardHistory.load(context).single().favorite)
108+
OpenLessClipboardHistory.delete(context, "keep")
109+
assertTrue(texts().isEmpty())
110+
}
111+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.openless.app
2+
import org.junit.Assert.assertEquals
3+
import org.junit.Assert.assertFalse
4+
import org.junit.Assert.assertTrue
5+
import org.junit.Test
6+
7+
class CloudNoteDeliveryTest {
8+
@Test fun blankTranscriptIsNotRetained() {
9+
assertFalse(cloudNoteShouldRetain(""))
10+
assertFalse(cloudNoteShouldRetain(" "))
11+
}
12+
13+
@Test fun failedSubmitKeepsTheTranscriptInTheMessage() {
14+
assertTrue(cloudNoteShouldRetain("明天开会"))
15+
val message = cloudNoteFailureMessage(
16+
cloudNoteFailureDetail(CloudNoteFailure.Http, 500),
17+
retained = true,
18+
persisted = true,
19+
)
20+
assertEquals("云笔记提交失败(500),内容已保存在键盘剪贴板", message.first)
21+
assertEquals(
22+
"Cloud note submit failed (500). The text was saved in the keyboard clipboard.",
23+
message.second,
24+
)
25+
}
26+
27+
@Test fun missingDestinationAndNetworkFailuresAlsoSayTheTextWasKept() {
28+
val missing = cloudNoteFailureMessage(
29+
cloudNoteFailureDetail(CloudNoteFailure.MissingDestination),
30+
retained = true,
31+
persisted = true,
32+
)
33+
assertEquals("请先在设置中填写云笔记的地址和 Token,内容已保存在键盘剪贴板", missing.first)
34+
val network = cloudNoteFailureMessage(
35+
cloudNoteFailureDetail(CloudNoteFailure.Network),
36+
retained = false,
37+
persisted = false,
38+
)
39+
assertEquals("云笔记提交失败,请检查网络", network.first)
40+
assertFalse(network.second.contains("clipboard"))
41+
}
42+
43+
@Test fun failedDiskWriteWarnsThatTheClipboardCopyIsTemporary() {
44+
val message = cloudNoteFailureMessage(
45+
cloudNoteFailureDetail(CloudNoteFailure.Http, 503),
46+
retained = true,
47+
persisted = false,
48+
)
49+
assertEquals("云笔记提交失败(503),内容仅暂存在键盘剪贴板,应用重启后会丢失,请及时复制", message.first)
50+
assertEquals(
51+
"Cloud note submit failed (503). The text is only in memory in the keyboard clipboard. Copy it before the app restarts.",
52+
message.second,
53+
)
54+
assertFalse(message.first.contains("已保存"))
55+
assertFalse(message.second.contains("was saved"))
56+
}
57+
}

0 commit comments

Comments
 (0)