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
d416181iko
app/src/main/java/soy/iko/opencode/MainActivity.kt+1−1
@@ -326,7 +326,7 @@ class MainActivity : FragmentActivity() {
326 // slashes. Decoded + validated against traversal/control characters.
326 // slashes. Decoded + validated against traversal/control characters.
327 val rawPath = data.path?.removePrefix("/file/")?.let { android.net.Uri.decode(it) }
327 val rawPath = data.path?.removePrefix("/file/")?.let { android.net.Uri.decode(it) }
328 if (!openFileHandled) {
328 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("..") }
330 ?.let { container.requestOpenFile(it); openFileHandled = true } ?: run {
330 ?.let { container.requestOpenFile(it); openFileHandled = true } ?: run {
331 if (!rawPath.isNullOrBlank()) showInvalidLinkToast = true
331 if (!rawPath.isNullOrBlank()) showInvalidLinkToast = true
332 }
332 }
app/src/main/java/soy/iko/opencode/data/model/Search.kt+13−2
@@ -68,9 +68,20 @@ data class SymbolResult(
68 val character: Int = 0,
68 val character: Int = 0,
69 )
69 )
70
70
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. */
72 val filePath: String
75 val filePath: String
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() }
73− get() = location.uri.removePrefix("file://").ifBlank { location.uri }
83+ ?: uri.removePrefix("file://").ifBlank { uri }
84+ }
74
85
75 /** 1-based line for display; the wire range is 0-based. */
86 /** 1-based line for display; the wire range is 0-based. */
76 val displayLine: Int get() = location.range.start.line + 1
87 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(
327 val next = if (muted) _mutedSessions.value - id else _mutedSessions.value + id
327 val next = if (muted) _mutedSessions.value - id else _mutedSessions.value + id
328 _mutedSessions.value = next
328 _mutedSessions.value = next
329 appContext?.let {
329 appContext?.let {
330− kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
330+ appScope.launch {
331 runCatchingCancellable { sessionPrefsStore.setMuted(id, !muted) }
331 runCatchingCancellable { sessionPrefsStore.setMuted(id, !muted) }
332 }
332 }
333 }
333 }
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+16−1
@@ -34,6 +34,12 @@ class RunForegroundService : Service() {
34 val sessionTitle = intent?.getStringExtra(EXTRA_SESSION_TITLE)?.takeIf { it.isNotBlank() }
34 val sessionTitle = intent?.getStringExtra(EXTRA_SESSION_TITLE)?.takeIf { it.isNotBlank() }
35 val sessionId = intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
35 val sessionId = intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
36 val progress = intent?.getStringExtra(EXTRA_PROGRESS)?.takeIf { it.isNotBlank() }
36 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()
37 val notification = buildNotification(sessionTitle, sessionId, progress)
43 val notification = buildNotification(sessionTitle, sessionId, progress)
38 // startForeground can throw ForegroundServiceStartNotAllowedException on
44 // startForeground can throw ForegroundServiceStartNotAllowedException on
39 // Android 12+ if the app is in the background when the service starts. Wrap
45 // Android 12+ if the app is in the background when the service starts. Wrap
@@ -81,7 +87,9 @@ class RunForegroundService : Service() {
81 .setOngoing(true)
87 .setOngoing(true)
82 // Show a live elapsed timer so the user can see how long the current run has
88 // Show a live elapsed timer so the user can see how long the current run has
83 // been going — no per-tick plumbing needed; the system renders the chronometer.
89 // 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)
85 .setUsesChronometer(true)
93 .setUsesChronometer(true)
86 .setShowWhen(true)
94 .setShowWhen(true)
87 .setPriority(NotificationCompat.PRIORITY_LOW)
95 .setPriority(NotificationCompat.PRIORITY_LOW)
@@ -150,6 +158,9 @@ class RunForegroundService : Service() {
150 // Safety net: if the process is being killed while a run is in progress,
158 // Safety net: if the process is being killed while a run is in progress,
151 // ensure the notification is removed rather than lingering.
159 // ensure the notification is removed rather than lingering.
152 ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
160 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
153 super.onDestroy()
164 super.onDestroy()
154 }
165 }
155
166
@@ -159,6 +170,10 @@ class RunForegroundService : Service() {
159 private const val TAG = "RunForegroundService"
170 private const val TAG = "RunForegroundService"
160 private const val NOTIF_ID = 1
171 private const val NOTIF_ID = 1
161 private const val NOTIF_TIMEOUT_ID = 2
172 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
162 // Brand accent for the foreground notifications' small-icon tint circle. Resolved
177 // Brand accent for the foreground notifications' small-icon tint circle. Resolved
163 // from resources (R.color.notif_brand) so it follows the app theme (light/dark)
178 // from resources (R.color.notif_brand) so it follows the app theme (light/dark)
164 // instead of a hardcoded constant. Lazily computed on first use from the app context.
179 // 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(
198 val listState = rememberLazyListState()
198 val listState = rememberLazyListState()
199 // Flat index of the selected agent, offset by the leading "Default" row when
199 // Flat index of the selected agent, offset by the leading "Default" row when
200 // it's shown. selected == null targets that Default row (index 0). -1 when the
200 // it's shown. selected == null targets that Default row (index 0). -1 when the
201− // selection isn't in the current list.
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.
202− val selectedIndex = remember(distinctAgents, selected, defaultMatches) {
204+ val selectedIndex = remember(filtered, selected, defaultMatches) {
203− selectedAgentIndex(distinctAgents, selected, defaultMatches)
205+ selectedAgentIndex(filtered, selected, defaultMatches)
204 }
206 }
205 // Re-scroll to the selected item whenever the filter changes (not just once on
207 // Re-scroll to the selected item whenever the filter changes (not just once on
206 // open), so clearing a filter that had hidden the selection brings it back into
208 // 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(
435 if (vm.attachments.value.size >= NetworkConfig.maxAttachments) {
435 if (vm.attachments.value.size >= NetworkConfig.maxAttachments) {
436 snackbar.showSnackbar(attachLimitMsg)
436 snackbar.showSnackbar(attachLimitMsg)
437 // The cap dropped the rest of this batch; account for the files we
437 // 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.
439 stagingFileTotal -= uris.size - index
444 stagingFileTotal -= uris.size - index
440− return@forEachIndexed
445+ return@launch
441 }
446 }
442 when (val result = uri.toAttachmentResult(appContext)) {
447 when (val result = uri.toAttachmentResult(appContext)) {
443 is AttachmentResult.Ok -> vm.addAttachment(result.attachment)
448 is AttachmentResult.Ok -> vm.addAttachment(result.attachment)