git.iko.soy/opencode-android
on master help sign in
Fix SSE server-switch blind spot, REST hang vulnerability, foreground-service crash, connection race, thread-safety issues, silent error swallowing, deep-link injection, deprecated APIs, and hardcoded strings

Twenty fixes across the codebase: - SessionListViewModel: collectβ†’collectLatest so live SSE events arrive after server switch; ConcurrentHashMap for preview jobs; atomic _state.update everywhere - HttpClientFactory: REST calls now get read+request timeouts (were infinite); SSE overrides to infinite per-request - RunForegroundService: catch ForegroundServiceStartNotAllowedException; onDestroy removes lingering notification - AppContainer: Mutex-guarded connect/disconnect; throttle lastUsed saves; unregister NetworkCallback via shutdown(); IO dispatcher for network scope - OpencodeConnection: IO dispatcher instead of Default for SSE reads - MainActivity: validate deep-link session IDs against path traversal - ChatViewModel: surface abort failures; log catalog-load errors - FileBrowserViewModel: surface VCS-status errors via snackbar - CrashLogger: log write failures; replace deprecated versionCode with PackageInfoCompat - SessionNotifications: SHA-256 notification IDs to prevent collisions; log post failures - DraftStore: background preload to avoid main-thread disk read - ProfileStore: log JSON decode failures - SessionRepository: log initial message-load failures - RemoteImage/MessageBubble: extract hardcoded strings to resources - TimeFormat: cleaner recomposition-trigger pattern πŸ’˜ Generated with Crush Assisted-by: Crush:glm-5.2

