Skip to content

Commit 09f9a85

Browse files
authored
feat(android): read-only offline cache for chat sessions and transcripts (#100227)
* feat(android): add read-only offline cache for chat sessions and transcripts Cache-first cold open plus offline browsing of the session list and cached transcripts. Room DB (KSP) scoped by stable gateway identity, bounded to 50 sessions / 200 text-only messages per session with eviction on write, destructive migrations only. Live chat.history/sessions.list responses replace cached rows wholesale via the existing generation-tracked reconciliation path; sending stays behind the existing health gate. Pairing/auth resets and gateway-side session deletes purge cached rows. Part of #100194 * chore(android): sync native i18n inventory * fix(android): bind transcript cache writes to gateway scope * docs(changelog): note Android offline chat cache * chore(android): refresh native i18n inventory * docs(changelog): keep Android offline cache unreleased * docs(changelog): drop PR-carried entry; release generation owns CHANGELOG
1 parent 426010d commit 09f9a85

9 files changed

Lines changed: 1064 additions & 87 deletions

File tree

apps/.i18n/native-source.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -123,23 +123,23 @@
123123
},
124124
{
125125
"kind": "ui-state-text",
126-
"line": 102,
126+
"line": 103,
127127
"path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt",
128128
"source": "Searching…",
129129
"surface": "android",
130130
"id": "native.android.dec357dfc9b8c0a5"
131131
},
132132
{
133133
"kind": "ui-state-text",
134-
"line": 191,
134+
"line": 192,
135135
"path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt",
136136
"source": "Mic off",
137137
"surface": "android",
138138
"id": "native.android.567482ea1118eb13"
139139
},
140140
{
141141
"kind": "ui-state-text",
142-
"line": 201,
142+
"line": 202,
143143
"path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt",
144144
"source": "Off",
145145
"surface": "android",
@@ -179,31 +179,31 @@
179179
},
180180
{
181181
"kind": "ui-state-text",
182-
"line": 514,
182+
"line": 516,
183183
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
184184
"source": "Offline",
185185
"surface": "android",
186186
"id": "native.android.c65b61de70a063e7"
187187
},
188188
{
189189
"kind": "conditional-branch",
190-
"line": 2049,
190+
"line": 2083,
191191
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
192192
"source": "Failed: this host requires wss:// or Tailscale Serve. No TLS endpoint detected.",
193193
"surface": "android",
194194
"id": "native.android.81009c40eed2216d"
195195
},
196196
{
197197
"kind": "conditional-branch",
198-
"line": 2051,
198+
"line": 2085,
199199
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
200200
"source": "Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry.",
201201
"surface": "android",
202202
"id": "native.android.467899bb510b8e34"
203203
},
204204
{
205205
"kind": "conditional-branch",
206-
"line": 2053,
206+
"line": 2087,
207207
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
208208
"source": "Failed: couldn't reach the secure gateway endpoint for this host.",
209209
"surface": "android",

apps/android/app/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ plugins {
5656
alias(libs.plugins.ktlint)
5757
alias(libs.plugins.kotlin.compose)
5858
alias(libs.plugins.kotlin.serialization)
59+
alias(libs.plugins.ksp)
5960
}
6061

6162
android {
@@ -217,6 +218,9 @@ dependencies {
217218
implementation(libs.kotlinx.serialization.json)
218219

219220
implementation(libs.androidx.security.crypto)
221+
// Read-only offline cache for chat sessions/transcripts (disposable, destructive migrations only).
222+
implementation(libs.androidx.room.runtime)
223+
ksp(libs.androidx.room.compiler)
220224
implementation(libs.androidx.exifinterface)
221225
implementation(libs.okhttp)
222226
implementation(libs.bcprov)

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import ai.openclaw.app.chat.ChatMessage
55
import ai.openclaw.app.chat.ChatPendingToolCall
66
import ai.openclaw.app.chat.ChatSessionEntry
77
import ai.openclaw.app.chat.OutgoingAttachment
8+
import ai.openclaw.app.chat.deleteChatTranscriptCacheDatabase
89
import ai.openclaw.app.gateway.DeviceAuthStore
910
import ai.openclaw.app.gateway.DeviceIdentityStore
1011
import ai.openclaw.app.gateway.GatewayEndpoint
@@ -306,6 +307,8 @@ class MainViewModel(
306307
val deviceAuthStore = DeviceAuthStore(prefs)
307308
deviceAuthStore.clearToken(deviceId, "node")
308309
deviceAuthStore.clearToken(deviceId, "operator")
310+
// No runtime means no open Room handle, so the cache file can be deleted directly.
311+
deleteChatTranscriptCacheDatabase(nodeApp)
309312
}
310313

311314
internal fun saveGatewayConfigAndConnect(plan: GatewayConnectPlan) {

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package ai.openclaw.app
22

3+
import ai.openclaw.app.chat.ChatCacheScope
34
import ai.openclaw.app.chat.ChatCommandEntry
45
import ai.openclaw.app.chat.ChatController
56
import ai.openclaw.app.chat.ChatMessage
67
import ai.openclaw.app.chat.ChatPendingToolCall
78
import ai.openclaw.app.chat.ChatSessionEntry
89
import ai.openclaw.app.chat.OutgoingAttachment
10+
import ai.openclaw.app.chat.RoomChatTranscriptCache
911
import ai.openclaw.app.gateway.DeviceAuthEntry
1012
import ai.openclaw.app.gateway.DeviceAuthStore
1113
import ai.openclaw.app.gateway.DeviceIdentityStore
@@ -661,6 +663,8 @@ class NodeRuntime(
661663
_gatewayUpdateAvailable.value = hello.updateAvailable
662664
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
663665
syncMainSessionKey(resolveAgentIdFromMainSessionKey(hello.mainSessionKey))
666+
// Every successful connection refreshes history, including reconnects whose main key did not change.
667+
chat.refresh()
664668
refreshGatewayControlPage()
665669
updateStatus {
666670
operatorConnectionProblem = null
@@ -836,14 +840,40 @@ class NodeRuntime(
836840
}
837841
}
838842

843+
private val chatTranscriptCache: RoomChatTranscriptCache =
844+
RoomChatTranscriptCache(context = appContext)
845+
839846
private val chat: ChatController =
840847
ChatController(
841848
scope = scope,
842849
session = operatorSession,
843850
json = json,
851+
transcriptCache = chatTranscriptCache,
852+
cacheScope = ::chatCacheScope,
844853
).also {
845854
it.applyMainSessionKey(_mainSessionKey.value)
846855
}
856+
857+
/**
858+
* Stable per-gateway scope for the offline chat cache; resolved per call so cached transcripts
859+
* never leak across gateways. Null (nothing paired/configured) disables cache reads and writes.
860+
*/
861+
private fun chatCacheGatewayId(): String? {
862+
connectedEndpoint?.stableId?.let { return it }
863+
if (manualEnabled.value) {
864+
val host = manualHost.value.trim()
865+
val port = manualPort.value
866+
if (host.isEmpty() || port !in 1..65535) return null
867+
return GatewayEndpoint.manual(host = host, port = port).stableId
868+
}
869+
return lastDiscoveredStableId.value.trim().takeIf { it.isNotEmpty() }
870+
}
871+
872+
private fun chatCacheScope(): ChatCacheScope? =
873+
chatCacheGatewayId()?.let { gatewayId ->
874+
ChatCacheScope(gatewayId = gatewayId, connectionGeneration = connectAttemptSeq.get())
875+
}
876+
847877
private val voiceReplySpeakerLazy: Lazy<TalkModeManager> =
848878
lazy {
849879
// Reuse the existing TalkMode speech engine for native Android TTS playback
@@ -1243,6 +1273,9 @@ class NodeRuntime(
12431273
val deviceId = identityStore.loadOrCreate().deviceId
12441274
deviceAuthStore.clearToken(deviceId, "node")
12451275
deviceAuthStore.clearToken(deviceId, "operator")
1276+
// 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() } }
12461279
}
12471280

12481281
/** Persists onboarding state; callers decide whether runtime startup is needed first. */
@@ -1928,6 +1961,7 @@ class NodeRuntime(
19281961
notificationOutbox.clear()
19291962
invalidateNodeCapabilityApprovalState()
19301963
val connectAttemptId = connectAttemptSeq.incrementAndGet()
1964+
chat.onGatewayScopeChanging()
19311965
_pendingGatewayTrust.value = null
19321966
val tls = connectionManager.resolveTlsParams(endpoint)
19331967
if (tls?.required == true) {
@@ -2113,6 +2147,7 @@ class NodeRuntime(
21132147
fun disconnect() {
21142148
notificationOutbox.clear()
21152149
connectAttemptSeq.incrementAndGet()
2150+
chat.onGatewayScopeChanging()
21162151
stopActiveVoiceSession()
21172152
connectedEndpoint = null
21182153
_gatewayControlPage.value = null

0 commit comments

Comments
 (0)