Skip to content

Commit 989e53c

Browse files
committed
fix(android): address overhaul review findings
1 parent bbcac00 commit 989e53c

21 files changed

Lines changed: 398 additions & 259 deletions

apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,6 @@ class MainViewModel(
9393
val cronStatus: StateFlow<GatewayCronStatus> = runtimeState(initial = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null)) { it.cronStatus }
9494
val cronJobs: StateFlow<List<GatewayCronJobSummary>> = runtimeState(initial = emptyList()) { it.cronJobs }
9595
val cronRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.cronRefreshing }
96-
val cronSaving: StateFlow<Boolean> = runtimeState(initial = false) { it.cronSaving }
9796
val cronErrorText: StateFlow<String?> = runtimeState(initial = null) { it.cronErrorText }
9897
val usageSummary: StateFlow<GatewayUsageSummary> = runtimeState(initial = GatewayUsageSummary(updatedAtMs = null, providers = emptyList())) { it.usageSummary }
9998
val usageRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.usageRefreshing }
@@ -329,6 +328,10 @@ class MainViewModel(
329328
ensureRuntime().setMicEnabled(enabled)
330329
}
331330

331+
fun cancelMicCapture() {
332+
ensureRuntime().cancelMicCapture()
333+
}
334+
332335
fun setTalkModeEnabled(enabled: Boolean) {
333336
ensureRuntime().setTalkModeEnabled(enabled)
334337
}
@@ -403,20 +406,6 @@ class MainViewModel(
403406
ensureRuntime().refreshCronJobs()
404407
}
405408

406-
fun createCronJob(
407-
name: String,
408-
message: String,
409-
scheduleKind: String,
410-
scheduleValue: String,
411-
) {
412-
ensureRuntime().createCronJob(
413-
name = name,
414-
message = message,
415-
scheduleKind = scheduleKind,
416-
scheduleValue = scheduleValue,
417-
)
418-
}
419-
420409
fun refreshUsage() {
421410
ensureRuntime().refreshUsage()
422411
}

apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt

Lines changed: 22 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@ import kotlinx.serialization.json.JsonArray
7070
import kotlinx.serialization.json.JsonObject
7171
import kotlinx.serialization.json.JsonPrimitive
7272
import kotlinx.serialization.json.buildJsonObject
73-
import java.time.Instant
7473
import java.util.UUID
7574
import java.util.concurrent.atomic.AtomicLong
7675

@@ -323,8 +322,6 @@ class NodeRuntime(
323322
val cronJobs: StateFlow<List<GatewayCronJobSummary>> = _cronJobs.asStateFlow()
324323
private val _cronRefreshing = MutableStateFlow(false)
325324
val cronRefreshing: StateFlow<Boolean> = _cronRefreshing.asStateFlow()
326-
private val _cronSaving = MutableStateFlow(false)
327-
val cronSaving: StateFlow<Boolean> = _cronSaving.asStateFlow()
328325
private val _cronErrorText = MutableStateFlow<String?>(null)
329326
val cronErrorText: StateFlow<String?> = _cronErrorText.asStateFlow()
330327
private val _usageSummary = MutableStateFlow(GatewayUsageSummary(updatedAtMs = null, providers = emptyList()))
@@ -413,6 +410,8 @@ class NodeRuntime(
413410
_gatewayVersion.value = null
414411
_gatewayUpdateAvailable.value = null
415412
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
413+
_gatewayDefaultAgentId.value = null
414+
_gatewayAgents.value = emptyList()
416415
_modelCatalog.value = emptyList()
417416
_modelAuthProviders.value = emptyList()
418417
_cronStatus.value = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null)
@@ -722,22 +721,6 @@ class NodeRuntime(
722721
}
723722
}
724723

