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
5832dcciko
app/src/main/java/soy/iko/opencode/MainActivity.kt+7−1
@@ -67,12 +67,18 @@ class MainActivity : ComponentActivity() {
67 ?.takeIf { it.host == "session" }
67 ?.takeIf { it.host == "session" }
68 ?.lastPathSegment
68 ?.lastPathSegment
69 val id = deepLinkId ?: intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
69 val id = deepLinkId ?: intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
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.
70− id?.let { container.requestOpenSession(it) }
73+ id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let { container.requestOpenSession(it) }
71 }
74 }
72
75
73 companion object {
76 companion object {
74 /** Intent extra carrying a session id to open (notifications / deep links). */
77 /** Intent extra carrying a session id to open (notifications / deep links). */
75 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
78 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_-]+")
76 }
82 }
77
83
78 /** Ask for POST_NOTIFICATIONS once on Android 13+ so run/completion notifications show. */
84 /** 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
3import soy.iko.opencode.data.model.BusEvent
3import soy.iko.opencode.data.model.BusEvent
4import android.util.Log
4import android.util.Log
5import io.ktor.client.HttpClient
5import io.ktor.client.HttpClient
6+import io.ktor.client.plugins.timeout
6import io.ktor.client.plugins.sse.sse
7import io.ktor.client.plugins.sse.sse
7import io.ktor.sse.ServerSentEvent
8import io.ktor.sse.ServerSentEvent
8import kotlinx.coroutines.CancellationException
9import kotlinx.coroutines.CancellationException
@@ -66,7 +67,19 @@ class EventStreamClient(
66 while (isActive) {
67 while (isActive) {
67 _state.value = ConnectionState.Connecting
68 _state.value = ConnectionState.Connecting
68 try {
69 try {
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.
69− client.sse("event") {
74+ client.sse(
75+ "event",
76+ request = {
77+ timeout {
78+ requestTimeoutMillis = Long.MAX_VALUE
79+ socketTimeoutMillis = Long.MAX_VALUE
80+ }
81+ },
82+ ) {
70 backoffMs = NetworkConfig.sseInitialBackoffMs
83 backoffMs = NetworkConfig.sseInitialBackoffMs
71 // `incoming` is a cold Flow; bridge it to a ReceiveChannel so each
84 // `incoming` is a cold Flow; bridge it to a ReceiveChannel so each
72 // element can be raced against an idle timeout via select.
85 // 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 {
27
27
28 engine {
28 engine {
29 config {
29 config {
30− // The SSE stream is long-lived; a socket read timeout would kill it
30+ // A normal read timeout for REST calls. The SSE stream overrides this
31− // mid-stream. REST calls rely on the connect timeout + withRetry for
31+ // to infinite inside the sse {} request block (see EventStreamClient),
32− // resilience instead. The SSE idle-timeout watchdog in EventStreamClient
32+ // so the long-lived event connection is never killed mid-stream.
33− // handles half-open connections.
34− readTimeout(0, TimeUnit.MILLISECONDS)
33+ readTimeout(NetworkConfig.readTimeoutSeconds, TimeUnit.SECONDS)
35 connectTimeout(NetworkConfig.connectTimeoutSeconds, TimeUnit.SECONDS)
34 connectTimeout(NetworkConfig.connectTimeoutSeconds, TimeUnit.SECONDS)
36 retryOnConnectionFailure(true)
35 retryOnConnectionFailure(true)
37 pingInterval(NetworkConfig.pingIntervalSeconds, TimeUnit.SECONDS)
36 pingInterval(NetworkConfig.pingIntervalSeconds, TimeUnit.SECONDS)
@@ -44,12 +43,12 @@ object HttpClientFactory {
44
43
45 install(SSE)
44 install(SSE)
46
45
47− // Disable the request-level timeout at the client level — it covers the whole
46+ // Request-level timeout for REST calls. The SSE stream overrides this to
48− // call including the body stream, so it would kill the long-lived SSE `/event`
47+ // infinite inside the sse {} request block (see EventStreamClient) so the
49− // connection after 60s (the previous default). The engine's connect timeout
48+ // long-lived /event connection isn't killed after 60s. The engine's connect
50− // and the SSE idle-timeout watchdog handle stuck connections instead.
49+ // timeout and the SSE idle-timeout watchdog handle stuck SSE connections.
51 install(HttpTimeout) {
50 install(HttpTimeout) {
52− requestTimeoutMillis = Long.MAX_VALUE
51+ requestTimeoutMillis = NetworkConfig.restRequestTimeoutMs
53 }
52 }
54
53
55 if (profile.hasAuth) {
54 if (profile.hasAuth) {
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+2
@@ -17,6 +17,8 @@ object NetworkConfig {
17 const val readTimeoutSeconds = 60L
17 const val readTimeoutSeconds = 60L
18 /** OkHttp WebSocket/interval ping, keeping the SSE socket alive through proxies. */
18 /** OkHttp WebSocket/interval ping, keeping the SSE socket alive through proxies. */
19 const val pingIntervalSeconds = 20L
19 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
20
22
21 // --- REST retry (OpencodeApiClient.withRetry) ---
23 // --- REST retry (OpencodeApiClient.withRetry) ---
22
24
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+4−1
@@ -2,6 +2,8 @@ package soy.iko.opencode.data.repo
2
2
3import android.content.Context
3import android.content.Context
4import android.os.Build
4import android.os.Build
5+import android.util.Log
6+import androidx.core.content.pm.PackageInfoCompat
5import kotlinx.coroutines.flow.MutableStateFlow
7import kotlinx.coroutines.flow.MutableStateFlow
6import kotlinx.coroutines.flow.StateFlow
8import kotlinx.coroutines.flow.StateFlow
7import kotlinx.coroutines.flow.asStateFlow
9import kotlinx.coroutines.flow.asStateFlow
@@ -39,6 +41,7 @@ class CrashLogger private constructor(private val appContext: Context) {
39 val previous = Thread.getDefaultUncaughtExceptionHandler()
41 val previous = Thread.getDefaultUncaughtExceptionHandler()
40 Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
42 Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
41 runCatching { writeReport(thread, throwable) }
43 runCatching { writeReport(thread, throwable) }
44+ .onFailure { Log.e("CrashLogger", "Failed to write crash report", it) }
42 previous?.uncaughtException(thread, throwable)
45 previous?.uncaughtException(thread, throwable)
43 }
46 }
44 refresh()
47 refresh()
@@ -97,7 +100,7 @@ class CrashLogger private constructor(private val appContext: Context) {
97
100
98 private fun appVersion(): String = runCatching {
101 private fun appVersion(): String = runCatching {
99 val pi = appContext.packageManager.getPackageInfo(appContext.packageName, 0)
102 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)})"
101 }.getOrDefault("unknown")
104 }.getOrDefault("unknown")
102
105
103 companion object {
106 companion object {
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+17−2
@@ -1,16 +1,31 @@
1package soy.iko.opencode.data.repo
1package soy.iko.opencode.data.repo
2
2
3import android.content.Context
3import android.content.Context
4+import kotlinx.coroutines.CoroutineScope
5+import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.SupervisorJob
7+import kotlinx.coroutines.launch
4
8
5/**
9/**
6 * Persists the in-progress message draft for each session so it survives
10 * Persists the in-progress message draft for each session so it survives
7 * back-navigation and process death. Backed by SharedPreferences because the
11 * back-navigation and process death. Backed by SharedPreferences because the
8 * UI needs the initial value synchronously and drafts are low-stakes data.
12 * 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.
9 */
16 */
10class DraftStore(context: Context) {
17class DraftStore(context: Context) {
11
18
12− private val prefs = context.applicationContext
19+ private val appContext = context.applicationContext
20+ private val prefs by lazy {
13− .getSharedPreferences("drafts", Context.MODE_PRIVATE)
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+ }
14
29
15 fun get(sessionId: String): String = prefs.getString(sessionId, "").orEmpty()
30 fun get(sessionId: String): String = prefs.getString(sessionId, "").orEmpty()
16
31
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+3−1
@@ -2,6 +2,7 @@ package soy.iko.opencode.data.repo
2
2
3import android.content.Context
3import android.content.Context
4import android.content.SharedPreferences
4import android.content.SharedPreferences
5+import android.util.Log
5import androidx.datastore.preferences.core.edit
6import androidx.datastore.preferences.core.edit
6import androidx.datastore.preferences.core.stringPreferencesKey
7import androidx.datastore.preferences.core.stringPreferencesKey
7import androidx.datastore.preferences.preferencesDataStore
8import androidx.datastore.preferences.preferencesDataStore
@@ -53,7 +54,8 @@ class ProfileStore(context: Context) {
53 val json = prefs[profilesKey] ?: return@map emptyList()
54 val json = prefs[profilesKey] ?: return@map emptyList()
54 runCatching {
55 runCatching {
55 OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), json)
56 OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), json)
57+ }.onFailure { Log.w("ProfileStore", "Failed to decode stored profiles, ignoring", it) }
56− }.getOrDefault(emptyList())
58+ .getOrDefault(emptyList())
57 .sortedByDescending { it.lastUsed }
59 .sortedByDescending { it.lastUsed }
58 .map { it.toProfile() }
60 .map { it.toProfile() }
59 }
61 }
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+4−1
@@ -13,6 +13,7 @@ import soy.iko.opencode.data.model.SessionIdle
13import soy.iko.opencode.data.model.UnknownMessage
13import soy.iko.opencode.data.model.UnknownMessage
14import soy.iko.opencode.data.network.EventStreamClient
14import soy.iko.opencode.data.network.EventStreamClient
15import soy.iko.opencode.data.network.OpencodeApiClient
15import soy.iko.opencode.data.network.OpencodeApiClient
16+import android.util.Log
16import kotlinx.coroutines.flow.Flow
17import kotlinx.coroutines.flow.Flow
17import kotlinx.coroutines.flow.channelFlow
18import kotlinx.coroutines.flow.channelFlow
18import kotlinx.coroutines.flow.conflate
19import kotlinx.coroutines.flow.conflate
@@ -70,7 +71,9 @@ class SessionRepository(
70 }
71 }
71 }
72 }
72
73
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())
74 lock.withLock { store.seed(initial) }
77 lock.withLock { store.seed(initial) }
75 publish()
78 publish()
76
79
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+50−18
@@ -4,6 +4,7 @@ import android.content.Context
4import android.net.ConnectivityManager
4import android.net.ConnectivityManager
5import android.net.Network
5import android.net.Network
6import android.net.NetworkRequest
6import android.net.NetworkRequest
7+import android.util.Log
7import soy.iko.opencode.data.model.BusEvent
8import soy.iko.opencode.data.model.BusEvent
8import soy.iko.opencode.data.model.MessagePartUpdated
9import soy.iko.opencode.data.model.MessagePartUpdated
9import soy.iko.opencode.data.model.MessageUpdated
10import soy.iko.opencode.data.model.MessageUpdated
@@ -22,6 +23,7 @@ import kotlinx.coroutines.CoroutineScope
22import kotlinx.coroutines.Dispatchers
23import kotlinx.coroutines.Dispatchers
23import kotlinx.coroutines.ExperimentalCoroutinesApi
24import kotlinx.coroutines.ExperimentalCoroutinesApi
24import kotlinx.coroutines.SupervisorJob
25import kotlinx.coroutines.SupervisorJob
26+import kotlinx.coroutines.cancel
25import kotlinx.coroutines.flow.MutableStateFlow
27import kotlinx.coroutines.flow.MutableStateFlow
26import kotlinx.coroutines.flow.StateFlow
28import kotlinx.coroutines.flow.StateFlow
27import kotlinx.coroutines.flow.asStateFlow
29import kotlinx.coroutines.flow.asStateFlow
@@ -30,6 +32,8 @@ import kotlinx.coroutines.flow.first
30import kotlinx.coroutines.flow.flatMapLatest
32import kotlinx.coroutines.flow.flatMapLatest
31import kotlinx.coroutines.flow.update
33import kotlinx.coroutines.flow.update
32import kotlinx.coroutines.launch
34import kotlinx.coroutines.launch
35+import kotlinx.coroutines.sync.Mutex
36+import kotlinx.coroutines.sync.withLock
33import java.util.concurrent.atomic.AtomicBoolean
37import java.util.concurrent.atomic.AtomicBoolean
34
38
35/**
39/**
@@ -38,7 +42,7 @@ import java.util.concurrent.atomic.AtomicBoolean
38 */
42 */
39class AppContainer(context: Context) {
43class AppContainer(context: Context) {
40
44
41− val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
45+ val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
42 val profileStore = ProfileStore(context)
46 val profileStore = ProfileStore(context)
43 val settingsStore = SettingsStore(context)
47 val settingsStore = SettingsStore(context)
44 val draftStore = DraftStore(context)
48 val draftStore = DraftStore(context)
@@ -124,11 +128,17 @@ class AppContainer(context: Context) {
124
128
125 init {
129 init {
126 NotificationChannels.create(appContext)
130 NotificationChannels.create(appContext)
127− registerNetworkMonitor()
128 observeMessageActivity()
131 observeMessageActivity()
129 appScope.launch { autoConnect() }
132 appScope.launch { autoConnect() }
130 }
133 }
131
134
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+
132 /**
142 /**
133 * Watch the SSE bus for new message activity and badge any session that isn't
143 * Watch the SSE bus for new message activity and badge any session that isn't
134 * currently open. This powers the unread dot on the session list so the user can
144 * currently open. This powers the unread dot on the session list so the user can
@@ -162,6 +172,15 @@ class AppContainer(context: Context) {
162 /** Session ids currently streaming an assistant run (best-effort, in-process). */
172 /** Session ids currently streaming an assistant run (best-effort, in-process). */
163 private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
173 private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
164
174
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+
165 /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
184 /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
166 private suspend fun notifySessionCompleted(sessionId: String) {
185 private suspend fun notifySessionCompleted(sessionId: String) {
167 val conn = activeConnection.value ?: return
186 val conn = activeConnection.value ?: return
@@ -195,29 +214,42 @@ class AppContainer(context: Context) {
195 * When the device regains connectivity, nudge the active SSE stream to reconnect
214 * When the device regains connectivity, nudge the active SSE stream to reconnect
196 * immediately instead of waiting out the exponential backoff.
215 * immediately instead of waiting out the exponential backoff.
197 */
216 */
217+ private val networkCallback: ConnectivityManager.NetworkCallback? = registerNetworkMonitor()
218+
198− private fun registerNetworkMonitor() {
219+ private fun registerNetworkMonitor(): ConnectivityManager.NetworkCallback? {
199 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
220 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
200− ?: return
221+ ?: return null
201− runCatching {
202− cm.registerNetworkCallback(NetworkRequest.Builder().build(), object : ConnectivityManager.NetworkCallback() {
222+ val callback = object : ConnectivityManager.NetworkCallback() {
203− override fun onAvailable(network: Network) {
223+ override fun onAvailable(network: Network) {
204− activeConnection.value?.events?.triggerReconnect()
224+ activeConnection.value?.events?.triggerReconnect()
205− }
206− })
225+ }
207 }
226 }
227+ runCatching { cm.registerNetworkCallback(NetworkRequest.Builder().build(), callback) }
228+ .onFailure { Log.w("AppContainer", "Failed to register network callback", it) }
229+ return callback
208 }
230 }
209
231
210− /** Open (or re-open) a connection to [profile], replacing any current one. */
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. */
211− suspend fun connect(profile: ServerProfile): OpencodeConnection {
235+ suspend fun connect(profile: ServerProfile): OpencodeConnection =
212− disconnect()
236+ connectionMutex.withLock {
237+ _activeConnection.value?.close()
238+ _activeConnection.value = null
213− val resolved = profileStore.resolve(profile).copy(lastUsed = System.currentTimeMillis())
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
214− profileStore.save(resolved)
243+ if (needsSave) profileStore.save(finalProfile)
215− return OpencodeConnection(resolved).also { _activeConnection.value = it }
244+ OpencodeConnection(finalProfile).also { _activeConnection.value = it }
216− }
245+ }
217
246
218 fun disconnect() {
247 fun disconnect() {
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.
219− _activeConnection.value?.close()
250+ val conn = _activeConnection.value ?: return
220 _activeConnection.value = null
251 _activeConnection.value = null
252+ conn.close()
221 }
253 }
222}
254}
223
255
app/src/main/java/soy/iko/opencode/di/OpencodeConnection.kt+1−1
@@ -17,7 +17,7 @@ import kotlinx.coroutines.cancel
17 */
17 */
18class OpencodeConnection(val profile: ServerProfile) {
18class OpencodeConnection(val profile: ServerProfile) {
19
19
20− private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
20+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
21
21
22 private val client = HttpClientFactory.create(profile)
22 private val client = HttpClientFactory.create(profile)
23 val api = OpencodeApiClient(client)
23 val api = OpencodeApiClient(client)
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+27−5
@@ -7,7 +7,9 @@ import android.content.Intent
7import android.content.pm.ServiceInfo
7import android.content.pm.ServiceInfo
8import android.os.Build
8import android.os.Build
9import android.os.IBinder
9import android.os.IBinder
10+import android.util.Log
10import androidx.core.app.NotificationCompat
11import androidx.core.app.NotificationCompat
12+import androidx.core.app.ServiceCompat
11import soy.iko.opencode.R
13import soy.iko.opencode.R
12
14
13/**
15/**
@@ -23,11 +25,23 @@ class RunForegroundService : Service() {
23
25
24 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
26 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
25 val notification = buildNotification()
27 val notification = buildNotification()
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,
26− if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
38+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
27− startForeground(NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
39+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
28− } else {
40+ } else {
29− startForeground(NOTIF_ID, notification)
41+ 0
30− }
42+ },
43+ )
44+ }.onFailure { Log.w(TAG, "startForeground failed; running without foreground priority", it) }
31 return START_NOT_STICKY
45 return START_NOT_STICKY
32 }
46 }
33
47
@@ -41,9 +55,17 @@ class RunForegroundService : Service() {
41 .setCategory(NotificationCompat.CATEGORY_PROGRESS)
55 .setCategory(NotificationCompat.CATEGORY_PROGRESS)
42 .build()
56 .build()
43
57
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+
44 override fun onBind(intent: Intent?): IBinder? = null
65 override fun onBind(intent: Intent?): IBinder? = null
45
66
46 companion object {
67 companion object {
68+ private const val TAG = "RunForegroundService"
47 private const val NOTIF_ID = 1
69 private const val NOTIF_ID = 1
48
70
49 fun start(context: Context) {
71 fun start(context: Context) {
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+19−3
@@ -4,10 +4,12 @@ import android.app.PendingIntent
4import android.content.Context
4import android.content.Context
5import android.content.Intent
5import android.content.Intent
6import android.os.Build
6import android.os.Build
7+import android.util.Log
7import androidx.core.app.NotificationCompat
8import androidx.core.app.NotificationCompat
8import androidx.core.app.NotificationManagerCompat
9import androidx.core.app.NotificationManagerCompat
9import soy.iko.opencode.MainActivity
10import soy.iko.opencode.MainActivity
10import soy.iko.opencode.R
11import soy.iko.opencode.R
12+import java.security.MessageDigest
11
13
12/**
14/**
13 * Builds and posts the "session completed" notification when a background session
15 * Builds and posts the "session completed" notification when a background session
@@ -20,6 +22,19 @@ import soy.iko.opencode.R
20object SessionNotifications {
22object SessionNotifications {
21
23
22 private const val NOTIF_ID_PREFIX = 4000
24 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+ }
23
38
24 fun postCompleted(context: Context, sessionId: String, title: String) {
39 fun postCompleted(context: Context, sessionId: String, title: String) {
25 // Respect the POST_NOTIFICATIONS runtime permission (Android 13+): if the user
40 // Respect the POST_NOTIFICATIONS runtime permission (Android 13+): if the user
@@ -32,14 +47,14 @@ object SessionNotifications {
32 ) {
47 ) {
33 return
48 return
34 }
49 }
35− val notifId = (NOTIF_ID_PREFIX + sessionId.hashCode()).and(0x7FFFFFFF)
50+ val notifId = notifIdFor(sessionId)
36 val openIntent = Intent(context, MainActivity::class.java).apply {
51 val openIntent = Intent(context, MainActivity::class.java).apply {
37 action = Intent.ACTION_VIEW
52 action = Intent.ACTION_VIEW
38 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
53 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
39 putExtra(MainActivity.EXTRA_SESSION_ID, sessionId)
54 putExtra(MainActivity.EXTRA_SESSION_ID, sessionId)
40 }
55 }
41 val pendingIntent = PendingIntent.getActivity(
56 val pendingIntent = PendingIntent.getActivity(
42− context, sessionId.hashCode(), openIntent,
57+ context, notifId, openIntent,
43 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
58 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
44 )
59 )
45 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
60 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
@@ -53,11 +68,12 @@ object SessionNotifications {
53 .build()
68 .build()
54
69
55 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
70 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
71+ .onFailure { Log.w(TAG, "Failed to post completion notification", it) }
56 }
72 }
57
73
58 /** Cancel a session's completion notification (e.g. when the user opens it). */
74 /** Cancel a session's completion notification (e.g. when the user opens it). */
59 fun cancel(context: Context, sessionId: String) {
75 fun cancel(context: Context, sessionId: String) {
60− val notifId = (NOTIF_ID_PREFIX + sessionId.hashCode()).and(0x7FFFFFFF)
76+ val notifId = notifIdFor(sessionId)
61 NotificationManagerCompat.from(context).cancel(notifId)
77 NotificationManagerCompat.from(context).cancel(notifId)
62 }
78 }
63}
79}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+11−3
@@ -19,6 +19,7 @@ import soy.iko.opencode.data.network.EventStreamClient
19import soy.iko.opencode.data.repo.SessionRepository
19import soy.iko.opencode.data.repo.SessionRepository
20import soy.iko.opencode.di.AppContainer
20import soy.iko.opencode.di.AppContainer
21import soy.iko.opencode.R
21import soy.iko.opencode.R
22+import android.util.Log
22import kotlinx.coroutines.ExperimentalCoroutinesApi
23import kotlinx.coroutines.ExperimentalCoroutinesApi
23import kotlinx.coroutines.flow.MutableStateFlow
24import kotlinx.coroutines.flow.MutableStateFlow
24import kotlinx.coroutines.flow.SharingStarted
25import kotlinx.coroutines.flow.SharingStarted
@@ -144,7 +145,9 @@ class ChatViewModel(
144 viewModelScope.launch {
145 viewModelScope.launch {
145 container.activeConnection.collectLatest { conn ->
146 container.activeConnection.collectLatest { conn ->
146 if (conn == null) return@collectLatest
147 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 ->
148 val options = resp.toOptions()
151 val options = resp.toOptions()
149 _models.value = options
152 _models.value = options
150 _selectedModel.value = resp.defaultOption(options)
153 _selectedModel.value = resp.defaultOption(options)
@@ -155,14 +158,18 @@ class ChatViewModel(
155 viewModelScope.launch {
158 viewModelScope.launch {
156 container.activeConnection.collectLatest { conn ->
159 container.activeConnection.collectLatest { conn ->
157 if (conn == null) return@collectLatest
160 if (conn == null) return@collectLatest
161+ runCatching { conn.api.agents() }
162+ .onFailure { Log.w("ChatViewModel", "Failed to load agent catalog", it) }
158− runCatching { conn.api.agents() }.getOrNull()?.let { _agents.value = it }
163+ .getOrNull()?.let { _agents.value = it }
159 }
164 }
160 }
165 }
161 // Load the command catalog (non-fatal).
166 // Load the command catalog (non-fatal).
162 viewModelScope.launch {
167 viewModelScope.launch {
163 container.activeConnection.collectLatest { conn ->
168 container.activeConnection.collectLatest { conn ->
164 if (conn == null) return@collectLatest
169 if (conn == null) return@collectLatest
170+ runCatching { conn.api.commands() }
171+ .onFailure { Log.w("ChatViewModel", "Failed to load command catalog", it) }
165− runCatching { conn.api.commands() }.getOrNull()?.let { _commands.value = it }
172+ .getOrNull()?.let { _commands.value = it }
166 }
173 }
167 }
174 }
168 // Resolve the human-readable session title for the app bar (non-fatal).
175 // Resolve the human-readable session title for the app bar (non-fatal).
@@ -249,6 +256,7 @@ class ChatViewModel(
249 val conn = connection ?: return
256 val conn = connection ?: return
250 viewModelScope.launch {
257 viewModelScope.launch {
251 runCatching { conn.repository.abort(sessionId) }
258 runCatching { conn.repository.abort(sessionId) }
259+ .onFailure { _error.value = container.friendlyError(it) }
252 _running.value = false
260 _running.value = false
253 }
261 }
254 }
262 }
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+6−3
@@ -13,7 +13,9 @@ import androidx.compose.runtime.Composable
13import androidx.compose.ui.Alignment
13import androidx.compose.ui.Alignment
14import androidx.compose.ui.Modifier
14import androidx.compose.ui.Modifier
15import androidx.compose.ui.draw.clip
15import androidx.compose.ui.draw.clip
16+import androidx.compose.ui.res.stringResource
16import androidx.compose.ui.unit.dp
17import androidx.compose.ui.unit.dp
18+import soy.iko.opencode.R
17import soy.iko.opencode.data.model.AssistantMessage
19import soy.iko.opencode.data.model.AssistantMessage
18import soy.iko.opencode.data.model.MessageWithParts
20import soy.iko.opencode.data.model.MessageWithParts
19import soy.iko.opencode.data.model.Tokens
21import soy.iko.opencode.data.model.Tokens
@@ -73,8 +75,9 @@ private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, imageC
73 val cost = info.cost
75 val cost = info.cost
74 val tokens = info.tokens
76 val tokens = info.tokens
75 if (info.isComplete && (cost != null || tokens != null)) {
77 if (info.isComplete && (cost != null || tokens != null)) {
78+ val tokenFormat = stringResource(R.string.tokens_in_out)
76 val parts = buildList {
79 val parts = buildList {
77− tokens?.let { add(formatTokens(it)) }
80+ tokens?.let { add(formatTokens(it, tokenFormat)) }
78 cost?.takeIf { it > 0 }?.let { add(formatCost(it)) }
81 cost?.takeIf { it > 0 }?.let { add(formatCost(it)) }
79 }
82 }
80 if (parts.isNotEmpty()) {
83 if (parts.isNotEmpty()) {
@@ -102,9 +105,9 @@ private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
102 }
105 }
103}
106}
104
107
105−private fun formatTokens(tokens: Tokens): String {
108+private fun formatTokens(tokens: Tokens, format: String): String {
106 val dec = java.text.DecimalFormat.getNumberInstance(java.util.Locale.US)
109 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))
108}
111}
109
112
110private fun formatCost(cost: Double): String =
113private 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
9import androidx.compose.ui.draw.clip
9import androidx.compose.ui.draw.clip
10import androidx.compose.ui.layout.ContentScale
10import androidx.compose.ui.layout.ContentScale
11import androidx.compose.ui.platform.LocalContext
11import androidx.compose.ui.platform.LocalContext
12+import androidx.compose.ui.res.stringResource
12import androidx.compose.ui.unit.dp
13import androidx.compose.ui.unit.dp
13import coil.compose.AsyncImage
14import coil.compose.AsyncImage
14import coil.request.ImageRequest
15import coil.request.ImageRequest
16+import soy.iko.opencode.R
15import soy.iko.opencode.data.model.FilePart
17import soy.iko.opencode.data.model.FilePart
16import soy.iko.opencode.data.model.ServerProfile
18import soy.iko.opencode.data.model.ServerProfile
17import soy.iko.opencode.data.network.HttpClientFactory
19import soy.iko.opencode.data.network.HttpClientFactory
@@ -76,7 +78,7 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
76 .build()
78 .build()
77 AsyncImage(
79 AsyncImage(
78 model = request,
80 model = request,
79− contentDescription = part.filename ?: "image",
81+ contentDescription = part.filename ?: stringResource(R.string.image),
80 contentScale = ContentScale.FillWidth,
82 contentScale = ContentScale.FillWidth,
81 modifier = modifier
83 modifier = modifier
82 .heightIn(max = 320.dp)
84 .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 {
36 */
36 */
37@Composable
37@Composable
38fun rememberRelativeTime(epochMillis: Long?, intervalMs: Long = 30_000L): String {
38fun 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.
39 var tick by remember { mutableLongStateOf(0L) }
41 var tick by remember { mutableLongStateOf(0L) }
40 LaunchedEffect(epochMillis) {
42 LaunchedEffect(epochMillis) {
41 while (true) {
43 while (true) {
@@ -43,5 +45,7 @@ fun rememberRelativeTime(epochMillis: Long?, intervalMs: Long = 30_000L): String
43 tick++
45 tick++
44 }
46 }
45 }
47 }
48+ // Read tick so Compose tracks it as a dependency and recomposes when it changes.
46− return relativeTime(epochMillis).also { @Suppress("UNUSED_EXPRESSION") tick }
49+ @Suppress("UNUSED_EXPRESSION") tick
50+ return relativeTime(epochMillis)
47}
51}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+13
@@ -28,10 +28,14 @@ import androidx.compose.material3.IconButton
28import androidx.compose.material3.MaterialTheme
28import androidx.compose.material3.MaterialTheme
29import androidx.compose.material3.OutlinedTextField
29import androidx.compose.material3.OutlinedTextField
30import androidx.compose.material3.Scaffold
30import androidx.compose.material3.Scaffold
31+import androidx.compose.material3.SnackbarHost
32+import androidx.compose.material3.SnackbarHostState
31import androidx.compose.material3.Text
33import androidx.compose.material3.Text
32import androidx.compose.material3.TopAppBar
34import androidx.compose.material3.TopAppBar
33import androidx.compose.runtime.Composable
35import androidx.compose.runtime.Composable
36+import androidx.compose.runtime.LaunchedEffect
34import androidx.compose.runtime.getValue
37import androidx.compose.runtime.getValue
38+import androidx.compose.runtime.remember
35import androidx.compose.ui.Alignment
39import androidx.compose.ui.Alignment
36import androidx.compose.ui.Modifier
40import androidx.compose.ui.Modifier
37import androidx.compose.ui.graphics.Color
41import androidx.compose.ui.graphics.Color
@@ -60,6 +64,14 @@ fun FileBrowserScreen(
60) {
64) {
61 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
65 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
62 val state by vm.state.collectAsStateWithLifecycle()
66 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+ }
63
75
64 Scaffold(
76 Scaffold(
65 topBar = {
77 topBar = {
@@ -72,6 +84,7 @@ fun FileBrowserScreen(
72 },
84 },
73 )
85 )
74 },
86 },
87+ snackbarHost = { SnackbarHost(snackbar) },
75 ) { padding ->
88 ) { padding ->
76 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
89 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
77 // Tappable breadcrumb trail so deep paths are navigable.
90 // 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() {
34 private val _state = MutableStateFlow(FileBrowserState())
34 private val _state = MutableStateFlow(FileBrowserState())
35 val state: StateFlow<FileBrowserState> = _state.asStateFlow()
35 val state: StateFlow<FileBrowserState> = _state.asStateFlow()
36
36
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+
37 private var searchJob: Job? = null
42 private var searchJob: Job? = null
38
43
39 init { open(""); loadStatus() }
44 init { open(""); loadStatus() }
@@ -46,6 +51,11 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
46 .onSuccess { entries ->
51 .onSuccess { entries ->
47 _state.value = _state.value.copy(statusMap = entries.associateBy { it.path })
52 _state.value = _state.value.copy(statusMap = entries.associateBy { it.path })
48 }
53 }
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+ }
49 }
59 }
50 }
60 }
51
61
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+20−17
@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
17import kotlinx.coroutines.flow.SharingStarted
17import kotlinx.coroutines.flow.SharingStarted
18import kotlinx.coroutines.flow.StateFlow
18import kotlinx.coroutines.flow.StateFlow
19import kotlinx.coroutines.flow.asStateFlow
19import kotlinx.coroutines.flow.asStateFlow
20+import kotlinx.coroutines.flow.collectLatest
20import kotlinx.coroutines.flow.flatMapLatest
21import kotlinx.coroutines.flow.flatMapLatest
21import kotlinx.coroutines.flow.stateIn
22import kotlinx.coroutines.flow.stateIn
22import kotlinx.coroutines.flow.update
23import kotlinx.coroutines.flow.update
@@ -90,8 +91,9 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
90 private var previewJob: Job? = null
91 private var previewJob: Job? = null
91
92
92 /** Per-session in-flight preview loads, so rapid SSE events for the same session
93 /** 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+ * 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. */
94− private val livePreviewJobs = mutableMapOf<String, Job>()
96+ private val livePreviewJobs = java.util.concurrent.ConcurrentHashMap<String, Job>()
95
97
96 private val activeProfileId: String?
98 private val activeProfileId: String?
97 get() = container.activeConnection.value?.profile?.id
99 get() = container.activeConnection.value?.profile?.id
@@ -105,16 +107,17 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
105 */
107 */
106 private fun observeSessionEvents() {
108 private fun observeSessionEvents() {
107 viewModelScope.launch {
109 viewModelScope.launch {
108− container.activeConnection.collect { conn ->
110+ container.activeConnection.collectLatest { conn ->
109− if (conn == null) return@collect
111+ if (conn == null) return@collectLatest
110 conn.events.events.collect { event ->
112 conn.events.events.collect { event ->
111 when (event) {
113 when (event) {
112 is SessionUpdated -> {
114 is SessionUpdated -> {
113 val session = event.properties.info
115 val session = event.properties.info
114− val current = _state.value.sessions
116+ _state.update { s ->
115− val updated = (current.filterNot { it.id == session.id } + session)
117+ val updated = (s.sessions.filterNot { it.id == session.id } + session)
116− .sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
118+ .sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
117− _state.value = _state.value.copy(sessions = updated)
119+ s.copy(sessions = updated)
120+ }
118 loadPreview(session.id)
121 loadPreview(session.id)
119 }
122 }
120 is SessionDeleted -> {
123 is SessionDeleted -> {
@@ -138,7 +141,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
138 }
141 }
139
142
140 fun setQuery(query: String) {
143 fun setQuery(query: String) {
141− _state.value = _state.value.copy(query = query)
144+ _state.update { it.copy(query = query) }
142 }
145 }
143
146
144 fun refresh() {
147 fun refresh() {
@@ -147,24 +150,24 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
147 if (_state.value.sessions.isEmpty()) {
150 if (_state.value.sessions.isEmpty()) {
148 _state.value = SessionListState(loading = false, error = container.string(R.string.not_connected))
151 _state.value = SessionListState(loading = false, error = container.string(R.string.not_connected))
149 } else {
152 } else {
150− _state.value = _state.value.copy(loading = false)
153+ _state.update { it.copy(loading = false) }
151 _transientError.value = container.string(R.string.not_connected)
154 _transientError.value = container.string(R.string.not_connected)
152 }
155 }
153 return
156 return
154 }
157 }
155 _serverLabel.value = conn.profile.displayLabel
158 _serverLabel.value = conn.profile.displayLabel
156− _state.value = _state.value.copy(loading = true, error = null)
159+ _state.update { it.copy(loading = true, error = null) }
157 _refreshing.value = true
160 _refreshing.value = true
158 viewModelScope.launch {
161 viewModelScope.launch {
159 runCatching { conn.repository.listSessions() }
162 runCatching { conn.repository.listSessions() }
160 .onSuccess { list ->
163 .onSuccess { list ->
161 val sorted = list.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
164 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) }
163 loadPreviews(sorted)
166 loadPreviews(sorted)
164 }
167 }
165 .onFailure {
168 .onFailure {
166 if (_state.value.sessions.isNotEmpty()) {
169 if (_state.value.sessions.isNotEmpty()) {
167− _state.value = _state.value.copy(loading = false)
170+ _state.update { it.copy(loading = false) }
168 _transientError.value = container.friendlyError(it)
171 _transientError.value = container.friendlyError(it)
169 } else {
172 } else {
170 _state.value = SessionListState(loading = false, error = container.friendlyError(it))
173 _state.value = SessionListState(loading = false, error = container.friendlyError(it))
@@ -226,7 +229,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
226 runCatching { conn.repository.createSession() }
229 runCatching { conn.repository.createSession() }
227 .onSuccess { onCreated(it.id); refresh() }
230 .onSuccess { onCreated(it.id); refresh() }
228 .onFailure {
231 .onFailure {
229− _state.value = _state.value.copy(error = null)
232+ _state.update { it.copy(error = null) }
230 _transientError.value = container.friendlyError(it)
233 _transientError.value = container.friendlyError(it)
231 }
234 }
232 }
235 }
@@ -241,7 +244,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
241 refresh()
244 refresh()
242 }
245 }
243 .onFailure {
246 .onFailure {
244− _state.value = _state.value.copy(error = null)
247+ _state.update { it.copy(error = null) }
245 _transientError.value = container.friendlyError(it)
248 _transientError.value = container.friendlyError(it)
246 }
249 }
247 }
250 }
@@ -255,7 +258,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
255 runCatching { conn.api.updateSession(session.id, title) }
258 runCatching { conn.api.updateSession(session.id, title) }
256 .onSuccess { refresh() }
259 .onSuccess { refresh() }
257 .onFailure {
260 .onFailure {
258− _state.value = _state.value.copy(error = null)
261+ _state.update { it.copy(error = null) }
259 _transientError.value = container.friendlyError(it)
262 _transientError.value = container.friendlyError(it)
260 }
263 }
261 }
264 }
@@ -266,7 +269,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
266 if (profile.id == activeProfileId) return
269 if (profile.id == activeProfileId) return
267 if (_switchingId.value != null) return
270 if (_switchingId.value != null) return
268 _switchingId.value = profile.id
271 _switchingId.value = profile.id
269− _state.value = _state.value.copy(loading = true, error = null)
272+ _state.update { it.copy(loading = true, error = null) }
270 viewModelScope.launch {
273 viewModelScope.launch {
271 val result = runCatching {
274 val result = runCatching {
272 val conn = container.connect(profile)
275 val conn = container.connect(profile)
app/src/main/res/values/strings.xml+4
@@ -130,6 +130,10 @@
130 <string name="file_label">File: %1$s</string>
130 <string name="file_label">File: %1$s</string>
131 <string name="file">File</string>
131 <string name="file">File</string>
132
132
133+ <!-- Message metadata -->
134+ <string name="image">image</string>
135+ <string name="tokens_in_out">%1$s in · %2$s out</string>
136+
133 <!-- Settings -->
137 <!-- Settings -->
134 <string name="appearance">Appearance</string>
138 <string name="appearance">Appearance</string>
135 <string name="dynamic_color">Dynamic color</string>
139 <string name="dynamic_color">Dynamic color</string>