git.iko.soy/opencode-android
on master help sign in
Fix six bugs: attachment cap batch abort, file deep-link validation, mute-toggle scope leak, agent picker scroll index, FGS chronometer base, and file URI path parsing
commitd416181d09bafbe9346016dc31207a7f40c88d8e authoriko <mail@iko.soy> dateSun 5 Jul 2026, 06:33 (3 months ago) parent081e3c4
6 files changed+43−10
app/src/main/java/soy/iko/opencode/MainActivity.kt+1−1 app/src/main/java/soy/iko/opencode/data/model/Search.kt+13−2 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+1−1 app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+16−1 app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+5−3 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+7−2
app/src/main/java/soy/iko/opencode/MainActivity.kt+1−1
@@ -326,7 +326,7 @@ class MainActivity : FragmentActivity() {
326326 // slashes. Decoded + validated against traversal/control characters.
327327 val rawPath = data.path?.removePrefix("/file/")?.let { android.net.Uri.decode(it) }
328328 if (!openFileHandled) {
329− rawPath?.takeIf { it.isNotBlank() && VALID_FILE_PATH.containsMatchIn(it) && !it.contains("..") }
329+ rawPath?.takeIf { it.isNotBlank() && VALID_FILE_PATH.matches(it) && !it.contains("..") }
330330 ?.let { container.requestOpenFile(it); openFileHandled = true } ?: run {
331331 if (!rawPath.isNullOrBlank()) showInvalidLinkToast = true
332332 }
app/src/main/java/soy/iko/opencode/data/model/Search.kt+13−2
@@ -68,9 +68,20 @@ data class SymbolResult(
6868 val character: Int = 0,
6969 )
7070
71− /** The file path portion of [Location.uri], stripping a `file://` scheme when present. */
71+ /** The file path portion of [Location.uri], stripping a `file://` scheme when present.
72+ * Handles both empty-authority (`file:///abs/path`) and non-empty-authority
73+ * (`file://host/abs/path`) URIs by parsing the URI and taking its path component,
74+ * rather than a prefix strip that would leave the authority glued to the path. */
7275 val filePath: String
73− get() = location.uri.removePrefix("file://").ifBlank { location.uri }
76+ get() {
77+ val uri = location.uri
78+ // Only parse file: URIs; for anything else, return as-is.
79+ if (!uri.startsWith("file:")) return uri
80+ return runCatching { java.net.URI(uri).path }
81+ .getOrNull()
82+ ?.takeIf { it.isNotBlank() }
83+ ?: uri.removePrefix("file://").ifBlank { uri }
84+ }
7485
7586 /** 1-based line for display; the wire range is 0-based. */
7687 val displayLine: Int get() = location.range.start.line + 1
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+1−1
@@ -327,7 +327,7 @@ open class AppContainer private constructor(
327327 val next = if (muted) _mutedSessions.value - id else _mutedSessions.value + id
328328 _mutedSessions.value = next
329329 appContext?.let {
330− kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
330+ appScope.launch {
331331 runCatchingCancellable { sessionPrefsStore.setMuted(id, !muted) }
332332 }
333333 }
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+16−1
@@ -34,6 +34,12 @@ class RunForegroundService : Service() {
3434 val sessionTitle = intent?.getStringExtra(EXTRA_SESSION_TITLE)?.takeIf { it.isNotBlank() }
3535 val sessionId = intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
3636 val progress = intent?.getStringExtra(EXTRA_PROGRESS)?.takeIf { it.isNotBlank() }
37+ // The service is re-started per progress update (AppContainer re-sends the intent on
38+ // each _runProgressText change). Capture the wall-clock start of the *first* intent
39+ // of this run and reuse it across rebuilds so the chronometer's base doesn't jump
40+ // back to 0:00 every time the agent advances a step. Reset to 0 in onDestroy once
41+ // the run ends (stop() → onDestroy) so the next run starts fresh.
42+ if (runStartMillis == 0L) runStartMillis = System.currentTimeMillis()
3743 val notification = buildNotification(sessionTitle, sessionId, progress)
3844 // startForeground can throw ForegroundServiceStartNotAllowedException on
3945 // Android 12+ if the app is in the background when the service starts. Wrap
@@ -81,7 +87,9 @@ class RunForegroundService : Service() {
8187 .setOngoing(true)
8288 // Show a live elapsed timer so the user can see how long the current run has
8389 // been going — no per-tick plumbing needed; the system renders the chronometer.
84− .setWhen(System.currentTimeMillis())
90+ // runStartMillis is captured once at the start of the run (see onStartCommand)
91+ // and reused across progress-driven rebuilds so the chronometer base is stable.
92+ .setWhen(runStartMillis)
8593 .setUsesChronometer(true)
8694 .setShowWhen(true)
8795 .setPriority(NotificationCompat.PRIORITY_LOW)
@@ -150,6 +158,9 @@ class RunForegroundService : Service() {
150158 // Safety net: if the process is being killed while a run is in progress,
151159 // ensure the notification is removed rather than lingering.
152160 ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
161+ // Reset the run start so the next run's chronometer begins fresh instead of
162+ // inheriting the previous run's elapsed base.
163+ runStartMillis = 0L
153164 super.onDestroy()
154165 }
155166
@@ -159,6 +170,10 @@ class RunForegroundService : Service() {
159170 private const val TAG = "RunForegroundService"
160171 private const val NOTIF_ID = 1
161172 private const val NOTIF_TIMEOUT_ID = 2
173+ // Wall-clock time the current run started, captured on the first onStartCommand of
174+ // the run and reused across progress-driven notification rebuilds so the chronometer
175+ // base is stable. Reset to 0 in onDestroy when the run ends.
176+ @Volatile private var runStartMillis: Long = 0L
162177 // Brand accent for the foreground notifications' small-icon tint circle. Resolved
163178 // from resources (R.color.notif_brand) so it follows the app theme (light/dark)
164179 // instead of a hardcoded constant. Lazily computed on first use from the app context.
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+5−3
@@ -198,9 +198,11 @@ fun AgentPickerSheet(
198198 val listState = rememberLazyListState()
199199 // Flat index of the selected agent, offset by the leading "Default" row when
200200 // it's shown. selected == null targets that Default row (index 0). -1 when the
201− // selection isn't in the current list.
202− val selectedIndex = remember(distinctAgents, selected, defaultMatches) {
203− selectedAgentIndex(distinctAgents, selected, defaultMatches)
201+ // selection isn't in the current list. Computed against `filtered` (not the
202+ // unfiltered distinctAgents) so a query that hides agents before the selection
203+ // still maps to the right visible row.
204+ val selectedIndex = remember(filtered, selected, defaultMatches) {
205+ selectedAgentIndex(filtered, selected, defaultMatches)
204206 }
205207 // Re-scroll to the selected item whenever the filter changes (not just once on
206208 // open), so clearing a filter that had hidden the selection brings it back into
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+7−2
@@ -435,9 +435,14 @@ fun ChatScreen(
435435 if (vm.attachments.value.size >= NetworkConfig.maxAttachments) {
436436 snackbar.showSnackbar(attachLimitMsg)
437437 // The cap dropped the rest of this batch; account for the files we
438− // won't process so stagingFileTotal doesn't linger over-counted.
438+ // won't process so stagingFileTotal doesn't linger over-counted,
439+ // then abandon the batch. return@launch (not return@forEachIndexed)
440+ // so the remaining uris aren't re-checked against a never-relieved
441+ // cap (which would over-decrement the counter and re-fire the snackbar
442+ // once per leftover uri). The `finally` below still decrements
443+ // stagingCount.
439444 stagingFileTotal -= uris.size - index
440− return@forEachIndexed
445+ return@launch
441446 }
442447 when (val result = uri.toAttachmentResult(appContext)) {
443448 is AttachmentResult.Ok -> vm.addAttachment(result.attachment)