feat: offline-first cache layer with Room

Room 2.8.4 as local SQLite read-cache for conversations and messages.
Sidebar and chat history load instantly from cache, background refresh
syncs with the server. Streaming stays in-memory, flushed to Room after
completion. Includes ChatViewModel, repositories, mappers, TypeConverters,
and unit tests.
This commit is contained in:
Bruno Deanoz 2026-06-21 12:50:46 +02:00
parent d8dcb25e3d
commit 9759d478a7
18 changed files with 910 additions and 19 deletions

423
OFFLINE_CACHE_ADD.md Normal file
View file

@ -0,0 +1,423 @@
# ADD: Offline-First Cache Layer (Room)
> Architecture Decision Document — Kaizen Android App
> Status: Draft v2, 2026-06-21
> Wichtig: Vor der Implementierung muss der aktuelle Code-Stand erneut geprüft werden. Dieses Dokument beschreibt die Architektur-Entscheidung und den Plan, nicht den exakten Code. Dateipfade, Signaturen und Patterns koennen sich seit dem Schreiben geaendert haben.
---
## 1. Problem
Die App hat keine lokale Datenbank. Jeder App-Start, jeder Sidebar-Swipe und jeder Chat-Wechsel blockiert auf einen Server-Fetch. Ohne Netzwerk ist die App komplett leer.
**Auswirkung auf die UX:**
- Sidebar oeffnet sich mit Ladezeit statt sofort
- Chat-Wechsel hat spuerbare Latenz (Netzwerk-RTT)
- Flugmodus / schlechtes Netz = leerer Bildschirm
- Kein Sofortstart-Gefuehl wie bei ChatGPT, Gemini, etc.
**Die Konkurrenz:** ChatGPT, Gemini und Claude nutzen lokale Offline-First-Datenbanken. Chats oeffnen sich instant (0ms), waehrend im Hintergrund mit dem Server synchronisiert wird.
---
## 2. Entscheidung
**Room** (Jetpack, SQLite-Wrapper) als lokaler Read-Cache.
### Warum Room:
- Compose-native: DAOs geben `Flow<List<T>>` zurueck -> `collectAsState()` in der UI
- Bereits im Jetpack-Stack (Compose, EncryptedSharedPreferences, Lifecycle)
- Compile-time Query-Checks
- Eingebauter Migrations-Support (bzw. `fallbackToDestructiveMigration()` fuer einen Cache)
- Groesstes Oekosystem, beste Doku
- Skaliert fuer die Zukunft: FTS-Suche, Media-Cache-Tracking, Attachment-Queries
### Verworfene Alternativen:
| Option | Grund gegen |
|--------|-------------|
| SQLDelight | Mehr Setup (eigenes Gradle-Plugin, raw SQL), weniger Compose-Integration |
| ObjectBox | Kleine Community, Vendor-Lock-in-Risiko |
| DataStore | Nur Key-Value, keine Queries |
| Raw SQLite | Kein Grund, das Rad neu zu erfinden |
| SharedPreferences JSON | Skaliert nicht fuer Messages (laedt alles in RAM), keine Queries |
### Plattform-Strategie:
Android = Room (Jetpack-nativ). Die iOS-App wird separat in Swift/SwiftUI mit SwiftData/CoreData gebaut — jede Plattform nutzt ihren nativen Stack. Kein KMP.
---
## 3. Architektur
### Prinzip: Read-Cache, nicht Sync-Engine
```
Server (PostgreSQL) = Source of Truth
Room (SQLite) = Lokaler Cache
Writes = Immer direkt an den Server
```
Kein Offline-Senden, kein Conflict-Resolution, kein Sync-Queue. Das waere ein riesiger Komplexitaetssprung, der aktuell keinen Mehrwert bringt.
### Streaming-Hybrid-Modell
Waehrend eines aktiven Streams werden Messages 60x/s in-memory mutiert. Room in dieser Frequenz zu beschreiben waere absurder I/O. Daher:
```
Historische Messages -> aus Room (Flow<List<MessageEntity>>)
Aktive Streaming-Msg -> in-memory (mutableStateOf<Message>)
Nach Stream-Ende -> in Room flushen (upsert)
```
Room ist Source of Truth fuer **ruhende** Daten. Waehrend des Streams ist der in-memory State fuehrend. Nach Abschluss werden die neuen Messages in Room geschrieben und der Flow aktualisiert die UI automatisch.
### Datenfluss (vorher vs. nachher)
```
VORHER:
App oeffnen -> fetch /api/v1/conversations (500ms+) -> anzeigen
Chat oeffnen -> fetch /api/v1/conversations/[id] (300ms+) -> anzeigen
NACHHER:
App oeffnen -> Room lesen (0ms) -> sofort anzeigen
-> Background: Server fetchen -> Room updaten -> UI reaktiv aktualisiert
Chat oeffnen -> Room lesen (0ms) -> sofort anzeigen
-> Background: Server fetchen (falls noetig) -> Room updaten
```
### Schichten
```
┌──────────────────────────────────┐
│ UI (Compose) │ ChatScreen, Sidebar, etc.
│ collectAsState(Flow<T>) │ Beobachtet Room-Flows
├──────────────────────────────────┤
│ ChatViewModel │ Haelt Room-Flows + Streaming-State
│ (wie SessionViewModel, manuell) │ Kein Hilt/Koin noetig
├──────────────────────────────────┤
│ Repository Layer │ Entscheidet: Room oder Server?
│ ConversationRepository │ Steuert Background-Refresh
│ MessageRepository │
├───────────────┬──────────────────┤
│ Room (DAO) │ KaizenApi │ Lokaler Cache │ Server-Calls
│ Flow<T> │ suspend fun │ (unveraendert)
└───────────────┴──────────────────┘
```
---
## 4. Room-Schema
### TypeConverter
```kotlin
class Converters {
private val json = Json { ignoreUnknownKeys = true }
@TypeConverter
fun attachmentsToJson(attachments: List<Attachment>): String =
json.encodeToString(attachments)
@TypeConverter
fun jsonToAttachments(value: String): List<Attachment> =
try { json.decodeFromString(value) } catch (_: Exception) { emptyList() }
}
```
### Entities
```kotlin
@Entity(tableName = "conversations")
data class ConversationEntity(
@PrimaryKey val id: String,
val title: String,
val locked: Boolean,
val pinned: Boolean,
val updatedAt: String?,
val cachedAt: Long, // System.currentTimeMillis(), fuer Stale-Check
)
@Entity(
tableName = "messages",
foreignKeys = [ForeignKey(
entity = ConversationEntity::class,
parentColumns = ["id"],
childColumns = ["conversationId"],
onDelete = ForeignKey.CASCADE,
)],
indices = [Index("conversationId")],
)
data class MessageEntity(
@PrimaryKey val id: String,
val conversationId: String,
val role: String, // "user" | "assistant"
val content: String,
val attachments: List<Attachment>,
val sortOrder: Int,
)
```
### Mapper-Funktionen
```kotlin
fun ConversationSummary.toEntity() = ConversationEntity(
id = id,
title = title,
locked = locked,
pinned = pinned,
updatedAt = updatedAt,
cachedAt = System.currentTimeMillis(),
)
fun ConversationEntity.toSummary() = ConversationSummary(
id = id,
title = title,
locked = locked,
pinned = pinned,
updatedAt = updatedAt,
)
fun StoredMessage.toEntity(conversationId: String, sortOrder: Int) = MessageEntity(
id = id,
conversationId = conversationId,
role = role,
content = content,
attachments = attachments,
sortOrder = sortOrder,
)
fun MessageEntity.toStoredMessage() = StoredMessage(
id = id,
role = role,
content = content,
attachments = attachments,
)
```
### DAOs
```kotlin
@Dao
interface ConversationDao {
@Query("SELECT * FROM conversations ORDER BY pinned DESC, updatedAt DESC")
fun observeAll(): Flow<List<ConversationEntity>>
@Upsert
suspend fun upsertAll(conversations: List<ConversationEntity>)
@Query("DELETE FROM conversations")
suspend fun deleteAll()
@Transaction
suspend fun replaceAll(conversations: List<ConversationEntity>) {
deleteAll()
upsertAll(conversations)
}
@Query("SELECT cachedAt FROM conversations LIMIT 1")
suspend fun lastCachedAt(): Long?
}
@Dao
interface MessageDao {
@Query("SELECT * FROM messages WHERE conversationId = :convId ORDER BY sortOrder")
fun observeByConversation(convId: String): Flow<List<MessageEntity>>
@Query("SELECT * FROM messages WHERE conversationId = :convId ORDER BY sortOrder")
suspend fun getByConversation(convId: String): List<MessageEntity>
@Upsert
suspend fun upsertAll(messages: List<MessageEntity>)
@Query("DELETE FROM messages WHERE conversationId = :convId")
suspend fun deleteByConversation(convId: String)
}
```
### Database
```kotlin
@Database(
entities = [ConversationEntity::class, MessageEntity::class],
version = 1,
)
@TypeConverters(Converters::class)
abstract class KaizenDatabase : RoomDatabase() {
abstract fun conversationDao(): ConversationDao
abstract fun messageDao(): MessageDao
companion object {
@Volatile private var instance: KaizenDatabase? = null
fun get(context: Context): KaizenDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
KaizenDatabase::class.java,
"kaizen-cache",
)
.fallbackToDestructiveMigration()
.build()
.also { instance = it }
}
}
}
```
`fallbackToDestructiveMigration()` ist bewusst: Room ist ein Cache, kein Source of Truth. Bei Schema-Aenderungen wird die DB weggeworfen und beim naechsten Server-Fetch neu gefuellt. Keine Migrations noetig.
---
## 5. Repository Layer
```kotlin
class ConversationRepository(
private val dao: ConversationDao,
) {
fun observeAll(): Flow<List<ConversationSummary>> =
dao.observeAll().map { list -> list.map { it.toSummary() } }
suspend fun refresh(baseUrl: String, token: String) {
when (val r = KaizenApi.fetchConversations(baseUrl, token)) {
is FetchResult.Ok -> {
val entities = r.data.map { it.toEntity() }
dao.replaceAll(entities)
}
is FetchResult.Fail -> { /* Cache bleibt stehen, kein Crash */ }
}
}
}
class MessageRepository(
private val dao: MessageDao,
) {
fun observeMessages(convId: String): Flow<List<MessageEntity>> =
dao.observeByConversation(convId)
suspend fun fetchAndCache(baseUrl: String, token: String, convId: String) {
val stored = KaizenApi.fetchMessages(baseUrl, token, convId)
val entities = stored.mapIndexed { i, m -> m.toEntity(convId, i) }
dao.deleteByConversation(convId)
dao.upsertAll(entities)
}
suspend fun cacheMessages(convId: String, messages: List<MessageEntity>) {
dao.upsertAll(messages)
}
}
```
---
## 6. ChatViewModel
Neuer ViewModel, analog zu `SessionViewModel` — manuell instanziiert, kein DI.
```kotlin
class ChatViewModel(context: Context) {
private val db = KaizenDatabase.get(context)
val conversationRepo = ConversationRepository(db.conversationDao())
val messageRepo = MessageRepository(db.messageDao())
}
```
Instanziierung in `MainActivity`, weitergabe an `ChatScreen` wie `SessionViewModel`.
---
## 7. UI-Aenderungen (Uebersicht)
### Sidebar
- Vorher: `conversations` ist ein `var` State, gefuellt durch `fetchConversations()`
- Nachher: `chatViewModel.conversationRepo.observeAll().collectAsState()` — instant, reaktiv
- Background-Refresh beim App-Start und bei `LifecycleResumeEffect`
### ChatScreen — openConversation()
- Vorher: `fetchMessages()` blockiert, dann `messages.addAll()`
- Nachher: Sofort aus Room laden (falls cached), parallel vom Server refreshen
- Room-Flow fuellt die historischen Messages, Streaming-Message bleibt in-memory
### ChatScreen — send()
- Waehrend des Streams: `messages` bleibt `mutableStateListOf` (in-memory, wie bisher)
- Nach Stream-Ende: `messageRepository.cacheMessages()` zusaetzlich zum bestehenden `saveMessages()`
- `conversationRepo.refresh()` aktualisiert die Sidebar reaktiv ueber den Room-Flow
### Streaming-Hybrid im Detail
```
1. User oeffnet Chat -> Room-Flow liefert gecachte Messages (instant)
-> Background: Server-Fetch -> Room-Update -> Flow emittiert neu
2. User sendet Message -> In-memory State fuehrt (wie bisher)
-> Stream laeuft, messages[idx].copy(content=...) in-memory
3. Stream endet -> messageRepo.cacheMessages(convId, newMessages)
-> conversationRepo.refresh() fuer Sidebar-Update
4. User wechselt Chat -> Zurueck zu Schritt 1
```
### Stale-Check
- `cachedAt` auf jeder Conversation — wenn aelter als z.B. 5 Minuten, Background-Refresh
- App-Start: immer refreshen, aber UI zeigt sofort den Cache
---
## 8. Neue Dependencies
```toml
# gradle/libs.versions.toml
room = "2.7.1" # Exakte Version beim Implementieren pruefen!
ksp = "..." # Muss zur Kotlin-Version passen (2.2.10)
# libraries
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# plugins
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
```
```kotlin
// build.gradle.kts
plugins {
alias(libs.plugins.ksp)
}
dependencies {
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
}
```
---
## 9. Implementierungs-Reihenfolge
| Schritt | Was | Abhaengigkeit |
|---------|-----|---------------|
| 1 | Room-Dependencies + KSP Plugin | — |
| 2 | TypeConverters + Entities + DAOs + KaizenDatabase (mit Singleton + fallbackToDestructiveMigration) | Schritt 1 |
| 3 | Mapper-Funktionen (toEntity/toSummary/toStoredMessage) | Schritt 2 |
| 4 | ConversationRepository + MessageRepository | Schritt 3 |
| 5 | ChatViewModel + Instanziierung in MainActivity | Schritt 4 |
| 6 | Sidebar auf Room-Flow umstellen | Schritt 5 |
| 7 | openConversation() auf Room umstellen (Hybrid: Room + Server-Refresh) | Schritt 5 |
| 8 | send() cached neue Messages in Room nach Stream-Ende | Schritt 5 |
---
## 10. Was sich NICHT aendert
- **KaizenApi** bleibt unveraendert — reine HTTP-Calls, kein Room-Wissen
- **StreamConsumer** bleibt unveraendert — Streaming-Parsing ist orthogonal zum Cache
- **Senden ist weiterhin nur online** — kein Offline-Queue, kein Conflict-Resolution
- **Server bleibt Source of Truth** — Room ist ein Cache, keine eigene Datenhaltung
- **SecureStore / ServerConfig** bleiben in EncryptedSharedPreferences
- **Models-Cache** bleibt in SecureStore (klein, flach, funktioniert)
---
## 11. Zukunft (nicht Teil dieser Iteration)
- **Offline-Queue fuer Sends** — Messages lokal queuen, bei Netzwerk senden (braucht Conflict-Resolution)
- **Inkrementeller Sync** — nur geaenderte Conversations holen statt die volle Liste (braucht Server-seitigen `since`-Parameter)
- **Media-Cache** — Bilder/Dateien lokal cachen (Coil oder eigener Disk-Cache)
- **Volltextsuche** — FTS4/FTS5 ueber gecachte Messages (Room unterstuetzt das nativ)
- **Attachment-Queries** — "Zeig mir alle Chats mit Bildern/PDFs" ueber die Room-DB

