# 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