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 kotlinx.coroutines.withContext
13import java.util.concurrent.Executors
16 * Persists the in-progress message draft for each session so it survives
17 * back-navigation and process death. Backed by SharedPreferences because the
18 * UI needs the initial value synchronously and drafts are low-stakes data.
20 * The prefs file is loaded on a background thread at construction time. Until
21 * the load completes, [drafts] reports an empty map and [get] returns "" rather
22 * than blocking the caller (previously a [CountDownLatch] could stall the main
23 * thread for up to 2 seconds on a slow disk).
25open class DraftStore private constructor(
26 private val appContext: Context?,
27 private val scope: CoroutineScope?,
28 @Suppress("unused") private val testMode: Boolean,
30 constructor(context: Context, scope: CoroutineScope) : this(context.applicationContext, scope, false)
31 protected constructor() : this(null, null, true)
33 private val prefs by lazy {
34 val sp = appContext?.getSharedPreferences("drafts", Context.MODE_PRIVATE)
35 ?: error("No context — override methods in test subclass")
36 prefsInitialized.set(true)
40 /** Tracks whether the [prefs] lazy has been resolved (i.e. the file has been
41 * opened from disk). Accessed from [flushDraft] to decide whether a synchronous
42 * write on the main thread is safe or whether the write must be deferred to a
43 * background thread to avoid blocking on first-time disk I/O. */
44 private val prefsInitialized = java.util.concurrent.atomic.AtomicBoolean(false)
46 /** Single-thread executor for flushDraft when prefs aren't initialized yet,
47 * so the main thread is never blocked on disk I/O. */
48 private val flushExecutor by lazy {
49 Executors.newSingleThreadExecutor { r ->
50 Thread(r, "DraftStore-flush").apply { isDaemon = true }
54 private val _drafts = MutableStateFlow<Map<String, String>>(emptyMap())
55 /** The full in-memory snapshot of all drafts, updated when prefs finish loading. */
56 open val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
58 private val _ready = MutableStateFlow(false)
59 /** True once the background prefs load has completed. */
60 open val ready: StateFlow<Boolean> = _ready.asStateFlow()
63 // Eagerly load the prefs file on a background thread so the first main-thread
64 // get() doesn't block on disk I/O. The loaded snapshot is published via [_drafts]
65 // so callers can observe readiness without blocking.
66 if (scope != null && appContext != null) {
67 scope.launch(Dispatchers.IO) {
68 val snapshot = runCatching {
69 prefs.all.mapValues { it.value as? String ?: "" }
70 }.getOrDefault(emptyMap())
71 // Merge instead of overwriting: any in-memory writes that arrived before
72 // the background load completed (e.g. a share-intent draft injected via
73 // setImmediate) take priority over the persisted snapshot so they aren't
75 _drafts.update { current ->
76 snapshot.toMutableMap().apply { putAll(current) }
85 /** Returns the draft for [sessionId], or "" if not found or still loading. */
86 open fun get(sessionId: String): String = _drafts.value[sessionId].orEmpty()
88 /** Update the in-memory draft immediately without persisting to disk.
89 * Used when navigation must see the new value synchronously (e.g. share-intent
90 * injection before ChatScreen composes). Call [set] afterwards to persist. */
91 open fun setImmediate(sessionId: String, text: String) {
92 _drafts.update { current ->
93 val updated = current.toMutableMap()
94 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
99 open suspend fun set(sessionId: String, text: String) {
100 _drafts.update { current ->
101 val updated = current.toMutableMap()
102 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
105 withContext(Dispatchers.IO) {
108 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
110 }.onFailure { Log.w("DraftStore", "Failed to persist draft for $sessionId", it) }
114 /** Remove the draft for a session (call on session deletion to avoid orphaned entries). */
115 open suspend fun remove(sessionId: String) {
116 _drafts.update { current ->
117 val updated = current.toMutableMap()
118 updated.remove(sessionId)
121 withContext(Dispatchers.IO) {
123 prefs.edit().remove(sessionId).apply()
124 }.onFailure { Log.w("DraftStore", "Failed to remove draft for $sessionId", it) }
129 * Synchronously persist a draft. Intended for [ViewModel.onCleared] where the
130 * viewModelScope is already cancelled and a suspending write can't be used.
132 * If the SharedPreferences file has already been opened (the normal case — the
133 * background init load touches it shortly after construction), the write uses
134 * [apply] (asynchronous) and returns immediately without blocking the main thread.
136 * If the prefs file has NOT been opened yet (a rare race where onCleared fires
137 * before the background init), the write is dispatched to a background thread
138 * instead of blocking the main thread on a synchronous disk read. The draft
139 * may be lost if the process exits before the write completes, but this is
140 * preferable to an ANR.
142 open fun flushDraft(sessionId: String, text: String) {
143 _drafts.update { current ->
144 val updated = current.toMutableMap()
145 if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
148 if (prefsInitialized.get()) {
149 // Prefs already opened — the lazy resolves instantly and apply() is async.
152 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
154 }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
156 // Prefs not yet opened — dispatch to a background thread to avoid a
157 // synchronous disk read on the main thread (which would ANR). The draft
158 // may be lost if the process exits before the write completes.
159 flushExecutor.execute {
162 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
164 }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
169 /** Shut down the background flush executor. Call from AppContainer.shutdown(). */
170 open fun shutdown() {
171 flushExecutor.shutdown()
172 // Await pending flushDraft writes so drafts aren't lost if the process exits
173 // immediately after shutdown (e.g. ANR-triggered process kill).
174 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