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.
14 KiB
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
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
@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
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
@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
@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
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.
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:
conversationsist einvarState, gefuellt durchfetchConversations() - Nachher:
chatViewModel.conversationRepo.observeAll().collectAsState()— instant, reaktiv - Background-Refresh beim App-Start und bei
LifecycleResumeEffect
ChatScreen — openConversation()
- Vorher:
fetchMessages()blockiert, dannmessages.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:
messagesbleibtmutableStateListOf(in-memory, wie bisher) - Nach Stream-Ende:
messageRepository.cacheMessages()zusaetzlich zum bestehendensaveMessages() 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
cachedAtauf jeder Conversation — wenn aelter als z.B. 5 Minuten, Background-Refresh- App-Start: immer refreshen, aber UI zeigt sofort den Cache
8. Neue Dependencies
# 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" }
// 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