From 9759d478a7e6103b4ee9c25e96be3d106ab01b59 Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Sun, 21 Jun 2026 12:50:46 +0200 Subject: [PATCH] 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. --- OFFLINE_CACHE_ADD.md | 423 ++++++++++++++++++ app/build.gradle.kts | 4 + .../main/java/dev/kaizen/app/MainActivity.kt | 6 +- .../java/dev/kaizen/app/chat/ChatScreen.kt | 65 ++- .../java/dev/kaizen/app/chat/ChatViewModel.kt | 12 + .../java/dev/kaizen/app/db/ConversationDao.kt | 28 ++ .../kaizen/app/db/ConversationRepository.kt | 25 ++ .../main/java/dev/kaizen/app/db/Converters.kt | 18 + .../main/java/dev/kaizen/app/db/Entities.kt | 36 ++ .../java/dev/kaizen/app/db/KaizenDatabase.kt | 34 ++ .../main/java/dev/kaizen/app/db/Mappers.kt | 37 ++ .../main/java/dev/kaizen/app/db/MessageDao.kt | 21 + .../dev/kaizen/app/db/MessageRepository.kt | 24 + .../java/dev/kaizen/app/ConvertersTest.kt | 64 +++ .../test/java/dev/kaizen/app/MappersTest.kt | 122 +++++ build.gradle.kts | 1 + gradle.properties | 4 +- gradle/libs.versions.toml | 5 + 18 files changed, 910 insertions(+), 19 deletions(-) create mode 100644 OFFLINE_CACHE_ADD.md create mode 100644 app/src/main/java/dev/kaizen/app/chat/ChatViewModel.kt create mode 100644 app/src/main/java/dev/kaizen/app/db/ConversationDao.kt create mode 100644 app/src/main/java/dev/kaizen/app/db/ConversationRepository.kt create mode 100644 app/src/main/java/dev/kaizen/app/db/Converters.kt create mode 100644 app/src/main/java/dev/kaizen/app/db/Entities.kt create mode 100644 app/src/main/java/dev/kaizen/app/db/KaizenDatabase.kt create mode 100644 app/src/main/java/dev/kaizen/app/db/Mappers.kt create mode 100644 app/src/main/java/dev/kaizen/app/db/MessageDao.kt create mode 100644 app/src/main/java/dev/kaizen/app/db/MessageRepository.kt create mode 100644 app/src/test/java/dev/kaizen/app/ConvertersTest.kt create mode 100644 app/src/test/java/dev/kaizen/app/MappersTest.kt diff --git a/OFFLINE_CACHE_ADD.md b/OFFLINE_CACHE_ADD.md new file mode 100644 index 0000000..eca11d4 --- /dev/null +++ b/OFFLINE_CACHE_ADD.md @@ -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>` 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>) +Aktive Streaming-Msg -> in-memory (mutableStateOf) +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) │ 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 │ suspend fun │ (unveraendert) +└───────────────┴──────────────────┘ +``` + +--- + +## 4. Room-Schema + +### TypeConverter + +```kotlin +class Converters { + private val json = Json { ignoreUnknownKeys = true } + + @TypeConverter + fun attachmentsToJson(attachments: List): String = + json.encodeToString(attachments) + + @TypeConverter + fun jsonToAttachments(value: String): List = + 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, + 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> + + @Upsert + suspend fun upsertAll(conversations: List) + + @Query("DELETE FROM conversations") + suspend fun deleteAll() + + @Transaction + suspend fun replaceAll(conversations: List) { + 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> + + @Query("SELECT * FROM messages WHERE conversationId = :convId ORDER BY sortOrder") + suspend fun getByConversation(convId: String): List + + @Upsert + suspend fun upsertAll(messages: List) + + @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> = + 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> = + 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) { + 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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9897a4b..64748a0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) } android { @@ -56,6 +57,9 @@ dependencies { implementation(libs.kotlinx.serialization.json) // Hardware-backed encrypted storage for the bearer token (EncryptedSharedPreferences) 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) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) diff --git a/app/src/main/java/dev/kaizen/app/MainActivity.kt b/app/src/main/java/dev/kaizen/app/MainActivity.kt index a77b021..1d66d62 100644 --- a/app/src/main/java/dev/kaizen/app/MainActivity.kt +++ b/app/src/main/java/dev/kaizen/app/MainActivity.kt @@ -10,6 +10,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import dev.kaizen.app.auth.LoginScreen import dev.kaizen.app.chat.ChatScreen +import dev.kaizen.app.chat.ChatViewModel import dev.kaizen.app.net.SecureStore import dev.kaizen.app.net.SessionViewModel import dev.kaizen.app.settings.SettingsViewModel @@ -28,10 +29,9 @@ class MainActivity : ComponentActivity() { window.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT } - // Initialize the decoupled viewmodels at the Activity scope (or Hilt/Dagger later) val settingsViewModel = SettingsViewModel() - // Restores the persisted (encrypted) server config — drives login vs. chat routing val sessionViewModel = SessionViewModel(SecureStore(applicationContext)) + val chatViewModel = ChatViewModel(applicationContext) // auto() picks light/dark system-bar icons based on the system theme enableEdgeToEdge( @@ -43,7 +43,7 @@ class MainActivity : ComponentActivity() { // Snapshot-state routing: logging in/out flips session.config and // recomposes this branch — no NavHost needed for two top-level states. if (sessionViewModel.isLoggedIn) { - ChatScreen(settingsViewModel = settingsViewModel, session = sessionViewModel) + ChatScreen(settingsViewModel = settingsViewModel, session = sessionViewModel, chat = chatViewModel) } else { LoginScreen(session = sessionViewModel) } diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt index 0516b39..d8679c4 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -53,6 +53,8 @@ import androidx.compose.material3.Text import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp 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.net.Attachment import dev.kaizen.app.net.ChatHttpException @@ -80,6 +82,7 @@ enum class AppScreen { Chat, Settings } fun ChatScreen( settingsViewModel: SettingsViewModel, session: SessionViewModel, + chat: ChatViewModel, modifier: Modifier = Modifier ) { val haptics = rememberHaptics() @@ -108,9 +111,9 @@ fun ChatScreen( var models by remember { mutableStateOf(session.store.loadModelsCache() ?: emptyList()) } 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(null) } - var conversations by remember { mutableStateOf>(emptyList()) } + val conversations by chat.conversationRepo.observeAll().collectAsState(initial = emptyList()) var loadError by remember { mutableStateOf(null) } @@ -153,10 +156,7 @@ fun ChatScreen( } is FetchResult.Fail -> errors.add(r.reason) } - when (val r = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)) { - is FetchResult.Ok -> conversations = r.data - is FetchResult.Fail -> errors.add(r.reason) - } + chat.conversationRepo.refresh(cfg.baseUrl, cfg.token) if (errors.isNotEmpty()) { loadError = errors.joinToString(" · ") } @@ -174,10 +174,7 @@ fun ChatScreen( fun refreshConversations() { val cfg = session.config ?: return - scope.launch { - val r = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token) - if (r is FetchResult.Ok) conversations = r.data - } + scope.launch { chat.conversationRepo.refresh(cfg.baseUrl, cfg.token) } } /** Start a fresh chat — drops the active conversation, the next send creates a new one. */ @@ -186,14 +183,15 @@ fun ChatScreen( conversationId = null } - /** Load a stored conversation into the linear message list. Locked ones are skipped (no in-app unlock yet). */ fun openConversation(summary: ConversationSummary) { if (summary.locked) return val cfg = session.config ?: return 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() - stored.forEach { m -> + val cachedList = chat.messageRepo.getCached(summary.id) + cachedList.forEach { m -> messages.add( Message( id = nextId++, @@ -205,6 +203,27 @@ fun ChatScreen( ) } 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), ), ) - if (saved && wasNewConversation) KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId) - if (saved) refreshConversations() + if (saved) { + // 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) { val idx = messages.indexOfLast { it.id == assistantId } diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatViewModel.kt b/app/src/main/java/dev/kaizen/app/chat/ChatViewModel.kt new file mode 100644 index 0000000..8af7869 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/chat/ChatViewModel.kt @@ -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()) +} diff --git a/app/src/main/java/dev/kaizen/app/db/ConversationDao.kt b/app/src/main/java/dev/kaizen/app/db/ConversationDao.kt new file mode 100644 index 0000000..644b523 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/db/ConversationDao.kt @@ -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> + + @Upsert + suspend fun upsertAll(conversations: List) + + @Query("DELETE FROM conversations") + suspend fun deleteAll() + + @Transaction + suspend fun replaceAll(conversations: List) { + deleteAll() + upsertAll(conversations) + } + + @Query("SELECT cachedAt FROM conversations LIMIT 1") + suspend fun lastCachedAt(): Long? +} diff --git a/app/src/main/java/dev/kaizen/app/db/ConversationRepository.kt b/app/src/main/java/dev/kaizen/app/db/ConversationRepository.kt new file mode 100644 index 0000000..9558461 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/db/ConversationRepository.kt @@ -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> = + 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() +} diff --git a/app/src/main/java/dev/kaizen/app/db/Converters.kt b/app/src/main/java/dev/kaizen/app/db/Converters.kt new file mode 100644 index 0000000..ca487fe --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/db/Converters.kt @@ -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): String = + json.encodeToString(attachments) + + @TypeConverter + fun jsonToAttachments(value: String): List = + try { json.decodeFromString(value) } catch (_: Exception) { emptyList() } +} diff --git a/app/src/main/java/dev/kaizen/app/db/Entities.kt b/app/src/main/java/dev/kaizen/app/db/Entities.kt new file mode 100644 index 0000000..9f4587e --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/db/Entities.kt @@ -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, + val sortOrder: Int, +) diff --git a/app/src/main/java/dev/kaizen/app/db/KaizenDatabase.kt b/app/src/main/java/dev/kaizen/app/db/KaizenDatabase.kt new file mode 100644 index 0000000..f046112 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/db/KaizenDatabase.kt @@ -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 } + } + } +} diff --git a/app/src/main/java/dev/kaizen/app/db/Mappers.kt b/app/src/main/java/dev/kaizen/app/db/Mappers.kt new file mode 100644 index 0000000..b99e5a2 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/db/Mappers.kt @@ -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, +) diff --git a/app/src/main/java/dev/kaizen/app/db/MessageDao.kt b/app/src/main/java/dev/kaizen/app/db/MessageDao.kt new file mode 100644 index 0000000..9e39e23 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/db/MessageDao.kt @@ -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> + + @Query("SELECT * FROM messages WHERE conversationId = :convId ORDER BY sortOrder") + suspend fun getByConversation(convId: String): List + + @Upsert + suspend fun upsertAll(messages: List) + + @Query("DELETE FROM messages WHERE conversationId = :convId") + suspend fun deleteByConversation(convId: String) +} diff --git a/app/src/main/java/dev/kaizen/app/db/MessageRepository.kt b/app/src/main/java/dev/kaizen/app/db/MessageRepository.kt new file mode 100644 index 0000000..1e5fa17 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/db/MessageRepository.kt @@ -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> = + dao.observeByConversation(convId) + + suspend fun getCached(convId: String): List = + 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) { + dao.upsertAll(messages) + } +} diff --git a/app/src/test/java/dev/kaizen/app/ConvertersTest.kt b/app/src/test/java/dev/kaizen/app/ConvertersTest.kt new file mode 100644 index 0000000..b7511c2 --- /dev/null +++ b/app/src/test/java/dev/kaizen/app/ConvertersTest.kt @@ -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) + } +} diff --git a/app/src/test/java/dev/kaizen/app/MappersTest.kt b/app/src/test/java/dev/kaizen/app/MappersTest.kt new file mode 100644 index 0000000..41cea08 --- /dev/null +++ b/app/src/test/java/dev/kaizen/app/MappersTest.kt @@ -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) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 18318be..0b5c6ec 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,4 +2,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.ksp) apply false } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 34c5e9e..87f169f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,4 +12,6 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects # org.gradle.parallel=true # Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official +# KSP + AGP 9.x built-in Kotlin compatibility (github.com/google/ksp/issues/2729) +android.disallowKotlinSourceSets=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ecae944..7cc5791 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,16 +7,20 @@ espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.13.0" kotlin = "2.2.10" +ksp = "2.2.10-2.0.2" composeBom = "2026.02.01" okhttp = "4.12.0" kotlinxSerialization = "1.7.3" securityCrypto = "1.1.0-alpha06" +room = "2.8.4" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } 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-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" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } 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" } 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" }