kaizen-app/OFFLINE_CACHE_ADD.md
Bruno Deanoz 9759d478a7 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.
2026-06-21 12:50:46 +02:00

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: 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

# 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