git.iko.soy/opencode-android
on d518ef7 help sign in
137 lines 7.8 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 /** Per-profile subdirectory: two servers with a colliding session id must not share a cache
44 * file, so entries are namespaced by profile id under [dir]. */
45 private fun profileDir(profileId: String): File? =
46 dir?.let { File(it, idRegex.replace(profileId, "_")).apply { mkdirs() } }
48 private fun fileFor(profileId: String, sessionId: String): File? =
49 profileDir(profileId)?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
51 /** Per-session write lock so concurrent save/remove/load on the same file (e.g. both
52 * panes of two-pane mode saving the same session, or a save racing a delete's remove)
53 * can't tear or half-write the cache file. Keyed by session id; the map grows only with
54 * the number of distinct sessions touched, and each [Mutex] is a few bytes. */
55 private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
56 private fun lockFor(sessionId: String): Mutex = locks.getOrPut(sessionId) { Mutex() }
58 /** Sessions whose cache file has been removed by [remove]. A teardown flush of an
59 * observer (CacheWriter.flushOnTeardown) runs under NonCancellable and can race with
60 * remove(): both serialize on [lockFor], but their relative order is nondeterministic.
61 * If the flush's save lands after remove, it re-creates the deleted session's cache
62 * file, defeating the deletion (a privacy/disk-leak with no background cleanup). The
63 * tombstone lets a post-remove save detect the deletion and skip. Keyed by
64 * "$profileId/$sessionId" — a bare sessionId would suppress a *different* server's
65 * saves if two servers share a colliding session id (the very case the per-profile
66 * namespacing at [profileDir] exists to defend against). Cleared by [load] so
67 * re-opening a session re-enables saving. Bounded by distinct deleted sessions. */
68 private val tombstones: MutableSet<String> =
69 java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
71 private fun tombstoneKey(profileId: String, sessionId: String): String = "$profileId/$sessionId"
73 /** Load the cached messages for [sessionId] under [profileId], or empty if none / unreadable. */
74 open suspend fun load(profileId: String, sessionId: String): List<MessageWithParts> = lockFor(sessionId).withLock {
75 // Re-opening a session re-enables saving: clear any tombstone so a fresh conversation
76 // write isn't suppressed by a prior deletion of the same (reused) id.
77 tombstones.remove(tombstoneKey(profileId, sessionId))
78 withContext(Dispatchers.IO) {
79 val file = fileFor(profileId, sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
80 runCatchingCancellable { OpencodeJson.decodeFromString(serializer, file.readText()) }
81 .getOrDefault(emptyList())
82 }
83 }
85 /** Persist [messages] for [sessionId] under [profileId] (an empty list deletes the file). */
86 open suspend fun save(profileId: String, sessionId: String, messages: List<MessageWithParts>): Unit = lockFor(sessionId).withLock {
87 // Skip if this session was deleted since the observer started: a teardown flush racing
88 // after remove() must not re-create the file. (Held under the same per-session lock as
89 // remove, so the tombstone is visible to a save that lost the race.)
90 if (tombstoneKey(profileId, sessionId) in tombstones) return@withLock
91 withContext(Dispatchers.IO) {
92 val file = fileFor(profileId, sessionId) ?: return@withContext
93 runCatchingCancellable {
94 if (messages.isEmpty()) file.delete()
95 else {
96 // FIX: write atomically (temp file + rename) like RecentSessionsStore so a
97 // process kill mid-write can't leave a truncated file that fails to decode,
98 // wiping the cached conversation on the next open.
99 val encoded = OpencodeJson.encodeToString(serializer, messages)
100 val tmp = File(file.parentFile, file.name + ".tmp")
101 tmp.writeText(encoded)
102 if (!tmp.renameTo(file)) {
103 file.writeText(encoded)
104 tmp.delete()
105 }
106 }
107 }.onFailure { Log.w("MessageCacheStore", "Failed to cache messages for $sessionId", it) }
108 Unit
109 }
110 }
112 /** Remove a session's cached messages (call on deletion). Clears the entry in every profile
113 * namespace: session ids are unique per server and deletion is terminal, so the caller
114 * needn't know which profile owned it. The per-session lock is intentionally NOT evicted —
115 * a concurrent teardown save() for this id must serialize against the same [Mutex] instance,
116 * and the map is bounded by the number of distinct sessions touched.
117 * [profileId] namespaces the tombstone so a delete on one server doesn't suppress another
118 * server's saves for a colliding session id. */
119 open suspend fun remove(profileId: String, sessionId: String) {
120 lockFor(sessionId).withLock {
121 // Set the tombstone before deleting so a concurrent/loser save sees it and skips,
122 // instead of re-creating the file we're about to delete.
123 tombstones.add(tombstoneKey(profileId, sessionId))
124 withContext(Dispatchers.IO) {
125 runCatchingCancellable {
126 val name = idRegex.replace(sessionId, "_") + ".json"
127 dir?.listFiles()?.forEach { entry ->
128 if (entry.isDirectory) File(entry, name).delete()
129 }
130 // Back-compat: pre-namespacing caches were written flat under [dir].
131 dir?.let { File(it, name).delete() }
132 }
133 Unit
134 }
135 }
136 }