git.iko.soy/opencode-android
on master help sign in
Fix 6 bugs: TTS premature clear, tool output line-cap collapse, persisted sort direction loss, cross-profile cache deletion, CrashLogger clear-all race, stale FileBrowser refresh job
f4137c0iko
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+15−20
@@ -168,36 +168,31 @@ class CrashLogger private constructor(private val appContext: Context) {
168 /** Deferred whole-directory clear, cancellable via [cancelScheduledClearAll] so the bulk
168 /** Deferred whole-directory clear, cancellable via [cancelScheduledClearAll] so the bulk
169 * action gets the same undo window as a single-report delete. Runs on the logger's own
169 * action gets the same undo window as a single-report delete. Runs on the logger's own
170 * scope so it commits even if the user leaves the Diagnostics screen. */
170 * scope so it commits even if the user leaves the Diagnostics screen. */
171− // @Volatile: the flag is read/written from both the UI thread (schedule/cancel calls
171+ // AtomicReference (not a bare @Volatile var): the timer-fired claim must be a CAS,
172− // from the Diagnostics screen) and the IO coroutine (which nulls it when the timer
172+ // not an unconditional null, else a reschedule that installed a newer job loses its
173− // fires). Without @Volatile the IO write isn't guaranteed visible to the UI thread's
173+ // entry to the old coroutine's null and the clear runs immediately instead of after
174− // cancel read — a data race that could let a cancel falsely report "already fired".
174+ // the new delay (and cancelScheduledClearAll then falsely reports "already fired").
175− // The sibling pendingDeletes map uses ConcurrentHashMap for the same reason.
175+ // Mirrors scheduleDelete's ConcurrentHashMap.remove(name, job) claim.
176− @Volatile
177− private var pendingClearAll: Job? = null
176+ private val pendingClearAll = java.util.concurrent.atomic.AtomicReference<Job?>(null)
178
177
179 fun scheduleClearAll(delayMs: Long) {
178 fun scheduleClearAll(delayMs: Long) {
180− pendingClearAll?.cancel()
181− pendingClearAll = scope.launch {
179+ val job = scope.launch {
182 delay(delayMs)
180 delay(delayMs)
183− // Claim the clear by nulling our entry BEFORE deleting, mirroring
181+ // Atomically claim the clear by CAS-ing our own entry to null, mirroring
184− // scheduleDelete's claim pattern: whichever nulls first wins. A concurrent
182+ // scheduleDelete's claim pattern: a reschedule (or cancel) that nulls first
185− // cancelScheduledClearAll that nulls first makes this a no-op.
183+ // makes this a no-op. Without the CAS, an unconditional null would wipe a
186− pendingClearAll = null
184+ // newer job installed by a reschedule.
185+ val mine = coroutineContext[Job]
186+ if (mine == null || !pendingClearAll.compareAndSet(mine, null)) return@launch
187 crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
187 crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
188 refresh()
188 refresh()
189 }
189 }
190+ pendingClearAll.getAndSet(job)?.cancel()
190 }
191 }
191
192
192 /** Cancel a pending clear-all (the Undo action). Returns true if it was still pending. */
193 /** Cancel a pending clear-all (the Undo action). Returns true if it was still pending. */
193 fun cancelScheduledClearAll(): Boolean {
194 fun cancelScheduledClearAll(): Boolean {
194− // Read and null under the volatile read; cancel the captured job after. A
195+ val job = pendingClearAll.getAndSet(null) ?: return false
195− // compareAndSet loop would be ideal but Job identity is enough here: if we read
196− // non-null, null it, and cancel it, the launched coroutine's pendingClearAll =
197− // null is a no-op on an already-nulled field. If the coroutine already nulled it
198− // (timer fired) we read null and return false — the authoritative answer.
199− val job = pendingClearAll ?: return false
200− pendingClearAll = null
201 job.cancel()
196 job.cancel()
202 return true
197 return true
203 }
198 }
app/src/main/java/soy/iko/opencode/data/repo/MessageCacheStore.kt+9−9
@@ -109,13 +109,15 @@ open class MessageCacheStore private constructor(
109 }
109 }
110 }
110 }
111
111
112− /** Remove a session's cached messages (call on deletion). Clears the entry in every profile
112+ /** Remove a session's cached messages (call on deletion). Deletes only the calling
113− * namespace: session ids are unique per server and deletion is terminal, so the caller
113+ * profile's cache file — iterating every profile namespace would nuke a *different*
114+ * server's cache for a colliding (but distinct) session id, the very case per-profile
114− * needn't know which profile owned it. The per-session lock is intentionally NOT evicted —
115+ * namespacing exists to defend against. The per-session lock is intentionally NOT
115− * a concurrent teardown save() for this id must serialize against the same [Mutex] instance,
116+ * evicted — a concurrent teardown save() for this id must serialize against the same
116− * and the map is bounded by the number of distinct sessions touched.
117+ * [Mutex] instance, and the map is bounded by the number of distinct sessions touched.
117 * [profileId] namespaces the tombstone so a delete on one server doesn't suppress another
118 * [profileId] namespaces the tombstone so a delete on one server doesn't suppress another
118− * server's saves for a colliding session id. */
119+ * server's saves for a colliding session id. The back-compat flat file (pre-namespacing)
120+ * is also cleared since it carries no profile namespace. */
119 open suspend fun remove(profileId: String, sessionId: String) {
121 open suspend fun remove(profileId: String, sessionId: String) {
120 lockFor(sessionId).withLock {
122 lockFor(sessionId).withLock {
121 // Set the tombstone before deleting so a concurrent/loser save sees it and skips,
123 // Set the tombstone before deleting so a concurrent/loser save sees it and skips,
@@ -124,9 +126,7 @@ open class MessageCacheStore private constructor(
124 withContext(Dispatchers.IO) {
126 withContext(Dispatchers.IO) {
125 runCatchingCancellable {
127 runCatchingCancellable {
126 val name = idRegex.replace(sessionId, "_") + ".json"
128 val name = idRegex.replace(sessionId, "_") + ".json"
127− dir?.listFiles()?.forEach { entry ->
129+ profileDir(profileId)?.let { File(it, name).delete() }
128− if (entry.isDirectory) File(entry, name).delete()
129− }
130 // Back-compat: pre-namespacing caches were written flat under [dir].
130 // Back-compat: pre-namespacing caches were written flat under [dir].
131 dir?.let { File(it, name).delete() }
131 dir?.let { File(it, name).delete() }
132 }
132 }
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+11−2
@@ -467,8 +467,17 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
467 val lineCount = remember(detail) { detail.lines().size }
467 val lineCount = remember(detail) { detail.lines().size }
468 val exceedsLimit = detail.length > COLLAPSED_LIMIT ||
468 val exceedsLimit = detail.length > COLLAPSED_LIMIT ||
469 lineCount > NetworkConfig.toolOutputCollapsedLimitLines
469 lineCount > NetworkConfig.toolOutputCollapsedLimitLines
470− val collapsed = remember(detail, isDiff) {
470+ val collapsed = remember(detail, isDiff, lineCount) {
471− collapseDiffPreview(detail, isDiff)
471+ val byChars = collapseDiffPreview(detail, isDiff)
472+ // Truncate by lines too when the line cap is the trigger: a short-but-tall
473+ // output (e.g. 60 short lines under the char cap) must still collapse to a
474+ // predictable mobile height, else the "collapsed" view renders the whole
475+ // block and the expand toggle is a no-op showing 0 hidden lines.
476+ if (lineCount > NetworkConfig.toolOutputCollapsedLimitLines) {
477+ byChars.lineSequence().take(NetworkConfig.toolOutputCollapsedLimitLines).joinToString("\n")
478+ } else {
479+ byChars
480+ }
472 }
481 }
473 var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
482 var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
474 val expandedState = stringResource(R.string.state_expanded)
483 val expandedState = stringResource(R.string.state_expanded)
app/src/main/java/soy/iko/opencode/ui/chat/TtsController.kt+22−6
@@ -86,19 +86,35 @@ class TtsController(context: Context) : RememberObserver {
86 val idx = utteranceId?.substringAfter('#')?.toIntOrNull()
86 val idx = utteranceId?.substringAfter('#')?.toIntOrNull()
87 if (idx != null) currentChunk = idx
87 if (idx != null) currentChunk = idx
88 }
88 }
89− override fun onDone(utteranceId: String?) = clearIfCurrent(utteranceId)
89+ override fun onDone(utteranceId: String?) {
90+ // Only the final chunk's onDone (bare id) clears the speaking state.
91+ // Intermediate chunks' onDone ("$id#$index") means that chunk finished
92+ // playing and the next will start — clearing here would wipe the Stop
93+ // button while queued chunks keep playing audio with no way to stop them.
94+ mainHandler.post {
95+ val speaking = _speakingId.value
96+ if (speaking != null && utteranceId == speaking) {
97+ _speakingId.value = null
98+ _state.value = TtsState.IDLE
99+ chunks = emptyList()
100+ currentChunk = 0
101+ pausedFromChunk = 0
102+ }
103+ }
104+ }
90 @Deprecated("Deprecated in Java", ReplaceWith(""))
105 @Deprecated("Deprecated in Java", ReplaceWith(""))
91 override fun onError(utteranceId: String?) = clearIfCurrent(utteranceId)
106 override fun onError(utteranceId: String?) = clearIfCurrent(utteranceId)
92 override fun onError(utteranceId: String?, errorCode: Int) = clearIfCurrent(utteranceId)
107 override fun onError(utteranceId: String?, errorCode: Int) = clearIfCurrent(utteranceId)
93 })
108 })
94 }
109 }
95
110
96− /** Clear the speaking state when the utterance that finished is the one we're tracking.
111+ /** Clear the speaking state when the utterance that errored is the one we're tracking.
97 * Accepts both the final chunk's id (the bare [id]) and an intermediate chunk's id
112 * Accepts both the final chunk's id (the bare [id]) and an intermediate chunk's id
98− * (`"$id#$index"`) — a [onError]/[onDone] on an intermediate chunk means the engine
113+ * (`"$id#$index"`) — an [onError] on any chunk means the engine stopped or errored
99− * stopped or errored mid-playback, so the speaking state must clear or the Stop button
114+ * mid-playback, so the speaking state must clear or the Stop button stays stuck (the
100− * stays stuck (the prior behavior only matched the bare id, leaving a runtime error on
115+ * prior behavior only matched the bare id, leaving a runtime error on an intermediate
101− * an intermediate chunk with no way to clear the UI). */
116+ * chunk with no way to clear the UI). [onDone] handles only the final chunk — see its
117+ * own comment. */
102 private fun clearIfCurrent(utteranceId: String?) {
118 private fun clearIfCurrent(utteranceId: String?) {
103 mainHandler.post {
119 mainHandler.post {
104 val speaking = _speakingId.value
120 val speaking = _speakingId.value
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+2
@@ -165,6 +165,7 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
165 ) }
165 ) }
166 openJob?.cancel()
166 openJob?.cancel()
167 searchJob?.cancel()
167 searchJob?.cancel()
168+ searchJob = null
168 openJob = viewModelScope.launch {
169 openJob = viewModelScope.launch {
169 runCatchingCancellable { client.listDirectory(path) }
170 runCatchingCancellable { client.listDirectory(path) }
170 .onSuccess { entries ->
171 .onSuccess { entries ->
@@ -207,6 +208,7 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
207 _state.update { it.copy(query = query, error = null) }
208 _state.update { it.copy(query = query, error = null) }
208 searchJob?.cancel()
209 searchJob?.cancel()
209 openJob?.cancel()
210 openJob?.cancel()
211+ openJob = null
210 when (_state.value.mode) {
212 when (_state.value.mode) {
211 SearchMode.FILES -> searchFiles(query)
213 SearchMode.FILES -> searchFiles(query)
212 SearchMode.TEXT -> searchContent(query) { it.findText(query) }
214 SearchMode.TEXT -> searchContent(query) { it.findText(query) }
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+14−12
@@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.StateFlow
33import kotlinx.coroutines.flow.asSharedFlow
33import kotlinx.coroutines.flow.asSharedFlow
34import kotlinx.coroutines.flow.asStateFlow
34import kotlinx.coroutines.flow.asStateFlow
35import kotlinx.coroutines.flow.collectLatest
35import kotlinx.coroutines.flow.collectLatest
36+import kotlinx.coroutines.flow.combine
36import kotlinx.coroutines.flow.first
37import kotlinx.coroutines.flow.first
37import kotlinx.coroutines.flow.flatMapLatest
38import kotlinx.coroutines.flow.flatMapLatest
38import kotlinx.coroutines.flow.flowOf
39import kotlinx.coroutines.flow.flowOf
@@ -312,20 +313,21 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
312 // skips persistence — the in-memory defaults still drive the list.
313 // skips persistence — the in-memory defaults still drive the list.
313 viewModelScope.launch {
314 viewModelScope.launch {
314 val store = runCatching { container.settingsStore }.getOrNull() ?: return@launch
315 val store = runCatching { container.settingsStore }.getOrNull() ?: return@launch
316+ // Apply persisted sort mode + direction TOGETHER so a nondeterministic DataStore
317+ // emission order can't let setSortMode()'s natural-direction reset (called from a
318+ // separate mode collector) overwrite a persisted non-natural direction. With two
319+ // collectors, if direction fired first it no-ops (matches the default), then the mode
320+ // collector resets direction to the mode's natural — losing a reversed TITLE sort
321+ // until the user manually re-toggles. combine emits both values atomically.
315− store.sessionSortMode.collect { name ->
322+ store.sessionSortMode.combine(store.sessionSortDescending) { name, desc -> name to desc }
323+ .collect { (name, desc) ->
316− val mode = runCatching { SessionSortMode.valueOf(name) }.getOrDefault(SessionSortMode.RECENT)
324+ val mode = runCatching { SessionSortMode.valueOf(name) }.getOrDefault(SessionSortMode.RECENT)
317− if (_state.value.sortMode != mode) setSortMode(mode, persist = false)
318− }
319− }
320− viewModelScope.launch {
321− val store = runCatching { container.settingsStore }.getOrNull() ?: return@launch
322− store.sessionSortDescending.collect { desc ->
323− if (_state.value.sortDescending != desc) {
325+ if (_state.value.sortMode != mode || _state.value.sortDescending != desc) {
324− _state.update {
326+ _state.update {
325− it.copy(sortDescending = desc, sessions = it.sessions.sortedByMode(it.sortMode, desc))
327+ it.copy(sortMode = mode, sortDescending = desc, sessions = it.sessions.sortedByMode(mode, desc))
328+ }
326 }
329 }
327 }
330 }
328− }
329 }
331 }
330 viewModelScope.launch {
332 viewModelScope.launch {
331 val store = runCatching { container.settingsStore }.getOrNull() ?: return@launch
333 val store = runCatching { container.settingsStore }.getOrNull() ?: return@launch