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.Serializable
10import kotlinx.serialization.decodeFromString
11import kotlinx.serialization.encodeToString
12import kotlinx.serialization.json.Json
13import soy.iko.opencode.util.runCatchingCancellable
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).
24data class PersistedAttachment(
29 val filename: String? = null,
33 * Persists attachments staged for each session so an interrupted compose survives process
34 * death, mirroring how [DraftStore] persists the text draft.
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.
40open class AttachmentDraftStore private constructor(
41 private val appContext: Context?,
42 @Suppress("unused") private val testMode: Boolean,
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() } }
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())
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()
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)
94 }.onFailure { Log.w("AttachmentDraftStore", "Failed to persist attachments for $sessionId", it) }
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() }
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