Skip to content
Draft
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
39 changes: 39 additions & 0 deletions openless-all/app/android/kotlin/CloudNoteDelivery.kt
Original file line number Diff line number Diff line change
@@ -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<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,
): Pair<String, String> {
val (zh, en) = detail
return if (retained) {
"$zh,内容已保存在键盘剪贴板" to "$en. The text was saved in the keyboard clipboard."
} else {
zh to en
}
}
46 changes: 42 additions & 4 deletions openless-all/app/android/kotlin/OpenLessImeService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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", "正在提交云笔记")
Expand All @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions openless-all/app/android/kotlin/test/CloudNoteDeliveryTest.kt
Original file line number Diff line number Diff line change
@@ -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"))
}
}
Original file line number Diff line number Diff line change
@@ -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');
2 changes: 2 additions & 0 deletions openless-all/app/scripts/copy-android-scaffolding.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,15 @@ const KOTLIN_FILES = [
'OpenLessRuntimeService.kt',
'OpenLessBackendWarmupActivity.kt',
'OpenLessClipboardHistory.kt',
'CloudNoteDelivery.kt',
'OpenLessKeyboardSettingsActivity.kt',
'OpenLessProcessRestartStats.kt',
'OpenLessBuildInfo.kt',
];

const KOTLIN_TEST_FILES = [
'ImeLearningPolicyTest.kt',
'CloudNoteDeliveryTest.kt',
'OpenLessContentReaderTest.kt',
'OpenLessCredentialCipherTest.kt',
'OpenLessShizukuBridgeTest.kt',
Expand Down
Loading