View file

@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
} }
android { android {
@ -56,6 +57,9 @@ dependencies {
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
// Hardware-backed encrypted storage for the bearer token (EncryptedSharedPreferences) // Hardware-backed encrypted storage for the bearer token (EncryptedSharedPreferences)
implementation(libs.androidx.security.crypto) implementation(libs.androidx.security.crypto)
// Room offline cache (local SQLite read-cache for conversations + messages)
implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)

View file

@ -10,6 +10,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import dev.kaizen.app.auth.LoginScreen import dev.kaizen.app.auth.LoginScreen
import dev.kaizen.app.chat.ChatScreen import dev.kaizen.app.chat.ChatScreen
import dev.kaizen.app.chat.ChatViewModel
import dev.kaizen.app.net.SecureStore import dev.kaizen.app.net.SecureStore
import dev.kaizen.app.net.SessionViewModel import dev.kaizen.app.net.SessionViewModel
import dev.kaizen.app.settings.SettingsViewModel import dev.kaizen.app.settings.SettingsViewModel
@ -28,10 +29,9 @@ class MainActivity : ComponentActivity() {
window.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT window.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
} }
// Initialize the decoupled viewmodels at the Activity scope (or Hilt/Dagger later)
val settingsViewModel = SettingsViewModel() val settingsViewModel = SettingsViewModel()
// Restores the persisted (encrypted) server config — drives login vs. chat routing
val sessionViewModel = SessionViewModel(SecureStore(applicationContext)) val sessionViewModel = SessionViewModel(SecureStore(applicationContext))
val chatViewModel = ChatViewModel(applicationContext)
// auto() picks light/dark system-bar icons based on the system theme // auto() picks light/dark system-bar icons based on the system theme
enableEdgeToEdge( enableEdgeToEdge(
@ -43,7 +43,7 @@ class MainActivity : ComponentActivity() {
// Snapshot-state routing: logging in/out flips session.config and // Snapshot-state routing: logging in/out flips session.config and
// recomposes this branch — no NavHost needed for two top-level states. // recomposes this branch — no NavHost needed for two top-level states.
if (sessionViewModel.isLoggedIn) { if (sessionViewModel.isLoggedIn) {
ChatScreen(settingsViewModel = settingsViewModel, session = sessionViewModel) ChatScreen(settingsViewModel = settingsViewModel, session = sessionViewModel, chat = chatViewModel)
} else { } else {
LoginScreen(session = sessionViewModel) LoginScreen(session = sessionViewModel)
} }

View file

@ -53,6 +53,8 @@ import androidx.compose.material3.Text
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.kaizen.app.db.MessageEntity
import dev.kaizen.app.db.toEntity
import dev.kaizen.app.haptics.rememberHaptics import dev.kaizen.app.haptics.rememberHaptics
import dev.kaizen.app.net.Attachment import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.ChatHttpException import dev.kaizen.app.net.ChatHttpException
@ -80,6 +82,7 @@ enum class AppScreen { Chat, Settings }
fun ChatScreen( fun ChatScreen(
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
session: SessionViewModel, session: SessionViewModel,
chat: ChatViewModel,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val haptics = rememberHaptics() val haptics = rememberHaptics()
@ -108,9 +111,9 @@ fun ChatScreen(
var models by remember { mutableStateOf(session.store.loadModelsCache() ?: emptyList()) } var models by remember { mutableStateOf(session.store.loadModelsCache() ?: emptyList()) }
var showModelSheet by remember { mutableStateOf(false) } var showModelSheet by remember { mutableStateOf(false) }
// Conversation persistence: the active server conversation + the sidebar list // Conversation persistence: sidebar list from Room (instant), active conversation id
var conversationId by remember { mutableStateOf<String?>(null) } var conversationId by remember { mutableStateOf<String?>(null) }
var conversations by remember { mutableStateOf<List<ConversationSummary>>(emptyList()) } val conversations by chat.conversationRepo.observeAll().collectAsState(initial = emptyList())
var loadError by remember { mutableStateOf<String?>(null) } var loadError by remember { mutableStateOf<String?>(null) }
@ -153,10 +156,7 @@ fun ChatScreen(
} }
is FetchResult.Fail -> errors.add(r.reason) is FetchResult.Fail -> errors.add(r.reason)
} }
when (val r = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)) { chat.conversationRepo.refresh(cfg.baseUrl, cfg.token)
is FetchResult.Ok -> conversations = r.data
is FetchResult.Fail -> errors.add(r.reason)
}
if (errors.isNotEmpty()) { if (errors.isNotEmpty()) {
loadError = errors.joinToString(" · ") loadError = errors.joinToString(" · ")
} }
@ -174,10 +174,7 @@ fun ChatScreen(
fun refreshConversations() { fun refreshConversations() {
val cfg = session.config ?: return val cfg = session.config ?: return
scope.launch { scope.launch { chat.conversationRepo.refresh(cfg.baseUrl, cfg.token) }
val r = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)
if (r is FetchResult.Ok) conversations = r.data
}
} }
/** Start a fresh chat — drops the active conversation, the next send creates a new one. */ /** Start a fresh chat — drops the active conversation, the next send creates a new one. */
@ -186,14 +183,15 @@ fun ChatScreen(
conversationId = null conversationId = null
} }
/** Load a stored conversation into the linear message list. Locked ones are skipped (no in-app unlock yet). */
fun openConversation(summary: ConversationSummary) { fun openConversation(summary: ConversationSummary) {
if (summary.locked) return if (summary.locked) return
val cfg = session.config ?: return val cfg = session.config ?: return
scope.launch { scope.launch {
val stored = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, summary.id) // Instant: load from Room cache first
val cached = chat.messageRepo.observeMessages(summary.id)
messages.clear() messages.clear()
stored.forEach { m -> val cachedList = chat.messageRepo.getCached(summary.id)
cachedList.forEach { m ->
messages.add( messages.add(
Message( Message(
id = nextId++, id = nextId++,
@ -205,6 +203,27 @@ fun ChatScreen(
) )
} }
conversationId = summary.id conversationId = summary.id
// Background: refresh from server and update Room + UI
val stored = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, summary.id)
if (stored.isNotEmpty()) {
val entities = stored.mapIndexed { i, m -> m.toEntity(summary.id, i) }
chat.messageRepo.cacheMessages(summary.id, entities)
// Only rebuild message list if content actually changed
if (stored.size != cachedList.size || stored.zip(cachedList).any { (s, c) -> s.id != c.id || s.content != c.content }) {
messages.clear()
stored.forEach { m ->
messages.add(
Message(
id = nextId++,
role = if (m.role == "assistant") Role.Assistant else Role.User,
content = m.content,
wireId = m.id,
attachments = m.attachments,
),
)
}
}
}
} }
} }
@ -272,8 +291,24 @@ fun ChatScreen(
SaveMessage(id = assistantWireId, parentId = userMsg.wireId, role = "assistant", content = finalContent, model = cfg.model), SaveMessage(id = assistantWireId, parentId = userMsg.wireId, role = "assistant", content = finalContent, model = cfg.model),
), ),
) )
if (saved && wasNewConversation) KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId) if (saved) {
if (saved) refreshConversations() // Cache new messages in Room for offline-first
val msgCount = messages.size
chat.messageRepo.cacheMessages(convId, listOf(
MessageEntity(
id = userMsg.wireId, conversationId = convId,
role = "user", content = trimmed,
attachments = uploadedAtts, sortOrder = msgCount - 2,
),
MessageEntity(
id = assistantWireId, conversationId = convId,
role = "assistant", content = finalContent,
attachments = emptyList(), sortOrder = msgCount - 1,
),
))
if (wasNewConversation) KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId)
refreshConversations()
}
} }
} catch (e: Exception) { } catch (e: Exception) {
val idx = messages.indexOfLast { it.id == assistantId } val idx = messages.indexOfLast { it.id == assistantId }

View file

@ -0,0 +1,12 @@
package dev.kaizen.app.chat
import android.content.Context
import dev.kaizen.app.db.ConversationRepository
import dev.kaizen.app.db.KaizenDatabase
import dev.kaizen.app.db.MessageRepository
class ChatViewModel(context: Context) {
private val db = KaizenDatabase.get(context)
val conversationRepo = ConversationRepository(db.conversationDao())
val messageRepo = MessageRepository(db.messageDao())
}

View file

@ -0,0 +1,28 @@
package dev.kaizen.app.db
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface ConversationDao {
@Query("SELECT * FROM conversations ORDER BY pinned DESC, updatedAt DESC")
fun observeAll(): Flow<List<ConversationEntity>>
@Upsert
suspend fun upsertAll(conversations: List<ConversationEntity>)
@Query("DELETE FROM conversations")
suspend fun deleteAll()
@Transaction
suspend fun replaceAll(conversations: List<ConversationEntity>) {
deleteAll()
upsertAll(conversations)
}
@Query("SELECT cachedAt FROM conversations LIMIT 1")
suspend fun lastCachedAt(): Long?
}

View file

@ -0,0 +1,25 @@
package dev.kaizen.app.db
import dev.kaizen.app.net.ConversationSummary
import dev.kaizen.app.net.FetchResult
import dev.kaizen.app.net.KaizenApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class ConversationRepository(private val dao: ConversationDao) {
fun observeAll(): Flow<List<ConversationSummary>> =
dao.observeAll().map { list -> list.map { it.toSummary() } }
suspend fun refresh(baseUrl: String, token: String) {
when (val r = KaizenApi.fetchConversations(baseUrl, token)) {
is FetchResult.Ok -> {
val entities = r.data.map { it.toEntity() }
dao.replaceAll(entities)
}
is FetchResult.Fail -> { /* cache stays, no crash */ }
}
}
suspend fun lastCachedAt(): Long? = dao.lastCachedAt()
}

View file

@ -0,0 +1,18 @@
package dev.kaizen.app.db
import androidx.room.TypeConverter
import dev.kaizen.app.net.Attachment
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class Converters {
private val json = Json { ignoreUnknownKeys = true }
@TypeConverter
fun attachmentsToJson(attachments: List<Attachment>): String =
json.encodeToString(attachments)
@TypeConverter
fun jsonToAttachments(value: String): List<Attachment> =
try { json.decodeFromString(value) } catch (_: Exception) { emptyList() }
}

View file

@ -0,0 +1,36 @@
package dev.kaizen.app.db
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import dev.kaizen.app.net.Attachment
@Entity(tableName = "conversations")
data class ConversationEntity(
@PrimaryKey val id: String,
val title: String,
val locked: Boolean,
val pinned: Boolean,
val updatedAt: String?,
val cachedAt: Long,
)
@Entity(
tableName = "messages",
foreignKeys = [ForeignKey(
entity = ConversationEntity::class,
parentColumns = ["id"],
childColumns = ["conversationId"],
onDelete = ForeignKey.CASCADE,
)],
indices = [Index("conversationId")],
)
data class MessageEntity(
@PrimaryKey val id: String,
val conversationId: String,
val role: String,
val content: String,
val attachments: List<Attachment>,
val sortOrder: Int,
)

View file

@ -0,0 +1,34 @@
package dev.kaizen.app.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@Database(
entities = [ConversationEntity::class, MessageEntity::class],
version = 1,
exportSchema = false,
)
@TypeConverters(Converters::class)
abstract class KaizenDatabase : RoomDatabase() {
abstract fun conversationDao(): ConversationDao
abstract fun messageDao(): MessageDao
companion object {
@Volatile private var instance: KaizenDatabase? = null
fun get(context: Context): KaizenDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
KaizenDatabase::class.java,
"kaizen-cache",
)
.fallbackToDestructiveMigration(false)
.build()
.also { instance = it }
}
}
}

