Skip to content

Commit fe50540

Browse files
committed
feat(android): add durable offline command outbox for chat sends
1 parent 13e5902 commit fe50540

15 files changed

Lines changed: 1665 additions & 84 deletions

apps/.i18n/native-source.json

Lines changed: 70 additions & 54 deletions
Large diffs are not rendered by default.

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package ai.openclaw.app
22

33
import ai.openclaw.app.chat.ChatCommandEntry
44
import ai.openclaw.app.chat.ChatMessage
5+
import ai.openclaw.app.chat.ChatOutboxItem
56
import ai.openclaw.app.chat.ChatPendingToolCall
67
import ai.openclaw.app.chat.ChatSessionEntry
78
import ai.openclaw.app.chat.OutgoingAttachment
@@ -215,6 +216,7 @@ class MainViewModel(
215216
val chatSessions: StateFlow<List<ChatSessionEntry>> = runtimeState(initial = emptyList()) { it.chatSessions }
216217
val pendingRunCount: StateFlow<Int> = runtimeState(initial = 0) { it.pendingRunCount }
217218
val chatCommands: StateFlow<List<ChatCommandEntry>> = runtimeState(initial = emptyList<ChatCommandEntry>()) { it.chatCommands }
219+
val chatOutboxItems: StateFlow<List<ChatOutboxItem>> = runtimeState(initial = emptyList()) { it.chatOutboxItems }
218220
val execApprovals: StateFlow<List<GatewayExecApprovalSummary>> = runtimeState(initial = emptyList()) { it.execApprovals }
219221
val execApprovalsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.execApprovalsRefreshing }
220222
val execApprovalsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.execApprovalsErrorText }
@@ -297,7 +299,7 @@ class MainViewModel(
297299
}
298300

299301
/** Clears setup credentials without starting the runtime just to discard first-run pairing auth. */
300-
private fun resetGatewaySetupAuth() {
302+
private suspend fun resetGatewaySetupAuth() {
301303
runtimeRef.value?.resetGatewaySetupAuth() ?: resetGatewaySetupAuthWithoutRuntime()
302304
}
303305

@@ -615,6 +617,14 @@ class MainViewModel(
615617
ensureRuntime().refreshChatCommands()
616618
}
617619

620+
fun retryChatOutboxCommand(id: String) {
621+
ensureRuntime().retryChatOutboxCommand(id)
622+
}
623+
624+
fun deleteChatOutboxCommand(id: String) {
625+
ensureRuntime().deleteChatOutboxCommand(id)
626+
}
627+
618628
fun sendChat(
619629
message: String,
620630
thinking: String,

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

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
package ai.openclaw.app
22

3+
import ai.openclaw.app.chat.ChatCacheDatabase
34
import ai.openclaw.app.chat.ChatCacheScope
45
import ai.openclaw.app.chat.ChatCommandEntry
56
import ai.openclaw.app.chat.ChatController
67
import ai.openclaw.app.chat.ChatMessage
8+
import ai.openclaw.app.chat.ChatOutboxItem
79
import ai.openclaw.app.chat.ChatPendingToolCall
810
import ai.openclaw.app.chat.ChatSessionEntry
911
import ai.openclaw.app.chat.OutgoingAttachment
12+
import ai.openclaw.app.chat.RoomChatCommandOutbox
1013
import ai.openclaw.app.chat.RoomChatTranscriptCache
1114
import ai.openclaw.app.gateway.DeviceAuthEntry
1215
import ai.openclaw.app.gateway.DeviceAuthStore
@@ -840,8 +843,14 @@ class NodeRuntime(
840843
}
841844
}
842845

846+
// One Room handle owns both chat stores (disposable transcript cache + durable command outbox).
847+
private val chatCacheDatabase: ChatCacheDatabase = ChatCacheDatabase.open(appContext)
848+
843849
private val chatTranscriptCache: RoomChatTranscriptCache =
844-
RoomChatTranscriptCache(context = appContext)
850+
RoomChatTranscriptCache(database = chatCacheDatabase)
851+
852+
private val chatCommandOutbox: RoomChatCommandOutbox =
853+
RoomChatCommandOutbox(database = chatCacheDatabase)
845854

846855
private val chat: ChatController =
847856
ChatController(
@@ -850,6 +859,7 @@ class NodeRuntime(
850859
json = json,
851860
transcriptCache = chatTranscriptCache,
852861
cacheScope = ::chatCacheScope,
862+
commandOutbox = chatCommandOutbox,
853863
).also {
854864
it.applyMainSessionKey(_mainSessionKey.value)
855865
}
@@ -1268,14 +1278,16 @@ class NodeRuntime(
12681278
fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value)
12691279

12701280
/** Clears setup credentials plus paired device tokens for both Android gateway roles. */
1271-
fun resetGatewaySetupAuth() {
1281+
suspend fun resetGatewaySetupAuth() {
12721282
prefs.clearGatewaySetupAuth()
12731283
val deviceId = identityStore.loadOrCreate().deviceId
12741284
deviceAuthStore.clearToken(deviceId, "node")
12751285
deviceAuthStore.clearToken(deviceId, "operator")
12761286
// A pairing/auth reset can precede pairing a different gateway principal at the same
1277-
// endpoint id; purge offline transcripts so they cannot surface under the new pairing.
1278-
scope.launch { runCatching { chatTranscriptCache.clearAll() } }
1287+
// endpoint id. The purge must complete before pairing continues: queued commands are
1288+
// replayed on reconnect, so an async purge could flush stale rows to the new principal.
1289+
runCatching { chatTranscriptCache.clearAll() }
1290+
runCatching { chatCommandOutbox.clearAll() }
12791291
}
12801292

12811293
/** Persists onboarding state; callers decide whether runtime startup is needed first. */
@@ -1310,6 +1322,11 @@ class NodeRuntime(
13101322
val chatSessions: StateFlow<List<ChatSessionEntry>> = chat.sessions
13111323
val pendingRunCount: StateFlow<Int> = chat.pendingRunCount
13121324
val chatCommands: StateFlow<List<ChatCommandEntry>> = chat.commands
1325+
val chatOutboxItems: StateFlow<List<ChatOutboxItem>> = chat.outboxItems
1326+
1327+
fun retryChatOutboxCommand(id: String) = chat.retryOutboxCommand(id)
1328+
1329+
fun deleteChatOutboxCommand(id: String) = chat.deleteOutboxCommand(id)
13131330

13141331
init {
13151332
if (prefs.voiceWakeMode.value != VoiceWakeMode.Off) {

0 commit comments

Comments
 (0)