diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 724c5251844b..2855927e0e6d 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -123,7 +123,7 @@ }, { "kind": "ui-state-text", - "line": 102, + "line": 103, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Searching…", "surface": "android", @@ -131,7 +131,7 @@ }, { "kind": "ui-state-text", - "line": 191, + "line": 192, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Mic off", "surface": "android", @@ -139,7 +139,7 @@ }, { "kind": "ui-state-text", - "line": 201, + "line": 202, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Off", "surface": "android", @@ -179,7 +179,7 @@ }, { "kind": "ui-state-text", - "line": 514, + "line": 516, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Offline", "surface": "android", @@ -187,7 +187,7 @@ }, { "kind": "conditional-branch", - "line": 2049, + "line": 2083, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: this host requires wss:// or Tailscale Serve. No TLS endpoint detected.", "surface": "android", @@ -195,7 +195,7 @@ }, { "kind": "conditional-branch", - "line": 2051, + "line": 2085, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry.", "surface": "android", @@ -203,7 +203,7 @@ }, { "kind": "conditional-branch", - "line": 2053, + "line": 2087, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: couldn't reach the secure gateway endpoint for this host.", "surface": "android", diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 4c0e91ba30ce..9992e747d70c 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -56,6 +56,7 @@ plugins { alias(libs.plugins.ktlint) alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) } android { @@ -217,6 +218,9 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(libs.androidx.security.crypto) + // Read-only offline cache for chat sessions/transcripts (disposable, destructive migrations only). + implementation(libs.androidx.room.runtime) + ksp(libs.androidx.room.compiler) implementation(libs.androidx.exifinterface) implementation(libs.okhttp) implementation(libs.bcprov) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index a3a0ce9b4016..fdeef3980c2c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -5,6 +5,7 @@ import ai.openclaw.app.chat.ChatMessage import ai.openclaw.app.chat.ChatPendingToolCall import ai.openclaw.app.chat.ChatSessionEntry import ai.openclaw.app.chat.OutgoingAttachment +import ai.openclaw.app.chat.deleteChatTranscriptCacheDatabase import ai.openclaw.app.gateway.DeviceAuthStore import ai.openclaw.app.gateway.DeviceIdentityStore import ai.openclaw.app.gateway.GatewayEndpoint @@ -306,6 +307,8 @@ class MainViewModel( val deviceAuthStore = DeviceAuthStore(prefs) 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) } internal fun saveGatewayConfigAndConnect(plan: GatewayConnectPlan) { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 77138865db73..cf8db48a1b98 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -1,11 +1,13 @@ package ai.openclaw.app +import ai.openclaw.app.chat.ChatCacheScope import ai.openclaw.app.chat.ChatCommandEntry import ai.openclaw.app.chat.ChatController import ai.openclaw.app.chat.ChatMessage import ai.openclaw.app.chat.ChatPendingToolCall import ai.openclaw.app.chat.ChatSessionEntry import ai.openclaw.app.chat.OutgoingAttachment +import ai.openclaw.app.chat.RoomChatTranscriptCache import ai.openclaw.app.gateway.DeviceAuthEntry import ai.openclaw.app.gateway.DeviceAuthStore import ai.openclaw.app.gateway.DeviceIdentityStore @@ -661,6 +663,8 @@ class NodeRuntime( _gatewayUpdateAvailable.value = hello.updateAvailable _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB syncMainSessionKey(resolveAgentIdFromMainSessionKey(hello.mainSessionKey)) + // Every successful connection refreshes history, including reconnects whose main key did not change. + chat.refresh() refreshGatewayControlPage() updateStatus { operatorConnectionProblem = null @@ -836,14 +840,40 @@ class NodeRuntime( } } + private val chatTranscriptCache: RoomChatTranscriptCache = + RoomChatTranscriptCache(context = appContext) + private val chat: ChatController = ChatController( scope = scope, session = operatorSession, json = json, + transcriptCache = chatTranscriptCache, + cacheScope = ::chatCacheScope, ).also { it.applyMainSessionKey(_mainSessionKey.value) } + + /** + * Stable per-gateway scope for the offline chat cache; resolved per call so cached transcripts + * never leak across gateways. Null (nothing paired/configured) disables cache reads and writes. + */ + private fun chatCacheGatewayId(): String? { + connectedEndpoint?.stableId?.let { return it } + if (manualEnabled.value) { + val host = manualHost.value.trim() + val port = manualPort.value + if (host.isEmpty() || port !in 1..65535) return null + return GatewayEndpoint.manual(host = host, port = port).stableId + } + return lastDiscoveredStableId.value.trim().takeIf { it.isNotEmpty() } + } + + private fun chatCacheScope(): ChatCacheScope? = + chatCacheGatewayId()?.let { gatewayId -> + ChatCacheScope(gatewayId = gatewayId, connectionGeneration = connectAttemptSeq.get()) + } + private val voiceReplySpeakerLazy: Lazy = lazy { // Reuse the existing TalkMode speech engine for native Android TTS playback @@ -1243,6 +1273,9 @@ class NodeRuntime( val deviceId = identityStore.loadOrCreate().deviceId deviceAuthStore.clearToken(deviceId, "node") 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() } } } /** Persists onboarding state; callers decide whether runtime startup is needed first. */ @@ -1928,6 +1961,7 @@ class NodeRuntime( notificationOutbox.clear() invalidateNodeCapabilityApprovalState() val connectAttemptId = connectAttemptSeq.incrementAndGet() + chat.onGatewayScopeChanging() _pendingGatewayTrust.value = null val tls = connectionManager.resolveTlsParams(endpoint) if (tls?.required == true) { @@ -2113,6 +2147,7 @@ class NodeRuntime( fun disconnect() { notificationOutbox.clear() connectAttemptSeq.incrementAndGet() + chat.onGatewayScopeChanging() stopActiveVoiceSession() connectedEndpoint = null _gatewayControlPage.value = null diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index e4d6c0cec659..cdffeee31c43 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -22,19 +22,31 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong +// Capture before suspend points; both fields must still match before gateway data reaches UI state. +internal data class ChatCacheScope( + val gatewayId: String, + val connectionGeneration: Long, +) + class ChatController internal constructor( private val scope: CoroutineScope, private val json: Json, private val requestGateway: suspend (method: String, paramsJson: String?) -> String, + private val transcriptCache: ChatTranscriptCache? = null, + private val cacheScope: () -> ChatCacheScope? = { null }, ) { - constructor( + internal constructor( scope: CoroutineScope, session: GatewaySession, json: Json, + transcriptCache: ChatTranscriptCache? = null, + cacheScope: () -> ChatCacheScope? = { null }, ) : this( scope = scope, json = json, requestGateway = { method, paramsJson -> session.request(method, paramsJson) }, + transcriptCache = transcriptCache, + cacheScope = cacheScope, ) private var appliedMainSessionKey = "main" @@ -47,6 +59,10 @@ class ChatController internal constructor( private val _messages = MutableStateFlow>(emptyList()) val messages: StateFlow> = _messages.asStateFlow() + // True while the transcript shown came from the offline cache and no live history replaced it yet. + private val _messagesFromCache = MutableStateFlow(false) + val messagesFromCache: StateFlow = _messagesFromCache.asStateFlow() + private val _historyLoading = MutableStateFlow(false) val historyLoading: StateFlow = _historyLoading.asStateFlow() @@ -84,6 +100,7 @@ class ChatController internal constructor( // Drops stale history responses after session switches or refresh races. private val historyLoadGeneration = AtomicLong(0) + private val gatewayScopeApplyLock = Any() private val newChatCreateInFlight = AtomicBoolean(false) private var lastHealthPollAtMs: Long? = null @@ -108,6 +125,18 @@ class ChatController internal constructor( _sessionId.value = null } + /** Invalidates and clears gateway-bound UI state before a target switch can race old responses. */ + fun onGatewayScopeChanging() { + synchronized(gatewayScopeApplyLock) { + beginHistoryLoad( + key = normalizeRequestedSessionKey(_sessionKey.value), + clearMessages = true, + markLoading = false, + ) + _sessions.value = emptyList() + } + } + /** Loads a chat session, normalizing "main" to the current gateway-provided main session key. */ fun load(sessionKey: String) { val key = normalizeRequestedSessionKey(sessionKey) @@ -227,6 +256,7 @@ class ChatController internal constructor( private fun beginHistoryLoad( key: String, clearMessages: Boolean, + markLoading: Boolean = true, ): Long { val generation = historyLoadGeneration.incrementAndGet() _sessionKey.value = key @@ -242,9 +272,10 @@ class ChatController internal constructor( publishPendingToolCalls() _streamingAssistantText.value = null _sessionId.value = null - _historyLoading.value = true + _historyLoading.value = markLoading if (clearMessages) { _messages.value = emptyList() + _messagesFromCache.value = false } return generation } @@ -483,24 +514,12 @@ class ChatController internal constructor( forceHealth: Boolean, refreshSessions: Boolean, ) { + // Cache-first cold open: prime before the live request so ordering is deterministic and the + // live chat.history response always replaces cached rows wholesale. + primeFromCache(sessionKey, generation) try { - val historyJson = - requestGateway( - "chat.history", - buildJsonObject { put("sessionKey", JsonPrimitive(sessionKey)) }.toString(), - ) - if (!isCurrentHistoryLoad(sessionKey, _sessionKey.value, generation, historyLoadGeneration.get())) return - val history = parseHistory(historyJson, sessionKey = sessionKey, previousMessages = _messages.value) - updateSessionFromHistory(history) - prunePersistedOptimisticMessages(history.messages) - _messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values) - _sessionId.value = history.sessionId - adoptInFlightRun(history.inFlightRun) + if (!fetchAndApplyHistory(sessionKey, generation, updateSessionInfo = true)) return _historyLoading.value = false - history.thinkingLevel - ?.trim() - ?.takeIf { it.isNotEmpty() } - ?.let { _thinkingLevel.value = it } pollHealthIfNeeded(force = forceHealth) if (refreshSessions) { @@ -513,8 +532,107 @@ class ChatController internal constructor( } } + /** + * Requests live history and applies it to controller state, replacing any cached transcript. + * Returns false when a newer load superseded this request (stale responses are dropped). + */ + private suspend fun fetchAndApplyHistory( + sessionKey: String, + generation: Long, + updateSessionInfo: Boolean, + ): Boolean { + val requestCacheScope = currentCacheScope() + val historyJson = + requestGateway( + "chat.history", + buildJsonObject { put("sessionKey", JsonPrimitive(sessionKey)) }.toString(), + ) + val history = parseHistory(historyJson, sessionKey = sessionKey, previousMessages = _messages.value) + val applied = + synchronized(gatewayScopeApplyLock) { + if ( + !isCurrentHistoryLoad(sessionKey, _sessionKey.value, generation, historyLoadGeneration.get()) || + requestCacheScope != currentCacheScope() + ) { + return@synchronized false + } + if (updateSessionInfo) { + updateSessionFromHistory(history) + } + prunePersistedOptimisticMessages(history.messages) + _messagesFromCache.value = false + _messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values) + _sessionId.value = history.sessionId + // All live history paths (bootstrap, reconnect recovery, cache-first + // replace) adopt the gateway's in-flight run snapshot so restored + // runs keep their pending state and streaming text. + adoptInFlightRun(history.inFlightRun) + history.thinkingLevel + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { _thinkingLevel.value = it } + true + } + if (!applied) return false + persistTranscript(requestCacheScope, sessionKey, history.messages) + return true + } + + /** Emits cached transcript/session rows for instant cold open; live data replaces them wholesale. */ + private suspend fun primeFromCache( + sessionKey: String, + generation: Long, + ) { + val cache = transcriptCache ?: return + val requestCacheScope = currentCacheScope() ?: return + if (_messages.value.isEmpty()) { + val cached = runCatching { cache.loadTranscript(requestCacheScope.gatewayId, sessionKey) }.getOrDefault(emptyList()) + synchronized(gatewayScopeApplyLock) { + if ( + cached.isNotEmpty() && + _messages.value.isEmpty() && + requestCacheScope == currentCacheScope() && + isCurrentHistoryLoad(sessionKey, _sessionKey.value, generation, historyLoadGeneration.get()) + ) { + _messagesFromCache.value = true + _messages.value = cached + } + } + } + if (_sessions.value.isEmpty()) { + val cachedSessions = runCatching { cache.loadSessions(requestCacheScope.gatewayId) }.getOrDefault(emptyList()) + synchronized(gatewayScopeApplyLock) { + if (cachedSessions.isNotEmpty() && _sessions.value.isEmpty() && requestCacheScope == currentCacheScope()) { + _sessions.value = cachedSessions + } + } + } + } + + // Write-through uses the scope captured before the live request. Re-resolving here could put + // an old response under a newly selected gateway. Failures are ignored: the cache is disposable. + private suspend fun persistTranscript( + requestCacheScope: ChatCacheScope?, + sessionKey: String, + messages: List, + ) { + val cache = transcriptCache ?: return + val gatewayId = requestCacheScope?.gatewayId ?: return + runCatching { cache.saveTranscript(gatewayId, sessionKey, messages) } + } + + private suspend fun persistSessions( + requestCacheScope: ChatCacheScope?, + sessions: List, + ) { + val cache = transcriptCache ?: return + val gatewayId = requestCacheScope?.gatewayId ?: return + runCatching { cache.saveSessions(gatewayId, sessions) } + } + private suspend fun fetchSessions(limit: Int?) { try { + val requestCacheScope = currentCacheScope() val params = buildJsonObject { put("includeGlobal", JsonPrimitive(true)) @@ -522,7 +640,15 @@ class ChatController internal constructor( if (limit != null && limit > 0) put("limit", JsonPrimitive(limit)) } val res = requestGateway("sessions.list", params.toString()) - _sessions.value = parseSessions(res) + val sessions = parseSessions(res) + val applied = + synchronized(gatewayScopeApplyLock) { + if (requestCacheScope != currentCacheScope()) return@synchronized false + _sessions.value = sessions + true + } + if (!applied) return + persistSessions(requestCacheScope, sessions) } catch (_: Throwable) { // best-effort } @@ -608,37 +734,11 @@ class ChatController internal constructor( _streamingAssistantText.value = null scope.launch { try { - val currentSessionKey = _sessionKey.value - val currentGeneration = historyLoadGeneration.get() - val historyJson = - requestGateway( - "chat.history", - buildJsonObject { put("sessionKey", JsonPrimitive(currentSessionKey)) }.toString(), - ) - if ( - !isCurrentHistoryLoad( - currentSessionKey, - _sessionKey.value, - currentGeneration, - historyLoadGeneration.get(), - ) - ) { - return@launch - } - val history = - parseHistory( - historyJson, - sessionKey = currentSessionKey, - previousMessages = _messages.value, - ) - updateSessionFromHistory(history) - prunePersistedOptimisticMessages(history.messages) - _messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values) - _sessionId.value = history.sessionId - history.thinkingLevel - ?.trim() - ?.takeIf { it.isNotEmpty() } - ?.let { _thinkingLevel.value = it } + fetchAndApplyHistory( + sessionKey = _sessionKey.value, + generation = historyLoadGeneration.get(), + updateSessionInfo = true, + ) } catch (_: Throwable) { // best-effort } @@ -816,36 +916,13 @@ class ChatController internal constructor( private fun refreshCurrentHistoryBestEffort() { scope.launch { try { - val currentSessionKey = _sessionKey.value - val currentGeneration = historyLoadGeneration.get() - val historyJson = - requestGateway( - "chat.history", - buildJsonObject { put("sessionKey", JsonPrimitive(currentSessionKey)) }.toString(), - ) - if ( - !isCurrentHistoryLoad( - currentSessionKey, - _sessionKey.value, - currentGeneration, - historyLoadGeneration.get(), - ) - ) { - return@launch - } - val history = - parseHistory( - historyJson, - sessionKey = currentSessionKey, - previousMessages = _messages.value, - ) - prunePersistedOptimisticMessages(history.messages) - _messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values) - _sessionId.value = history.sessionId - history.thinkingLevel - ?.trim() - ?.takeIf { it.isNotEmpty() } - ?.let { _thinkingLevel.value = it } + fetchAndApplyHistory( + sessionKey = _sessionKey.value, + generation = historyLoadGeneration.get(), + // Intentionally skips session-info upserts: post-send refreshes should not reorder the + // session list; sessions.changed events own that. + updateSessionInfo = false, + ) } catch (_: Throwable) { // best-effort } @@ -960,6 +1037,19 @@ class ChatController internal constructor( private fun removeSessionEntry(sessionKey: String?) { val key = sessionKey?.trim()?.takeIf { it.isNotEmpty() } ?: return _sessions.value = _sessions.value.filterNot { it.key == key } + // Gateway-side deletes must also purge the offline copy, or the deleted transcript would + // reappear on the next offline cold open. + val cache = transcriptCache ?: return + val requestCacheScope = currentCacheScope() ?: return + scope.launch { + runCatching { cache.deleteSession(requestCacheScope.gatewayId, key) } + } + } + + private fun currentCacheScope(): ChatCacheScope? { + val scope = cacheScope() ?: return null + val gatewayId = scope.gatewayId.trim().takeIf { it.isNotEmpty() } ?: return null + return if (gatewayId == scope.gatewayId) scope else scope.copy(gatewayId = gatewayId) } private fun normalizeThinking(raw: String): String = diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatTranscriptCache.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatTranscriptCache.kt new file mode 100644 index 000000000000..7f73db2ac046 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatTranscriptCache.kt @@ -0,0 +1,312 @@ +package ai.openclaw.app.chat + +import android.content.Context +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.withTransaction +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import java.util.UUID + +/** Upper bound of cached session rows per gateway; oldest list positions are evicted on write. */ +internal const val MAX_CACHED_SESSIONS = 50 + +internal const val CHAT_TRANSCRIPT_CACHE_DB_NAME = "chat-transcript-cache.db" + +/** + * Deletes the cache database file outright. Only safe while no [RoomChatTranscriptCache] is open + * in this process; used by pairing-reset paths that run before the node runtime exists. + */ +internal fun deleteChatTranscriptCacheDatabase(context: Context) { + context.deleteDatabase(CHAT_TRANSCRIPT_CACHE_DB_NAME) +} + +/** Upper bound of cached transcript rows per session; only the newest messages are kept. */ +internal const val MAX_CACHED_MESSAGES_PER_SESSION = 200 + +/** + * Read-only offline cache of chat sessions and transcripts. + * + * The cache is disposable: it only speeds up cold open and enables offline browsing. + * Live `chat.history` / `sessions.list` responses always replace cached data wholesale. + */ +interface ChatTranscriptCache { + suspend fun loadSessions(gatewayId: String): List + + suspend fun loadTranscript( + gatewayId: String, + sessionKey: String, + ): List + + suspend fun saveSessions( + gatewayId: String, + sessions: List, + ) + + suspend fun saveTranscript( + gatewayId: String, + sessionKey: String, + messages: List, + ) + + /** Removes one session and its transcript, so gateway-side deletes also purge offline copies. */ + suspend fun deleteSession( + gatewayId: String, + sessionKey: String, + ) + + /** Purges every cached row for all gateways; used when pairing/auth state is reset. */ + suspend fun clearAll() +} + +@Entity(tableName = "cached_sessions", primaryKeys = ["gatewayId", "sessionKey"]) +internal data class CachedSessionEntity( + val gatewayId: String, + val sessionKey: String, + val displayName: String?, + val updatedAtMs: Long?, + // Preserves gateway list order so offline session rows render in the familiar order. + val rowOrder: Int, +) + +@Entity(tableName = "cached_messages", primaryKeys = ["gatewayId", "sessionKey", "rowOrder"]) +internal data class CachedMessageEntity( + val gatewayId: String, + val sessionKey: String, + val rowOrder: Int, + val role: String, + // JSON array of text part strings; attachments/binary parts are never persisted. + val textPartsJson: String, + val timestampMs: Long?, + // Kept so live history reconciliation can match cached rows by identity key. + val idempotencyKey: String?, +) + +@Dao +internal interface ChatCacheDao { + @Query("SELECT * FROM cached_sessions WHERE gatewayId = :gatewayId ORDER BY rowOrder ASC") + suspend fun sessions(gatewayId: String): List + + @Query( + "SELECT * FROM cached_messages WHERE gatewayId = :gatewayId AND sessionKey = :sessionKey ORDER BY rowOrder ASC", + ) + suspend fun messages( + gatewayId: String, + sessionKey: String, + ): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSessions(rows: List) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertSessionStub(row: CachedSessionEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertMessages(rows: List) + + @Query("DELETE FROM cached_sessions WHERE gatewayId = :gatewayId") + suspend fun deleteSessions(gatewayId: String) + + @Query("DELETE FROM cached_sessions") + suspend fun deleteAllSessions() + + @Query("DELETE FROM cached_messages") + suspend fun deleteAllMessages() + + @Query("DELETE FROM cached_sessions WHERE gatewayId = :gatewayId AND sessionKey = :sessionKey") + suspend fun deleteSessionRow( + gatewayId: String, + sessionKey: String, + ) + + @Query("DELETE FROM cached_messages WHERE gatewayId = :gatewayId AND sessionKey = :sessionKey") + suspend fun deleteTranscript( + gatewayId: String, + sessionKey: String, + ) + + @Query("SELECT COALESCE(MAX(rowOrder), -1) + 1 FROM cached_sessions WHERE gatewayId = :gatewayId") + suspend fun nextSessionRowOrder(gatewayId: String): Int + + // Keeps the just-written session even when the cache is full: without the exclusion, a stub + // inserted at the highest rowOrder would be evicted immediately and deep-session transcripts + // could never be cached once MAX_CACHED_SESSIONS rows exist. + @Query( + "DELETE FROM cached_sessions WHERE gatewayId = :gatewayId AND sessionKey != :keepSessionKey AND sessionKey NOT IN " + + "(SELECT sessionKey FROM cached_sessions WHERE gatewayId = :gatewayId AND sessionKey != :keepSessionKey " + + "ORDER BY rowOrder ASC LIMIT :keep)", + ) + suspend fun evictSessionsBeyondKeeping( + gatewayId: String, + keepSessionKey: String, + keep: Int, + ) + + // Transcripts must never outlive their session row; this keeps total cache size bounded + // by MAX_CACHED_SESSIONS * MAX_CACHED_MESSAGES_PER_SESSION rows per gateway. + @Query( + "DELETE FROM cached_messages WHERE gatewayId = :gatewayId AND sessionKey NOT IN " + + "(SELECT sessionKey FROM cached_sessions WHERE gatewayId = :gatewayId)", + ) + suspend fun evictOrphanedTranscripts(gatewayId: String) +} + +@Database( + entities = [CachedSessionEntity::class, CachedMessageEntity::class], + version = 1, + exportSchema = false, +) +internal abstract class ChatCacheDatabase : RoomDatabase() { + abstract fun dao(): ChatCacheDao + + companion object { + fun open(context: Context): ChatCacheDatabase = + Room + .databaseBuilder(context, ChatCacheDatabase::class.java, CHAT_TRANSCRIPT_CACHE_DB_NAME) + // The cache is disposable by contract: any schema bump drops and rebuilds instead of migrating. + .fallbackToDestructiveMigration(dropAllTables = true) + .build() + } +} + +/** + * Room-backed [ChatTranscriptCache]. Callers bind every operation to the gateway scope captured + * before their suspend point, so a connection switch cannot re-scope an old response. + */ +class RoomChatTranscriptCache internal constructor( + private val database: ChatCacheDatabase, +) : ChatTranscriptCache { + constructor(context: Context) : this(database = ChatCacheDatabase.open(context)) + + private val json = Json + private val textPartsSerializer = ListSerializer(String.serializer()) + + override suspend fun loadSessions(gatewayId: String): List { + val gateway = scopedGatewayId(gatewayId) ?: return emptyList() + return database.dao().sessions(gateway).map { row -> + ChatSessionEntry( + key = row.sessionKey, + updatedAtMs = row.updatedAtMs, + displayName = row.displayName, + ) + } + } + + override suspend fun loadTranscript( + gatewayId: String, + sessionKey: String, + ): List { + val gateway = scopedGatewayId(gatewayId) ?: return emptyList() + val key = sessionKey.trim().takeIf { it.isNotEmpty() } ?: return emptyList() + return database.dao().messages(gateway, key).map { row -> + ChatMessage( + id = UUID.randomUUID().toString(), + role = row.role, + content = decodeTextParts(row.textPartsJson).map { ChatMessageContent(type = "text", text = it) }, + timestampMs = row.timestampMs, + idempotencyKey = row.idempotencyKey, + ) + } + } + + override suspend fun saveSessions( + gatewayId: String, + sessions: List, + ) { + val gateway = scopedGatewayId(gatewayId) ?: return + val rows = + sessions.take(MAX_CACHED_SESSIONS).mapIndexed { index, session -> + CachedSessionEntity( + gatewayId = gateway, + sessionKey = session.key, + displayName = session.displayName, + updatedAtMs = session.updatedAtMs, + rowOrder = index, + ) + } + val dao = database.dao() + database.withTransaction { + dao.deleteSessions(gateway) + dao.insertSessions(rows) + dao.evictOrphanedTranscripts(gateway) + } + } + + override suspend fun saveTranscript( + gatewayId: String, + sessionKey: String, + messages: List, + ) { + val gateway = scopedGatewayId(gatewayId) ?: return + val key = sessionKey.trim().takeIf { it.isNotEmpty() } ?: return + // Text rows only: attachment/binary parts are dropped, and messages without any text are skipped. + val rows = + messages + .mapNotNull { message -> + val textParts = message.content.filter { it.type == "text" }.mapNotNull { it.text } + if (textParts.isEmpty()) return@mapNotNull null + message to textParts + }.takeLast(MAX_CACHED_MESSAGES_PER_SESSION) + .mapIndexed { index, (message, textParts) -> + CachedMessageEntity( + gatewayId = gateway, + sessionKey = key, + rowOrder = index, + role = message.role, + textPartsJson = json.encodeToString(textPartsSerializer, textParts), + timestampMs = message.timestampMs, + idempotencyKey = message.idempotencyKey, + ) + } + val dao = database.dao() + database.withTransaction { + dao.deleteTranscript(gateway, key) + dao.insertMessages(rows) + // A transcript may arrive for a session missing from the cached list (e.g. deep session + // switch); keep a stub row so the transcript stays reachable, then re-apply the bounds. + dao.insertSessionStub( + CachedSessionEntity( + gatewayId = gateway, + sessionKey = key, + displayName = null, + updatedAtMs = null, + rowOrder = dao.nextSessionRowOrder(gateway), + ), + ) + dao.evictSessionsBeyondKeeping(gateway, keepSessionKey = key, keep = MAX_CACHED_SESSIONS - 1) + dao.evictOrphanedTranscripts(gateway) + } + } + + override suspend fun clearAll() { + val dao = database.dao() + database.withTransaction { + dao.deleteAllSessions() + dao.deleteAllMessages() + } + } + + override suspend fun deleteSession( + gatewayId: String, + sessionKey: String, + ) { + val gateway = scopedGatewayId(gatewayId) ?: return + val key = sessionKey.trim().takeIf { it.isNotEmpty() } ?: return + val dao = database.dao() + database.withTransaction { + dao.deleteSessionRow(gateway, key) + dao.deleteTranscript(gateway, key) + } + } + + private fun scopedGatewayId(gatewayId: String): String? = gatewayId.trim().takeIf { it.isNotEmpty() } + + private fun decodeTextParts(encoded: String): List = runCatching { json.decodeFromString(textPartsSerializer, encoded) }.getOrDefault(emptyList()) +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerTranscriptCacheTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerTranscriptCacheTest.kt new file mode 100644 index 000000000000..f32f8ed36765 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerTranscriptCacheTest.kt @@ -0,0 +1,332 @@ +package ai.openclaw.app.chat + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatControllerTranscriptCacheTest { + private val json = Json { ignoreUnknownKeys = true } + private val gatewayScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) + + private class FakeTranscriptCache : ChatTranscriptCache { + val transcripts = mutableMapOf, List>() + var sessions: List = emptyList() + val savedTranscripts = mutableListOf>>() + val savedSessions = mutableListOf>>() + val deletedSessions = mutableListOf>() + + override suspend fun loadSessions(gatewayId: String): List = sessions + + override suspend fun loadTranscript( + gatewayId: String, + sessionKey: String, + ): List = transcripts[gatewayId to sessionKey].orEmpty() + + override suspend fun saveSessions( + gatewayId: String, + sessions: List, + ) { + savedSessions += gatewayId to sessions + } + + override suspend fun saveTranscript( + gatewayId: String, + sessionKey: String, + messages: List, + ) { + savedTranscripts += Triple(gatewayId, sessionKey, messages) + } + + override suspend fun deleteSession( + gatewayId: String, + sessionKey: String, + ) { + deletedSessions += gatewayId to sessionKey + } + + override suspend fun clearAll() { + transcripts.clear() + sessions = emptyList() + } + } + + private fun cachedMessage( + text: String, + role: String = "assistant", + timestampMs: Long = 1L, + ): ChatMessage = + ChatMessage( + id = "cached-$text", + role = role, + content = listOf(ChatMessageContent(type = "text", text = text)), + timestampMs = timestampMs, + ) + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun offlineColdOpenShowsCachedTranscriptAndSessionsAndKeepsSendBlocked() = + runTest { + val cache = FakeTranscriptCache() + cache.transcripts["gateway-a" to "main"] = listOf(cachedMessage("cached hello"), cachedMessage("cached reply")) + cache.sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = 5, displayName = "Main")) + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> throw IllegalStateException("offline") }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + ) + + controller.load("main") + advanceUntilIdle() + + assertEquals( + listOf("cached hello", "cached reply"), + controller.messages.value.map { it.content.single().text }, + ) + assertTrue(controller.messagesFromCache.value) + assertEquals(listOf("main"), controller.sessions.value.map { it.key }) + assertFalse(controller.healthOk.value) + + val accepted = + controller.sendMessageAwaitAcceptance(message = "hi", thinkingLevel = "off", attachments = emptyList()) + assertFalse(accepted) + assertEquals("Gateway health not OK; cannot send", controller.errorText.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun cachedTranscriptEmitsFirstThenLiveHistoryReplacesWholesale() = + runTest { + val cache = FakeTranscriptCache() + cache.transcripts["gateway-a" to "main"] = + listOf( + cachedMessage("cached hello", role = "user", timestampMs = 10), + cachedMessage("stale line", role = "assistant", timestampMs = 11), + ) + val historyGate = CompletableDeferred() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "chat.history" -> { + historyGate.await() + """ + { + "sessionId": "session-1", + "messages": [ + { "role": "user", "content": "cached hello", "timestamp": 10 }, + { "role": "assistant", "content": "fresh reply", "timestamp": 20 } + ] + } + """.trimIndent() + } + else -> "{}" + } + }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + ) + + controller.load("main") + runCurrent() + + // Cached transcript is visible while chat.history is still in flight. + assertTrue(controller.messagesFromCache.value) + assertEquals( + listOf("cached hello", "stale line"), + controller.messages.value.map { it.content.single().text }, + ) + val cachedFirstMessageId = + controller.messages.value + .first() + .id + + historyGate.complete(Unit) + advanceUntilIdle() + + assertFalse(controller.messagesFromCache.value) + assertEquals( + listOf("cached hello", "fresh reply"), + controller.messages.value.map { it.content.single().text }, + ) + // Existing reconciliation keeps stable ids for rows the live history confirms. + val liveFirstMessageId = + controller.messages.value + .first() + .id + assertEquals(cachedFirstMessageId, liveFirstMessageId) + // Live history is written through to the cache. + val savedTranscript = cache.savedTranscripts.last() + assertEquals("gateway-a", savedTranscript.first) + assertEquals("main", savedTranscript.second) + assertEquals( + listOf("cached hello", "fresh reply"), + savedTranscript.third.map { it.content.single().text }, + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun switchSessionOfflineShowsCachedTranscriptForThatSession() = + runTest { + val cache = FakeTranscriptCache() + cache.transcripts["gateway-a" to "agent:other:main"] = listOf(cachedMessage("other session text")) + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> throw IllegalStateException("offline") }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + ) + controller.load("main") + advanceUntilIdle() + assertEquals(emptyList(), controller.messages.value) + + controller.switchSession("agent:other:main") + advanceUntilIdle() + + assertEquals( + listOf("other session text"), + controller.messages.value.map { it.content.single().text }, + ) + assertTrue(controller.messagesFromCache.value) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun sessionDeleteEventPurgesCachedSession() = + runTest { + val cache = FakeTranscriptCache() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { _, _ -> "{}" }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + ) + + controller.handleGatewayEvent( + "sessions.changed", + """{"reason":"delete","sessionKey":"agent:old:main"}""", + ) + advanceUntilIdle() + + assertEquals(listOf("gateway-a" to "agent:old:main"), cache.deletedSessions) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun liveSessionListIsWrittenThroughToCache() = + runTest { + val cache = FakeTranscriptCache() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + when (method) { + "sessions.list" -> """{"sessions":[{"key":"main","updatedAt":7,"displayName":"Main"}]}""" + "chat.history" -> """{"sessionId":"session-1","messages":[]}""" + else -> "{}" + } + }, + transcriptCache = cache, + cacheScope = { gatewayScope }, + ) + + controller.load("main") + advanceUntilIdle() + + assertEquals("gateway-a", cache.savedSessions.last().first) + assertEquals( + listOf("main"), + cache.savedSessions + .last() + .second + .map { it.key }, + ) + assertEquals(listOf("main"), controller.sessions.value.map { it.key }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun oldGatewayHistoryResponseIsNeitherAppliedNorCachedAfterScopeChange() = + runTest { + val cache = FakeTranscriptCache() + val historyGate = CompletableDeferred() + var currentScope = gatewayScope + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + if (method == "chat.history") { + historyGate.await() + """{"sessionId":"old","messages":[{"role":"assistant","content":"old gateway"}]}""" + } else { + "{}" + } + }, + transcriptCache = cache, + cacheScope = { currentScope }, + ) + + controller.load("main") + runCurrent() + assertTrue(controller.historyLoading.value) + currentScope = ChatCacheScope(gatewayId = "gateway-b", connectionGeneration = 2) + controller.onGatewayScopeChanging() + assertFalse(controller.historyLoading.value) + historyGate.complete(Unit) + advanceUntilIdle() + + assertTrue(controller.messages.value.isEmpty()) + assertTrue(cache.savedTranscripts.isEmpty()) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun oldGatewaySessionListIsNeitherAppliedNorCachedAfterScopeChange() = + runTest { + val cache = FakeTranscriptCache() + val sessionsGate = CompletableDeferred() + var currentScope = gatewayScope + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, _ -> + if (method == "sessions.list") { + sessionsGate.await() + """{"sessions":[{"key":"old-gateway-session"}]}""" + } else { + "{}" + } + }, + transcriptCache = cache, + cacheScope = { currentScope }, + ) + + controller.refreshSessions() + runCurrent() + currentScope = ChatCacheScope(gatewayId = "gateway-b", connectionGeneration = 2) + sessionsGate.complete(Unit) + advanceUntilIdle() + + assertTrue(controller.sessions.value.isEmpty()) + assertTrue(cache.savedSessions.isEmpty()) + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatTranscriptCacheTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatTranscriptCacheTest.kt new file mode 100644 index 000000000000..ef9242701391 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatTranscriptCacheTest.kt @@ -0,0 +1,196 @@ +package ai.openclaw.app.chat + +import androidx.room.Room +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class RoomChatTranscriptCacheTest { + private val database: ChatCacheDatabase = + Room + .inMemoryDatabaseBuilder(RuntimeEnvironment.getApplication(), ChatCacheDatabase::class.java) + .build() + + @After + fun tearDown() { + database.close() + } + + private fun cache(): RoomChatTranscriptCache = RoomChatTranscriptCache(database = database) + + private fun message( + text: String, + role: String = "user", + timestampMs: Long? = 1L, + idempotencyKey: String? = null, + extraParts: List = emptyList(), + ): ChatMessage = + ChatMessage( + id = "id-$text", + role = role, + content = listOf(ChatMessageContent(type = "text", text = text)) + extraParts, + timestampMs = timestampMs, + idempotencyKey = idempotencyKey, + ) + + @Test + fun transcriptRoundTripKeepsTextRowsOnly() = + runTest { + val store = cache() + val imagePart = ChatMessageContent(type = "image", mimeType = "image/png", fileName = "a.png", base64 = "AAAA") + store.saveTranscript( + gatewayId = "gateway-a", + sessionKey = "main", + messages = + listOf( + message("hello", role = "user", timestampMs = 10, idempotencyKey = "run-1:user", extraParts = listOf(imagePart)), + // Attachment-only messages have no cacheable text and are skipped entirely. + ChatMessage(id = "img", role = "user", content = listOf(imagePart), timestampMs = 11), + message("world", role = "assistant", timestampMs = 12), + ), + ) + + val loaded = store.loadTranscript("gateway-a", "main") + + assertEquals(listOf("hello", "world"), loaded.map { it.content.single().text }) + assertTrue(loaded.all { message -> message.content.all { part -> part.type == "text" && part.base64 == null } }) + assertEquals(listOf("user", "assistant"), loaded.map { it.role }) + assertEquals(listOf(10L, 12L), loaded.map { it.timestampMs }) + assertEquals(listOf("run-1:user", null), loaded.map { it.idempotencyKey }) + } + + @Test + fun transcriptWriteKeepsOnlyNewestBoundedMessages() = + runTest { + val store = cache() + store.saveTranscript( + gatewayId = "gateway-a", + sessionKey = "main", + messages = (0 until MAX_CACHED_MESSAGES_PER_SESSION + 50).map { index -> message("m$index", timestampMs = index.toLong()) }, + ) + + val loadedTexts = store.loadTranscript("gateway-a", "main").map { it.content.single().text } + + assertEquals(MAX_CACHED_MESSAGES_PER_SESSION, loadedTexts.size) + assertEquals("m50", loadedTexts.first()) + assertEquals("m249", loadedTexts.last()) + } + + @Test + fun sessionWriteEvictsBeyondBoundAndDropsOrphanedTranscripts() = + runTest { + val store = cache() + store.saveTranscript(gatewayId = "gateway-a", sessionKey = "session-10", messages = listOf(message("kept"))) + store.saveTranscript(gatewayId = "gateway-a", sessionKey = "session-55", messages = listOf(message("evicted"))) + + store.saveSessions( + gatewayId = "gateway-a", + sessions = + (0 until MAX_CACHED_SESSIONS + 10).map { index -> + ChatSessionEntry(key = "session-$index", updatedAtMs = 1000L - index, displayName = "Session $index") + }, + ) + + val sessions = store.loadSessions("gateway-a") + assertEquals(MAX_CACHED_SESSIONS, sessions.size) + assertEquals("session-0", sessions.first().key) + assertEquals("session-${MAX_CACHED_SESSIONS - 1}", sessions.last().key) + assertEquals("Session 0", sessions.first().displayName) + assertEquals(listOf("kept"), store.loadTranscript("gateway-a", "session-10").map { it.content.single().text }) + assertEquals(emptyList(), store.loadTranscript("gateway-a", "session-55")) + } + + @Test + fun transcriptForSessionOutsideFullCachedListSurvivesEviction() = + runTest { + val store = cache() + store.saveSessions( + gatewayId = "gateway-a", + sessions = + (0 until MAX_CACHED_SESSIONS).map { index -> + ChatSessionEntry(key = "session-$index", updatedAtMs = 1000L - index) + }, + ) + + store.saveTranscript(gatewayId = "gateway-a", sessionKey = "deep-session", messages = listOf(message("deep text"))) + + assertEquals(listOf("deep text"), store.loadTranscript("gateway-a", "deep-session").map { it.content.single().text }) + val sessionKeys = store.loadSessions("gateway-a").map { it.key } + assertEquals(MAX_CACHED_SESSIONS, sessionKeys.size) + assertTrue(sessionKeys.contains("deep-session")) + } + + @Test + fun deleteSessionRemovesSessionRowAndTranscript() = + runTest { + val store = cache() + store.saveSessions( + gatewayId = "gateway-a", + sessions = + listOf( + ChatSessionEntry(key = "main", updatedAtMs = 1), + ChatSessionEntry(key = "other", updatedAtMs = 2), + ), + ) + store.saveTranscript(gatewayId = "gateway-a", sessionKey = "main", messages = listOf(message("delete me"))) + store.saveTranscript(gatewayId = "gateway-a", sessionKey = "other", messages = listOf(message("keep me"))) + + store.deleteSession("gateway-a", "main") + + assertEquals(emptyList(), store.loadTranscript("gateway-a", "main")) + assertEquals(listOf("other"), store.loadSessions("gateway-a").map { it.key }) + assertEquals(listOf("keep me"), store.loadTranscript("gateway-a", "other").map { it.content.single().text }) + } + + @Test + fun transcriptsAreScopedToGatewayIdentity() = + runTest { + val store = cache() + store.saveTranscript(gatewayId = "gateway-a", sessionKey = "main", messages = listOf(message("gateway a text"))) + store.saveSessions("gateway-a", listOf(ChatSessionEntry(key = "main", updatedAtMs = 1))) + + assertEquals(emptyList(), store.loadTranscript("gateway-b", "main")) + assertEquals(emptyList(), store.loadSessions("gateway-b")) + store.saveTranscript(gatewayId = "gateway-b", sessionKey = "main", messages = listOf(message("gateway b text"))) + + assertEquals(listOf("gateway a text"), store.loadTranscript("gateway-a", "main").map { it.content.single().text }) + assertEquals(listOf("main"), store.loadSessions("gateway-a").map { it.key }) + } + + @Test + fun clearAllPurgesEveryGatewayScope() = + runTest { + val store = cache() + store.saveSessions("gateway-a", listOf(ChatSessionEntry(key = "main", updatedAtMs = 1))) + store.saveTranscript(gatewayId = "gateway-a", sessionKey = "main", messages = listOf(message("a text"))) + store.saveTranscript(gatewayId = "gateway-b", sessionKey = "main", messages = listOf(message("b text"))) + + store.clearAll() + + assertEquals(emptyList(), store.loadTranscript("gateway-b", "main")) + assertEquals(emptyList(), store.loadSessions("gateway-b")) + assertEquals(emptyList(), store.loadTranscript("gateway-a", "main")) + assertEquals(emptyList(), store.loadSessions("gateway-a")) + } + + @Test + fun blankGatewayIdentityDisablesReadsAndWrites() = + runTest { + val store = cache() + store.saveTranscript(gatewayId = "", sessionKey = "main", messages = listOf(message("must not persist"))) + store.saveSessions("", listOf(ChatSessionEntry(key = "main", updatedAtMs = 1))) + + assertEquals(emptyList(), store.loadTranscript("", "main")) + assertEquals(emptyList(), store.loadSessions("")) + + // Nothing was written under a fallback scope either. + assertEquals(emptyList(), store.loadTranscript("gateway-a", "main")) + assertEquals(emptyList(), store.loadSessions("gateway-a")) + } +} diff --git a/apps/android/gradle/libs.versions.toml b/apps/android/gradle/libs.versions.toml index f24201b81d36..f2c87be78e94 100644 --- a/apps/android/gradle/libs.versions.toml +++ b/apps/android/gradle/libs.versions.toml @@ -18,12 +18,14 @@ dnsjava = "3.6.5" junit = "4.13.2" junit-vintage = "6.1.0" kotest = "6.1.11" +ksp = "2.3.9" ktlint-gradle = "14.2.0" kotlin = "2.4.0" material = "1.14.0" okhttp = "5.3.2" barcode-scanning = "17.3.0" robolectric = "4.16.1" +room = "2.8.4" serialization-json = "1.11.0" [libraries] @@ -43,6 +45,8 @@ androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "androidx-exifinterface" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "androidx-security" } androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext" } androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-uiautomator" } @@ -72,4 +76,5 @@ android-application = { id = "com.android.application", version.ref = "agp" } android-test = { id = "com.android.test", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" }