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.
This commit is contained in:
parent
d8dcb25e3d
commit
9759d478a7
18 changed files with 910 additions and 19 deletions
423
OFFLINE_CACHE_ADD.md
Normal file
423
OFFLINE_CACHE_ADD.md
Normal file
|
|
@ -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<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
|
||||||
|
|
@ -2,6 +2,7 @@ plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
@ -56,6 +57,9 @@ dependencies {
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
// Hardware-backed encrypted storage for the bearer token (EncryptedSharedPreferences)
|
// Hardware-backed encrypted storage for the bearer token (EncryptedSharedPreferences)
|
||||||
implementation(libs.androidx.security.crypto)
|
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)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import dev.kaizen.app.auth.LoginScreen
|
import dev.kaizen.app.auth.LoginScreen
|
||||||
import dev.kaizen.app.chat.ChatScreen
|
import dev.kaizen.app.chat.ChatScreen
|
||||||
|
import dev.kaizen.app.chat.ChatViewModel
|
||||||
import dev.kaizen.app.net.SecureStore
|
import dev.kaizen.app.net.SecureStore
|
||||||
import dev.kaizen.app.net.SessionViewModel
|
import dev.kaizen.app.net.SessionViewModel
|
||||||
import dev.kaizen.app.settings.SettingsViewModel
|
import dev.kaizen.app.settings.SettingsViewModel
|
||||||
|
|
@ -28,10 +29,9 @@ class MainActivity : ComponentActivity() {
|
||||||
window.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
|
window.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize the decoupled viewmodels at the Activity scope (or Hilt/Dagger later)
|
|
||||||
val settingsViewModel = SettingsViewModel()
|
val settingsViewModel = SettingsViewModel()
|
||||||
// Restores the persisted (encrypted) server config — drives login vs. chat routing
|
|
||||||
val sessionViewModel = SessionViewModel(SecureStore(applicationContext))
|
val sessionViewModel = SessionViewModel(SecureStore(applicationContext))
|
||||||
|
val chatViewModel = ChatViewModel(applicationContext)
|
||||||
|
|
||||||
// auto() picks light/dark system-bar icons based on the system theme
|
// auto() picks light/dark system-bar icons based on the system theme
|
||||||
enableEdgeToEdge(
|
enableEdgeToEdge(
|
||||||
|
|
@ -43,7 +43,7 @@ class MainActivity : ComponentActivity() {
|
||||||
// Snapshot-state routing: logging in/out flips session.config and
|
// Snapshot-state routing: logging in/out flips session.config and
|
||||||
// recomposes this branch — no NavHost needed for two top-level states.
|
// recomposes this branch — no NavHost needed for two top-level states.
|
||||||
if (sessionViewModel.isLoggedIn) {
|
if (sessionViewModel.isLoggedIn) {
|
||||||
ChatScreen(settingsViewModel = settingsViewModel, session = sessionViewModel)
|
ChatScreen(settingsViewModel = settingsViewModel, session = sessionViewModel, chat = chatViewModel)
|
||||||
} else {
|
} else {
|
||||||
LoginScreen(session = sessionViewModel)
|
LoginScreen(session = sessionViewModel)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ import androidx.compose.material3.Text
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.haptics.rememberHaptics
|
||||||
import dev.kaizen.app.net.Attachment
|
import dev.kaizen.app.net.Attachment
|
||||||
import dev.kaizen.app.net.ChatHttpException
|
import dev.kaizen.app.net.ChatHttpException
|
||||||
|
|
@ -80,6 +82,7 @@ enum class AppScreen { Chat, Settings }
|
||||||
fun ChatScreen(
|
fun ChatScreen(
|
||||||
settingsViewModel: SettingsViewModel,
|
settingsViewModel: SettingsViewModel,
|
||||||
session: SessionViewModel,
|
session: SessionViewModel,
|
||||||
|
chat: ChatViewModel,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val haptics = rememberHaptics()
|
val haptics = rememberHaptics()
|
||||||
|
|
@ -108,9 +111,9 @@ fun ChatScreen(
|
||||||
var models by remember { mutableStateOf(session.store.loadModelsCache() ?: emptyList()) }
|
var models by remember { mutableStateOf(session.store.loadModelsCache() ?: emptyList()) }
|
||||||
var showModelSheet by remember { mutableStateOf(false) }
|
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<String?>(null) }
|
var conversationId by remember { mutableStateOf<String?>(null) }
|
||||||
var conversations by remember { mutableStateOf<List<ConversationSummary>>(emptyList()) }
|
val conversations by chat.conversationRepo.observeAll().collectAsState(initial = emptyList())
|
||||||
|
|
||||||
var loadError by remember { mutableStateOf<String?>(null) }
|
var loadError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
|
@ -153,10 +156,7 @@ fun ChatScreen(
|
||||||
}
|
}
|
||||||
is FetchResult.Fail -> errors.add(r.reason)
|
is FetchResult.Fail -> errors.add(r.reason)
|
||||||
}
|
}
|
||||||
when (val r = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)) {
|
chat.conversationRepo.refresh(cfg.baseUrl, cfg.token)
|
||||||
is FetchResult.Ok -> conversations = r.data
|
|
||||||
is FetchResult.Fail -> errors.add(r.reason)
|
|
||||||
}
|
|
||||||
if (errors.isNotEmpty()) {
|
if (errors.isNotEmpty()) {
|
||||||
loadError = errors.joinToString(" · ")
|
loadError = errors.joinToString(" · ")
|
||||||
}
|
}
|
||||||
|
|
@ -174,10 +174,7 @@ fun ChatScreen(
|
||||||
|
|
||||||
fun refreshConversations() {
|
fun refreshConversations() {
|
||||||
val cfg = session.config ?: return
|
val cfg = session.config ?: return
|
||||||
scope.launch {
|
scope.launch { chat.conversationRepo.refresh(cfg.baseUrl, cfg.token) }
|
||||||
val r = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)
|
|
||||||
if (r is FetchResult.Ok) conversations = r.data
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Start a fresh chat — drops the active conversation, the next send creates a new one. */
|
/** Start a fresh chat — drops the active conversation, the next send creates a new one. */
|
||||||
|
|
@ -186,14 +183,15 @@ fun ChatScreen(
|
||||||
conversationId = null
|
conversationId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load a stored conversation into the linear message list. Locked ones are skipped (no in-app unlock yet). */
|
|
||||||
fun openConversation(summary: ConversationSummary) {
|
fun openConversation(summary: ConversationSummary) {
|
||||||
if (summary.locked) return
|
if (summary.locked) return
|
||||||
val cfg = session.config ?: return
|
val cfg = session.config ?: return
|
||||||
scope.launch {
|
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()
|
messages.clear()
|
||||||
stored.forEach { m ->
|
val cachedList = chat.messageRepo.getCached(summary.id)
|
||||||
|
cachedList.forEach { m ->
|
||||||
messages.add(
|
messages.add(
|
||||||
Message(
|
Message(
|
||||||
id = nextId++,
|
id = nextId++,
|
||||||
|
|
@ -205,6 +203,27 @@ fun ChatScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
conversationId = summary.id
|
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),
|
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) {
|
||||||
if (saved) refreshConversations()
|
// 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) {
|
} catch (e: Exception) {
|
||||||
val idx = messages.indexOfLast { it.id == assistantId }
|
val idx = messages.indexOfLast { it.id == assistantId }
|
||||||
|
|
|
||||||
12
app/src/main/java/dev/kaizen/app/chat/ChatViewModel.kt
Normal file
12
app/src/main/java/dev/kaizen/app/chat/ChatViewModel.kt
Normal file
|
|
@ -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())
|
||||||
|
}
|
||||||
28
app/src/main/java/dev/kaizen/app/db/ConversationDao.kt
Normal file
28
app/src/main/java/dev/kaizen/app/db/ConversationDao.kt
Normal file
|
|
@ -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<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?
|
||||||
|
}
|
||||||
|
|
@ -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<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 stays, no crash */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun lastCachedAt(): Long? = dao.lastCachedAt()
|
||||||
|
}
|
||||||
18
app/src/main/java/dev/kaizen/app/db/Converters.kt
Normal file
18
app/src/main/java/dev/kaizen/app/db/Converters.kt
Normal file
|
|
@ -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<Attachment>): String =
|
||||||
|
json.encodeToString(attachments)
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun jsonToAttachments(value: String): List<Attachment> =
|
||||||
|
try { json.decodeFromString(value) } catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
36
app/src/main/java/dev/kaizen/app/db/Entities.kt
Normal file
36
app/src/main/java/dev/kaizen/app/db/Entities.kt
Normal file
|
|
@ -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<Attachment>,
|
||||||
|
val sortOrder: Int,
|
||||||
|
)
|
||||||
34
app/src/main/java/dev/kaizen/app/db/KaizenDatabase.kt
Normal file
34
app/src/main/java/dev/kaizen/app/db/KaizenDatabase.kt
Normal file
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/src/main/java/dev/kaizen/app/db/Mappers.kt
Normal file
37
app/src/main/java/dev/kaizen/app/db/Mappers.kt
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
21
app/src/main/java/dev/kaizen/app/db/MessageDao.kt
Normal file
21
app/src/main/java/dev/kaizen/app/db/MessageDao.kt
Normal file
|
|
@ -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<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)
|
||||||
|
}
|
||||||
24
app/src/main/java/dev/kaizen/app/db/MessageRepository.kt
Normal file
24
app/src/main/java/dev/kaizen/app/db/MessageRepository.kt
Normal file
|
|
@ -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<List<MessageEntity>> =
|
||||||
|
dao.observeByConversation(convId)
|
||||||
|
|
||||||
|
suspend fun getCached(convId: String): List<MessageEntity> =
|
||||||
|
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<MessageEntity>) {
|
||||||
|
dao.upsertAll(messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/src/test/java/dev/kaizen/app/ConvertersTest.kt
Normal file
64
app/src/test/java/dev/kaizen/app/ConvertersTest.kt
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
122
app/src/test/java/dev/kaizen/app/MappersTest.kt
Normal file
122
app/src/test/java/dev/kaizen/app/MappersTest.kt
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,4 +2,5 @@
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
alias(libs.plugins.kotlin.compose) apply false
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.ksp) apply false
|
||||||
}
|
}
|
||||||
|
|
@ -12,4 +12,6 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||||
# org.gradle.parallel=true
|
# org.gradle.parallel=true
|
||||||
# Kotlin code style for this project: "official" or "obsolete":
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
|
# KSP + AGP 9.x built-in Kotlin compatibility (github.com/google/ksp/issues/2729)
|
||||||
|
android.disallowKotlinSourceSets=false
|
||||||
|
|
@ -7,16 +7,20 @@ espressoCore = "3.7.0"
|
||||||
lifecycleRuntimeKtx = "2.10.0"
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
activityCompose = "1.13.0"
|
activityCompose = "1.13.0"
|
||||||
kotlin = "2.2.10"
|
kotlin = "2.2.10"
|
||||||
|
ksp = "2.2.10-2.0.2"
|
||||||
composeBom = "2026.02.01"
|
composeBom = "2026.02.01"
|
||||||
okhttp = "4.12.0"
|
okhttp = "4.12.0"
|
||||||
kotlinxSerialization = "1.7.3"
|
kotlinxSerialization = "1.7.3"
|
||||||
securityCrypto = "1.1.0-alpha06"
|
securityCrypto = "1.1.0-alpha06"
|
||||||
|
room = "2.8.4"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
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-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" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
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" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue