git.iko.soy/opencode-android
on master help sign in
Fix two-pane share loss, ServerEditViewModel hang, foreground service leak, disconnect race, SSE auth retry and handshake hang, URL path encoding, notification ID prefix, and several error-handling and lifecycle gaps
3e723a1iko
app/src/main/java/soy/iko/opencode/MainActivity.kt+21−5
@@ -24,13 +24,16 @@ class MainActivity : ComponentActivity() {
24 ActivityResultContracts.RequestPermission(),
24 ActivityResultContracts.RequestPermission(),
25 ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
25 ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
26
26
27+ private var shareIntentHandled = false
28+
27 override fun onCreate(savedInstanceState: Bundle?) {
29 override fun onCreate(savedInstanceState: Bundle?) {
28 installSplashScreen()
30 installSplashScreen()
29 super.onCreate(savedInstanceState)
31 super.onCreate(savedInstanceState)
30 enableEdgeToEdge()
32 enableEdgeToEdge()
33+ savedInstanceState?.let { shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false) }
31 val container = (application as OpencodeApp).container
34 val container = (application as OpencodeApp).container
32− if (savedInstanceState == null) handleIntent(intent)
35+ handleIntent(intent)
33− maybeRequestNotificationPermission()
36+ if (savedInstanceState == null) maybeRequestNotificationPermission()
34 setContent {
37 setContent {
35 val themeMode by container.settingsStore.themeMode
38 val themeMode by container.settingsStore.themeMode
36 .collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
39 .collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
@@ -50,6 +53,7 @@ class MainActivity : ComponentActivity() {
50 override fun onNewIntent(intent: Intent) {
53 override fun onNewIntent(intent: Intent) {
51 super.onNewIntent(intent)
54 super.onNewIntent(intent)
52 setIntent(intent)
55 setIntent(intent)
56+ shareIntentHandled = false
53 handleIntent(intent)
57 handleIntent(intent)
54 }
58 }
55
59
@@ -57,9 +61,14 @@ class MainActivity : ComponentActivity() {
57 * and session ids from notification taps / deep links so we can open them. */
61 * and session ids from notification taps / deep links so we can open them. */
58 private fun handleIntent(intent: Intent?) {
62 private fun handleIntent(intent: Intent?) {
59 val container = (application as OpencodeApp).container
63 val container = (application as OpencodeApp).container
60− if (intent?.action == Intent.ACTION_SEND) {
64+ if (intent?.action == Intent.ACTION_SEND && !shareIntentHandled) {
61− val text = intent.getStringExtra(Intent.EXTRA_TEXT)?.takeIf { it.isNotBlank() }
65+ val text = intent.getStringExtra(Intent.EXTRA_TEXT)
66+ ?.takeIf { it.isNotBlank() }
67+ ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
68+ if (text != null) {
62− if (text != null) container.setPendingShare(text)
69+ container.setPendingShare(text)
70+ shareIntentHandled = true
71+ }
63 }
72 }
64 // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
73 // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
65 val deepLinkId = intent?.takeIf { it.action == Intent.ACTION_VIEW }
74 val deepLinkId = intent?.takeIf { it.action == Intent.ACTION_VIEW }
@@ -79,6 +88,13 @@ class MainActivity : ComponentActivity() {
79
88
80 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
89 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
81 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
90 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
91+
92+ private const val KEY_SHARE_HANDLED = "shareIntentHandled"
93+ }
94+
95+ override fun onSaveInstanceState(outState: Bundle) {
96+ super.onSaveInstanceState(outState)
97+ outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled)
82 }
98 }
83
99
84 /** Ask for POST_NOTIFICATIONS once on Android 13+ so run/completion notifications show. */
100 /** Ask for POST_NOTIFICATIONS once on Android 13+ so run/completion notifications show. */
app/src/main/java/soy/iko/opencode/OpencodeApp.kt+5
@@ -13,4 +13,9 @@ class OpencodeApp : Application() {
13 CrashLogger.get(this).install()
13 CrashLogger.get(this).install()
14 container = AppContainer(this)
14 container = AppContainer(this)
15 }
15 }
16+
17+ override fun onTerminate() {
18+ super.onTerminate()
19+ container.shutdown()
20+ }
16}
21}
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+18−1
@@ -59,6 +59,11 @@ class EventStreamClient(
59 /** Conflated channel used to cut the reconnect backoff short when network returns. */
59 /** Conflated channel used to cut the reconnect backoff short when network returns. */
60 private val reconnectSignal = Channel<Unit>(Channel.CONFLATED)
60 private val reconnectSignal = Channel<Unit>(Channel.CONFLATED)
61
61
62+ /** Returns true if the exception is an SSE auth failure (401/403) that should stop retrying. */
63+ private fun isSseAuthFailure(e: Throwable): Boolean =
64+ e is io.ktor.client.plugins.ClientRequestException &&
65+ (e.response.status.value == 401 || e.response.status.value == 403)
66+
62 /** Request an immediate reconnect, skipping any in-progress backoff. */
67 /** Request an immediate reconnect, skipping any in-progress backoff. */
63 fun triggerReconnect() { reconnectSignal.trySend(Unit) }
68 fun triggerReconnect() { reconnectSignal.trySend(Unit) }
64
69
@@ -81,11 +86,18 @@ class EventStreamClient(
81 request = {
86 request = {
82 timeout {
87 timeout {
83 requestTimeoutMillis = Long.MAX_VALUE
88 requestTimeoutMillis = Long.MAX_VALUE
89+ // Use a finite socket timeout so a server that accepts TCP but
90+ // never sends SSE headers doesn't hang indefinitely. Once events
91+ // are flowing, the idle-timeout watchdog handles half-open detection.
84− socketTimeoutMillis = Long.MAX_VALUE
92+ socketTimeoutMillis = NetworkConfig.sseHandshakeTimeoutMs
85 }
93 }
86 },
94 },
87 ) {
95 ) {
88 backoffMs = NetworkConfig.sseInitialBackoffMs
96 backoffMs = NetworkConfig.sseInitialBackoffMs
97+ // Clear any stale reconnect signal from a triggerReconnect() call
98+ // made while the previous stream was healthy, so it doesn't suppress
99+ // the next legitimate backoff.
100+ while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
89 // `incoming` is a cold Flow; bridge it to a ReceiveChannel so each
101 // `incoming` is a cold Flow; bridge it to a ReceiveChannel so each
90 // element can be raced against an idle timeout via select.
102 // element can be raced against an idle timeout via select.
91 val events = incoming.produceIn(scope)
103 val events = incoming.produceIn(scope)
@@ -126,6 +138,11 @@ class EventStreamClient(
126 } catch (c: CancellationException) {
138 } catch (c: CancellationException) {
127 throw c
139 throw c
128 } catch (e: Exception) {
140 } catch (e: Exception) {
141+ if (isSseAuthFailure(e)) {
142+ Log.w("EventStream", "SSE auth failed, stopping reconnect", e)
143+ _state.value = ConnectionState.Disconnected
144+ throw e
145+ }
129 Log.w("EventStream", "SSE stream error, will retry", e)
146 Log.w("EventStream", "SSE stream error, will retry", e)
130 }
147 }
131 _state.value = ConnectionState.Disconnected
148 _state.value = ConnectionState.Disconnected
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+2−2
@@ -1,7 +1,5 @@
1package soy.iko.opencode.data.network
1package soy.iko.opencode.data.network
2
2
3−import java.util.concurrent.TimeUnit
4−
5/**
3/**
6 * Centralized tuning parameters for the network and presentation layers. Keeping these
4 * Centralized tuning parameters for the network and presentation layers. Keeping these
7 * in one place makes them easy to find and adjust without hunting through source files,
5 * in one place makes them easy to find and adjust without hunting through source files,
@@ -39,6 +37,8 @@ object NetworkConfig {
39 const val sseIdleTimeoutMs = 90_000L
37 const val sseIdleTimeoutMs = 90_000L
40 /** Buffer capacity for the SSE events SharedFlow; prevents a slow subscriber stalling the read loop. */
38 /** Buffer capacity for the SSE events SharedFlow; prevents a slow subscriber stalling the read loop. */
41 const val sseEventBufferCapacity = 64
39 const val sseEventBufferCapacity = 64
40+ /** Max time to wait for the first SSE event before treating the connection as stale. */
41+ const val sseHandshakeTimeoutMs = 30_000L
42
42
43 // --- Session list (SessionListViewModel) ---
43 // --- Session list (SessionListViewModel) ---
44
44
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+16−8
@@ -29,6 +29,7 @@ import io.ktor.http.ContentType
29import io.ktor.http.contentType
29import io.ktor.http.contentType
30import kotlinx.coroutines.delay
30import kotlinx.coroutines.delay
31import kotlin.random.Random
31import kotlin.random.Random
32+import java.net.URLEncoder
32
33
33/**
34/**
34 * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
35 * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
@@ -52,17 +53,17 @@ class OpencodeApiClient(private val client: HttpClient) {
52 }.body()
53 }.body()
53
54
54 suspend fun updateSession(id: String, title: String): Session =
55 suspend fun updateSession(id: String, title: String): Session =
55− client.patch("session/$id") {
56+ client.patch("session/${encode(id)}") {
56 contentType(ContentType.Application.Json)
57 contentType(ContentType.Application.Json)
57 setBody(UpdateSessionRequest(title = title))
58 setBody(UpdateSessionRequest(title = title))
58 }.body()
59 }.body()
59
60
60 suspend fun deleteSession(id: String) {
61 suspend fun deleteSession(id: String) {
61− client.delete("session/$id")
62+ client.delete("session/${encode(id)}")
62 }
63 }
63
64
64 suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
65 suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
65− client.get("session/$sessionId/message").body()
66+ client.get("session/${encode(sessionId)}/message").body()
66 }
67 }
67
68
68 suspend fun sendPrompt(
69 suspend fun sendPrompt(
@@ -71,7 +72,7 @@ class OpencodeApiClient(private val client: HttpClient) {
71 model: ModelRef? = null,
72 model: ModelRef? = null,
72 agent: String? = null,
73 agent: String? = null,
73 ): MessageWithParts =
74 ): MessageWithParts =
74− client.post("session/$sessionId/message") {
75+ client.post("session/${encode(sessionId)}/message") {
75 contentType(ContentType.Application.Json)
76 contentType(ContentType.Application.Json)
76 setBody(
77 setBody(
77 PromptRequest(
78 PromptRequest(
@@ -83,7 +84,7 @@ class OpencodeApiClient(private val client: HttpClient) {
83 }.body()
84 }.body()
84
85
85 suspend fun abort(sessionId: String) {
86 suspend fun abort(sessionId: String) {
86− client.post("session/$sessionId/abort")
87+ client.post("session/${encode(sessionId)}/abort")
87 }
88 }
88
89
89 /** Invoke a slash-command by name via `POST /session/:id/command`. */
90 /** Invoke a slash-command by name via `POST /session/:id/command`. */
@@ -93,7 +94,7 @@ class OpencodeApiClient(private val client: HttpClient) {
93 arguments: String = "",
94 arguments: String = "",
94 agent: String? = null,
95 agent: String? = null,
95 ): MessageWithParts =
96 ): MessageWithParts =
96− client.post("session/$sessionId/command") {
97+ client.post("session/${encode(sessionId)}/command") {
97 contentType(ContentType.Application.Json)
98 contentType(ContentType.Application.Json)
98 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
99 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
99 }.body()
100 }.body()
@@ -116,7 +117,7 @@ class OpencodeApiClient(private val client: HttpClient) {
116 permissionId: String,
117 permissionId: String,
117 response: PermissionResponse,
118 response: PermissionResponse,
118 ) = withRetry {
119 ) = withRetry {
119− client.post("session/$sessionId/permissions/$permissionId") {
120+ client.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
120 contentType(ContentType.Application.Json)
121 contentType(ContentType.Application.Json)
121 setBody(PermissionReplyBody(response.wire))
122 setBody(PermissionReplyBody(response.wire))
122 }
123 }
@@ -169,7 +170,10 @@ internal suspend fun <T> withRetryInternal(
169 throw c
170 throw c
170 } catch (t: Exception) {
171 } catch (t: Exception) {
171 lastError = t
172 lastError = t
172− if (t is ClientRequestException) throw t
173+ if (t is ClientRequestException) {
174+ // 429 (Too Many Requests) is transient — retry with backoff.
175+ if (t.response.status.value != 429) throw t
176+ }
173 if (attempt < maxAttempts) {
177 if (attempt < maxAttempts) {
174 val baseDelay = initialDelayMs * (1 shl (attempt - 1))
178 val baseDelay = initialDelayMs * (1 shl (attempt - 1))
175 val jitter = (baseDelay * jitterFactor * Random.nextDouble()).toLong()
179 val jitter = (baseDelay * jitterFactor * Random.nextDouble()).toLong()
@@ -179,3 +183,7 @@ internal suspend fun <T> withRetryInternal(
179 }
183 }
180 throw lastError ?: IllegalStateException("withRetry failed without error")
184 throw lastError ?: IllegalStateException("withRetry failed without error")
181}
185}
186+
187+/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
188+private fun encode(segment: String): String =
189+ URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+6−1
@@ -5,9 +5,13 @@ import android.content.pm.PackageManager
5import android.os.Build
5import android.os.Build
6import android.util.Log
6import android.util.Log
7import androidx.core.content.pm.PackageInfoCompat
7import androidx.core.content.pm.PackageInfoCompat
8+import kotlinx.coroutines.CoroutineScope
9+import kotlinx.coroutines.Dispatchers
10+import kotlinx.coroutines.SupervisorJob
8import kotlinx.coroutines.flow.MutableStateFlow
11import kotlinx.coroutines.flow.MutableStateFlow
9import kotlinx.coroutines.flow.StateFlow
12import kotlinx.coroutines.flow.StateFlow
10import kotlinx.coroutines.flow.asStateFlow
13import kotlinx.coroutines.flow.asStateFlow
14+import kotlinx.coroutines.launch
11import java.io.File
15import java.io.File
12import java.io.PrintWriter
16import java.io.PrintWriter
13import java.io.StringWriter
17import java.io.StringWriter
@@ -34,6 +38,7 @@ class CrashLogger private constructor(private val appContext: Context) {
34 )
38 )
35
39
36 private val crashDir = File(appContext.filesDir, "crashes").apply { mkdirs() }
40 private val crashDir = File(appContext.filesDir, "crashes").apply { mkdirs() }
41+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
37
42
38 private val _reports = MutableStateFlow<List<CrashReport>>(emptyList())
43 private val _reports = MutableStateFlow<List<CrashReport>>(emptyList())
39 val reports: StateFlow<List<CrashReport>> = _reports.asStateFlow()
44 val reports: StateFlow<List<CrashReport>> = _reports.asStateFlow()
@@ -48,7 +53,7 @@ class CrashLogger private constructor(private val appContext: Context) {
48 // Load crash reports off the main thread so startup isn't blocked by file I/O
53 // Load crash reports off the main thread so startup isn't blocked by file I/O
49 // (listFiles + reading the first line of each report). The StateFlow updates
54 // (listFiles + reading the first line of each report). The StateFlow updates
50 // whenever the scan completes, so the Diagnostics screen reflects the result.
55 // whenever the scan completes, so the Diagnostics screen reflects the result.
51− Thread { refresh() }.start()
56+ scope.launch { refresh() }
52 }
57 }
53
58
54 fun refresh() {
59 fun refresh() {
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+10−5
@@ -251,6 +251,7 @@ class AppContainer(context: Context) {
251 connectionMutex.withLock {
251 connectionMutex.withLock {
252 _activeConnection.value?.close()
252 _activeConnection.value?.close()
253 _activeConnection.value = null
253 _activeConnection.value = null
254+ activeRuns.clear()
254 val now = System.currentTimeMillis()
255 val now = System.currentTimeMillis()
255 val resolved = profileStore.resolve(profile)
256 val resolved = profileStore.resolve(profile)
256 val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
257 val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
@@ -260,11 +261,15 @@ class AppContainer(context: Context) {
260 }
261 }
261
262
262 fun disconnect() {
263 fun disconnect() {
263− // Non-suspending best-effort disconnect; if a connect is in progress under the
264+ // Launch on appScope so disconnect acquires the connection mutex, preventing
264− // mutex, the connection it sets will be closed on the next disconnect/connect.
265+ // a race where a concurrent connect() sets a new connection after disconnect
265− val conn = _activeConnection.value ?: return
266+ // reads null — which would leak the just-created connection.
266− _activeConnection.value = null
267+ appScope.launch {
267− conn.close()
268+ connectionMutex.withLock {
269+ _activeConnection.value?.close()
270+ _activeConnection.value = null
271+ }
272+ }
268 }
273 }
269}
274}
270
275
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+4−1
@@ -41,7 +41,10 @@ class RunForegroundService : Service() {
41 0
41 0
42 },
42 },
43 )
43 )
44+ }.onFailure {
44− }.onFailure { Log.w(TAG, "startForeground failed; running without foreground priority", it) }
45+ Log.w(TAG, "startForeground failed; running without foreground priority", it)
46+ stopSelf()
47+ }
45 return START_NOT_STICKY
48 return START_NOT_STICKY
46 }
49 }
47
50
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+6−2
@@ -30,10 +30,14 @@ object SessionNotifications {
30 private fun notifIdFor(sessionId: String): Int {
30 private fun notifIdFor(sessionId: String): Int {
31 val digest = MessageDigest.getInstance("SHA-256").digest(sessionId.toByteArray())
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.
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
33+ val hash = ((digest[0].toInt() and 0xFF) shl 24 or
34 (digest[1].toInt() and 0xFF) shl 16 or
34 (digest[1].toInt() and 0xFF) shl 16 or
35 (digest[2].toInt() and 0xFF) shl 8 or
35 (digest[2].toInt() and 0xFF) shl 8 or
36− (digest[3].toInt() and 0xFF))).and(0x7FFFFFFF)
36+ (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
37+ // Offset by NOTIF_ID_PREFIX, wrapping within the positive Int range so the
38+ // prefix is preserved (not masked away) and ids don't collide with the
39+ // foreground service's NOTIF_ID = 1.
40+ return (NOTIF_ID_PREFIX + hash).and(0x7FFFFFFF)
37 }
41 }
38
42
39 fun postCompleted(context: Context, sessionId: String, title: String) {
43 fun postCompleted(context: Context, sessionId: String, title: String) {
app/src/main/java/soy/iko/opencode/ui/Routes.kt+2−2
@@ -10,10 +10,10 @@ object Routes {
10
10
11 const val SERVER_EDIT = "server_edit"
11 const val SERVER_EDIT = "server_edit"
12 fun serverEdit(id: String? = null) =
12 fun serverEdit(id: String? = null) =
13− if (id.isNullOrEmpty()) SERVER_EDIT else "$SERVER_EDIT?id=$id"
13+ if (id.isNullOrEmpty()) SERVER_EDIT else "$SERVER_EDIT?id=${android.net.Uri.encode(id)}"
14
14
15 const val CHAT = "chat"
15 const val CHAT = "chat"
16− fun chat(sessionId: String) = "$CHAT/$sessionId"
16+ fun chat(sessionId: String) = "$CHAT/${android.net.Uri.encode(sessionId)}"
17
17
18 const val FILE_VIEW = "file_view"
18 const val FILE_VIEW = "file_view"
19 fun fileView(path: String) = "$FILE_VIEW?path=${android.net.Uri.encode(path)}"
19 fun fileView(path: String) = "$FILE_VIEW?path=${android.net.Uri.encode(path)}"
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+12−2
@@ -58,7 +58,12 @@ private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
58 return if (url.startsWith("http://") || url.startsWith("https://")) {
58 return if (url.startsWith("http://") || url.startsWith("https://")) {
59 url
59 url
60 } else {
60 } else {
61+ // Resolve relative URL against the base. Collapse any ../ segments to
62+ // prevent path traversal from escaping the server's base path.
61− HttpClientFactory.normalizeBaseUrl(ctx.baseUrl) + url.trimStart('/')
63+ val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
64+ val resolved = java.net.URI(base).resolve(url).normalize().toString()
65+ // Guard: if normalization escaped the base path, fall back to the base.
66+ if (!resolved.startsWith(base)) base else resolved
62 }
67 }
63}
68}
64
69
@@ -74,7 +79,12 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
74 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader) {
79 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader) {
75 ImageRequest.Builder(context)
80 ImageRequest.Builder(context)
76 .data(model)
81 .data(model)
82+ .apply {
83+ // Only send Basic auth over HTTPS; sending credentials over
84+ // cleartext HTTP would expose them on the network.
85+ val isHttps = (model as? String)?.startsWith("https://") == true
77− .apply { ctx.basicAuthHeader?.let { addHeader("Authorization", it) } }
86+ if (isHttps) ctx.basicAuthHeader?.let { addHeader("Authorization", it) }
87+ }
78 .crossfade(true)
88 .crossfade(true)
79 .build()
89 .build()
80 }
90 }
app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+1
@@ -40,6 +40,7 @@ fun rememberRelativeTime(epochMillis: Long?, intervalMs: Long = 30_000L): String
40 // on each interval, re-evaluating relativeTime() so the label stays fresh.
40 // on each interval, re-evaluating relativeTime() so the label stays fresh.
41 var tick by remember { mutableLongStateOf(0L) }
41 var tick by remember { mutableLongStateOf(0L) }
42 LaunchedEffect(epochMillis) {
42 LaunchedEffect(epochMillis) {
43+ if (epochMillis == null) return@LaunchedEffect
43 while (true) {
44 while (true) {
44 delay(intervalMs)
45 delay(intervalMs)
45 tick++
46 tick++
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+4−1
@@ -113,7 +113,10 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
113 _state.value = _state.value.copy(results = emptyList(), searching = false)
113 _state.value = _state.value.copy(results = emptyList(), searching = false)
114 return
114 return
115 }
115 }
116− val client = api ?: return
116+ val client = api ?: run {
117+ _state.value = _state.value.copy(searching = false, error = container.string(R.string.not_connected))
118+ return
119+ }
117 searchJob = viewModelScope.launch {
120 searchJob = viewModelScope.launch {
118 delay(NetworkConfig.fileSearchDebounceMs) // debounce
121 delay(NetworkConfig.fileSearchDebounceMs) // debounce
119 _state.value = _state.value.copy(searching = true)
122 _state.value = _state.value.copy(searching = true)
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+8
@@ -121,6 +121,14 @@ fun ServerEditScreen(
121 ) {
121 ) {
122 Text(stringResource(R.string.save))
122 Text(stringResource(R.string.save))
123 }
123 }
124+ state.error?.let { message ->
125+ Text(
126+ message,
127+ color = androidx.compose.material3.MaterialTheme.colorScheme.error,
128+ style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
129+ modifier = Modifier.fillMaxWidth(),
130+ )
131+ }
124 }
132 }
125 }
133 }
126 }
134 }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+25−16
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.StateFlow
9import kotlinx.coroutines.flow.asStateFlow
9import kotlinx.coroutines.flow.asStateFlow
10import kotlinx.coroutines.flow.first
10import kotlinx.coroutines.flow.first
11import kotlinx.coroutines.launch
11import kotlinx.coroutines.launch
12+import kotlinx.coroutines.withTimeoutOrNull
12import java.util.UUID
13import java.util.UUID
13
14
14data class ServerEditState(
15data class ServerEditState(
@@ -18,6 +19,7 @@ data class ServerEditState(
18 val username: String = "",
19 val username: String = "",
19 val password: String = "",
20 val password: String = "",
20 val loaded: Boolean = false,
21 val loaded: Boolean = false,
22+ val error: String? = null,
21) {
23) {
22 val canSave: Boolean get() = baseUrl.isNotBlank()
24 val canSave: Boolean get() = baseUrl.isNotBlank()
23 val isNew: Boolean get() = id == null
25 val isNew: Boolean get() = id == null
@@ -34,7 +36,9 @@ class ServerEditViewModel(
34 init {
36 init {
35 viewModelScope.launch {
37 viewModelScope.launch {
36 if (profileId != null) {
38 if (profileId != null) {
39+ val existing = withTimeoutOrNull(5_000) {
37− val existing = container.profileStore.profiles.first().firstOrNull { it.id == profileId }
40+ container.profileStore.profiles.first().firstOrNull { it.id == profileId }
41+ }
38 if (existing != null) {
42 if (existing != null) {
39 _state.value = ServerEditState(
43 _state.value = ServerEditState(
40 id = existing.id,
44 id = existing.id,
@@ -59,21 +63,26 @@ class ServerEditViewModel(
59 val s = _state.value
63 val s = _state.value
60 if (!s.canSave) return
64 if (!s.canSave) return
61 viewModelScope.launch {
65 viewModelScope.launch {
66+ val result = runCatching {
62− val existingLastUsed = if (s.id != null) {
67+ val existingLastUsed = if (s.id != null) {
68+ withTimeoutOrNull(5_000) {
63− container.profileStore.profiles.first()
69+ container.profileStore.profiles.first()
64− .firstOrNull { it.id == s.id }?.lastUsed ?: 0L
70+ .firstOrNull { it.id == s.id }?.lastUsed
71+ } ?: 0L
65− } else 0L
72+ } else 0L
66− container.profileStore.save(
73+ container.profileStore.save(
67− ServerProfile(
74+ ServerProfile(
68− id = s.id ?: UUID.randomUUID().toString(),
75+ id = s.id ?: UUID.randomUUID().toString(),
69− label = s.label.trim(),
76+ label = s.label.trim(),
70− baseUrl = s.baseUrl.trim(),
77+ baseUrl = s.baseUrl.trim(),
71− username = s.username.trim().takeIf { it.isNotBlank() },
78+ username = s.username.trim().takeIf { it.isNotBlank() },
72− password = s.password.trim().takeIf { it.isNotEmpty() },
79+ password = s.password.trim().takeIf { it.isNotEmpty() },
73− lastUsed = existingLastUsed,
80+ lastUsed = existingLastUsed,
74− ),
81+ ),
75− )
82+ )
83+ }
76− onDone()
84+ result.onSuccess { onDone() }
85+ .onFailure { _state.value = _state.value.copy(error = it.message ?: "Failed to save") }
77 }
86 }
78 }
87 }
79}
88}
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+4−1
@@ -48,6 +48,9 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
48 }
48 }
49
49
50 fun delete(profile: ServerProfile) {
50 fun delete(profile: ServerProfile) {
51+ viewModelScope.launch {
51− viewModelScope.launch { container.profileStore.delete(profile.id) }
52+ runCatching { container.profileStore.delete(profile.id) }
53+ .onFailure { _error.value = container.friendlyError(it) }
54+ }
52 }
55 }
53}
56}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+12−9
@@ -53,6 +53,7 @@ import androidx.compose.runtime.LaunchedEffect
53import androidx.compose.runtime.getValue
53import androidx.compose.runtime.getValue
54import androidx.compose.runtime.mutableStateOf
54import androidx.compose.runtime.mutableStateOf
55import androidx.compose.runtime.remember
55import androidx.compose.runtime.remember
56+import androidx.compose.runtime.saveable.rememberSaveable
56import androidx.compose.runtime.setValue
57import androidx.compose.runtime.setValue
57import androidx.compose.ui.Alignment
58import androidx.compose.ui.Alignment
58import androidx.compose.ui.Modifier
59import androidx.compose.ui.Modifier
@@ -96,8 +97,10 @@ fun SessionListScreen(
96 val haptics = LocalHapticFeedback.current
97 val haptics = LocalHapticFeedback.current
97 val snackbar = remember { SnackbarHostState() }
98 val snackbar = remember { SnackbarHostState() }
98 var showServerMenu by remember { mutableStateOf(false) }
99 var showServerMenu by remember { mutableStateOf(false) }
99− var pendingDelete by remember { mutableStateOf<Session?>(null) }
100+ var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
100− var pendingRename by remember { mutableStateOf<Session?>(null) }
101+ var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
102+ val pendingDelete = pendingDeleteId?.let { id -> state.sessions.firstOrNull { it.id == id } }
103+ val pendingRename = pendingRenameId?.let { id -> state.sessions.firstOrNull { it.id == id } }
101
104
102 LaunchedEffect(transientError) {
105 LaunchedEffect(transientError) {
103 val msg = transientError ?: return@LaunchedEffect
106 val msg = transientError ?: return@LaunchedEffect
@@ -233,8 +236,8 @@ fun SessionListScreen(
233 preview = state.previews[session.id],
236 preview = state.previews[session.id],
234 unread = unread.contains(session.id),
237 unread = unread.contains(session.id),
235 onClick = { onOpenSession(session.id) },
238 onClick = { onOpenSession(session.id) },
236− onRename = { pendingRename = session },
239+ onRename = { pendingRenameId = session.id },
237− onDelete = { pendingDelete = session },
240+ onDelete = { pendingDeleteId = session.id },
238 modifier = Modifier.animateItem(),
241 modifier = Modifier.animateItem(),
239 )
242 )
240 }
243 }
@@ -248,18 +251,18 @@ fun SessionListScreen(
248
251
249 pendingDelete?.let { session ->
252 pendingDelete?.let { session ->
250 AlertDialog(
253 AlertDialog(
251− onDismissRequest = { pendingDelete = null },
254+ onDismissRequest = { pendingDeleteId = null },
252 title = { Text(stringResource(R.string.delete_session_title)) },
255 title = { Text(stringResource(R.string.delete_session_title)) },
253 text = { Text(stringResource(R.string.delete_session_text, session.displayTitle)) },
256 text = { Text(stringResource(R.string.delete_session_text, session.displayTitle)) },
254 confirmButton = {
257 confirmButton = {
255 TextButton(onClick = {
258 TextButton(onClick = {
256 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
259 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
257− pendingDelete = null
260+ pendingDeleteId = null
258 vm.deleteSession(session)
261 vm.deleteSession(session)
259 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
262 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
260 },
263 },
261 dismissButton = {
264 dismissButton = {
262− TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.cancel)) }
265+ TextButton(onClick = { pendingDeleteId = null }) { Text(stringResource(R.string.cancel)) }
263 },
266 },
264 )
267 )
265 }
268 }
@@ -271,10 +274,10 @@ fun SessionListScreen(
271 onTitleChange = { title = it },
274 onTitleChange = { title = it },
272 onConfirm = {
275 onConfirm = {
273 val newName = title.trim()
276 val newName = title.trim()
274− pendingRename = null
277+ pendingRenameId = null
275 if (newName.isNotEmpty() && newName != session.title) vm.renameSession(session, newName)
278 if (newName.isNotEmpty() && newName != session.title) vm.renameSession(session, newName)
276 },
279 },
277− onDismiss = { pendingRename = null },
280+ onDismiss = { pendingRenameId = null },
278 )
281 )
279 }
282 }
280}
283}
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+6−1
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.fillMaxSize
7import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.widthIn
9import androidx.compose.foundation.layout.widthIn
10+import androidx.activity.compose.BackHandler
10import androidx.compose.material.icons.Icons
11import androidx.compose.material.icons.Icons
11import androidx.compose.material.icons.automirrored.filled.Chat
12import androidx.compose.material.icons.automirrored.filled.Chat
12import androidx.compose.material3.Icon
13import androidx.compose.material3.Icon
@@ -65,7 +66,10 @@ fun TwoPaneSessionChat(
65 ) {
66 ) {
66 SessionListScreen(
67 SessionListScreen(
67 container = container,
68 container = container,
68− onOpenSession = { id -> selected = id },
69+ onOpenSession = { id ->
70+ container.consumePendingShare()?.let { container.draftStore.set(id, it) }
71+ selected = id
72+ },
69 onDisconnect = onDisconnect,
73 onDisconnect = onDisconnect,
70 onOpenFiles = onOpenFiles,
74 onOpenFiles = onOpenFiles,
71 onOpenSettings = onOpenSettings,
75 onOpenSettings = onOpenSettings,
@@ -77,6 +81,7 @@ fun TwoPaneSessionChat(
77 if (sessionId == null) {
81 if (sessionId == null) {
78 EmptyDetail()
82 EmptyDetail()
79 } else {
83 } else {
84+ BackHandler { selected = null }
80 key(sessionId) {
85 key(sessionId) {
81 ChatScreen(
86 ChatScreen(
82 container = container,
87 container = container,