@@ -9,8 +9,8 @@ import kotlinx.coroutines.flow.asStateFlow
9import kotlinx.coroutines.flow.update
9import kotlinx.coroutines.flow.update
10import android.util.Log
10import android.util.Log
11import kotlinx.coroutines.launch
11import kotlinx.coroutines.launch
12−import kotlinx.coroutines.runBlocking
13import kotlinx.coroutines.withContext
12import kotlinx.coroutines.withContext
13+import java.util.concurrent.Executors
16 * Persists the in-progress message draft for each session so it survives
16 * Persists the in-progress message draft for each session so it survives
@@ -36,6 +36,12 @@ class DraftStore(context: Context, scope: CoroutineScope) {
36 * background thread to avoid blocking on first-time disk I/O. */
36 * background thread to avoid blocking on first-time disk I/O. */
37 private val prefsInitialized = java.util.concurrent.atomic.AtomicBoolean(false)
37 private val prefsInitialized = java.util.concurrent.atomic.AtomicBoolean(false)
39+ /** Single-thread executor for flushDraft when prefs aren't initialized yet,
40+ * so the main thread is never blocked on disk I/O. */
41+ private val flushExecutor = Executors.newSingleThreadExecutor { r ->
42+ Thread(r, "DraftStore-flush").apply { isDaemon = true }
39 private val _drafts = MutableStateFlow<Map<String, String>>(emptyMap())
45 private val _drafts = MutableStateFlow<Map<String, String>>(emptyMap())
40 /** The full in-memory snapshot of all drafts, updated when prefs finish loading. */
46 /** The full in-memory snapshot of all drafts, updated when prefs finish loading. */
41 val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
47 val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
@@ -60,6 +66,17 @@ class DraftStore(context: Context, scope: CoroutineScope) {
60 /** Returns the draft for [sessionId], or "" if not found or still loading. */
66 /** Returns the draft for [sessionId], or "" if not found or still loading. */
61 fun get(sessionId: String): String = _drafts.value[sessionId].orEmpty()
67 fun get(sessionId: String): String = _drafts.value[sessionId].orEmpty()
69+ /** Update the in-memory draft immediately without persisting to disk.
70+ * Used when navigation must see the new value synchronously (e.g. share-intent
71+ * injection before ChatScreen composes). Call [set] afterwards to persist. */
72+ fun setImmediate(sessionId: String, text: String) {
73+ _drafts.update { current ->
74+ val updated = current.toMutableMap()
75+ if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
63 suspend fun set(sessionId: String, text: String) {
80 suspend fun set(sessionId: String, text: String) {
64 _drafts.update { current ->
81 _drafts.update { current ->
65 val updated = current.toMutableMap()
82 val updated = current.toMutableMap()
@@ -98,9 +115,10 @@ class DraftStore(context: Context, scope: CoroutineScope) {
98 * [apply] (asynchronous) and returns immediately without blocking the main thread.
115 * [apply] (asynchronous) and returns immediately without blocking the main thread.
100 * If the prefs file has NOT been opened yet (a rare race where onCleared fires
117 * If the prefs file has NOT been opened yet (a rare race where onCleared fires
101− * before the background init), accessing the lazy would synchronously load and
118+ * before the background init), the write is dispatched to a background thread
102− * parse the XML on the main thread. To avoid this, the write is deferred to a
119+ * instead of blocking the main thread on a synchronous disk read. The draft
103− * blocking call on the IO dispatcher instead.
120+ * may be lost if the process exits before the write completes, but this is
121+ * preferable to an ANR.
105 fun flushDraft(sessionId: String, text: String) {
123 fun flushDraft(sessionId: String, text: String) {
106 _drafts.update { current ->
124 _drafts.update { current ->
@@ -116,16 +134,16 @@ class DraftStore(context: Context, scope: CoroutineScope) {
116 }.apply()
134 }.apply()
117 }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
135 }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
137+ // Prefs not yet opened — dispatch to a background thread to avoid a
119− // Prefs not yet opened — avoid a synchronous disk read on the main thread
138+ // synchronous disk read on the main thread (which would ANR). The draft
120− // by blocking on IO instead. This is acceptable in onCleared() because the
139+ // may be lost if the process exits before the write completes.
121− // viewModelScope is already dead and there's no suspending alternative.
140+ flushExecutor.execute {
122− runCatching {
141+ runCatching {
123− runBlocking(Dispatchers.IO) {
124 prefs.edit().apply {
142 prefs.edit().apply {
125 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
143 if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
126 }.apply()
144 }.apply()
128− }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
145+ }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }