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
489f8c7iko
app/src/androidTest/java/soy/iko/opencode/data/repo/DraftStoreTest.kt+10−2
@@ -45,7 +45,15 @@ class DraftStoreTest {
45
45
46 @Test
46 @Test
47 fun getReturnsEmptyForUnknownSession() {
47 fun getReturnsEmptyForUnknownSession() {
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.
48− val store = DraftStore(context, CoroutineScope(SupervisorJob()))
51+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
52+ try {
53+ val store = DraftStore(context, scope)
49− assertEquals("", store.get("never-set-${System.nanoTime()}"))
54+ assertEquals("", store.get("never-set-${System.nanoTime()}"))
55+ } finally {
56+ scope.cancel()
57+ }
50 }
58 }
51}
59}
app/src/main/java/soy/iko/opencode/MainActivity.kt+19−6
@@ -342,7 +342,7 @@ class MainActivity : FragmentActivity() {
342 // slashes. Decoded + validated against traversal/control characters.
342 // slashes. Decoded + validated against traversal/control characters.
343 val rawPath = data.path?.removePrefix("/file/")?.let { android.net.Uri.decode(it) }
343 val rawPath = data.path?.removePrefix("/file/")?.let { android.net.Uri.decode(it) }
344 if (!openFileHandled) {
344 if (!openFileHandled) {
345− rawPath?.takeIf { it.isNotBlank() && VALID_FILE_PATH.matches(it) && !it.contains("..") }
345+ rawPath?.takeIf { isValidFilePath(it) }
346 ?.let { container.requestOpenFile(it); openFileHandled = true } ?: run {
346 ?.let { container.requestOpenFile(it); openFileHandled = true } ?: run {
347 if (!rawPath.isNullOrBlank()) showInvalidLinkToast = true
347 if (!rawPath.isNullOrBlank()) showInvalidLinkToast = true
348 }
348 }
@@ -355,8 +355,13 @@ class MainActivity : FragmentActivity() {
355 }
355 }
356 }
356 }
357 }
357 }
358− // Fall back to the EXTRA_SESSION_ID extra (notifications / widget) when no deep link matched.
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.
359− if (!openSessionHandled) {
364+ if (data?.host == null && !openSessionHandled) {
360 intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
365 intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
361 val profileId = intent.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
366 val profileId = intent.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
362 ?.takeIf { it.isNotBlank() }
367 ?.takeIf { it.isNotBlank() }
@@ -376,9 +381,17 @@ class MainActivity : FragmentActivity() {
376 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
381 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
377 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
382 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
378
383
379− /** A file path must be made of path-ish characters and not carry traversal. Used to
384+ /** A file path must not carry traversal segments or control characters. Looser than a
380− * sanity-check the file deep link before handing it to the viewer. */
385+ * positive allowlist: real workspace paths commonly contain `~`, `:`, `+`, `=`, `,`,
381− private val VALID_FILE_PATH = Regex("[A-Za-z0-9_\\-./ ]+")
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+ }
382
395
383 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
396 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
384 private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
397 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(
167 settingsStore.setCompactMessageSpacing(settings.compactMessageSpacing)
167 settingsStore.setCompactMessageSpacing(settings.compactMessageSpacing)
168 settingsStore.setHapticsEnabled(settings.hapticsEnabled)
168 settingsStore.setHapticsEnabled(settings.hapticsEnabled)
169 settingsStore.setReducedMotion(settings.reducedMotion)
169 settingsStore.setReducedMotion(settings.reducedMotion)
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]+)*$"))) {
170− settingsStore.setLanguageOverride(settings.languageOverride)
184+ settingsStore.setLanguageOverride(lang)
185+ }
171 settingsStore.setNotifRunComplete(settings.notifRunComplete)
186 settingsStore.setNotifRunComplete(settings.notifRunComplete)
172 settingsStore.setNotifPermission(settings.notifPermission)
187 settingsStore.setNotifPermission(settings.notifPermission)
173 settingsStore.setNotifError(settings.notifError)
188 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) {
163 /** Deferred whole-directory clear, cancellable via [cancelScheduledClearAll] so the bulk
163 /** Deferred whole-directory clear, cancellable via [cancelScheduledClearAll] so the bulk
164 * action gets the same undo window as a single-report delete. Runs on the logger's own
164 * action gets the same undo window as a single-report delete. Runs on the logger's own
165 * scope so it commits even if the user leaves the Diagnostics screen. */
165 * 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
166 private var pendingClearAll: Job? = null
172 private var pendingClearAll: Job? = null
167
173
168 fun scheduleClearAll(delayMs: Long) {
174 fun scheduleClearAll(delayMs: Long) {
169 pendingClearAll?.cancel()
175 pendingClearAll?.cancel()
170 pendingClearAll = scope.launch {
176 pendingClearAll = scope.launch {
171 delay(delayMs)
177 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.
172 pendingClearAll = null
181 pendingClearAll = null
173 crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
182 crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
174 refresh()
183 refresh()
@@ -177,6 +186,11 @@ class CrashLogger private constructor(private val appContext: Context) {
177
186
178 /** Cancel a pending clear-all (the Undo action). Returns true if it was still pending. */
187 /** Cancel a pending clear-all (the Undo action). Returns true if it was still pending. */
179 fun cancelScheduledClearAll(): Boolean {
188 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.
180 val job = pendingClearAll ?: return false
194 val job = pendingClearAll ?: return false
181 pendingClearAll = null
195 pendingClearAll = null
182 job.cancel()
196 job.cancel()
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+7−1
@@ -9,6 +9,7 @@ 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 soy.iko.opencode.util.runCatchingCancellable
12import java.util.concurrent.Executors
13import java.util.concurrent.Executors
13
14
14/**
15/**
@@ -79,7 +80,12 @@ open class DraftStore private constructor(
79 // so callers can observe readiness without blocking.
80 // so callers can observe readiness without blocking.
80 if (scope != null && appContext != null) {
81 if (scope != null && appContext != null) {
81 scope.launch(Dispatchers.IO) {
82 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 {
83 prefs.all.mapValues { it.value as? String ?: "" }
89 prefs.all.mapValues { it.value as? String ?: "" }
84 }.getOrDefault(emptyMap())
90 }.getOrDefault(emptyMap())
85 // Partition persisted keys: follow-up keys carry a prefix, everything else
91 // 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 {
52 val tmp = File(target.parentFile, "$FILE.tmp")
52 val tmp = File(target.parentFile, "$FILE.tmp")
53 tmp.writeText(encoded)
53 tmp.writeText(encoded)
54 // Atomic replace on POSIX filesystems (app's internal storage); the reader
54 // 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).
56 if (!tmp.renameTo(target)) {
61 if (!tmp.renameTo(target)) {
57− target.writeText(encoded)
62+ target.delete()
58− tmp.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+ }
59 }
71 }
60 }
72 }
61 Unit
73 Unit
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+22−1
@@ -637,6 +637,12 @@ open class AppContainer private constructor(
637 // mutex. Otherwise the runBlocking below would deadlock waiting for a coroutine
637 // mutex. Otherwise the runBlocking below would deadlock waiting for a coroutine
638 // that can't be cancelled until after the runBlocking completes.
638 // that can't be cancelled until after the runBlocking completes.
639 appScope.cancel()
639 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
640 // Bounded runBlocking so a stuck mutex or a slow close() can't ANR the app.
646 // Bounded runBlocking so a stuck mutex or a slow close() can't ANR the app.
641 // 2 seconds is generous: appScope.cancel() already triggered cancellation of
647 // 2 seconds is generous: appScope.cancel() already triggered cancellation of
642 // any coroutine holding the mutex; we're just waiting for it to unwind.
648 // any coroutine holding the mutex; we're just waiting for it to unwind.
@@ -645,11 +651,26 @@ open class AppContainer private constructor(
645 connectionMutex.withLock {
651 connectionMutex.withLock {
646 runCatching { _activeConnection.value?.close() }
652 runCatching { _activeConnection.value?.close() }
647 _activeConnection.value = null
653 _activeConnection.value = null
654+ closed = true
648 }
655 }
649 }
656 }
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.
651 _activeConnection.value = null
661 _activeConnection.value = null
652 }
662 }
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+ }
653 val cm = appContext?.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
674 val cm = appContext?.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
654 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
675 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
655 if (!skipInit) soy.iko.opencode.data.repo.CrashLogger.get(appContext!!).shutdown()
676 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
28 */
28 */
29class RunForegroundService : Service() {
29class RunForegroundService : Service() {
30
30
31+ @android.annotation.SuppressLint("MissingPermission")
31 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
32 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
32 // The session title (when a single run is active) is passed via the intent extra so
33 // The session title (when a single run is active) is passed via the intent extra so
33 // the notification can identify which session is running, instead of a generic
34 // the notification can identify which session is running, instead of a generic
@@ -42,25 +43,37 @@ class RunForegroundService : Service() {
42 // the run ends (stop() → onDestroy) so the next run starts fresh.
43 // the run ends (stop() → onDestroy) so the next run starts fresh.
43 if (runStartMillis == 0L) runStartMillis = System.currentTimeMillis()
44 if (runStartMillis == 0L) runStartMillis = System.currentTimeMillis()
44 val notification = buildNotification(sessionTitle, sessionId, progress)
45 val notification = buildNotification(sessionTitle, sessionId, progress)
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
45− // startForeground can throw ForegroundServiceStartNotAllowedException on
49+ // updates the new call can throw ForegroundServiceStartNotAllowedException even
46− // Android 12+ if the app is in the background when the service starts. Wrap
50+ // though the service is already in the foreground. That would drop foreground
47− // it so a backgrounded start (e.g. the user navigates away at the wrong
51+ // priority mid-run (the catch below stopSelf()s, killing the FGS while
48− // moment) doesn't crash the app — the SSE stream will just continue without
52+ // anyRunActive is still true). After the first successful startForeground,
49− // foreground priority and may be killed by the system sooner.
53+ // progress updates refresh the notification via NotificationManager.notify
54+ // directly, which never throws the background-start exception.
55+ if (!isInForeground) {
50− runCatching {
56+ runCatching {
51− ServiceCompat.startForeground(
57+ ServiceCompat.startForeground(
52− this,
58+ this,
53− NOTIF_ID,
59+ NOTIF_ID,
54− notification,
60+ notification,
55− if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
61+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
56− ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
62+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
57− } else {
63+ } else {
58− 0
64+ 0
59− },
65+ },
60− )
66+ )
61− }.onFailure {
67+ }.onFailure {
62− Log.w(TAG, "startForeground failed; running without foreground priority", it)
68+ Log.w(TAG, "startForeground failed; running without foreground priority", it)
63− stopSelf()
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) }
64 }
77 }
65 return START_NOT_STICKY
78 return START_NOT_STICKY
66 }
79 }
@@ -159,9 +172,10 @@ class RunForegroundService : Service() {
159 // Safety net: if the process is being killed while a run is in progress,
172 // Safety net: if the process is being killed while a run is in progress,
160 // ensure the notification is removed rather than lingering.
173 // ensure the notification is removed rather than lingering.
161 ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
174 ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
162− // Reset the run start so the next run's chronometer begins fresh instead of
175+ // Reset the run start and foreground state so the next run's chronometer begins
163− // inheriting the previous run's elapsed base.
176+ // fresh and startForeground is re-asserted on the first intent of the next run.
164 runStartMillis = 0L
177 runStartMillis = 0L
178+ isInForeground = false
165 super.onDestroy()
179 super.onDestroy()
166 }
180 }
167
181
@@ -184,6 +198,11 @@ class RunForegroundService : Service() {
184 // the run and reused across progress-driven notification rebuilds so the chronometer
198 // the run and reused across progress-driven notification rebuilds so the chronometer
185 // base is stable. Reset to 0 in onDestroy when the run ends.
199 // base is stable. Reset to 0 in onDestroy when the run ends.
186 @Volatile private var runStartMillis: Long = 0L
200 @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
187 // Brand accent for the foreground notifications' small-icon tint circle. Resolved
206 // Brand accent for the foreground notifications' small-icon tint circle. Resolved
188 // from resources (R.color.notif_brand) so it follows the app theme (light/dark)
207 // from resources (R.color.notif_brand) so it follows the app theme (light/dark)
189 // instead of a hardcoded constant. Lazily computed on first use from the app context
208 // 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 {
180 * completions collapse into one stackable entry instead of filling the shade. */
180 * completions collapse into one stackable entry instead of filling the shade. */
181 @SuppressLint("MissingPermission")
181 @SuppressLint("MissingPermission")
182 private fun postCompletedSummary(context: Context) {
182 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+ )
183 val summary = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
194 val summary = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
184 .setSmallIcon(R.drawable.ic_stat_notify)
195 .setSmallIcon(R.drawable.ic_stat_notify)
185 .setContentTitle(context.getString(R.string.notif_completed_title))
196 .setContentTitle(context.getString(R.string.notif_completed_title))
186 .setContentText(context.getString(R.string.app_name))
197 .setContentText(context.getString(R.string.app_name))
187− .setSmallIcon(R.drawable.ic_stat_notify)
198+ .setContentIntent(contentIntent)
188 .setAutoCancel(true)
199 .setAutoCancel(true)
189 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
200 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
190 .setGroup(GROUP_COMPLETED)
201 .setGroup(GROUP_COMPLETED)
app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+9−1
@@ -26,6 +26,7 @@ import androidx.compose.runtime.LaunchedEffect
26import androidx.compose.runtime.getValue
26import androidx.compose.runtime.getValue
27import androidx.compose.runtime.mutableStateOf
27import androidx.compose.runtime.mutableStateOf
28import androidx.compose.runtime.remember
28import androidx.compose.runtime.remember
29+import androidx.compose.runtime.saveable.rememberSaveable
29import androidx.compose.runtime.saveable.rememberSaveableStateHolder
30import androidx.compose.runtime.saveable.rememberSaveableStateHolder
30import androidx.compose.runtime.setValue
31import androidx.compose.runtime.setValue
31import androidx.compose.ui.Alignment
32import androidx.compose.ui.Alignment
@@ -136,7 +137,14 @@ fun AppLockGate(enabled: Boolean, reLockDelaySeconds: Int = 0, content: @Composa
136 // app-switch (e.g. glancing at another app for a few seconds) doesn't re-prompt — the most
137 // app-switch (e.g. glancing at another app for a few seconds) doesn't re-prompt — the most
137 // common reason users disable biometric locks. A killed process resets unlocked anyway, so the
138 // common reason users disable biometric locks. A killed process resets unlocked anyway, so the
138 // grace period only governs the in-memory re-lock decision across a surviving background stint.
139 // grace period only governs the in-memory re-lock decision across a surviving background stint.
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.
139− val lastStopTimeMs = remember { longArrayOf(0L) }
147+ val lastStopTimeMs = rememberSaveable { longArrayOf(0L) }
140 val reLockDelayMs = reLockDelaySeconds * 1000L
148 val reLockDelayMs = reLockDelaySeconds * 1000L
141
149
142 val title = stringResource(R.string.app_lock_prompt_title)
150 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(
2518 ),
2518 ),
2519 keyboardActions = KeyboardActions(
2519 keyboardActions = KeyboardActions(
2520 onSend = {
2520 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).
2521− if (enabled && hasContent) onSend()
2526+ if (enabled && hasContent) {
2527+ if (running) onQueueFollowUp(value) else onSend()
2528+ }
2522 },
2529 },
2523 ),
2530 ),
2524 )
2531 )
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+14−5
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.heightIn
7import androidx.compose.foundation.layout.heightIn
8import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.size
9import androidx.compose.foundation.layout.size
10+import androidx.compose.foundation.clickable
10import androidx.compose.foundation.rememberScrollState
11import androidx.compose.foundation.rememberScrollState
11import androidx.compose.foundation.text.selection.SelectionContainer
12import androidx.compose.foundation.text.selection.SelectionContainer
12import androidx.compose.foundation.verticalScroll
13import androidx.compose.foundation.verticalScroll
@@ -96,11 +97,18 @@ fun PermissionDialog(
96 elapsedMs = (elapsedMs + tickMs).coerceAtMost(autoRejectMs)
97 elapsedMs = (elapsedMs + tickMs).coerceAtMost(autoRejectMs)
97 }
98 }
98 // Haptic on auto-reject so a walked-away user is signaled that the decision was made.
99 // Haptic on auto-reject so a walked-away user is signaled that the decision was made.
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) {
99− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
106+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
100− // Leave a persistent trace (notification) so a returning user who missed the dialog
107+ // 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.
108+ // closing understands why the run stopped — the in-app dialog is gone by then.
102− onAutoReject()
109+ onAutoReject()
103− respond(PermissionResponse.REJECT)
110+ respond(PermissionResponse.REJECT)
111+ }
104 }
112 }
105 val showReminder = elapsedMs >= NetworkConfig.permissionReminderThresholdMs
113 val showReminder = elapsedMs >= NetworkConfig.permissionReminderThresholdMs
106 // In the final 60 seconds, switch to a per-second countdown so the imminent auto-reject
114 // 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
283 Row(
291 Row(
284 modifier = Modifier
292 modifier = Modifier
285 .fillMaxWidth()
293 .fillMaxWidth()
294+ .clickable(role = Role.Button) { expanded = !expanded }
286 .semantics { stateDescription = if (expanded) expandedState else collapsedState }
295 .semantics { stateDescription = if (expanded) expandedState else collapsedState }
287 .padding(vertical = 2.dp),
296 .padding(vertical = 2.dp),
288 verticalAlignment = Alignment.CenterVertically,
297 verticalAlignment = Alignment.CenterVertically,
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+31−10
@@ -307,17 +307,38 @@ fun FileViewScreen(
307 dir.listFiles()?.forEach { f ->
307 dir.listFiles()?.forEach { f ->
308 runCatching { if (f.lastModified() < cutoff) f.delete() }
308 runCatching { if (f.lastModified() < cutoff) f.delete() }
309 }
309 }
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"
310− val file = java.io.File(dir, filename.ifBlank { "file.txt" })
323+ val file = java.io.File(dir, safeName)
324+ try {
311− file.writeText(rawText)
325+ file.writeText(rawText)
312− val authority = context.packageName + ".fileprovider"
326+ val authority = context.packageName + ".fileprovider"
313− val uri = androidx.core.content.FileProvider.getUriForFile(context, authority, file)
327+ val uri = androidx.core.content.FileProvider.getUriForFile(context, authority, file)
314− val view = Intent(Intent.ACTION_VIEW).apply {
328+ val view = Intent(Intent.ACTION_VIEW).apply {
315− setDataAndType(uri, android.webkit.MimeTypeMap.getSingleton()
329+ setDataAndType(uri, android.webkit.MimeTypeMap.getSingleton()
316− .getMimeTypeFromExtension(filename.substringAfterLast('.', ""))
330+ .getMimeTypeFromExtension(safeName.substringAfterLast('.', ""))
317− ?: "text/plain")
331+ ?: "text/plain")
318− addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
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
319 }
341 }
320− context.startActivity(view)
321 }
342 }
322 }.isSuccess
343 }.isSuccess
323 if (!ok) scope.launch { snackbar.showSnackbar(context.getString(R.string.open_externally_failed)) }
344 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(
471 if (duplicate) {
471 if (duplicate) {
472 throw java.io.IOException(container.string(R.string.duplicate_server_warning))
472 throw java.io.IOException(container.string(R.string.duplicate_server_warning))
473 }
473 }
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.
474 val existingLastUsed = if (s.id != null) {
481 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()
476 } else 0L
483 } else 0L
477 val saved = ServerProfile(
484 val saved = ServerProfile(
478 id = s.id ?: UUID.randomUUID().toString(),
485 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) {
146 "### ${report.fileName}\n\n" + logger.readReport(report.fileName).orEmpty()
146 "### ${report.fileName}\n\n" + logger.readReport(report.fileName).orEmpty()
147 }
147 }
148 context.contentResolver.openOutputStream(uri)?.use { it.write(all.toByteArray()) }
148 context.contentResolver.openOutputStream(uri)?.use { it.write(all.toByteArray()) }
149+ ?: error("no output stream")
149 }
150 }
150 }.isSuccess
151 }.isSuccess
151 if (ok) snackbar.showSnackbar(context.getString(R.string.export_all))
152 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 {
208 val vm = makeVm(container)
208 val vm = makeVm(container)
209 assertTrue(vm.send("hello"))
209 assertTrue(vm.send("hello"))
210 assertFalse(vm.running.value.not()) // running is true
210 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) }
211 vm.runCommand(Command(name = "compact"))
217 vm.runCommand(Command(name = "compact"))
218+ testScheduler.advanceUntilIdle()
219+ collectJob.cancel()
212− // Still running from the first send; runCommand should not override
220+ // Still running from the first send; runCommand should not override.
213 assertTrue(vm.running.value)
221 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())
214 }
225 }
215
226
216 // --- abort() ---
227 // --- abort() ---
app/src/test/java/soy/iko/opencode/ui/file/FileBrowserViewModelTest.kt+16−4
@@ -4,11 +4,13 @@ package soy.iko.opencode.ui.file
4
4
5import kotlinx.coroutines.Dispatchers
5import kotlinx.coroutines.Dispatchers
6import kotlinx.coroutines.ExperimentalCoroutinesApi
6import kotlinx.coroutines.ExperimentalCoroutinesApi
7+import kotlinx.coroutines.flow.toList
7import kotlinx.coroutines.test.StandardTestDispatcher
8import kotlinx.coroutines.test.StandardTestDispatcher
8import kotlinx.coroutines.test.TestScope
9import kotlinx.coroutines.test.TestScope
9import kotlinx.coroutines.test.resetMain
10import kotlinx.coroutines.test.resetMain
10import kotlinx.coroutines.test.runTest
11import kotlinx.coroutines.test.runTest
11import kotlinx.coroutines.test.setMain
12import kotlinx.coroutines.test.setMain
13+import kotlinx.coroutines.launch
12import org.junit.After
14import org.junit.After
13import org.junit.Assert.assertEquals
15import org.junit.Assert.assertEquals
14import org.junit.Assert.assertFalse
16import org.junit.Assert.assertFalse
@@ -17,6 +19,7 @@ import org.junit.Assert.assertNull
17import org.junit.Assert.assertTrue
19import org.junit.Assert.assertTrue
18import org.junit.Before
20import org.junit.Before
19import org.junit.Test
21import org.junit.Test
22+import java.io.IOException
20import soy.iko.opencode.data.model.FileNode
23import soy.iko.opencode.data.model.FileNode
21import soy.iko.opencode.data.model.FileStatusEntry
24import soy.iko.opencode.data.model.FileStatusEntry
22import soy.iko.opencode.data.model.ServerProfile
25import soy.iko.opencode.data.model.ServerProfile
@@ -185,11 +188,20 @@ class FileBrowserViewModelTest {
185 @Test
188 @Test
186 fun loadStatus_failureSetsTransientError() = testScope.runTest {
189 fun loadStatus_failureSetsTransientError() = testScope.runTest {
187 val api = FakeOpencodeApiClient()
190 val api = FakeOpencodeApiClient()
188− // Make fileStatus throw by not providing entries and having the fake return empty
191+ api.fileStatusThrows = IOException("network error")
189− // (the fake doesn't throw, so we just verify the happy path doesn't set error)
190 val container = makeContainer(api)
192 val container = makeContainer(api)
191− val vm = makeVm(container)
193+ // Collect transient errors BEFORE constructing the VM: loadStatus() runs from the
192− // With empty status, no transient error event is emitted.
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())
193 }
205 }
194
206
195 // --- connection availability ---
207 // --- connection availability ---
app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+18−3
@@ -261,11 +261,26 @@ class SessionListViewModelTest {
261 val container = makeContainer(repo = repo)
261 val container = makeContainer(repo = repo)
262 val vm = makeVm(container)
262 val vm = makeVm(container)
263
263
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+
264 vm.deleteSession(Session(id = "s1", title = "Chat 1"))
276 vm.deleteSession(Session(id = "s1", title = "Chat 1"))
265 testScheduler.advanceUntilIdle()
277 testScheduler.advanceUntilIdle()
266− // The deleteSession in FakeSessionRepository is a no-op, but refresh() is called
278+ collectJob.cancel()
279+
267− // which re-fetches sessions. The session should still be in the list since the
280+ // The session should be optimistically removed from the visible list.
268− // fake delete doesn't actually remove it.
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"))
269 }
284 }
270
285
271 // --- switchServer ---
286 // --- switchServer ---
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+8−2
@@ -124,6 +124,7 @@ class FakeOpencodeApiClient : OpencodeApiClient() {
124 var directoryListing: List<FileNode> = emptyList()
124 var directoryListing: List<FileNode> = emptyList()
125 var fileSearchResults: List<String> = emptyList()
125 var fileSearchResults: List<String> = emptyList()
126 var fileStatusEntries: List<FileStatusEntry> = emptyList()
126 var fileStatusEntries: List<FileStatusEntry> = emptyList()
127+ var fileStatusThrows: Throwable? = null
127 var fileContent: FileContent = FileContent(content = "")
128 var fileContent: FileContent = FileContent(content = "")
128 var projectsList: List<Project> = emptyList()
129 var projectsList: List<Project> = emptyList()
129 var pathInfo: PathInfo = PathInfo()
130 var pathInfo: PathInfo = PathInfo()
@@ -235,7 +236,10 @@ class FakeOpencodeApiClient : OpencodeApiClient() {
235 override suspend fun findFiles(query: String): List<String> = fileSearchResults
236 override suspend fun findFiles(query: String): List<String> = fileSearchResults
236 override suspend fun listDirectory(path: String): List<FileNode> = directoryListing
237 override suspend fun listDirectory(path: String): List<FileNode> = directoryListing
237 override suspend fun readFile(path: String): FileContent = fileContent
238 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+ }
239}
243}
240
244
241// ---------------------------------------------------------------------------
245// ---------------------------------------------------------------------------
@@ -269,6 +273,8 @@ class FakeSessionRepository(
269 var listSessionsGate: kotlinx.coroutines.CompletableDeferred<Unit>? = null
273 var listSessionsGate: kotlinx.coroutines.CompletableDeferred<Unit>? = null
270 var abortCalls: List<String> = emptyList()
274 var abortCalls: List<String> = emptyList()
271 private set
275 private set
276+ var deleteCalls: List<String> = emptyList()
277+ private set
272 var lastCreatedDirectory: String? = null
278 var lastCreatedDirectory: String? = null
273 private set
279 private set
274
280
@@ -284,7 +290,7 @@ class FakeSessionRepository(
284 lastCreatedDirectory = directory
290 lastCreatedDirectory = directory
285 return createdSession
291 return createdSession
286 }
292 }
287− override suspend fun deleteSession(id: String) { /* no-op */ }
293+ override suspend fun deleteSession(id: String) { deleteCalls = deleteCalls + id }
288 override suspend fun abort(sessionId: String) { abortCalls = abortCalls + sessionId }
294 override suspend fun abort(sessionId: String) { abortCalls = abortCalls + sessionId }
289
295
290 override suspend fun sendPrompt(
296 override suspend fun sendPrompt(