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
This commit is contained in:
Peter Steinberger
2026-07-05 21:14:46 +01:00
committed by GitHub
parent 426010dfb9
commit 09f9a85145
9 changed files with 1064 additions and 87 deletions

View File

@@ -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",

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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<TalkModeManager> =
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

View File

@@ -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<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _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<Boolean> = _messagesFromCache.asStateFlow()
private val _historyLoading = MutableStateFlow(false)
val historyLoading: StateFlow<Boolean> = _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<ChatMessage>,
) {
val cache = transcriptCache ?: return
val gatewayId = requestCacheScope?.gatewayId ?: return
runCatching { cache.saveTranscript(gatewayId, sessionKey, messages) }
}
private suspend fun persistSessions(
requestCacheScope: ChatCacheScope?,
sessions: List<ChatSessionEntry>,
) {
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 =

View File

@@ -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<ChatSessionEntry>
suspend fun loadTranscript(
gatewayId: String,
sessionKey: String,
): List<ChatMessage>
suspend fun saveSessions(
gatewayId: String,
sessions: List<ChatSessionEntry>,
)
suspend fun saveTranscript(
gatewayId: String,
sessionKey: String,
messages: List<ChatMessage>,
)
/** 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<CachedSessionEntity>
@Query(
"SELECT * FROM cached_messages WHERE gatewayId = :gatewayId AND sessionKey = :sessionKey ORDER BY rowOrder ASC",
)
suspend fun messages(
gatewayId: String,
sessionKey: String,
): List<CachedMessageEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSessions(rows: List<CachedSessionEntity>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertSessionStub(row: CachedSessionEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessages(rows: List<CachedMessageEntity>)
@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<ChatSessionEntry> {
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<ChatMessage> {
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<ChatSessionEntry>,
) {
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<ChatMessage>,
) {
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<String> = runCatching { json.decodeFromString(textPartsSerializer, encoded) }.getOrDefault(emptyList())
}

View File

@@ -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<Pair<String, String>, List<ChatMessage>>()
var sessions: List<ChatSessionEntry> = emptyList()
val savedTranscripts = mutableListOf<Triple<String, String, List<ChatMessage>>>()
val savedSessions = mutableListOf<Pair<String, List<ChatSessionEntry>>>()
val deletedSessions = mutableListOf<Pair<String, String>>()
override suspend fun loadSessions(gatewayId: String): List<ChatSessionEntry> = sessions
override suspend fun loadTranscript(
gatewayId: String,
sessionKey: String,
): List<ChatMessage> = transcripts[gatewayId to sessionKey].orEmpty()
override suspend fun saveSessions(
gatewayId: String,
sessions: List<ChatSessionEntry>,
) {
savedSessions += gatewayId to sessions
}
override suspend fun saveTranscript(
gatewayId: String,
sessionKey: String,
messages: List<ChatMessage>,
) {
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<Unit>()
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<ChatMessage>(), 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<Unit>()
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<Unit>()
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())
}
}

View File

@@ -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<ChatMessageContent> = 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<ChatMessage>(), 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<ChatMessage>(), 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<ChatMessage>(), store.loadTranscript("gateway-b", "main"))
assertEquals(emptyList<ChatSessionEntry>(), 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<ChatMessage>(), store.loadTranscript("gateway-b", "main"))
assertEquals(emptyList<ChatSessionEntry>(), store.loadSessions("gateway-b"))
assertEquals(emptyList<ChatMessage>(), store.loadTranscript("gateway-a", "main"))
assertEquals(emptyList<ChatSessionEntry>(), 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<ChatMessage>(), store.loadTranscript("", "main"))
assertEquals(emptyList<ChatSessionEntry>(), store.loadSessions(""))
// Nothing was written under a fallback scope either.
assertEquals(emptyList<ChatMessage>(), store.loadTranscript("gateway-a", "main"))
assertEquals(emptyList<ChatSessionEntry>(), store.loadSessions("gateway-a"))
}
}

View File

@@ -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" }