1package soy.iko.opencode.data.repo
3import android.content.Context
4import kotlinx.coroutines.CoroutineScope
5import kotlinx.coroutines.Dispatchers
6import kotlinx.coroutines.flow.MutableStateFlow
7import kotlinx.coroutines.flow.StateFlow
8import kotlinx.coroutines.flow.asStateFlow
9import kotlinx.coroutines.flow.update
10import android.util.Log
11import kotlinx.coroutines.launch
12import java.util.concurrent.Executors
15 * Persists the in-progress message draft for each session so it survives
16 * back-navigation and process death. Backed by SharedPreferences because the
17 * UI needs the initial value synchronously and drafts are low-stakes data.
19 * The prefs file is loaded on a background thread at construction time. Until
20 * the load completes, [drafts] reports an empty map and [get] returns "" rather
21 * than blocking the caller (previously a [CountDownLatch] could stall the main
22 * thread for up to 2 seconds on a slow disk).
24open class DraftStore private constructor(
25 private val appContext: Context?,
26 private val scope: CoroutineScope?,
27 @Suppress("unused") private val testMode: Boolean,
29 constructor(context: Context, scope: CoroutineScope) : this(context.applicationContext, scope, false)
30 protected constructor() : this(null, null, true)
32 private val prefs by lazy {
33 appContext?.getSharedPreferences("drafts", Context.MODE_PRIVATE)
34 ?: error("No context — override methods in test subclass")
37 /** Single-thread executor for flushDraft when prefs aren't initialized yet,
38 * so the main thread is never blocked on disk I/O. */
39 private val flushExecutor by lazy {
40 Executors.newSingleThreadExecutor { r ->
41 Thread(r, "DraftStore-flush").apply { isDaemon = true }
45 private val _drafts = MutableStateFlow<Map<String, String>>(emptyMap())
46 /** The full in-memory snapshot of all drafts, updated when prefs finish loading. */
47 open val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
49 /** Queued follow-up messages per session, persisted under a namespaced key so a
50 * follow-up queued mid-run survives process death (the in-memory-only StateFlow in
51 * ChatViewModel would otherwise lose it, along with the cleared draft). */
52 private val _followUps = MutableStateFlow<Map<String, String>>(emptyMap())
54 private val _ready = MutableStateFlow(false)
55 /** True once the background prefs load has completed. */
56 open val ready: StateFlow<Boolean> = _ready.asStateFlow()
58 /** Keys written in-memory (set / cleared / removed) *before* the async prefs load
59 * finished. For these the in-memory state is authoritative — the load must not
60 * resurrect a persisted value the user has since cleared, which a plain putAll(current)
61 * merge cannot express (it can add/override, but not delete). Consulted once, in the
62 * init merge. Separate sets because drafts and follow-ups share the session-id key space
63 * but live under different prefs keys. */
64 // ConcurrentHashMap-backed sets: weakly-consistent iteration so the init merge's forEach
65 // can't throw ConcurrentModificationException if a mutator adds a key concurrently.
66 private val touchedDraftKeys: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet()
67 private val touchedFollowUpKeys: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet()
69 private fun markDraftTouched(sessionId: String) {
70 if (!_ready.value) touchedDraftKeys.add(sessionId)
72 private fun markFollowUpTouched(sessionId: String) {
73 if (!_ready.value) touchedFollowUpKeys.add(sessionId)
77 // Eagerly load the prefs file on a background thread so the first main-thread
78 // get() doesn't block on disk I/O. The loaded snapshot is published via [_drafts]
79 // so callers can observe readiness without blocking.
80 if (scope != null && appContext != null) {
81 scope.launch(Dispatchers.IO) {
82 val all = runCatching {
83 prefs.all.mapValues { it.value as? String ?: "" }
84 }.getOrDefault(emptyMap())
85 // Partition persisted keys: follow-up keys carry a prefix, everything else
86 // is a plain per-session draft.
87 val drafts = mutableMapOf<String, String>()
88 val followUps = mutableMapOf<String, String>()
89 all.forEach { (k, v) ->
90 if (k.startsWith(FOLLOWUP_PREFIX)) followUps[k.removePrefix(FOLLOWUP_PREFIX)] = v
93 // Merge instead of overwriting: any in-memory writes that arrived before
94 // the background load completed (e.g. a share-intent draft injected via
95 // setImmediate) take priority over the persisted snapshot so they aren't
96 // clobbered. For keys the user explicitly touched before the load finished,
97 // drop the persisted entry first so a value they *cleared* isn't resurrected
98 // (the cleared key is absent from `current`, so putAll alone can't remove it).
99 _drafts.update { current ->
101 touchedDraftKeys.forEach { remove(it) }
105 _followUps.update { current ->
107 touchedFollowUpKeys.forEach { remove(it) }
118 /** Returns the draft for [sessionId], or "" if not found or still loading. */
119 open fun get(sessionId: String): String = _drafts.value[sessionId].orEmpty()
121 /** Returns the queued follow-up for [sessionId], or "" if none / still loading. */
122 open fun getFollowUp(sessionId: String): String = _followUps.value[sessionId].orEmpty()
124 /** Persist a queued follow-up immediately (fire-and-forget, main-thread-safe).
125 * Mirrors [flushDraft]: async apply() when prefs are open, else a background thread,
126 * so a follow-up queued just before process death is flushed before the process exits.
127 * A blank [text] clears the stored follow-up. */
128 open fun flushFollowUp(sessionId: String, text: String) {
129 markFollowUpTouched(sessionId)
130 _followUps.update { current ->
131 val updated = current.toMutableMap()
132 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
135 writeAsync(FOLLOWUP_PREFIX + sessionId, text)
138 /** Persist [key]=[text] (or remove it when blank) without blocking the main thread on
139 * first-time disk I/O — see [flushDraft] for the prefs-not-yet-open rationale. */
140 private fun writeAsync(key: String, text: String) {
144 if (text.isBlank()) remove(key) else putString(key, text)
146 }.onFailure { Log.w("DraftStore", "Failed to persist $key", it) }
148 // FIX: always submit to the single-thread flushExecutor (never apply directly) so
149 // writes persist in submission order. Previously the post-init fast path applied
150 // synchronously on the caller's thread, which could land BEFORE an earlier still-queued
151 // write, persisting stale text. The task resolves the prefs lazy and calls apply().
152 runCatching { flushExecutor.execute { write() } }
155 /** Update the in-memory draft immediately without persisting to disk.
156 * Used when navigation must see the new value synchronously (e.g. share-intent
157 * injection before ChatScreen composes). Call [set] afterwards to persist. */
158 open fun setImmediate(sessionId: String, text: String) {
159 markDraftTouched(sessionId)
160 _drafts.update { current ->
161 val updated = current.toMutableMap()
162 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
167 open suspend fun set(sessionId: String, text: String) {
168 markDraftTouched(sessionId)
169 _drafts.update { current ->
170 val updated = current.toMutableMap()
171 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
174 // Route persistence through the single-thread flushExecutor (like the other writers)
175 // rather than the shared IO pool, so this write can't race ahead of an earlier queued
176 // flush and land stale. writeAsync persists sessionId=text (or removes it when blank),
177 // which is exactly this draft's persistence.
178 writeAsync(sessionId, text)
181 /** Remove the draft for a session (call on session deletion to avoid orphaned entries).
182 * Also clears any queued follow-up so a deleted session leaves nothing behind. */
183 open suspend fun remove(sessionId: String) {
184 markDraftTouched(sessionId)
185 markFollowUpTouched(sessionId)
186 _drafts.update { current ->
187 val updated = current.toMutableMap()
188 updated.remove(sessionId)
191 _followUps.update { current ->
192 val updated = current.toMutableMap()
193 updated.remove(sessionId)
196 // Submit the (combined draft + follow-up) removal to the same single-thread
197 // flushExecutor the other writers use, so it can't race ahead of an earlier queued
198 // write and resurrect stale text. Mirrors flushDraft's submission pattern.
200 flushExecutor.execute {
202 prefs.edit().remove(sessionId).remove(FOLLOWUP_PREFIX + sessionId).apply()
203 }.onFailure { Log.w("DraftStore", "Failed to remove draft for $sessionId", it) }
209 * Synchronously persist a draft. Intended for [ViewModel.onCleared] where the
210 * viewModelScope is already cancelled and a suspending write can't be used.
212 * If the SharedPreferences file has already been opened (the normal case — the
213 * background init load touches it shortly after construction), the write uses
214 * [apply] (asynchronous) and returns immediately without blocking the main thread.
216 * If the prefs file has NOT been opened yet (a rare race where onCleared fires
217 * before the background init), the write is dispatched to a background thread
218 * instead of blocking the main thread on a synchronous disk read. The draft
219 * may be lost if the process exits before the write completes, but this is
220 * preferable to an ANR.
222 open fun flushDraft(sessionId: String, text: String) {
223 markDraftTouched(sessionId)
224 _drafts.update { current ->
225 val updated = current.toMutableMap()
226 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
229 // FIX: always route the persistence through the single-thread flushExecutor so writes
230 // land in submission order. Previously, once prefs were open this applied synchronously
231 // on the calling (main) thread, so a newer direct write could persist BEFORE an older
232 // still-queued write completed, clobbering the draft with stale text. The executor task
233 // resolves the prefs lazy and uses apply() (async) exactly as before; the write may be
234 // lost only if the process exits before the queued task runs (as previously).
236 flushExecutor.execute {
239 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
241 }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
246 private companion object {
247 /** Key prefix distinguishing persisted follow-ups from plain drafts in the shared
248 * prefs file. The leading/trailing spaces can't collide with a real opencode session
249 * id (which are non-blank, space-free tokens like "ses_..."). */
250 const val FOLLOWUP_PREFIX = " followup "
253 /** Shut down the background flush executor. Call from AppContainer.shutdown(). */
254 open fun shutdown() {
255 flushExecutor.shutdown()
256 // Await pending flushDraft writes so drafts aren't lost if the process exits
257 // immediately after shutdown (e.g. ANR-triggered process kill).
258 runCatching { flushExecutor.awaitTermination(2, java.util.concurrent.TimeUnit.SECONDS) }
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