725-
fun createCronJob(
726-
name: String,
727-
message: String,
728-
scheduleKind: String,
729-
scheduleValue: String,
730-
) {
731-
scope.launch {
732-
createCronJobOnGateway(
733-
name = name,
734-
message = message,
735-
scheduleKind = scheduleKind,
736-
scheduleValue = scheduleValue,
737-
)
738-
}
739-
}
740-
741724
fun refreshUsage() {
742725
scope.launch {
743726
refreshUsageFromGateway()
@@ -1102,6 +1085,12 @@ class NodeRuntime(
11021085
setVoiceCaptureMode(if (value) VoiceCaptureMode.ManualMic else VoiceCaptureMode.Off)
11031086
}
11041087

1088+
fun cancelMicCapture() {
1089+
micCapture.cancelMicCapture()
1090+
setVoiceCaptureMode(VoiceCaptureMode.Off, persistManualMic = false)
1091+
prefs.setVoiceMicEnabled(false)
1092+
}
1093+
11051094
fun setTalkModeEnabled(value: Boolean) {
11061095
setVoiceCaptureMode(if (value) VoiceCaptureMode.TalkMode else VoiceCaptureMode.Off)
11071096
}
@@ -1763,119 +1752,6 @@ class NodeRuntime(
17631752
}
17641753
}
17651754

1766-
private suspend fun createCronJobOnGateway(
1767-
name: String,
1768-
message: String,
1769-
scheduleKind: String,
1770-
scheduleValue: String,
1771-
) {
1772-
_cronSaving.value = true
1773-
_cronErrorText.value = null
1774-
if (!operatorConnected) {
1775-
_cronErrorText.value = "Connect the gateway before creating a cron job."
1776-
_cronSaving.value = false
1777-
return
1778-
}
1779-
try {
1780-
val cleanName = name.trim()
1781-
val cleanMessage = message.trim()
1782-
if (cleanName.isEmpty()) {
1783-
throw IllegalArgumentException("Add a job name.")
1784-
}
1785-
if (cleanMessage.isEmpty()) {
1786-
throw IllegalArgumentException("Add the message OpenClaw should run.")
1787-
}
1788-
val schedule = buildCronCreateSchedule(scheduleKind = scheduleKind, scheduleValue = scheduleValue.trim())
1789-
val params =
1790-
buildJsonObject {
1791-
put("name", JsonPrimitive(cleanName))
1792-
put("enabled", JsonPrimitive(true))
1793-
put("deleteAfterRun", JsonPrimitive(scheduleKind == "Once"))
1794-
put("sessionTarget", JsonPrimitive("isolated"))
1795-
put("wakeMode", JsonPrimitive("now"))
1796-
put("schedule", schedule)
1797-
put(
1798-
"payload",
1799-
buildJsonObject {
1800-
put("kind", JsonPrimitive("agentTurn"))
1801-
put("message", JsonPrimitive(cleanMessage))
1802-
},
1803-
)
1804-
put(
1805-
"delivery",
1806-
buildJsonObject {
1807-
put("mode", JsonPrimitive("announce"))
1808-
put("channel", JsonPrimitive("last"))
1809-
put("bestEffort", JsonPrimitive(true))
1810-
},
1811-
)
1812-
}
1813-
operatorSession.request("cron.add", params.toString())
1814-
refreshCronFromGateway()
1815-
} catch (err: IllegalArgumentException) {
1816-
_cronErrorText.value = err.message ?: "Could not create cron job."
1817-
} catch (_: Throwable) {
1818-
_cronErrorText.value = "Could not create cron job."
1819-
} finally {
1820-
_cronSaving.value = false
1821-
}
1822-
}
1823-
1824-
private fun buildCronCreateSchedule(
1825-
scheduleKind: String,
1826-
scheduleValue: String,
1827-
): JsonObject =
1828-
when (scheduleKind) {
1829-
"Every" ->
1830-
buildJsonObject {
1831-
put("kind", JsonPrimitive("every"))
1832-
put("everyMs", JsonPrimitive(parseCronDurationMs(scheduleValue)))
1833-
}
1834-
"Once" ->
1835-
buildJsonObject {
1836-
put("kind", JsonPrimitive("at"))
1837-
put("at", JsonPrimitive(resolveCronAtIso(scheduleValue)))
1838-
}
1839-
"Cron" ->
1840-
buildJsonObject {
1841-
val expr = scheduleValue.takeIf { it.isNotEmpty() } ?: throw IllegalArgumentException("Add a cron expression.")
1842-
put("kind", JsonPrimitive("cron"))
1843-
put("expr", JsonPrimitive(expr))
1844-
put("staggerMs", JsonPrimitive(0))
1845-
}
1846-
else -> throw IllegalArgumentException("Choose a schedule.")
1847-
}
1848-
1849-
private fun resolveCronAtIso(value: String): String {
1850-
val clean = value.trim()
1851-
if (clean.startsWith("+")) {
1852-
return Instant.ofEpochMilli(System.currentTimeMillis() + parseCronDurationMs(clean.drop(1))).toString()
1853-
}
1854-
return try {
1855-
Instant.parse(clean).toString()
1856-
} catch (_: Throwable) {
1857-
throw IllegalArgumentException("Use ISO time or +30m.")
1858-
}
1859-
}
1860-
1861-
private fun parseCronDurationMs(value: String): Long {
1862-
val match =
1863-
Regex("""^(\d+)\s*([mhd])$""")
1864-
.matchEntire(value.trim().lowercase())
1865-
?: throw IllegalArgumentException("Use 15m, 1h, or 1d.")
1866-
val amount = match.groupValues[1].toLong()
1867-
if (amount <= 0) {
1868-
throw IllegalArgumentException("Use a duration greater than zero.")
1869-
}
1870-
val unit = match.groupValues[2]
1871-
return amount *
1872-
when (unit) {
1873-
"m" -> 60_000L
1874-
"h" -> 3_600_000L
1875-
else -> 86_400_000L
1876-
}
1877-
}
1878-
18791755
private suspend fun refreshUsageFromGateway() {
18801756
_usageRefreshing.value = true
18811757
_usageErrorText.value = null
@@ -2151,12 +2027,7 @@ class NodeRuntime(
21512027
scheduleLabel = cronScheduleLabel(schedule),
21522028
promptPreview = cronPayloadPreview(payload),
21532029
nextRunAtMs = state.long("nextRunAtMs"),
2154-
lastRunStatus =
2155-
state
2156-
?.get("lastRunStatus")
2157-
.asStringOrNull()
2158-
?.trim()
2159-
?.takeIf { it.isNotEmpty() },
2030+
lastRunStatus = cronJobLastRunStatus(state),
21602031
)
21612032
}.orEmpty()
21622033

@@ -2649,11 +2520,7 @@ internal fun resolveOperatorSessionConnectAuth(
26492520

26502521
val explicitBootstrapToken = auth.bootstrapToken?.trim()?.takeIf { it.isNotEmpty() }
26512522
if (explicitBootstrapToken != null) {
2652-
return NodeRuntime.GatewayConnectAuth(
2653-
token = null,
2654-
bootstrapToken = explicitBootstrapToken,
2655-
password = null,
2656-
)
2523+
return null
26572524
}
26582525

26592526
return NodeRuntime.GatewayConnectAuth(
@@ -2867,6 +2734,18 @@ private fun JsonObject?.double(key: String): Double? = (this?.get(key) as? JsonP
28672734

28682735
private fun JsonObject?.boolean(key: String): Boolean = (this?.get(key) as? JsonPrimitive)?.content?.trim() == "true"
28692736

2737+
internal fun cronJobLastRunStatus(state: JsonObject?): String? =
2738+
state
2739+
.cronStatus("lastStatus")
2740+
?: state.cronStatus("lastRunStatus")
2741+
2742+
private fun JsonObject?.cronStatus(key: String): String? =
2743+
this
2744+
?.get(key)
2745+
.asStringOrNull()
2746+
?.trim()
2747+
?.takeIf { it.isNotEmpty() }
2748+
28702749
fun providerDisplayName(provider: String): String =
28712750
when (provider.trim().lowercase()) {
28722751
"openai" -> "OpenAI"

apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ class ChatController(
233233
true
234234
} catch (err: Throwable) {
235235
clearPendingRun(runId)
236+
removeOptimisticMessage(runId)
236237
_errorText.value = err.message
237238
false
238239
}
@@ -374,7 +375,13 @@ class ChatController(
374375
if (state == "error") {
375376
_errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed"
376377
}
377-
if (runId != null) clearPendingRun(runId) else clearPendingRuns()
378+
if (runId != null) {
379+
clearPendingRun(runId)
380+
optimisticMessagesByRunId.remove(runId)
381+
} else {
382+
clearPendingRuns()
383+
optimisticMessagesByRunId.clear()
384+
}
378385
pendingToolCallsById.clear()
379386
publishPendingToolCalls()
380387
_streamingAssistantText.value = null
@@ -476,6 +483,7 @@ class ChatController(
476483
}
477484
if (!stillPending) return@launch
478485
clearPendingRun(runId)
486+
removeOptimisticMessage(runId)
479487
_errorText.value = "Timed out waiting for a reply; try again or refresh."
480488
}
481489
}
@@ -493,12 +501,18 @@ class ChatController(
493501
job.cancel()
494502
}
495503
pendingRunTimeoutJobs.clear()
504+
optimisticMessagesByRunId.clear()
496505
synchronized(pendingRuns) {
497506
pendingRuns.clear()
498507
_pendingRunCount.value = 0
499508
}
500509
}
501510

511+
private fun removeOptimisticMessage(runId: String) {
512+
val message = optimisticMessagesByRunId.remove(runId) ?: return
513+
_messages.value = _messages.value.filterNot { it.id == message.id }
514+
}
515+
502516
private fun parseHistory(
503517
historyJson: String,
504518
sessionKey: String,
@@ -631,22 +645,48 @@ internal fun mergeOptimisticMessages(
631645
): List<ChatMessage> {
632646
if (optimistic.isEmpty()) return incoming
633647

634-
val incomingKeys = incoming.mapNotNull(::messageIdentityKey).toSet()
648+
val unmatchedIncoming = incoming.toMutableList()
635649
val missingOptimistic =
636650
optimistic.filter { message ->
637-
val key = messageIdentityKey(message) ?: return@filter false
638-
key !in incomingKeys
651+
val matchIndex =
652+
unmatchedIncoming.indexOfFirst { incomingMessage ->
653+
incomingMessageConsumesOptimistic(incomingMessage, message)
654+
}
655+
if (matchIndex >= 0) {
656+
unmatchedIncoming.removeAt(matchIndex)
657+
false
658+
} else {
659+
true
660+
}
639661
}
640662
if (missingOptimistic.isEmpty()) return incoming
641663

642664
return (incoming + missingOptimistic).sortedWith(compareBy<ChatMessage> { it.timestampMs ?: Long.MAX_VALUE }.thenBy { it.id })
643665
}
644666

645667
internal fun messageIdentityKey(message: ChatMessage): String? {
668+
val contentKey = messageContentIdentityKey(message) ?: return null
669+
val timestamp = message.timestampMs?.toString().orEmpty()
670+
if (timestamp.isEmpty() && contentKey.isEmpty()) return null
671+
return listOf(contentKey, timestamp).joinToString(separator = "|")
672+
}
673+
674+
private fun optimisticMessageIdentityKey(message: ChatMessage): String? = messageContentIdentityKey(message)
675+
676+
private fun incomingMessageConsumesOptimistic(
677+
incoming: ChatMessage,
678+
optimistic: ChatMessage,
679+
): Boolean {
680+
if (optimisticMessageIdentityKey(incoming) != optimisticMessageIdentityKey(optimistic)) return false
681+
val incomingTimestamp = incoming.timestampMs ?: return false
682+
val optimisticTimestamp = optimistic.timestampMs ?: return true
683+
return incomingTimestamp >= optimisticTimestamp
684+
}
685+
686+
private fun messageContentIdentityKey(message: ChatMessage): String? {
646687
val role = message.role.trim().lowercase()
647688
if (role.isEmpty()) return null
648689

649-
val timestamp = message.timestampMs?.toString().orEmpty()
650690
val contentFingerprint =
651691
message.content.joinToString(separator = "\u001E") { part ->
652692
listOf(
@@ -664,8 +704,7 @@ internal fun messageIdentityKey(message: ChatMessage): String? {
664704
).joinToString(separator = "\u001F")
665705
}
666706

667-
if (timestamp.isEmpty() && contentFingerprint.isEmpty()) return null
668-
return listOf(role, timestamp, contentFingerprint).joinToString(separator = "|")
707+
return listOf(role, contentFingerprint).joinToString(separator = "|")
669708
}
670709

671710
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject

apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,7 @@ class GatewaySession(
618618
val allowedOperatorScopes =
619619
setOf(
620620
"operator.approvals",
621+
"operator.pairing",
621622
"operator.read",
622623
"operator.write",
623624
)

apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ class ConnectionManager(
167167
scopes =
168168
listOf(
169169
"operator.approvals",
170+
"operator.pairing",
170171
"operator.read",
171172
"operator.write",
172173
),

0 commit comments

Comments
 (0)