View file

@ -0,0 +1,37 @@
package dev.kaizen.app.db
import dev.kaizen.app.net.ConversationSummary
import dev.kaizen.app.net.StoredMessage
fun ConversationSummary.toEntity() = ConversationEntity(
id = id,
title = title,
locked = locked,
pinned = pinned,
updatedAt = updatedAt,
cachedAt = System.currentTimeMillis(),
)
fun ConversationEntity.toSummary() = ConversationSummary(
id = id,
title = title,
locked = locked,
pinned = pinned,
updatedAt = updatedAt,
)
fun StoredMessage.toEntity(conversationId: String, sortOrder: Int) = MessageEntity(
id = id,
conversationId = conversationId,
role = role,
content = content,
attachments = attachments,
sortOrder = sortOrder,
)
fun MessageEntity.toStoredMessage() = StoredMessage(
id = id,
role = role,
content = content,
attachments = attachments,
)

View file

@ -0,0 +1,21 @@
package dev.kaizen.app.db
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface MessageDao {
@Query("SELECT * FROM messages WHERE conversationId = :convId ORDER BY sortOrder")
fun observeByConversation(convId: String): Flow<List<MessageEntity>>
@Query("SELECT * FROM messages WHERE conversationId = :convId ORDER BY sortOrder")
suspend fun getByConversation(convId: String): List<MessageEntity>
@Upsert
suspend fun upsertAll(messages: List<MessageEntity>)
@Query("DELETE FROM messages WHERE conversationId = :convId")
suspend fun deleteByConversation(convId: String)
}

