git.iko.soy/opencode-android
on 06fd41b help sign in
114 lines 5.3 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.Serializable
10import kotlinx.serialization.decodeFromString
11import kotlinx.serialization.encodeToString
12import kotlinx.serialization.json.Json
13import soy.iko.opencode.util.runCatchingCancellable
14import java.io.File
16/**
17 * One staged (not-yet-sent) attachment, in the minimal form needed to re-send and preview
18 * it after a process restart. Only the self-contained wire payload is kept — the base64
19 * `data:` [url] — because the original content Uri's read permission does NOT survive a
20 * process restart, whereas the data URL is self-sufficient (and Coil re-renders it as the
21 * thumbnail for images).
22 */
23@Serializable
24data class PersistedAttachment(
25 val id: String,
26 val name: String,
27 val mime: String,
28 val url: String,
29 val filename: String? = null,
32/**
33 * Persists attachments staged for each session so an interrupted compose survives process
34 * death, mirroring how [DraftStore] persists the text draft.
35 *
36 * Backed by one small JSON file per session under filesDir (not cacheDir, which the OS may
37 * evict) rather than SharedPreferences: a base64 payload can be several megabytes and
38 * SharedPreferences loads its whole file into memory on first access.
39 */
40open class AttachmentDraftStore private constructor(
41 private val appContext: Context?,
42 @Suppress("unused") private val testMode: Boolean,
43) {
44 constructor(context: Context) : this(context.applicationContext, false)
45 protected constructor() : this(null, true)
47 private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
49 /** Session ids are validated tokens upstream, but sanitize anyway so a stray id can't
50 * escape the directory. */
51 private val idRegex = Regex("[^A-Za-z0-9_-]")
53 private val dir: File? by lazy {
54 appContext?.let { File(it.filesDir, "attachment_drafts").apply { mkdirs() } }
55 }
57 private fun fileFor(sessionId: String): File? =
58 dir?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
60 /** Per-session write lock so rapid add/remove/clear mutations (each launched as an
61 * independent coroutine by the ViewModel) serialize their writes to the same file
62 * instead of racing — otherwise an older snapshot could land last and overwrite the
63 * newer one on disk, or two writes could tear the file. */
64 private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
65 private fun lockFor(sessionId: String): Mutex = locks.getOrPut(sessionId) { Mutex() }
67 /** Load the staged attachments for [sessionId], or empty if none / unreadable. */
68 open suspend fun load(sessionId: String): List<PersistedAttachment> = lockFor(sessionId).withLock {
69 withContext(Dispatchers.IO) {
70 val file = fileFor(sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
71 runCatchingCancellable { json.decodeFromString<List<PersistedAttachment>>(file.readText()) }
72 .getOrDefault(emptyList())
73 }
74 }
76 /** Persist [attachments] for [sessionId] (an empty list deletes the file). */
77 open suspend fun save(sessionId: String, attachments: List<PersistedAttachment>): Unit = lockFor(sessionId).withLock {
78 withContext(Dispatchers.IO) {
79 val file = fileFor(sessionId) ?: return@withContext
80 runCatchingCancellable {
81 if (attachments.isEmpty()) file.delete()
82 else {
83 // FIX: write atomically (temp file + rename) like RecentSessionsStore so a
84 // process kill mid-write can't leave a truncated file that fails to decode
85 // and silently drops the staged attachments on reload.
86 val encoded = json.encodeToString(attachments)
87 val tmp = File(file.parentFile, file.name + ".tmp")
88 tmp.writeText(encoded)
89 if (!tmp.renameTo(file)) {
90 file.writeText(encoded)
91 tmp.delete()
92 }
93 }
94 }.onFailure { Log.w("AttachmentDraftStore", "Failed to persist attachments for $sessionId", it) }
95 Unit
96 }
97 }
99 /** Remove staged attachments for a session (call on session deletion). */
100 open suspend fun remove(sessionId: String) {
101 // Keep the per-session Mutex in [locks] rather than evicting it: remove() is not always
102 // terminal — a concurrent teardown save() for the same session must serialize against this
103 // delete under the *same* Mutex instance. Evicting here would let that save() acquire a
104 // fresh Mutex, so the two operations no longer serialize (resurrecting an orphan file, or
105 // theoretically tearing a write). The bounded per-session map growth matches the other
106 // lock-keyed stores.
107 lockFor(sessionId).withLock {
108 withContext(Dispatchers.IO) {
109 runCatchingCancellable { fileFor(sessionId)?.delete() }
110 Unit
111 }
112 }
113 }