1package soy.iko.opencode.data.repo
3import android.content.Context
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
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.
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.
26open class MessageCacheStore private constructor(
27 private val appContext: Context?,
28 @Suppress("unused") private val testMode: Boolean,
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() } }
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())
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()
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)
107 }.onFailure { Log.w("MessageCacheStore", "Failed to cache messages for $sessionId", it) }
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()
130 // Back-compat: pre-namespacing caches were written flat under [dir].
131 dir?.let { File(it, name).delete() }
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list