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
41 changes: 41 additions & 0 deletions openless-all/app/android/kotlin/CloudNoteDelivery.kt
Original file line number Diff line number Diff line change
@@ -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<String, String> {
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<String, String>,
retained: Boolean,
persisted: Boolean,
): Pair<String, String> {
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
}
}
33 changes: 26 additions & 7 deletions openless-all/app/android/kotlin/OpenLessClipboardHistory.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<ClipboardEntry>? = null

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

Expand All @@ -43,6 +48,7 @@ object OpenLessClipboardHistory {

@Synchronized
fun load(context: Context): List<ClipboardEntry> {
unsavedEntries?.let { return it }
return try {
val target = file(context)
if (!target.exists()) return emptyList()
Expand All @@ -58,8 +64,10 @@ object OpenLessClipboardHistory {
}

@Synchronized
private fun save(context: Context, entries: List<ClipboardEntry>) {
try {
private fun save(context: Context, entries: List<ClipboardEntry>): Boolean {
val target = file(context)
val temporary = File(target.parentFile, "$FILE_NAME.tmp")
return try {
val array = JSONArray()
entries.forEach { entry ->
array.put(
Expand All @@ -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()
}
}

Expand All @@ -82,19 +100,20 @@ 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.
val wasFavorite = current.any { it.text == text && it.favorite }
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. */
Expand Down
46 changes: 39 additions & 7 deletions openless-all/app/android/kotlin/OpenLessImeService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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())
}
}
57 changes: 57 additions & 0 deletions openless-all/app/android/kotlin/test/CloudNoteDeliveryTest.kt
Original file line number Diff line number Diff line change
@@ -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"))
}
}
Loading
Loading