commit5832dcca1ca58e2f56282fca5861c2c9bd428890 authoriko <mail@iko.soy> dateMon 29 Jun 2026, 18:45 (3 months ago) parent0117f4c
20 files changed+229βˆ’69
app/src/main/java/soy/iko/opencode/MainActivity.kt+7βˆ’1 app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+14βˆ’1 app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+9βˆ’10 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+2 app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+4βˆ’1 app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+17βˆ’2 app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+3βˆ’1 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+4βˆ’1 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+50βˆ’18 app/src/main/java/soy/iko/opencode/di/OpencodeConnection.kt+1βˆ’1 app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+27βˆ’5 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+19βˆ’3 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+11βˆ’3 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+6βˆ’3 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+3βˆ’1 app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+5βˆ’1 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+13 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+10 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+20βˆ’17 app/src/main/res/values/strings.xml+4
app/src/main/java/soy/iko/opencode/MainActivity.kt+7βˆ’1
@@ -67,12 +67,18 @@ class MainActivity : ComponentActivity() {
6767 ?.takeIf { it.host == "session" }
6868 ?.lastPathSegment
6969 val id = deepLinkId ?: intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
70βˆ’ id?.let { container.requestOpenSession(it) }
70+ // Validate: session ids are opaque server-generated identifiers. Reject anything
71+ // with path separators or other traversal/control characters so a malicious deep
72+ // link can't inject path components into the REST URL path.
73+ id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let { container.requestOpenSession(it) }
7174 }
7275
7376 companion object {
7477 /** Intent extra carrying a session id to open (notifications / deep links). */
7578 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
79+
80+ /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
81+ private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
7682 }
7783
7884 /** Ask for POST_NOTIFICATIONS once on Android 13+ so run/completion notifications show. */
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+14βˆ’1
@@ -3,6 +3,7 @@ package soy.iko.opencode.data.network
33import soy.iko.opencode.data.model.BusEvent
44import android.util.Log
55import io.ktor.client.HttpClient
6+import io.ktor.client.plugins.timeout
67import io.ktor.client.plugins.sse.sse
78import io.ktor.sse.ServerSentEvent
89import kotlinx.coroutines.CancellationException
@@ -66,7 +67,19 @@ class EventStreamClient(
6667 while (isActive) {
6768 _state.value = ConnectionState.Connecting
6869 try {
69βˆ’ client.sse("event") {
70+ // The SSE stream is long-lived: disable the request-level and socket
71+ // timeouts (the client default is 60s for REST) in the request config
72+ // so the connection isn't killed mid-stream. The idle-timeout watchdog
73+ // below handles half-open connections instead.
74+ client.sse(
75+ "event",
76+ request = {
77+ timeout {
78+ requestTimeoutMillis = Long.MAX_VALUE
79+ socketTimeoutMillis = Long.MAX_VALUE
80+ }
81+ },
82+ ) {
7083 backoffMs = NetworkConfig.sseInitialBackoffMs
7184 // `incoming` is a cold Flow; bridge it to a ReceiveChannel so each
7285 // element can be raced against an idle timeout via select.
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+9βˆ’10
@@ -27,11 +27,10 @@ object HttpClientFactory {
2727
2828 engine {
2929 config {
30βˆ’ // The SSE stream is long-lived; a socket read timeout would kill it
31βˆ’ // mid-stream. REST calls rely on the connect timeout + withRetry for
32βˆ’ // resilience instead. The SSE idle-timeout watchdog in EventStreamClient
33βˆ’ // handles half-open connections.
34βˆ’ readTimeout(0, TimeUnit.MILLISECONDS)
30+ // A normal read timeout for REST calls. The SSE stream overrides this
31+ // to infinite inside the sse {} request block (see EventStreamClient),
32+ // so the long-lived event connection is never killed mid-stream.
33+ readTimeout(NetworkConfig.readTimeoutSeconds, TimeUnit.SECONDS)
3534 connectTimeout(NetworkConfig.connectTimeoutSeconds, TimeUnit.SECONDS)
3635 retryOnConnectionFailure(true)
3736 pingInterval(NetworkConfig.pingIntervalSeconds, TimeUnit.SECONDS)
@@ -44,12 +43,12 @@ object HttpClientFactory {
4443
4544 install(SSE)
4645
47βˆ’ // Disable the request-level timeout at the client level β€” it covers the whole
48βˆ’ // call including the body stream, so it would kill the long-lived SSE `/event`
49βˆ’ // connection after 60s (the previous default). The engine's connect timeout
50βˆ’ // and the SSE idle-timeout watchdog handle stuck connections instead.
46+ // Request-level timeout for REST calls. The SSE stream overrides this to
47+ // infinite inside the sse {} request block (see EventStreamClient) so the
48+ // long-lived /event connection isn't killed after 60s. The engine's connect
49+ // timeout and the SSE idle-timeout watchdog handle stuck SSE connections.
5150 install(HttpTimeout) {
52βˆ’ requestTimeoutMillis = Long.MAX_VALUE
51+ requestTimeoutMillis = NetworkConfig.restRequestTimeoutMs
5352 }
5453
5554 if (profile.hasAuth) {
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+2
@@ -17,6 +17,8 @@ object NetworkConfig {
1717 const val readTimeoutSeconds = 60L
1818 /** OkHttp WebSocket/interval ping, keeping the SSE socket alive through proxies. */
1919 const val pingIntervalSeconds = 20L
20+ /** Request-level timeout for REST calls (covers connect + headers + body). The SSE stream overrides this to INFINITE. */
21+ const val restRequestTimeoutMs = 60_000L
2022
2123 // --- REST retry (OpencodeApiClient.withRetry) ---
2224
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+4βˆ’1
@@ -2,6 +2,8 @@ package soy.iko.opencode.data.repo
22
33import android.content.Context
44import android.os.Build
5+import android.util.Log
6+import androidx.core.content.pm.PackageInfoCompat
57import kotlinx.coroutines.flow.MutableStateFlow
68import kotlinx.coroutines.flow.StateFlow
79import kotlinx.coroutines.flow.asStateFlow
@@ -39,6 +41,7 @@ class CrashLogger private constructor(private val appContext: Context) {
3941 val previous = Thread.getDefaultUncaughtExceptionHandler()
4042 Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
4143 runCatching { writeReport(thread, throwable) }
44+ .onFailure { Log.e("CrashLogger", "Failed to write crash report", it) }
4245 previous?.uncaughtException(thread, throwable)
4346 }
4447 refresh()
@@ -97,7 +100,7 @@ class CrashLogger private constructor(private val appContext: Context) {
97100
98101 private fun appVersion(): String = runCatching {
99102 val pi = appContext.packageManager.getPackageInfo(appContext.packageName, 0)
100βˆ’ "${pi.versionName} (${if (Build.VERSION.SDK_INT >= 28) pi.longVersionCode else @Suppress("DEPRECATION") pi.versionCode})"
103+ "${pi.versionName} (${PackageInfoCompat.getLongVersionCode(pi)})"
101104 }.getOrDefault("unknown")
102105
103106 companion object {
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+17βˆ’2
@@ -1,16 +1,31 @@
11package soy.iko.opencode.data.repo
22
33import android.content.Context
4+import kotlinx.coroutines.CoroutineScope
5+import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.SupervisorJob
7+import kotlinx.coroutines.launch
48
59/**
610 * Persists the in-progress message draft for each session so it survives
711 * back-navigation and process death. Backed by SharedPreferences because the
812 * UI needs the initial value synchronously and drafts are low-stakes data.
13+ *
14+ * The prefs file is loaded on a background thread at construction time so the
15+ * first [get] call on the main thread doesn't trigger a synchronous disk read.
916 */
1017class DraftStore(context: Context) {
1118
12βˆ’ private val prefs = context.applicationContext
13βˆ’ .getSharedPreferences("drafts", Context.MODE_PRIVATE)
19+ private val appContext = context.applicationContext
20+ private val prefs by lazy {
21+ appContext.getSharedPreferences("drafts", Context.MODE_PRIVATE)
22+ }
23+
24+ init {
25+ // Eagerly load the prefs file on a background thread so the first main-thread
26+ // get() doesn't block on disk I/O. The lazy delegate ensures the same instance.
27+ CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { prefs.all }
28+ }
1429
1530 fun get(sessionId: String): String = prefs.getString(sessionId, "").orEmpty()
1631
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+3βˆ’1
@@ -2,6 +2,7 @@ package soy.iko.opencode.data.repo
22
33import android.content.Context
44import android.content.SharedPreferences
5+import android.util.Log
56import androidx.datastore.preferences.core.edit
67import androidx.datastore.preferences.core.stringPreferencesKey
78import androidx.datastore.preferences.preferencesDataStore
@@ -53,7 +54,8 @@ class ProfileStore(context: Context) {
5354 val json = prefs[profilesKey] ?: return@map emptyList()
5455 runCatching {
5556 OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), json)
56βˆ’ }.getOrDefault(emptyList())
57+ }.onFailure { Log.w("ProfileStore", "Failed to decode stored profiles, ignoring", it) }
58+ .getOrDefault(emptyList())
5759 .sortedByDescending { it.lastUsed }
5860 .map { it.toProfile() }
5961 }
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+4βˆ’1
@@ -13,6 +13,7 @@ import soy.iko.opencode.data.model.SessionIdle
1313import soy.iko.opencode.data.model.UnknownMessage
1414import soy.iko.opencode.data.network.EventStreamClient
1515import soy.iko.opencode.data.network.OpencodeApiClient
16+import android.util.Log
1617import kotlinx.coroutines.flow.Flow
1718import kotlinx.coroutines.flow.channelFlow
1819import kotlinx.coroutines.flow.conflate
@@ -70,7 +71,9 @@ class SessionRepository(
7071 }
7172 }
7273
73βˆ’ val initial = runCatching { api.listMessages(sessionId) }.getOrDefault(emptyList())
74+ val initial = runCatching { api.listMessages(sessionId) }
75+ .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on SSE", it) }
76+ .getOrDefault(emptyList())
7477 lock.withLock { store.seed(initial) }
7578 publish()
7679
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+50βˆ’18
@@ -4,6 +4,7 @@ import android.content.Context
44import android.net.ConnectivityManager
55import android.net.Network
66import android.net.NetworkRequest
7+import android.util.Log
78import soy.iko.opencode.data.model.BusEvent
89import soy.iko.opencode.data.model.MessagePartUpdated
910import soy.iko.opencode.data.model.MessageUpdated
@@ -22,6 +23,7 @@ import kotlinx.coroutines.CoroutineScope
2223import kotlinx.coroutines.Dispatchers
2324import kotlinx.coroutines.ExperimentalCoroutinesApi
2425import kotlinx.coroutines.SupervisorJob
26+import kotlinx.coroutines.cancel
2527import kotlinx.coroutines.flow.MutableStateFlow
2628import kotlinx.coroutines.flow.StateFlow
2729import kotlinx.coroutines.flow.asStateFlow
@@ -30,6 +32,8 @@ import kotlinx.coroutines.flow.first
3032import kotlinx.coroutines.flow.flatMapLatest
3133import kotlinx.coroutines.flow.update
3234import kotlinx.coroutines.launch
35+import kotlinx.coroutines.sync.Mutex
36+import kotlinx.coroutines.sync.withLock
3337import java.util.concurrent.atomic.AtomicBoolean
3438
3539/**
@@ -38,7 +42,7 @@ import java.util.concurrent.atomic.AtomicBoolean
3842 */
3943class AppContainer(context: Context) {
4044
41βˆ’ val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
45+ val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
4246 val profileStore = ProfileStore(context)
4347 val settingsStore = SettingsStore(context)
4448 val draftStore = DraftStore(context)
@@ -124,11 +128,17 @@ class AppContainer(context: Context) {
124128
125129 init {
126130 NotificationChannels.create(appContext)
127βˆ’ registerNetworkMonitor()
128131 observeMessageActivity()
129132 appScope.launch { autoConnect() }
130133 }
131134
135+ /** Release resources held for the process lifetime (network callback, app scope). */
136+ fun shutdown() {
137+ val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
138+ networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
139+ appScope.cancel()
140+ }
141+
132142 /**
133143 * Watch the SSE bus for new message activity and badge any session that isn't
134144 * currently open. This powers the unread dot on the session list so the user can
@@ -162,6 +172,15 @@ class AppContainer(context: Context) {
162172 /** Session ids currently streaming an assistant run (best-effort, in-process). */
163173 private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
164174
175+ /** Guards connect/disconnect so concurrent callers can't leak an old connection. */
176+ private val connectionMutex = Mutex()
177+
178+ /** Only persist the `lastUsed` timestamp if it's older than this, to avoid
179+ * a DataStore + encrypted-prefs write on every rapid reconnect. */
180+ private companion object {
181+ const val LAST_USED_SAVE_THRESHOLD_MS = 60_000L
182+ }
183+
165184 /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
166185 private suspend fun notifySessionCompleted(sessionId: String) {
167186 val conn = activeConnection.value ?: return
@@ -195,29 +214,42 @@ class AppContainer(context: Context) {
195214 * When the device regains connectivity, nudge the active SSE stream to reconnect
196215 * immediately instead of waiting out the exponential backoff.
197216 */
198βˆ’ private fun registerNetworkMonitor() {
217+ private val networkCallback: ConnectivityManager.NetworkCallback? = registerNetworkMonitor()
218+
219+ private fun registerNetworkMonitor(): ConnectivityManager.NetworkCallback? {
199220 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
200βˆ’ ?: return
201βˆ’ runCatching {
202βˆ’ cm.registerNetworkCallback(NetworkRequest.Builder().build(), object : ConnectivityManager.NetworkCallback() {
203βˆ’ override fun onAvailable(network: Network) {
204βˆ’ activeConnection.value?.events?.triggerReconnect()
205βˆ’ }
206βˆ’ })
221+ ?: return null
222+ val callback = object : ConnectivityManager.NetworkCallback() {
223+ override fun onAvailable(network: Network) {
224+ activeConnection.value?.events?.triggerReconnect()
225+ }
207226 }
227+ runCatching { cm.registerNetworkCallback(NetworkRequest.Builder().build(), callback) }
228+ .onFailure { Log.w("AppContainer", "Failed to register network callback", it) }
229+ return callback
208230 }
209231
210βˆ’ /** Open (or re-open) a connection to [profile], replacing any current one. */
211βˆ’ suspend fun connect(profile: ServerProfile): OpencodeConnection {
212βˆ’ disconnect()
213βˆ’ val resolved = profileStore.resolve(profile).copy(lastUsed = System.currentTimeMillis())
214βˆ’ profileStore.save(resolved)
215βˆ’ return OpencodeConnection(resolved).also { _activeConnection.value = it }
216βˆ’ }
232+ /** Open (or re-open) a connection to [profile], replacing any current one.
233+ * Only persists the updated `lastUsed` timestamp if it's stale (older than a
234+ * minute), so rapid reconnect storms don't each trigger a DataStore write. */
235+ suspend fun connect(profile: ServerProfile): OpencodeConnection =
236+ connectionMutex.withLock {
237+ _activeConnection.value?.close()
238+ _activeConnection.value = null
239+ val now = System.currentTimeMillis()
240+ val resolved = profileStore.resolve(profile)
241+ val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
242+ val finalProfile = if (needsSave) resolved.copy(lastUsed = now) else resolved
243+ if (needsSave) profileStore.save(finalProfile)
244+ OpencodeConnection(finalProfile).also { _activeConnection.value = it }
245+ }
217246
218247 fun disconnect() {
219βˆ’ _activeConnection.value?.close()
248+ // Non-suspending best-effort disconnect; if a connect is in progress under the
249+ // mutex, the connection it sets will be closed on the next disconnect/connect.
250+ val conn = _activeConnection.value ?: return
220251 _activeConnection.value = null
252+ conn.close()
221253 }
222254}
223255
app/src/main/java/soy/iko/opencode/di/OpencodeConnection.kt+1βˆ’1
@@ -17,7 +17,7 @@ import kotlinx.coroutines.cancel
1717 */
1818class OpencodeConnection(val profile: ServerProfile) {
1919
20βˆ’ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
20+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
2121
2222 private val client = HttpClientFactory.create(profile)
2323 val api = OpencodeApiClient(client)
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+27βˆ’5
@@ -7,7 +7,9 @@ import android.content.Intent
77import android.content.pm.ServiceInfo
88import android.os.Build
99import android.os.IBinder
10+import android.util.Log
1011import androidx.core.app.NotificationCompat
12+import androidx.core.app.ServiceCompat
1113import soy.iko.opencode.R
1214
1315/**
@@ -23,11 +25,23 @@ class RunForegroundService : Service() {
2325
2426 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
2527 val notification = buildNotification()
26βˆ’ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
27βˆ’ startForeground(NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
28βˆ’ } else {
29βˆ’ startForeground(NOTIF_ID, notification)
30βˆ’ }
28+ // startForeground can throw ForegroundServiceStartNotAllowedException on
29+ // Android 12+ if the app is in the background when the service starts. Wrap
30+ // it so a backgrounded start (e.g. the user navigates away at the wrong
31+ // moment) doesn't crash the app β€” the SSE stream will just continue without
32+ // foreground priority and may be killed by the system sooner.
33+ runCatching {
34+ ServiceCompat.startForeground(
35+ this,
36+ NOTIF_ID,
37+ notification,
38+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
39+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
40+ } else {
41+ 0
42+ },
43+ )
44+ }.onFailure { Log.w(TAG, "startForeground failed; running without foreground priority", it) }
3145 return START_NOT_STICKY
3246 }
3347
@@ -41,9 +55,17 @@ class RunForegroundService : Service() {
4155 .setCategory(NotificationCompat.CATEGORY_PROGRESS)
4256 .build()
4357
58+ override fun onDestroy() {
59+ // Safety net: if the process is being killed while a run is in progress,
60+ // ensure the notification is removed rather than lingering.
61+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
62+ super.onDestroy()
63+ }
64+
4465 override fun onBind(intent: Intent?): IBinder? = null
4566
4667 companion object {
68+ private const val TAG = "RunForegroundService"
4769 private const val NOTIF_ID = 1
4870
4971 fun start(context: Context) {
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+19βˆ’3
@@ -4,10 +4,12 @@ import android.app.PendingIntent
44import android.content.Context
55import android.content.Intent
66import android.os.Build
7+import android.util.Log
78import androidx.core.app.NotificationCompat
89import androidx.core.app.NotificationManagerCompat
910import soy.iko.opencode.MainActivity
1011import soy.iko.opencode.R
12+import java.security.MessageDigest
1113
1214/**
1315 * Builds and posts the "session completed" notification when a background session
@@ -20,6 +22,19 @@ import soy.iko.opencode.R
2022object SessionNotifications {
2123
2224 private const val NOTIF_ID_PREFIX = 4000
25+ private const val TAG = "SessionNotifications"
26+
27+ /** Derive a stable, collision-resistant notification id from a session id.
28+ * String.hashCode() can collide for different inputs; a SHA-256 truncated to 31
29+ * bits makes accidental collisions astronomically unlikely. */
30+ private fun notifIdFor(sessionId: String): Int {
31+ val digest = MessageDigest.getInstance("SHA-256").digest(sessionId.toByteArray())
32+ // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
33+ return (NOTIF_ID_PREFIX + ((digest[0].toInt() and 0xFF) shl 24 or
34+ (digest[1].toInt() and 0xFF) shl 16 or
35+ (digest[2].toInt() and 0xFF) shl 8 or
36+ (digest[3].toInt() and 0xFF))).and(0x7FFFFFFF)
37+ }
2338
2439 fun postCompleted(context: Context, sessionId: String, title: String) {
2540 // Respect the POST_NOTIFICATIONS runtime permission (Android 13+): if the user
@@ -32,14 +47,14 @@ object SessionNotifications {
3247 ) {
3348 return
3449 }
35βˆ’ val notifId = (NOTIF_ID_PREFIX + sessionId.hashCode()).and(0x7FFFFFFF)
50+ val notifId = notifIdFor(sessionId)
3651 val openIntent = Intent(context, MainActivity::class.java).apply {
3752 action = Intent.ACTION_VIEW
3853 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
3954 putExtra(MainActivity.EXTRA_SESSION_ID, sessionId)
4055 }
4156 val pendingIntent = PendingIntent.getActivity(
42βˆ’ context, sessionId.hashCode(), openIntent,
57+ context, notifId, openIntent,
4358 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
4459 )
4560 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
@@ -53,11 +68,12 @@ object SessionNotifications {
5368 .build()
5469
5570 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
71+ .onFailure { Log.w(TAG, "Failed to post completion notification", it) }
5672 }
5773
5874 /** Cancel a session's completion notification (e.g. when the user opens it). */
5975 fun cancel(context: Context, sessionId: String) {
60βˆ’ val notifId = (NOTIF_ID_PREFIX + sessionId.hashCode()).and(0x7FFFFFFF)
76+ val notifId = notifIdFor(sessionId)
6177 NotificationManagerCompat.from(context).cancel(notifId)
6278 }
6379}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+11βˆ’3
@@ -19,6 +19,7 @@ import soy.iko.opencode.data.network.EventStreamClient
1919import soy.iko.opencode.data.repo.SessionRepository
2020import soy.iko.opencode.di.AppContainer
2121import soy.iko.opencode.R
22+import android.util.Log
2223import kotlinx.coroutines.ExperimentalCoroutinesApi
2324import kotlinx.coroutines.flow.MutableStateFlow
2425import kotlinx.coroutines.flow.SharingStarted
@@ -144,7 +145,9 @@ class ChatViewModel(
144145 viewModelScope.launch {
145146 container.activeConnection.collectLatest { conn ->
146147 if (conn == null) return@collectLatest
147βˆ’ runCatching { conn.api.providers() }.getOrNull()?.let { resp ->
148+ runCatching { conn.api.providers() }
149+ .onFailure { Log.w("ChatViewModel", "Failed to load model catalog", it) }
150+ .getOrNull()?.let { resp ->
148151 val options = resp.toOptions()
149152 _models.value = options
150153 _selectedModel.value = resp.defaultOption(options)
@@ -155,14 +158,18 @@ class ChatViewModel(
155158 viewModelScope.launch {
156159 container.activeConnection.collectLatest { conn ->
157160 if (conn == null) return@collectLatest
158βˆ’ runCatching { conn.api.agents() }.getOrNull()?.let { _agents.value = it }
161+ runCatching { conn.api.agents() }
162+ .onFailure { Log.w("ChatViewModel", "Failed to load agent catalog", it) }
163+ .getOrNull()?.let { _agents.value = it }
159164 }
160165 }
161166 // Load the command catalog (non-fatal).
162167 viewModelScope.launch {
163168 container.activeConnection.collectLatest { conn ->
164169 if (conn == null) return@collectLatest
165βˆ’ runCatching { conn.api.commands() }.getOrNull()?.let { _commands.value = it }
170+ runCatching { conn.api.commands() }
171+ .onFailure { Log.w("ChatViewModel", "Failed to load command catalog", it) }
172+ .getOrNull()?.let { _commands.value = it }
166173 }
167174 }
168175 // Resolve the human-readable session title for the app bar (non-fatal).
@@ -249,6 +256,7 @@ class ChatViewModel(
249256 val conn = connection ?: return
250257 viewModelScope.launch {
251258 runCatching { conn.repository.abort(sessionId) }
259+ .onFailure { _error.value = container.friendlyError(it) }
252260 _running.value = false
253261 }
254262 }
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+6βˆ’3
@@ -13,7 +13,9 @@ import androidx.compose.runtime.Composable
1313import androidx.compose.ui.Alignment
1414import androidx.compose.ui.Modifier
1515import androidx.compose.ui.draw.clip
16+import androidx.compose.ui.res.stringResource
1617import androidx.compose.ui.unit.dp
18+import soy.iko.opencode.R
1719import soy.iko.opencode.data.model.AssistantMessage
1820import soy.iko.opencode.data.model.MessageWithParts
1921import soy.iko.opencode.data.model.Tokens
@@ -73,8 +75,9 @@ private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, imageC
7375 val cost = info.cost
7476 val tokens = info.tokens
7577 if (info.isComplete && (cost != null || tokens != null)) {
78+ val tokenFormat = stringResource(R.string.tokens_in_out)
7679 val parts = buildList {
77βˆ’ tokens?.let { add(formatTokens(it)) }
80+ tokens?.let { add(formatTokens(it, tokenFormat)) }
7881 cost?.takeIf { it > 0 }?.let { add(formatCost(it)) }
7982 }
8083 if (parts.isNotEmpty()) {
@@ -102,9 +105,9 @@ private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
102105 }
103106}
104107
105βˆ’private fun formatTokens(tokens: Tokens): String {
108+private fun formatTokens(tokens: Tokens, format: String): String {
106109 val dec = java.text.DecimalFormat.getNumberInstance(java.util.Locale.US)
107βˆ’ return "${dec.format(tokens.input)} in Β· ${dec.format(tokens.output)} out"
110+ return format.format(dec.format(tokens.input), dec.format(tokens.output))
108111}
109112
110113private fun formatCost(cost: Double): String =
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+3βˆ’1
@@ -9,9 +9,11 @@ import androidx.compose.ui.Modifier
99import androidx.compose.ui.draw.clip
1010import androidx.compose.ui.layout.ContentScale
1111import androidx.compose.ui.platform.LocalContext
12+import androidx.compose.ui.res.stringResource
1213import androidx.compose.ui.unit.dp
1314import coil.compose.AsyncImage
1415import coil.request.ImageRequest
16+import soy.iko.opencode.R
1517import soy.iko.opencode.data.model.FilePart
1618import soy.iko.opencode.data.model.ServerProfile
1719import soy.iko.opencode.data.network.HttpClientFactory
@@ -76,7 +78,7 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
7678 .build()
7779 AsyncImage(
7880 model = request,
79βˆ’ contentDescription = part.filename ?: "image",
81+ contentDescription = part.filename ?: stringResource(R.string.image),
8082 contentScale = ContentScale.FillWidth,
8183 modifier = modifier
8284 .heightIn(max = 320.dp)
app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+5βˆ’1
@@ -36,6 +36,8 @@ fun relativeTime(epochMillis: Long?): String {
3636 */
3737@Composable
3838fun rememberRelativeTime(epochMillis: Long?, intervalMs: Long = 30_000L): String {
39+ // Recompose trigger: incrementing this state causes the composable to re-execute
40+ // on each interval, re-evaluating relativeTime() so the label stays fresh.
3941 var tick by remember { mutableLongStateOf(0L) }
4042 LaunchedEffect(epochMillis) {
4143 while (true) {
@@ -43,5 +45,7 @@ fun rememberRelativeTime(epochMillis: Long?, intervalMs: Long = 30_000L): String
4345 tick++
4446 }
4547 }
46βˆ’ return relativeTime(epochMillis).also { @Suppress("UNUSED_EXPRESSION") tick }
48+ // Read tick so Compose tracks it as a dependency and recomposes when it changes.
49+ @Suppress("UNUSED_EXPRESSION") tick
50+ return relativeTime(epochMillis)
4751}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+13
@@ -28,10 +28,14 @@ import androidx.compose.material3.IconButton
2828import androidx.compose.material3.MaterialTheme
2929import androidx.compose.material3.OutlinedTextField
3030import androidx.compose.material3.Scaffold
31+import androidx.compose.material3.SnackbarHost
32+import androidx.compose.material3.SnackbarHostState
3133import androidx.compose.material3.Text
3234import androidx.compose.material3.TopAppBar
3335import androidx.compose.runtime.Composable
36+import androidx.compose.runtime.LaunchedEffect
3437import androidx.compose.runtime.getValue
38+import androidx.compose.runtime.remember
3539import androidx.compose.ui.Alignment
3640import androidx.compose.ui.Modifier
3741import androidx.compose.ui.graphics.Color
@@ -60,6 +64,14 @@ fun FileBrowserScreen(
6064) {
6165 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
6266 val state by vm.state.collectAsStateWithLifecycle()
67+ val transientError by vm.transientError.collectAsStateWithLifecycle()
68+ val snackbar = remember { SnackbarHostState() }
69+
70+ LaunchedEffect(transientError) {
71+ val msg = transientError ?: return@LaunchedEffect
72+ snackbar.showSnackbar(msg)
73+ vm.clearTransientError()
74+ }
6375
6476 Scaffold(
6577 topBar = {
@@ -72,6 +84,7 @@ fun FileBrowserScreen(
7284 },
7385 )
7486 },
87+ snackbarHost = { SnackbarHost(snackbar) },
7588 ) { padding ->
7689 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
7790 // Tappable breadcrumb trail so deep paths are navigable.
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+10
@@ -34,6 +34,11 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
3434 private val _state = MutableStateFlow(FileBrowserState())
3535 val state: StateFlow<FileBrowserState> = _state.asStateFlow()
3636
37+ /** Transient errors (failed VCS status fetch, etc.) surfaced without hiding the file list. */
38+ private val _transientError = MutableStateFlow<String?>(null)
39+ val transientError: StateFlow<String?> = _transientError.asStateFlow()
40+ fun clearTransientError() { _transientError.value = null }
41+
3742 private var searchJob: Job? = null
3843
3944 init { open(""); loadStatus() }
@@ -46,6 +51,11 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
4651 .onSuccess { entries ->
4752 _state.value = _state.value.copy(statusMap = entries.associateBy { it.path })
4853 }
54+ .onFailure {
55+ // VCS status is best-effort: don't block the file browser, but surface
56+ // a transient error so the user knows why badges are missing.
57+ _transientError.value = container.friendlyError(it)
58+ }
4959 }
5060 }
5161
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+20βˆ’17
@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
1717import kotlinx.coroutines.flow.SharingStarted
1818import kotlinx.coroutines.flow.StateFlow
1919import kotlinx.coroutines.flow.asStateFlow
20+import kotlinx.coroutines.flow.collectLatest
2021import kotlinx.coroutines.flow.flatMapLatest
2122import kotlinx.coroutines.flow.stateIn
2223import kotlinx.coroutines.flow.update
@@ -90,8 +91,9 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
9091 private var previewJob: Job? = null
9192
9293 /** Per-session in-flight preview loads, so rapid SSE events for the same session
93βˆ’ * coalesce into a single request instead of firing one per event. */
94βˆ’ private val livePreviewJobs = mutableMapOf<String, Job>()
94+ * coalesce into a single request instead of firing one per event. Accessed from
95+ * multiple coroutines (the SSE event handler and [loadPreview]), so kept concurrent. */
96+ private val livePreviewJobs = java.util.concurrent.ConcurrentHashMap<String, Job>()
9597
9698 private val activeProfileId: String?
9799 get() = container.activeConnection.value?.profile?.id
@@ -105,16 +107,17 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
105107 */
106108 private fun observeSessionEvents() {
107109 viewModelScope.launch {
108βˆ’ container.activeConnection.collect { conn ->
109βˆ’ if (conn == null) return@collect
110+ container.activeConnection.collectLatest { conn ->
111+ if (conn == null) return@collectLatest
110112 conn.events.events.collect { event ->
111113 when (event) {
112114 is SessionUpdated -> {
113115 val session = event.properties.info
114βˆ’ val current = _state.value.sessions
115βˆ’ val updated = (current.filterNot { it.id == session.id } + session)
116βˆ’ .sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
117βˆ’ _state.value = _state.value.copy(sessions = updated)
116+ _state.update { s ->
117+ val updated = (s.sessions.filterNot { it.id == session.id } + session)
118+ .sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
119+ s.copy(sessions = updated)
120+ }
118121 loadPreview(session.id)
119122 }
120123 is SessionDeleted -> {
@@ -138,7 +141,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
138141 }
139142
140143 fun setQuery(query: String) {
141βˆ’ _state.value = _state.value.copy(query = query)
144+ _state.update { it.copy(query = query) }
142145 }
143146
144147 fun refresh() {
@@ -147,24 +150,24 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
147150 if (_state.value.sessions.isEmpty()) {
148151 _state.value = SessionListState(loading = false, error = container.string(R.string.not_connected))
149152 } else {
150βˆ’ _state.value = _state.value.copy(loading = false)
153+ _state.update { it.copy(loading = false) }
151154 _transientError.value = container.string(R.string.not_connected)
152155 }
153156 return
154157 }
155158 _serverLabel.value = conn.profile.displayLabel
156βˆ’ _state.value = _state.value.copy(loading = true, error = null)
159+ _state.update { it.copy(loading = true, error = null) }
157160 _refreshing.value = true
158161 viewModelScope.launch {
159162 runCatching { conn.repository.listSessions() }
160163 .onSuccess { list ->
161164 val sorted = list.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
162βˆ’ _state.value = _state.value.copy(sessions = sorted, loading = false, error = null)
165+ _state.update { it.copy(sessions = sorted, loading = false, error = null) }
163166 loadPreviews(sorted)
164167 }
165168 .onFailure {
166169 if (_state.value.sessions.isNotEmpty()) {
167βˆ’ _state.value = _state.value.copy(loading = false)
170+ _state.update { it.copy(loading = false) }
168171 _transientError.value = container.friendlyError(it)
169172 } else {
170173 _state.value = SessionListState(loading = false, error = container.friendlyError(it))
@@ -226,7 +229,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
226229 runCatching { conn.repository.createSession() }
227230 .onSuccess { onCreated(it.id); refresh() }
228231 .onFailure {
229βˆ’ _state.value = _state.value.copy(error = null)
232+ _state.update { it.copy(error = null) }
230233 _transientError.value = container.friendlyError(it)
231234 }
232235 }
@@ -241,7 +244,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
241244 refresh()
242245 }
243246 .onFailure {
244βˆ’ _state.value = _state.value.copy(error = null)
247+ _state.update { it.copy(error = null) }
245248 _transientError.value = container.friendlyError(it)
246249 }
247250 }
@@ -255,7 +258,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
255258 runCatching { conn.api.updateSession(session.id, title) }
256259 .onSuccess { refresh() }
257260 .onFailure {
258βˆ’ _state.value = _state.value.copy(error = null)
261+ _state.update { it.copy(error = null) }
259262 _transientError.value = container.friendlyError(it)
260263 }
261264 }
@@ -266,7 +269,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
266269 if (profile.id == activeProfileId) return
267270 if (_switchingId.value != null) return
268271 _switchingId.value = profile.id
269βˆ’ _state.value = _state.value.copy(loading = true, error = null)
272+ _state.update { it.copy(loading = true, error = null) }
270273 viewModelScope.launch {
271274 val result = runCatching {
272275 val conn = container.connect(profile)
app/src/main/res/values/strings.xml+4
@@ -130,6 +130,10 @@
130130 <string name="file_label">File: %1$s</string>
131131 <string name="file">File</string>
132132
133+ <!-- Message metadata -->
134+ <string name="image">image</string>
135+ <string name="tokens_in_out">%1$s in Β· %2$s out</string>
136+
133137 <!-- Settings -->
134138 <string name="appearance">Appearance</string>
135139 <string name="dynamic_color">Dynamic color</string>