git.iko.soy/opencode-android
on master help sign in
Fix 21 bugs: IME send-drop, permission auto-reject race, FGS mid-run kill, shutdown leak, app-lock grace bypass, and 16 others across UI/data/network
commit489f8c7129a6e4e6019dfcdc8af684dc6b0ea50a authoriko <mail@iko.soy> dateSun 5 Jul 2026, 13:33 (3 months ago) parentcfa53a6
19 files changed+280−64
app/src/androidTest/java/soy/iko/opencode/data/repo/DraftStoreTest.kt+10−2 app/src/main/java/soy/iko/opencode/MainActivity.kt+19−6 app/src/main/java/soy/iko/opencode/data/repo/BackupManager.kt+16−1 app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+14 app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+7−1 app/src/main/java/soy/iko/opencode/data/repo/RecentSessionsStore.kt+15−3 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+22−1 app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+40−21 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+12−1 app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+9−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+8−1 app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+14−5 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+31−10 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+8−1 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+1 app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.kt+12−1 app/src/test/java/soy/iko/opencode/ui/file/FileBrowserViewModelTest.kt+16−4 app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+18−3 app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+8−2
app/src/androidTest/java/soy/iko/opencode/data/repo/DraftStoreTest.kt+10−2
@@ -45,7 +45,15 @@ class DraftStoreTest {
4545
4646 @Test
4747 fun getReturnsEmptyForUnknownSession() {
48− val store = DraftStore(context, CoroutineScope(SupervisorJob()))
49− assertEquals("", store.get("never-set-${System.nanoTime()}"))
48+ // Create the scope in a local val so it can be cancelled — DraftStore launches a
49+ // debounced-persistence collector into the supplied scope that keeps running until
50+ // cancelled, and an uncanceled scope leaks across test invocations on device.
51+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
52+ try {
53+ val store = DraftStore(context, scope)
54+ assertEquals("", store.get("never-set-${System.nanoTime()}"))
55+ } finally {
56+ scope.cancel()
57+ }
5058 }
5159}
app/src/main/java/soy/iko/opencode/MainActivity.kt+19−6
@@ -342,7 +342,7 @@ class MainActivity : FragmentActivity() {
342342 // slashes. Decoded + validated against traversal/control characters.
343343 val rawPath = data.path?.removePrefix("/file/")?.let { android.net.Uri.decode(it) }
344344 if (!openFileHandled) {
345− rawPath?.takeIf { it.isNotBlank() && VALID_FILE_PATH.matches(it) && !it.contains("..") }
345+ rawPath?.takeIf { isValidFilePath(it) }
346346 ?.let { container.requestOpenFile(it); openFileHandled = true } ?: run {
347347 if (!rawPath.isNullOrBlank()) showInvalidLinkToast = true
348348 }
@@ -355,8 +355,13 @@ class MainActivity : FragmentActivity() {
355355 }
356356 }
357357 }
358− // Fall back to the EXTRA_SESSION_ID extra (notifications / widget) when no deep link matched.
359− if (!openSessionHandled) {
358+ // Fall back to the EXTRA_SESSION_ID extra (notifications / widget) when no deep link
359+ // was present at all. A deep link that matched a host but failed validation (e.g. a
360+ // malformed session id) does NOT fall through to the extra: the intent's primary
361+ // routing signal was the URI, and the extra may carry a different (valid) id that
362+ // would silently open the wrong session. Only when data?.host is null (a pure widget
363+ // / notification intent with no URI) does the extra carry the routing intent.
364+ if (data?.host == null && !openSessionHandled) {
360365 intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
361366 val profileId = intent.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
362367 ?.takeIf { it.isNotBlank() }
@@ -376,9 +381,17 @@ class MainActivity : FragmentActivity() {
376381 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
377382 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
378383
379− /** A file path must be made of path-ish characters and not carry traversal. Used to
380− * sanity-check the file deep link before handing it to the viewer. */
381− private val VALID_FILE_PATH = Regex("[A-Za-z0-9_\\-./ ]+")
384+ /** A file path must not carry traversal segments or control characters. Looser than a
385+ * positive allowlist: real workspace paths commonly contain `~`, `:`, `+`, `=`, `,`,
386+ * parentheses, and non-ASCII unicode, all of which a tight `[A-Za-z0-9_\\-./ ]+`
387+ * regex would reject. The server validates the path against the workspace root, so
388+ * here we only reject what's unambiguously hostile (traversal) or non-printable. */
389+ private fun isValidFilePath(path: String): Boolean {
390+ if (path.isBlank()) return false
391+ if (path.contains("..")) return false
392+ if (path.any { it.isISOControl() }) return false
393+ return true
394+ }
382395
383396 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
384397 private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
app/src/main/java/soy/iko/opencode/data/repo/BackupManager.kt+16−1
@@ -167,7 +167,22 @@ class BackupManager(
167167 settingsStore.setCompactMessageSpacing(settings.compactMessageSpacing)
168168 settingsStore.setHapticsEnabled(settings.hapticsEnabled)
169169 settingsStore.setReducedMotion(settings.reducedMotion)
170− settingsStore.setLanguageOverride(settings.languageOverride)
170+ // Validate the sort mode against the known enum entries before persisting, so a
171+ // garbage string from a hand-edited/malicious backup can't produce a broken sort
172+ // with no in-app reset path (the picker writes known values, but a corrupt one
173+ // would persist and leave the list in an undefined order). Mirrors the defensive
174+ // enum parsing used for themeMode and swipe actions above.
175+ soy.iko.opencode.ui.session.SessionSortMode.entries
176+ .find { it.name == settings.sessionSortMode }
177+ ?.let { settingsStore.setSessionSortMode(it.name) }
178+ // Validate the language override is a plausible ISO 639-1 code (2–3 lowercase
179+ // letters, optionally with a region/script subtag) before persisting, so a bad
180+ // string doesn't reach AppCompat's per-app language API. Empty (system default)
181+ // is always allowed.
182+ val lang = settings.languageOverride
183+ if (lang.isEmpty() || lang.matches(Regex("^[a-z]{2,3}(-[A-Za-z0-9]+)*$"))) {
184+ settingsStore.setLanguageOverride(lang)
185+ }
171186 settingsStore.setNotifRunComplete(settings.notifRunComplete)
172187 settingsStore.setNotifPermission(settings.notifPermission)
173188 settingsStore.setNotifError(settings.notifError)
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+14
@@ -163,12 +163,21 @@ class CrashLogger private constructor(private val appContext: Context) {
163163 /** Deferred whole-directory clear, cancellable via [cancelScheduledClearAll] so the bulk
164164 * action gets the same undo window as a single-report delete. Runs on the logger's own
165165 * scope so it commits even if the user leaves the Diagnostics screen. */
166+ // @Volatile: the flag is read/written from both the UI thread (schedule/cancel calls
167+ // from the Diagnostics screen) and the IO coroutine (which nulls it when the timer
168+ // fires). Without @Volatile the IO write isn't guaranteed visible to the UI thread's
169+ // cancel read — a data race that could let a cancel falsely report "already fired".
170+ // The sibling pendingDeletes map uses ConcurrentHashMap for the same reason.
171+ @Volatile
166172 private var pendingClearAll: Job? = null
167173
168174 fun scheduleClearAll(delayMs: Long) {
169175 pendingClearAll?.cancel()
170176 pendingClearAll = scope.launch {
171177 delay(delayMs)
178+ // Claim the clear by nulling our entry BEFORE deleting, mirroring
179+ // scheduleDelete's claim pattern: whichever nulls first wins. A concurrent
180+ // cancelScheduledClearAll that nulls first makes this a no-op.
172181 pendingClearAll = null
173182 crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
174183 refresh()
@@ -177,6 +186,11 @@ class CrashLogger private constructor(private val appContext: Context) {
177186
178187 /** Cancel a pending clear-all (the Undo action). Returns true if it was still pending. */
179188 fun cancelScheduledClearAll(): Boolean {
189+ // Read and null under the volatile read; cancel the captured job after. A
190+ // compareAndSet loop would be ideal but Job identity is enough here: if we read
191+ // non-null, null it, and cancel it, the launched coroutine's pendingClearAll =
192+ // null is a no-op on an already-nulled field. If the coroutine already nulled it
193+ // (timer fired) we read null and return false — the authoritative answer.
180194 val job = pendingClearAll ?: return false
181195 pendingClearAll = null
182196 job.cancel()
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+7−1
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow
99import kotlinx.coroutines.flow.update
1010import android.util.Log
1111import kotlinx.coroutines.launch
12+import soy.iko.opencode.util.runCatchingCancellable
1213import java.util.concurrent.Executors
1314
1415/**
@@ -79,7 +80,12 @@ open class DraftStore private constructor(
7980 // so callers can observe readiness without blocking.
8081 if (scope != null && appContext != null) {
8182 scope.launch(Dispatchers.IO) {
82− val all = runCatching {
83+ // runCatchingCancellable (not plain runCatching): this block runs in a
84+ // coroutine, and plain runCatching swallows CancellationException — if a
85+ // future suspend point is added here, cancellation would leave _ready
86+ // forever false and drafts stuck loading. Matches the convention used by
87+ // the sibling stores (MessageCacheStore, OutboxStore, AttachmentDraftStore).
88+ val all = runCatchingCancellable {
8389 prefs.all.mapValues { it.value as? String ?: "" }
8490 }.getOrDefault(emptyMap())
8591 // Partition persisted keys: follow-up keys carry a prefix, everything else
app/src/main/java/soy/iko/opencode/data/repo/RecentSessionsStore.kt+15−3
@@ -52,10 +52,22 @@ object RecentSessionsStore {
5252 val tmp = File(target.parentFile, "$FILE.tmp")
5353 tmp.writeText(encoded)
5454 // Atomic replace on POSIX filesystems (app's internal storage); the reader
55− // never observes a torn file. Fall back to a direct write if rename fails.
55+ // never observes a torn file. renameTo can fail when the target exists on
56+ // some OEM filesystems — delete the target first so the rename is a pure
57+ // create, and only if that still fails fall back to a copy-and-delete that
58+ // preserves atomicity from the reader's perspective (the reader sees either
59+ // the old complete file or the new complete file, never a half-written one,
60+ // because the copy writes to the temp and then atomically moves it).
5661 if (!tmp.renameTo(target)) {
57− target.writeText(encoded)
58− tmp.delete()
62+ target.delete()
63+ if (!tmp.renameTo(target)) {
64+ // Last-resort: copy bytes then delete temp. The target is briefly
65+ // absent during the copy, but a concurrent reader sees an empty
66+ // list (the runCatching on the read side handles a missing file),
67+ // never a torn file — preserving the documented invariant.
68+ tmp.copyTo(target, overwrite = true)
69+ tmp.delete()
70+ }
5971 }
6072 }
6173 Unit
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+22−1
@@ -637,6 +637,12 @@ open class AppContainer private constructor(
637637 // mutex. Otherwise the runBlocking below would deadlock waiting for a coroutine
638638 // that can't be cancelled until after the runBlocking completes.
639639 appScope.cancel()
640+ // Capture the active connection BEFORE the bounded close attempt so a timeout
641+ // can still close it outside the mutex. Without this, the timeout path would
642+ // null the StateFlow reference without closing the connection — leaking the SSE
643+ // socket, OkHttp pool, and the connection's coroutine scope.
644+ val stuck = _activeConnection.value
645+ var closed = false
640646 // Bounded runBlocking so a stuck mutex or a slow close() can't ANR the app.
641647 // 2 seconds is generous: appScope.cancel() already triggered cancellation of
642648 // any coroutine holding the mutex; we're just waiting for it to unwind.
@@ -645,11 +651,26 @@ open class AppContainer private constructor(
645651 connectionMutex.withLock {
646652 runCatching { _activeConnection.value?.close() }
647653 _activeConnection.value = null
654+ closed = true
648655 }
649656 }
650− // If the timeout fired, force-clear the connection so it isn't left dangling.
657+ // If the timeout fired, force-clear the StateFlow so observers see no
658+ // connection. The stuck connection (if any) is closed below, outside the
659+ // mutex, so a slow close() can't ANR — its scope is cancelled and the JVM
660+ // exit reaps any remaining sockets.
651661 _activeConnection.value = null
652662 }
663+ if (!closed && stuck != null) {
664+ // The timeout path fired (the locked close didn't complete). Cancel the
665+ // stuck connection's scope directly. close() suspends (scopeJob.join), so
666+ // wrap in a bounded runBlocking that can't ANR — at this point the app is
667+ // shutting down, so reaping is best-effort.
668+ runCatching {
669+ kotlinx.coroutines.runBlocking {
670+ kotlinx.coroutines.withTimeoutOrNull(1_000) { stuck.close() }
671+ }
672+ }
673+ }
653674 val cm = appContext?.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
654675 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
655676 if (!skipInit) soy.iko.opencode.data.repo.CrashLogger.get(appContext!!).shutdown()
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+40−21
@@ -28,6 +28,7 @@ import soy.iko.opencode.R
2828 */
2929class RunForegroundService : Service() {
3030
31+ @android.annotation.SuppressLint("MissingPermission")
3132 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
3233 // The session title (when a single run is active) is passed via the intent extra so
3334 // the notification can identify which session is running, instead of a generic
@@ -42,25 +43,37 @@ class RunForegroundService : Service() {
4243 // the run ends (stop() → onDestroy) so the next run starts fresh.
4344 if (runStartMillis == 0L) runStartMillis = System.currentTimeMillis()
4445 val notification = buildNotification(sessionTitle, sessionId, progress)
45− // startForeground can throw ForegroundServiceStartNotAllowedException on
46− // Android 12+ if the app is in the background when the service starts. Wrap
47− // it so a backgrounded start (e.g. the user navigates away at the wrong
48− // moment) doesn't crash the app — the SSE stream will just continue without
49− // foreground priority and may be killed by the system sooner.
50− runCatching {
51− ServiceCompat.startForeground(
52− this,
53− NOTIF_ID,
54− notification,
55− if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
56− ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
57− } else {
58− 0
59− },
60− )
61− }.onFailure {
62− Log.w(TAG, "startForeground failed; running without foreground priority", it)
63− stopSelf()
46+ // Only call startForeground once per service lifetime — re-calling it on every
47+ // progress-driven re-start re-binds the "must call startForeground within 5s"
48+ // obligation each time, and if the app is backgrounded between two progress
49+ // updates the new call can throw ForegroundServiceStartNotAllowedException even
50+ // though the service is already in the foreground. That would drop foreground
51+ // priority mid-run (the catch below stopSelf()s, killing the FGS while
52+ // anyRunActive is still true). After the first successful startForeground,
53+ // progress updates refresh the notification via NotificationManager.notify
54+ // directly, which never throws the background-start exception.
55+ if (!isInForeground) {
56+ runCatching {
57+ ServiceCompat.startForeground(
58+ this,
59+ NOTIF_ID,
60+ notification,
61+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
62+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
63+ } else {
64+ 0
65+ },
66+ )
67+ }.onFailure {
68+ Log.w(TAG, "startForeground failed; running without foreground priority", it)
69+ stopSelf()
70+ }.also { isInForeground = it.isSuccess }
71+ } else {
72+ // Already in the foreground: refresh the notification in place. NotificationManager
73+ // updates an ongoing notification without re-asserting foreground priority.
74+ runCatching {
75+ NotificationManagerCompat.from(this).notify(NOTIF_ID, notification)
76+ }.onFailure { Log.w(TAG, "Failed to update foreground notification", it) }
6477 }
6578 return START_NOT_STICKY
6679 }
@@ -159,9 +172,10 @@ class RunForegroundService : Service() {
159172 // Safety net: if the process is being killed while a run is in progress,
160173 // ensure the notification is removed rather than lingering.
161174 ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
162− // Reset the run start so the next run's chronometer begins fresh instead of
163− // inheriting the previous run's elapsed base.
175+ // Reset the run start and foreground state so the next run's chronometer begins
176+ // fresh and startForeground is re-asserted on the first intent of the next run.
164177 runStartMillis = 0L
178+ isInForeground = false
165179 super.onDestroy()
166180 }
167181
@@ -184,6 +198,11 @@ class RunForegroundService : Service() {
184198 // the run and reused across progress-driven notification rebuilds so the chronometer
185199 // base is stable. Reset to 0 in onDestroy when the run ends.
186200 @Volatile private var runStartMillis: Long = 0L
201+ // Whether the service has successfully called startForeground and is currently in
202+ // the foreground. Tracked so progress-driven re-starts (which re-enter
203+ // onStartCommand) don't re-call startForeground and re-bind the 5s obligation each
204+ // time — that would let a backgrounded re-start throw and stopSelf() the FGS mid-run.
205+ @Volatile private var isInForeground: Boolean = false
187206 // Brand accent for the foreground notifications' small-icon tint circle. Resolved
188207 // from resources (R.color.notif_brand) so it follows the app theme (light/dark)
189208 // instead of a hardcoded constant. Lazily computed on first use from the app context
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+12−1
@@ -180,11 +180,22 @@ object SessionNotifications {
180180 * completions collapse into one stackable entry instead of filling the shade. */
181181 @SuppressLint("MissingPermission")
182182 private fun postCompletedSummary(context: Context) {
183+ // The summary needs a content intent so tapping it opens the app (and so
184+ // setAutoCancel can fire on tap — without a PendingIntent the tap does nothing
185+ // and the summary can only be dismissed by swiping). A plain open-app intent is
186+ // right for a group summary: there's no single session to route to.
187+ val openIntent = Intent(context, MainActivity::class.java).apply {
188+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
189+ }
190+ val contentIntent = PendingIntent.getActivity(
191+ context, 0, openIntent,
192+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
193+ )
183194 val summary = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
184195 .setSmallIcon(R.drawable.ic_stat_notify)
185196 .setContentTitle(context.getString(R.string.notif_completed_title))
186197 .setContentText(context.getString(R.string.app_name))
187− .setSmallIcon(R.drawable.ic_stat_notify)
198+ .setContentIntent(contentIntent)
188199 .setAutoCancel(true)
189200 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
190201 .setGroup(GROUP_COMPLETED)
app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+9−1
@@ -26,6 +26,7 @@ import androidx.compose.runtime.LaunchedEffect
2626import androidx.compose.runtime.getValue
2727import androidx.compose.runtime.mutableStateOf
2828import androidx.compose.runtime.remember
29+import androidx.compose.runtime.saveable.rememberSaveable
2930import androidx.compose.runtime.saveable.rememberSaveableStateHolder
3031import androidx.compose.runtime.setValue
3132import androidx.compose.ui.Alignment
@@ -136,7 +137,14 @@ fun AppLockGate(enabled: Boolean, reLockDelaySeconds: Int = 0, content: @Composa
136137 // app-switch (e.g. glancing at another app for a few seconds) doesn't re-prompt — the most
137138 // common reason users disable biometric locks. A killed process resets unlocked anyway, so the
138139 // grace period only governs the in-memory re-lock decision across a surviving background stint.
139− val lastStopTimeMs = remember { longArrayOf(0L) }
140+ //
141+ // rememberSaveable (not plain remember): a config change NOT in the manifest's configChanges
142+ // (e.g. a locale change, which recreates the Activity) would otherwise reset this to 0L, and
143+ // the new DisposableEffect observer gets ON_START delivered synchronously — the grace check
144+ // sees lastStopTimeMs == 0 and re-locks even though the user unlocked 2s ago and only changed
145+ // the system language. The stop timestamp is not security-sensitive (it's a wall-clock value),
146+ // so persisting it through the Bundle is safe and preserves the grace period across recreation.
147+ val lastStopTimeMs = rememberSaveable { longArrayOf(0L) }
140148 val reLockDelayMs = reLockDelaySeconds * 1000L
141149
142150 val title = stringResource(R.string.app_lock_prompt_title)
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+8−1
@@ -2518,7 +2518,14 @@ private fun ChatInputBar(
25182518 ),
25192519 keyboardActions = KeyboardActions(
25202520 onSend = {
2521− if (enabled && hasContent) onSend()
2521+ // Mirror the hardware-Enter handler: during a run the soft-keyboard
2522+ // send key must queue the follow-up, not silently drop it. vm.send()
2523+ // returns false while a run is active (beginRun() rejects), so calling
2524+ // onSend() directly here would lose the typed message with no feedback
2525+ // for touch-only users (no hardware Enter path available).
2526+ if (enabled && hasContent) {
2527+ if (running) onQueueFollowUp(value) else onSend()
2528+ }
25222529 },
25232530 ),
25242531 )
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+14−5
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
77import androidx.compose.foundation.layout.heightIn
88import androidx.compose.foundation.layout.padding
99import androidx.compose.foundation.layout.size
10+import androidx.compose.foundation.clickable
1011import androidx.compose.foundation.rememberScrollState
1112import androidx.compose.foundation.text.selection.SelectionContainer
1213import androidx.compose.foundation.verticalScroll
@@ -96,11 +97,18 @@ fun PermissionDialog(
9697 elapsedMs = (elapsedMs + tickMs).coerceAtMost(autoRejectMs)
9798 }
9899 // Haptic on auto-reject so a walked-away user is signaled that the decision was made.
99− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
100− // Leave a persistent trace (notification) so a returning user who missed the dialog
101− // closing understands why the run stopped — the in-app dialog is gone by then.
102− onAutoReject()
103− respond(PermissionResponse.REJECT)
100+ // Guard on `responded`: if the user answered within the final tick before the timeout,
101+ // the delay() resumed before recomposition cancelled this effect. respond(REJECT) is
102+ // itself guarded by `responded` (no-op), but onAutoReject() posts a real "permission
103+ // auto-rejected" notification — without this guard a user who explicitly answered would
104+ // get a false "you didn't respond" notification for a permission they did respond to.
105+ if (!responded) {
106+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
107+ // Leave a persistent trace (notification) so a returning user who missed the dialog
108+ // closing understands why the run stopped — the in-app dialog is gone by then.
109+ onAutoReject()
110+ respond(PermissionResponse.REJECT)
111+ }
104112 }
105113 val showReminder = elapsedMs >= NetworkConfig.permissionReminderThresholdMs
106114 // In the final 60 seconds, switch to a per-second countdown so the imminent auto-reject
@@ -283,6 +291,7 @@ private fun PermissionMetadataBlock(metadata: kotlinx.serialization.json.JsonEle
283291 Row(
284292 modifier = Modifier
285293 .fillMaxWidth()
294+ .clickable(role = Role.Button) { expanded = !expanded }
286295 .semantics { stateDescription = if (expanded) expandedState else collapsedState }
287296 .padding(vertical = 2.dp),
288297 verticalAlignment = Alignment.CenterVertically,
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+31−10
@@ -307,17 +307,38 @@ fun FileViewScreen(
307307 dir.listFiles()?.forEach { f ->
308308 runCatching { if (f.lastModified() < cutoff) f.delete() }
309309 }
310− val file = java.io.File(dir, filename.ifBlank { "file.txt" })
311− file.writeText(rawText)
312− val authority = context.packageName + ".fileprovider"
313− val uri = androidx.core.content.FileProvider.getUriForFile(context, authority, file)
314− val view = Intent(Intent.ACTION_VIEW).apply {
315− setDataAndType(uri, android.webkit.MimeTypeMap.getSingleton()
316− .getMimeTypeFromExtension(filename.substringAfterLast('.', ""))
317− ?: "text/plain")
318− addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
310+ // Sanitize the filename: replace characters that are invalid in
311+ // a filesystem filename or that FileProvider.getUriForFile
312+ // rejects (path separators, NUL, and Windows-illegal chars that
313+ // also trip the provider), and reject `..` traversal. Without
314+ // this, a path ending in an unusual character (e.g. `:`, `?`)
315+ // would throw at getUriForFile AFTER writeText succeeded, orphaning
316+ // the cache file. Sanitize up front so the write and the URI use
317+ // the same safe name.
318+ val safeName = filename.ifBlank { "file.txt" }
319+ .substringAfterLast('/')
320+ .replace(Regex("[\\\\/:*?\"<>|\\u0000]"), "_")
321+ .takeIf { it.isNotBlank() && it != "." && it != ".." }
322+ ?: "file.txt"
323+ val file = java.io.File(dir, safeName)
324+ try {
325+ file.writeText(rawText)
326+ val authority = context.packageName + ".fileprovider"
327+ val uri = androidx.core.content.FileProvider.getUriForFile(context, authority, file)
328+ val view = Intent(Intent.ACTION_VIEW).apply {
329+ setDataAndType(uri, android.webkit.MimeTypeMap.getSingleton()
330+ .getMimeTypeFromExtension(safeName.substringAfterLast('.', ""))
331+ ?: "text/plain")
332+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
333+ }
334+ context.startActivity(view)
335+ } catch (e: Throwable) {
336+ // getUriForFile or startActivity can throw after the cache
337+ // file was written; delete it so a failure doesn't orphan
338+ // cache files that the TTL prune won't reap for a while.
339+ runCatching { file.delete() }
340+ throw e
319341 }
320− context.startActivity(view)
321342 }
322343 }.isSuccess
323344 if (!ok) scope.launch { snackbar.showSnackbar(context.getString(R.string.open_externally_failed)) }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+8−1
@@ -471,8 +471,15 @@ class ServerEditViewModel(
471471 if (duplicate) {
472472 throw java.io.IOException(container.string(R.string.duplicate_server_warning))
473473 }
474+ // Preserve the existing profile's lastUsed so re-saving doesn't reset its
475+ // sort order. When the timeout fired (existing is empty) and this is an
476+ // edit of an existing profile (s.id != null), we can't know the prior
477+ // lastUsed — fall back to the current wall-clock time rather than 0 (which
478+ // would silently drop the user's most-recently-used server to the bottom of
479+ // the server list). A fresh save bumping lastUsed to "now" is the same
480+ // behavior connectLocked applies on a real connect, so this is consistent.
474481 val existingLastUsed = if (s.id != null) {
475− existing.firstOrNull { it.id == s.id }?.lastUsed ?: 0L
482+ existing.firstOrNull { it.id == s.id }?.lastUsed ?: System.currentTimeMillis()
476483 } else 0L
477484 val saved = ServerProfile(
478485 id = s.id ?: UUID.randomUUID().toString(),
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+1
@@ -146,6 +146,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
146146 "### ${report.fileName}\n\n" + logger.readReport(report.fileName).orEmpty()
147147 }
148148 context.contentResolver.openOutputStream(uri)?.use { it.write(all.toByteArray()) }
149+ ?: error("no output stream")
149150 }
150151 }.isSuccess
151152 if (ok) snackbar.showSnackbar(context.getString(R.string.export_all))
app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.kt+12−1
@@ -208,9 +208,20 @@ class ChatViewModelTest {
208208 val vm = makeVm(container)
209209 assertTrue(vm.send("hello"))
210210 assertFalse(vm.running.value.not()) // running is true
211+ // Collect error events so we can assert the rejection actually fired. A plain
212+ // assertTrue(vm.running.value) afterwards can't distinguish rejection from
213+ // acceptance (both leave running true), so without this the test would pass
214+ // even if the beginRun() guard were removed.
215+ val errors = mutableListOf<ChatError>()
216+ val collectJob = launch { vm.errorEvents.toList(errors) }
211217 vm.runCommand(Command(name = "compact"))
212− // Still running from the first send; runCommand should not override
218+ testScheduler.advanceUntilIdle()
219+ collectJob.cancel()
220+ // Still running from the first send; runCommand should not override.
213221 assertTrue(vm.running.value)
222+ // The rejection emits a command_busy error — the distinguishing signal that the
223+ // guard fired. Without it, runCommand would have proceeded and no error would fire.
224+ assertTrue("expected a command_busy error on rejection", errors.isNotEmpty())
214225 }
215226
216227 // --- abort() ---
app/src/test/java/soy/iko/opencode/ui/file/FileBrowserViewModelTest.kt+16−4
@@ -4,11 +4,13 @@ package soy.iko.opencode.ui.file
44
55import kotlinx.coroutines.Dispatchers
66import kotlinx.coroutines.ExperimentalCoroutinesApi
7+import kotlinx.coroutines.flow.toList
78import kotlinx.coroutines.test.StandardTestDispatcher
89import kotlinx.coroutines.test.TestScope
910import kotlinx.coroutines.test.resetMain
1011import kotlinx.coroutines.test.runTest
1112import kotlinx.coroutines.test.setMain
13+import kotlinx.coroutines.launch
1214import org.junit.After
1315import org.junit.Assert.assertEquals
1416import org.junit.Assert.assertFalse
@@ -17,6 +19,7 @@ import org.junit.Assert.assertNull
1719import org.junit.Assert.assertTrue
1820import org.junit.Before
1921import org.junit.Test
22+import java.io.IOException
2023import soy.iko.opencode.data.model.FileNode
2124import soy.iko.opencode.data.model.FileStatusEntry
2225import soy.iko.opencode.data.model.ServerProfile
@@ -185,11 +188,20 @@ class FileBrowserViewModelTest {
185188 @Test
186189 fun loadStatus_failureSetsTransientError() = testScope.runTest {
187190 val api = FakeOpencodeApiClient()
188− // Make fileStatus throw by not providing entries and having the fake return empty
189− // (the fake doesn't throw, so we just verify the happy path doesn't set error)
191+ api.fileStatusThrows = IOException("network error")
190192 val container = makeContainer(api)
191− val vm = makeVm(container)
192− // With empty status, no transient error event is emitted.
193+ // Collect transient errors BEFORE constructing the VM: loadStatus() runs from the
194+ // VM's init (via collectLatest on activeConnection), so the failure + emit happens
195+ // during makeVm's advanceUntilIdle. Starting the collector first captures it.
196+ val errors = mutableListOf<String>()
197+ // A placeholder VM is needed to access transientErrors, but the VM must be created
198+ // with the collector already running. Create the VM without advancing, start the
199+ // collector, then advance.
200+ val vm = FileBrowserViewModel(container)
201+ val collectJob = launch { vm.transientErrors.toList(errors) }
202+ testScheduler.advanceUntilIdle()
203+ collectJob.cancel()
204+ assertTrue("expected a transient error on fileStatus failure", errors.isNotEmpty())
193205 }
194206
195207 // --- connection availability ---
app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+18−3
@@ -261,11 +261,26 @@ class SessionListViewModelTest {
261261 val container = makeContainer(repo = repo)
262262 val vm = makeVm(container)
263263
264+ // Collect the undo events so we can assert the ViewModel emitted one — the
265+ // synchronous signal that deleteSession fired (the actual REST delete is
266+ // deferred via container.scheduleSessionDelete on the app's IO scope with a
267+ // 5s undo delay, which the test scheduler doesn't flush; the undo event is
268+ // the VM's direct, synchronous side effect). The collector must be subscribed
269+ // BEFORE deleteSession fires: SharedFlow only delivers to current subscribers,
270+ // and _undoEvents.tryEmit runs synchronously inside deleteSession.
271+ val undoEvents = mutableListOf<String>()
272+ val collectJob = launch { vm.undoEvents.toList(undoEvents) }
273+ // Let the collector coroutine run and subscribe before we emit.
274+ testScheduler.advanceUntilIdle()
275+
264276 vm.deleteSession(Session(id = "s1", title = "Chat 1"))
265277 testScheduler.advanceUntilIdle()
266− // The deleteSession in FakeSessionRepository is a no-op, but refresh() is called
267− // which re-fetches sessions. The session should still be in the list since the
268− // fake delete doesn't actually remove it.
278+ collectJob.cancel()
279+
280+ // The session should be optimistically removed from the visible list.
281+ assertFalse(vm.state.value.sessions.any { it.id == "s1" })
282+ // The undo event for this session should have fired so the UI can offer Undo.
283+ assertTrue("expected an undo event for s1", undoEvents.contains("s1"))
269284 }
270285
271286 // --- switchServer ---
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+8−2
@@ -124,6 +124,7 @@ class FakeOpencodeApiClient : OpencodeApiClient() {
124124 var directoryListing: List<FileNode> = emptyList()
125125 var fileSearchResults: List<String> = emptyList()
126126 var fileStatusEntries: List<FileStatusEntry> = emptyList()
127+ var fileStatusThrows: Throwable? = null
127128 var fileContent: FileContent = FileContent(content = "")
128129 var projectsList: List<Project> = emptyList()
129130 var pathInfo: PathInfo = PathInfo()
@@ -235,7 +236,10 @@ class FakeOpencodeApiClient : OpencodeApiClient() {
235236 override suspend fun findFiles(query: String): List<String> = fileSearchResults
236237 override suspend fun listDirectory(path: String): List<FileNode> = directoryListing
237238 override suspend fun readFile(path: String): FileContent = fileContent
238− override suspend fun fileStatus(): List<FileStatusEntry> = fileStatusEntries
239+ override suspend fun fileStatus(): List<FileStatusEntry> {
240+ fileStatusThrows?.let { throw it }
241+ return fileStatusEntries
242+ }
239243}
240244
241245// ---------------------------------------------------------------------------
@@ -269,6 +273,8 @@ class FakeSessionRepository(
269273 var listSessionsGate: kotlinx.coroutines.CompletableDeferred<Unit>? = null
270274 var abortCalls: List<String> = emptyList()
271275 private set
276+ var deleteCalls: List<String> = emptyList()
277+ private set
272278 var lastCreatedDirectory: String? = null
273279 private set
274280
@@ -284,7 +290,7 @@ class FakeSessionRepository(
284290 lastCreatedDirectory = directory
285291 return createdSession
286292 }
287− override suspend fun deleteSession(id: String) { /* no-op */ }
293+ override suspend fun deleteSession(id: String) { deleteCalls = deleteCalls + id }
288294 override suspend fun abort(sessionId: String) { abortCalls = abortCalls + sessionId }
289295
290296 override suspend fun sendPrompt(