View file

@ -0,0 +1,24 @@
package dev.kaizen.app.db
import dev.kaizen.app.net.KaizenApi
import kotlinx.coroutines.flow.Flow
class MessageRepository(private val dao: MessageDao) {
fun observeMessages(convId: String): Flow<List<MessageEntity>> =
dao.observeByConversation(convId)
suspend fun getCached(convId: String): List<MessageEntity> =
dao.getByConversation(convId)
suspend fun fetchAndCache(baseUrl: String, token: String, convId: String) {
val stored = KaizenApi.fetchMessages(baseUrl, token, convId)
val entities = stored.mapIndexed { i, m -> m.toEntity(convId, i) }
dao.deleteByConversation(convId)
dao.upsertAll(entities)
}
suspend fun cacheMessages(convId: String, messages: List<MessageEntity>) {
dao.upsertAll(messages)
}
}

View file

@ -0,0 +1,64 @@
package dev.kaizen.app
import dev.kaizen.app.db.Converters
import dev.kaizen.app.net.Attachment
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ConvertersTest {
private val converters = Converters()
@Test
fun emptyListRoundTrip() {
val json = converters.attachmentsToJson(emptyList())
val result = converters.jsonToAttachments(json)
assertTrue(result.isEmpty())
}
@Test
fun singleAttachmentRoundTrip() {
val original = listOf(
Attachment(url = "https://cdn.kaizen.dev/img.png", kind = "image", name = "img.png", mimeType = "image/png", size = 2048)
)
val json = converters.attachmentsToJson(original)
val result = converters.jsonToAttachments(json)
assertEquals(1, result.size)
assertEquals("https://cdn.kaizen.dev/img.png", result[0].url)
assertEquals("image", result[0].kind)
assertEquals("img.png", result[0].name)
assertEquals("image/png", result[0].mimeType)
assertEquals(2048L, result[0].size)
}
@Test
fun multipleAttachmentsRoundTrip() {
val original = listOf(
Attachment(url = "https://a.com/1.pdf", kind = "document", name = "1.pdf"),
Attachment(url = "https://a.com/2.mp4", kind = "video", name = "2.mp4", size = 999999),
)
val json = converters.attachmentsToJson(original)
val result = converters.jsonToAttachments(json)
assertEquals(2, result.size)
assertEquals("1.pdf", result[0].name)
assertEquals("2.mp4", result[1].name)
assertEquals(999999L, result[1].size)
}
@Test
fun malformedJsonReturnsEmptyList() {
val result = converters.jsonToAttachments("not valid json")
assertTrue(result.isEmpty())
}
@Test
fun unknownFieldsAreIgnored() {
val json = """[{"url":"https://x.com/f","kind":"image","name":"f","mimeType":"","unknownField":42}]"""
val result = converters.jsonToAttachments(json)
assertEquals(1, result.size)
assertEquals("https://x.com/f", result[0].url)
}
}

