feat(android): read-only offline cache for chat sessions and transcripts#100227
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: af990f29bd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| private val chatTranscriptCache: RoomChatTranscriptCache = | ||
| RoomChatTranscriptCache( | ||
| context = appContext, | ||
| gatewayId = ::chatCacheGatewayId, |
There was a problem hiding this comment.
Bind cache writes to the request gateway
When a chat.history/sessions.list request to gateway A is in flight and the user starts connecting or pairing gateway B, connectAfterTlsCheck updates connectedEndpoint before the old response necessarily returns, while ChatController.persistTranscript/persistSessions write after parsing the response. Because this cache resolves gatewayId through the live getter here, the stale A response can be saved under B's stable id and later replay A's transcript during an offline B cold open. Capture the gateway id or connection epoch at the start of the history/session load and drop responses that no longer match before writing the cache.
Useful? React with 👍 / 👎.
|
Codex review: needs real behavior proof before merge. Reviewed July 5, 2026, 4:10 PM ET / 20:10 UTC. Summary PR surface: Other +977. Total +977 across 9 files. Reproducibility: yes. for the review blockers by source inspection: the pending-connect cache scope, async purge, session replacement, and Sessions-tab refresh paths are visible at the current PR head. I did not run an emulator or device reproduction because this review is read-only. Review metrics: 2 noteworthy metrics.
Stored data model Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Proof guidance:
Mantis proof suggestion Risk before merge
Maintainer options:
Next step before merge
Maintainer decision needed
Security Review findings
Review detailsBest possible solution: Land only after cache reads are blocked during pending connects, reset purges are ordered before reconnect/cache reads, cached deep sessions and direct Sessions-tab offline entry work, real Android proof is attached, and maintainers accept the transcript-at-rest boundary. Do we have a high-confidence way to reproduce the issue? Yes for the review blockers by source inspection: the pending-connect cache scope, async purge, session replacement, and Sessions-tab refresh paths are visible at the current PR head. I did not run an emulator or device reproduction because this review is read-only. Is this the best way to solve the issue? No. The Room cache-first design is plausible, but it is not the best mergeable form until cache isolation, reset ordering, deep-session retention, Sessions-tab priming, real Android proof, and transcript-at-rest acceptance are resolved. Full review comments:
Overall correctness: patch is incorrect AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against a04b6ced4f96. Label changesLabel justifications:
Evidence reviewedPR surface: Other +977. Total +977 across 9 files. View PR surface stats
Security concerns:
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
Review history (8 earlier review cycles)
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e0701c7dc5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| deviceAuthStore.clearToken(deviceId, "operator") | ||
| // A pairing/auth reset can precede pairing a different gateway principal at the same | ||
| // endpoint id; purge offline transcripts so they cannot surface under the new pairing. | ||
| scope.launch { runCatching { chatTranscriptCache.clearAll() } } |
There was a problem hiding this comment.
Await cache purge before reconnecting
When saved auth is replaced through the setup flow, MainViewModel.saveGatewayConfigAndConnect calls this method and then immediately writes the new endpoint/auth and reconnects. Because the transcript purge is only scheduled here, a re-pair to a different gateway principal at the same stable id can load the old cache before clearAll() runs (or keep it if the process dies first), so private transcripts can survive the very reset path that is supposed to prevent cross-principal replay. Make the auth-reset path wait for the Room purge before it returns or before any reconnect/cache prime can happen.
Useful? React with 👍 / 👎.
e0701c7 to
a19a3d4
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a19a3d4ba9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| dao.deleteSessions(gateway) | ||
| dao.insertSessions(rows) | ||
| dao.evictOrphanedTranscripts(gateway) |
There was a problem hiding this comment.
Preserve active deep transcripts during list refresh
When a user opens a session that is not in the newest cached window, saveTranscript() first creates a stub so that deep session remains reachable, but a subsequent session-list refresh from ChatScreen/SessionsScreen replaces all session rows here and then drops orphaned transcripts. If the refreshed top-50 list does not include the active deep session, the just-saved transcript is deleted, so the current session will not be available on the next offline cold open. Preserve the active/stubbed session while replacing the list, or avoid orphan eviction for transcripts that were explicitly cached from a live history load.
Useful? React with 👍 / 👎.
|
Land-ready after the Android cache review and repair.
Known proof gap: no emulator was installed and the available ADB target remained unauthorized, so this has no on-device screenshot/instrumentation evidence. |
There was a problem hiding this comment.
💡 Codex Review
For migrations where the old BlueBubbles config had allowFrom but no groupAllowFrom, this example tells users to write groupAllowFrom: [], but the new behavior above says an explicit empty array disables the allowFrom fallback. The runtime does exactly that (configuredGroupAllowFrom is non-null in extensions/imessage/src/monitor/monitor-provider.ts:452-457), so following this step silently drops every group message under groupPolicy: "allowlist"; omit the key when copying no group allowlist, or populate it with the allowed senders/chat targets.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
df9eb0e to
b658f9b
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b658f9b06a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| implementation(libs.androidx.security.crypto) | ||
| // Read-only offline cache for chat sessions/transcripts (disposable, destructive migrations only). | ||
| implementation(libs.androidx.room.runtime) |
There was a problem hiding this comment.
Add the Room notice to the Android license assets
Because this adds androidx.room:room-runtime to the packaged Android app, the scoped guide in apps/android/AGENTS.md requires auditing/updating apps/android/THIRD_PARTY_LICENSES/openclaw/licenses/ when Android dependencies change. The Licenses screen is backed only by those .txt assets (as reflected by AndroidLicenseNoticesTest's expected titles), and there is currently no Room/AndroidX notice, so release builds would ship the new Room runtime without a visible third-party license entry.
Useful? React with 👍 / 👎.
b658f9b to
f2d1528
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f2d1528b70
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| junit = "4.13.2" | ||
| junit-vintage = "6.1.0" | ||
| kotest = "6.1.11" | ||
| ksp = "2.3.9" |
There was a problem hiding this comment.
Pin KSP to a published plugin coordinate
Adding alias(libs.plugins.ksp) makes every Android Gradle task resolve com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:2.3.9 during configuration; I ran ./gradlew :app:tasks --no-daemon --stacktrace from apps/android and Gradle fails before creating the task graph because version 2.3.9 is not found in Google, Maven Central, or the plugin portal. Until this is changed to a published KSP version compatible with the Kotlin plugin, no Android build or test can run.
Useful? React with 👍 / 👎.
| if (_sessions.value.isEmpty()) { | ||
| val cachedSessions = runCatching { cache.loadSessions(requestCacheScope.gatewayId) }.getOrDefault(emptyList()) |
There was a problem hiding this comment.
Prime cached sessions outside chat history loads
Because cached sessions are only read inside primeFromCache, an offline cold start that goes straight to the Sessions tab never populates the list: SessionsScreen skips refreshChatSessions when disconnected, and no chat-history bootstrap has run to reach this branch. Load cached session rows from the refreshSessions path as well, or prime them on Sessions entry, so the advertised offline session browsing works before the user opens Chat.
Useful? React with 👍 / 👎.
f2d1528 to
b9fc80e
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b9fc80e6cc
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| * never leak across gateways. Null (nothing paired/configured) disables cache reads and writes. | ||
| */ | ||
| private fun chatCacheGatewayId(): String? { | ||
| connectedEndpoint?.stableId?.let { return it } |
There was a problem hiding this comment.
Clear the old gateway scope before pending connects
When a user starts connecting or re-pairing gateway B while gateway A is still in connectedEndpoint, beginConnect only clears chat state and connectedEndpoint is not replaced until connectAfterTlsCheck finishes the TLS probe/trust flow. Any Chat load/refresh during that pending window reaches this branch, resolves A's stable id, and primeFromCache/fetchSessions can repopulate A's cached sessions or transcript after the UI was cleared for B. Clear or mark the pending endpoint before cache scopes are resolved so an in-progress gateway switch cannot surface the previous gateway's transcript.
Useful? React with 👍 / 👎.
b9fc80e to
17b5cd4
Compare
…scripts 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
17b5cd4 to
a259a40
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a259a40899
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| deviceAuthStore.clearToken(deviceId, "node") | ||
| deviceAuthStore.clearToken(deviceId, "operator") | ||
| // No runtime means no open Room handle, so the cache file can be deleted directly. | ||
| deleteChatTranscriptCacheDatabase(nodeApp) |
There was a problem hiding this comment.
Check the process runtime before deleting the cache DB
When a ViewModel is recreated while NodeApp still owns a live NodeRuntime, runtimeRef.value is still null until ensureRuntime() is called, so this path can delete the Room database file even though the existing runtime already has RoomChatTranscriptCache open. In that re-pair/setup flow the purge may not actually clear the open cache handle before saveGatewayConfigAndConnect() immediately reuses the singleton runtime, leaving old transcripts available under the new pairing; use the app singleton runtime (peekRuntime()/ensureRuntime() as appropriate) and clear through Room instead of assuming a null ViewModel ref means no open handle.
Useful? React with 👍 / 👎.
|
Merged via squash.
|
…pts (openclaw#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 openclaw#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
What Problem This Solves
Cold-opening the chat tab shows a blank screen until the gateway responds, and when the gateway is unreachable there is nothing to read at all. Previously fetched conversations were held only in memory and lost on every process restart.
Part of #100194
Why This Change Was Made
The app already has everything needed for a read-only cache: a stable per-gateway endpoint identity (the same id used for TLS fingerprint pinning) and a history reconciliation path that drops stale responses via generation tracking. This change adds a small, disposable Room database (androidx.room via KSP) that write-throughs live
chat.history/sessions.listresults and replays them on the next cold open. No merge algorithm was invented: cached rows are emitted first, then the live response replaces them wholesale through the existing reconciliation path, which also keeps list item identity stable. The cache is bounded (50 sessions, 200 newest messages per session, eviction on write), schema-versioned with destructive migration only, and stores text parts only — attachments and binary content never reach disk. Cache rows are keyed by the gateway's stable identity so transcripts cannot cross gateways; pairing/auth resets purge the database, and gateway-side session deletes purge the matching cached rows.User Impact
Evidence
cd apps/android && ./gradlew :app:testPlayDebugUnitTest --tests 'ai.openclaw.app.chat.*'— green (59 tests, 13 new); full:app:testPlayDebugUnitTestsuite green (run twice, incl. after final changes).RoomChatTranscriptCacheTest(round-trip, text-only rows, 200/50 bounds + eviction incl. full-cache deep-session case, gateway-identity scoping, delete/clear purges) andChatControllerTranscriptCacheTest(offline cold open shows cache + send stays blocked, cache-first emission then wholesale live replacement with stable message ids and write-through, offline session switch, delete-event purge).Known gaps: sub-coroutine-step cross-gateway write race during an active gateway switch (self-heals on next write; strict fix needs connection-epoch plumbing — follow-up); per-message events deliberately not written through (cache refreshes on each online open); no on-device instrumentation pass.