git.iko.soy/opencode-android
on 325e359 help sign in
176 lines 8.0 kB history raw
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
15/**
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.
19 *
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).
24 */
25open class DraftStore private constructor(
26 private val appContext: Context?,
27 private val scope: CoroutineScope?,
28 @Suppress("unused") private val testMode: Boolean,
29) {
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)
37 sp
38 }
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 }
51 }
52 }
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()
62 init {
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
74 // clobbered.
75 _drafts.update { current ->
76 snapshot.toMutableMap().apply { putAll(current) }
77 }
78 _ready.value = true
79 }
80 } else {
81 _ready.value = true
82 }
83 }
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
95 updated
96 }
97 }
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
103 updated
104 }
105 withContext(Dispatchers.IO) {
106 runCatching {
107 prefs.edit().apply {
108 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
109 }.apply()
110 }.onFailure { Log.w("DraftStore", "Failed to persist draft for $sessionId", it) }
111 }
112 }
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)
119 updated
120 }
121 withContext(Dispatchers.IO) {
122 runCatching {
123 prefs.edit().remove(sessionId).apply()
124 }.onFailure { Log.w("DraftStore", "Failed to remove draft for $sessionId", it) }
125 }
126 }
128 /**
129 * Synchronously persist a draft. Intended for [ViewModel.onCleared] where the
130 * viewModelScope is already cancelled and a suspending write can't be used.
131 *
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.
135 *
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.
141 */
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
146 updated
147 }
148 if (prefsInitialized.get()) {
149 // Prefs already opened — the lazy resolves instantly and apply() is async.
150 runCatching {
151 prefs.edit().apply {
152 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
153 }.apply()
154 }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
155 } else {
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 {
160 runCatching {
161 prefs.edit().apply {
162 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
163 }.apply()
164 }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
165 }
166 }
167 }
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) }
175 }