View file

@ -0,0 +1,122 @@
package dev.kaizen.app
import dev.kaizen.app.db.ConversationEntity
import dev.kaizen.app.db.MessageEntity
import dev.kaizen.app.db.toEntity
import dev.kaizen.app.db.toStoredMessage
import dev.kaizen.app.db.toSummary
import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.ConversationSummary
import dev.kaizen.app.net.StoredMessage
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class MappersTest {
@Test
fun conversationSummaryToEntity() {
val summary = ConversationSummary(
id = "conv-1",
title = "Test Chat",
locked = false,
pinned = true,
updatedAt = "2026-06-21T12:00:00Z",
)
val entity = summary.toEntity()
assertEquals("conv-1", entity.id)
assertEquals("Test Chat", entity.title)
assertEquals(false, entity.locked)
assertEquals(true, entity.pinned)
assertEquals("2026-06-21T12:00:00Z", entity.updatedAt)
assertTrue(entity.cachedAt > 0)
}
@Test
fun conversationEntityToSummary() {
val entity = ConversationEntity(
id = "conv-2",
title = "Another Chat",
locked = true,
pinned = false,
updatedAt = null,
cachedAt = 1000L,
)
val summary = entity.toSummary()
assertEquals("conv-2", summary.id)
assertEquals("Another Chat", summary.title)
assertEquals(true, summary.locked)
assertEquals(false, summary.pinned)
assertEquals(null, summary.updatedAt)
}
@Test
fun conversationRoundTrip() {
val original = ConversationSummary(
id = "rt-1", title = "Round Trip", locked = false, pinned = true,
updatedAt = "2026-06-21T08:00:00Z",
)
val roundTripped = original.toEntity().toSummary()
assertEquals(original.id, roundTripped.id)
assertEquals(original.title, roundTripped.title)
assertEquals(original.locked, roundTripped.locked)
assertEquals(original.pinned, roundTripped.pinned)
assertEquals(original.updatedAt, roundTripped.updatedAt)
}
@Test
fun storedMessageToEntity() {
val attachment = Attachment(url = "https://example.com/img.png", kind = "image", name = "img.png")
val msg = StoredMessage(
id = "msg-1",
role = "assistant",
content = "Hello there",
attachments = listOf(attachment),
)
val entity = msg.toEntity(conversationId = "conv-1", sortOrder = 3)
assertEquals("msg-1", entity.id)
assertEquals("conv-1", entity.conversationId)
assertEquals("assistant", entity.role)
assertEquals("Hello there", entity.content)
assertEquals(3, entity.sortOrder)
assertEquals(1, entity.attachments.size)
assertEquals("img.png", entity.attachments[0].name)
}
@Test
fun messageEntityToStoredMessage() {
val entity = MessageEntity(
id = "msg-2",
conversationId = "conv-1",
role = "user",
content = "Hi",
attachments = emptyList(),
sortOrder = 0,
)
val stored = entity.toStoredMessage()
assertEquals("msg-2", stored.id)
assertEquals("user", stored.role)
assertEquals("Hi", stored.content)
assertTrue(stored.attachments.isEmpty())
}
@Test
fun messageRoundTrip() {
val attachment = Attachment(url = "https://f.co/a.pdf", kind = "document", name = "a.pdf", mimeType = "application/pdf", size = 1024)
val original = StoredMessage(id = "rt-msg", role = "user", content = "Check this", attachments = listOf(attachment))
val roundTripped = original.toEntity("c1", 5).toStoredMessage()
assertEquals(original.id, roundTripped.id)
assertEquals(original.role, roundTripped.role)
assertEquals(original.content, roundTripped.content)
assertEquals(original.attachments.size, roundTripped.attachments.size)
assertEquals(original.attachments[0].url, roundTripped.attachments[0].url)
assertEquals(original.attachments[0].mimeType, roundTripped.attachments[0].mimeType)
assertEquals(original.attachments[0].size, roundTripped.attachments[0].size)
}
}

View file

@ -2,4 +2,5 @@
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false
} }

View file

@ -12,4 +12,6 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true # org.gradle.parallel=true
# Kotlin code style for this project: "official" or "obsolete": # Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official kotlin.code.style=official
# KSP + AGP 9.x built-in Kotlin compatibility (github.com/google/ksp/issues/2729)
android.disallowKotlinSourceSets=false

View file

@ -7,16 +7,20 @@ espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.13.0" activityCompose = "1.13.0"
kotlin = "2.2.10" kotlin = "2.2.10"
ksp = "2.2.10-2.0.2"
composeBom = "2026.02.01" composeBom = "2026.02.01"
okhttp = "4.12.0" okhttp = "4.12.0"
kotlinxSerialization = "1.7.3" kotlinxSerialization = "1.7.3"
securityCrypto = "1.1.0-alpha06" securityCrypto = "1.1.0-alpha06"
room = "2.8.4"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
@ -37,4 +41,5 @@ androidx-compose-material-icons-extended = { group = "androidx.compose.material"
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }