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.
423 lines
14 KiB
Markdown
423 lines
14 KiB
Markdown
# 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
|