git.iko.soy/opencode-android
on 5d56632 help sign in
92 lines 4.4 kB history raw
1package soy.iko.opencode.data.repo
3import android.content.Context
4import android.util.Log
5import kotlinx.coroutines.Dispatchers
6import kotlinx.coroutines.sync.Mutex
7import kotlinx.coroutines.sync.withLock
8import kotlinx.coroutines.withContext
9import kotlinx.serialization.builtins.ListSerializer
10import soy.iko.opencode.data.model.MessageWithParts
11import soy.iko.opencode.data.network.OpencodeJson
12import soy.iko.opencode.util.runCatchingCancellable
13import java.io.File
15/**
16 * On-disk cache of a session's reduced message list, so a conversation shows instantly on
17 * open (and remains readable while offline / before the network responds) instead of a blank
18 * screen until `GET /session/:id/message` returns.
19 *
20 * One JSON file per session under filesDir, encoded with [OpencodeJson] so the same sealed
21 * polymorphism used on the wire round-trips (unknown parts/messages are concrete
22 * `__unknown` variants, so they encode too). Every operation is best-effort — a cache
23 * miss or a write failure just falls back to the network — so problems never surface to
24 * the user. No annotation processing (Room/KSP) per the project's manual-DI convention.
25 */
26open class MessageCacheStore private constructor(
27 private val appContext: Context?,
28 @Suppress("unused") private val testMode: Boolean,
29) {
30 constructor(context: Context) : this(context.applicationContext, false)
31 protected constructor() : this(null, true)
33 private val serializer = ListSerializer(MessageWithParts.serializer())
35 /** Session ids are validated tokens upstream, but sanitize anyway so a stray id can't
36 * escape the directory. */
37 private val idRegex = Regex("[^A-Za-z0-9_-]")
39 private val dir: File? by lazy {
40 appContext?.let { File(it.filesDir, "message_cache").apply { mkdirs() } }
41 }
43 private fun fileFor(sessionId: String): File? =
44 dir?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
46 /** Per-session write lock so concurrent save/remove/load on the same file (e.g. both
47 * panes of two-pane mode saving the same session, or a save racing a delete's remove)
48 * can't tear or half-write the cache file. Keyed by session id; the map grows only with
49 * the number of distinct sessions touched, and each [Mutex] is a few bytes. */
50 private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
51 private fun lockFor(sessionId: String): Mutex = locks.getOrPut(sessionId) { Mutex() }
53 /** Load the cached messages for [sessionId], or empty if none / unreadable. */
54 open suspend fun load(sessionId: String): List<MessageWithParts> = lockFor(sessionId).withLock {
55 withContext(Dispatchers.IO) {
56 val file = fileFor(sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
57 runCatchingCancellable { OpencodeJson.decodeFromString(serializer, file.readText()) }
58 .getOrDefault(emptyList())
59 }
60 }
62 /** Persist [messages] for [sessionId] (an empty list deletes the file). */
63 open suspend fun save(sessionId: String, messages: List<MessageWithParts>): Unit = lockFor(sessionId).withLock {
64 withContext(Dispatchers.IO) {
65 val file = fileFor(sessionId) ?: return@withContext
66 runCatchingCancellable {
67 if (messages.isEmpty()) file.delete()
68 else {
69 // FIX: write atomically (temp file + rename) like RecentSessionsStore so a
70 // process kill mid-write can't leave a truncated file that fails to decode,
71 // wiping the cached conversation on the next open.
72 val encoded = OpencodeJson.encodeToString(serializer, messages)
73 val tmp = File(file.parentFile, file.name + ".tmp")
74 tmp.writeText(encoded)
75 if (!tmp.renameTo(file)) {
76 file.writeText(encoded)
77 tmp.delete()
78 }
79 }
80 }.onFailure { Log.w("MessageCacheStore", "Failed to cache messages for $sessionId", it) }
81 Unit
82 }
83 }
85 /** Remove a session's cached messages (call on deletion). */
86 open suspend fun remove(sessionId: String): Unit = lockFor(sessionId).withLock {
87 withContext(Dispatchers.IO) {
88 runCatchingCancellable { fileFor(sessionId)?.delete() }
89 Unit
90 }
91 }