git.iko.soy/opencode-android
on master help sign in
Fix completion-notification dead code, non-idempotent sendPrompt retry, CrashLogger thread-safety and path traversal, ProfileStore keystore crash, share-intent crashes, stale catalogs on server switch, deprecated getPackageInfo API, accessibility roles, and add backoff jitter
commit0bc1edabf6d356e7b819b225ed94b742370bd72c authoriko <mail@iko.soy> dateMon 29 Jun 2026, 19:58 (3 months ago) parent6839df9
17 files changed+94−42
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+6−3 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+2 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+8−4 app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+17−4 app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+15−10 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+10−3 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+1−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+4−4 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+1−1 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+1−1 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+3−1 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+1−1 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+6−2 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+2−1 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+2−2 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+13−2 app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.kt+2−2
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+6−3
@@ -101,9 +101,11 @@ class EventStreamClient(
101101 // Stream closed cleanly: exit to reconnect without a warning log.
102102 val sse = result.getOrNull() ?: break
103103 val data = sse.data ?: continue
104− val event = runCatching {
104+ val event = try {
105105 OpencodeJson.decodeFromString(BusEvent.serializer(), data)
106− }.getOrNull() ?: continue
106+ } catch (e: kotlinx.serialization.SerializationException) {
107+ continue
108+ }
107109 // Any successfully decoded event means the stream is live;
108110 // don't wait for a ServerConnected event to show "Connected".
109111 if (_state.value != ConnectionState.Connected) {
@@ -124,9 +126,10 @@ class EventStreamClient(
124126 if (!isActive) break
125127 // Wait for the backoff, but allow a reconnect signal to cut it short.
126128 var signaled = false
129+ val jitter = (backoffMs * 0.2 * Math.random()).toLong()
127130 select {
128131 reconnectSignal.onReceive { signaled = true }
129− onTimeout(backoffMs) { /* normal backoff elapsed */ }
132+ onTimeout(backoffMs + jitter) { /* normal backoff elapsed */ }
130133 }
131134 backoffMs = if (signaled) NetworkConfig.sseInitialBackoffMs else (backoffMs * 2).coerceAtMost(NetworkConfig.sseMaxBackoffMs)
132135 }
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+2
@@ -26,6 +26,8 @@ object NetworkConfig {
2626 const val retryMaxAttempts = 3
2727 /** Initial backoff delay; doubles on each successive failure (exponential backoff). */
2828 const val retryInitialDelayMs = 500L
29+ /** Jitter factor for retry backoff (0.2 = ±20% random jitter added to each delay). */
30+ const val retryJitterFactor = 0.2
2931
3032 // --- SSE stream (EventStreamClient) ---
3133
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+8−4
@@ -69,7 +69,7 @@ class OpencodeApiClient(private val client: HttpClient) {
6969 text: String,
7070 model: ModelRef? = null,
7171 agent: String? = null,
72− ): MessageWithParts = withRetry {
72+ ): MessageWithParts =
7373 client.post("session/$sessionId/message") {
7474 contentType(ContentType.Application.Json)
7575 setBody(
@@ -80,7 +80,6 @@ class OpencodeApiClient(private val client: HttpClient) {
8080 ),
8181 )
8282 }.body()
83− }
8483
8584 suspend fun abort(sessionId: String) {
8685 client.post("session/$sessionId/abort")
@@ -145,7 +144,7 @@ private suspend fun <T> withRetry(
145144 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
146145 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
147146 block: suspend () -> T,
148−): T = withRetryInternal(maxAttempts, initialDelayMs, block)
147+): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
149148
150149/**
151150 * Retry a suspending block up to [maxAttempts] times with exponential backoff.
@@ -158,6 +157,7 @@ private suspend fun <T> withRetry(
158157internal suspend fun <T> withRetryInternal(
159158 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
160159 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
160+ jitterFactor: Double = NetworkConfig.retryJitterFactor,
161161 block: suspend () -> T,
162162): T {
163163 var lastError: Throwable? = null
@@ -169,7 +169,11 @@ internal suspend fun <T> withRetryInternal(
169169 } catch (t: Throwable) {
170170 lastError = t
171171 if (t is ClientRequestException) throw t
172− if (attempt < maxAttempts) delay(initialDelayMs * (1 shl (attempt - 1)))
172+ if (attempt < maxAttempts) {
173+ val baseDelay = initialDelayMs * (1 shl (attempt - 1))
174+ val jitter = (baseDelay * jitterFactor * Math.random()).toLong()
175+ delay(baseDelay + jitter)
176+ }
173177 }
174178 }
175179 throw lastError ?: IllegalStateException("withRetry failed without error")
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+17−4
@@ -1,6 +1,7 @@
11package soy.iko.opencode.data.repo
22
33import android.content.Context
4+import android.content.pm.PackageManager
45import android.os.Build
56import android.util.Log
67import androidx.core.content.pm.PackageInfoCompat
@@ -61,13 +62,16 @@ class CrashLogger private constructor(private val appContext: Context) {
6162 }
6263
6364 fun readReport(fileName: String): String? {
64− val file = File(crashDir, fileName)
65− if (!file.isFile) return null
65+ val file = File(crashDir, fileName).canonicalFile
66+ if (!file.path.startsWith(crashDir.canonicalPath + File.separator) || !file.isFile) return null
6667 return runCatching { file.readText() }.getOrNull()
6768 }
6869
6970 fun deleteReport(fileName: String) {
70− File(crashDir, fileName).delete()
71+ val file = File(crashDir, fileName).canonicalFile
72+ if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
73+ file.delete()
74+ }
7175 refresh()
7276 }
7377
@@ -99,11 +103,20 @@ class CrashLogger private constructor(private val appContext: Context) {
99103 }
100104
101105 private fun appVersion(): String = runCatching {
102− val pi = appContext.packageManager.getPackageInfo(appContext.packageName, 0)
106+ val pi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
107+ appContext.packageManager.getPackageInfo(
108+ appContext.packageName,
109+ PackageManager.PackageInfoFlags.of(0),
110+ )
111+ } else {
112+ @Suppress("DEPRECATION")
113+ appContext.packageManager.getPackageInfo(appContext.packageName, 0)
114+ }
103115 "${pi.versionName} (${PackageInfoCompat.getLongVersionCode(pi)})"
104116 }.getOrDefault("unknown")
105117
106118 companion object {
119+ @Volatile
107120 private var instance: CrashLogger? = null
108121
109122 fun get(context: Context): CrashLogger =
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+15−10
@@ -28,16 +28,21 @@ class ProfileStore(context: Context) {
2828 private val profilesKey = stringPreferencesKey("profiles_json")
2929
3030 private val securePrefs: SharedPreferences by lazy {
31− val masterKey = MasterKey.Builder(appContext)
32− .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
33− .build()
34− EncryptedSharedPreferences.create(
35− appContext,
36− "server_secrets",
37− masterKey,
38− EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
39− EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
40− )
31+ runCatching {
32+ val masterKey = MasterKey.Builder(appContext)
33+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
34+ .build()
35+ EncryptedSharedPreferences.create(
36+ appContext,
37+ "server_secrets",
38+ masterKey,
39+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
40+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
41+ )
42+ }.getOrElse {
43+ Log.e("ProfileStore", "Encrypted prefs unavailable, falling back to plaintext", it)
44+ appContext.getSharedPreferences("server_secrets_fallback", Context.MODE_PRIVATE)
45+ }
4146 }
4247
4348 /** Stored shape on DataStore (everything except the secret password). */
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+10−3
@@ -153,6 +153,16 @@ class AppContainer(context: Context) {
153153 activeConnection
154154 .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
155155 .collect { event ->
156+ // SessionIdle is not "message activity" (don't badge as unread)
157+ // but signals a run finished — fire a completion notification if the
158+ // session isn't currently being viewed and was actively streaming.
159+ if (event is SessionIdle) {
160+ val idleSid = event.properties.sessionID ?: return@collect
161+ if (idleSid != _currentSession.value && activeRuns.remove(idleSid)) {
162+ notifySessionCompleted(idleSid)
163+ }
164+ return@collect
165+ }
156166 val sid = sessionOf(event) ?: return@collect
157167 if (sid != _currentSession.value) {
158168 _unread.update { it + sid }
@@ -162,9 +172,6 @@ class AppContainer(context: Context) {
162172 if (event is MessagePartUpdated || event is MessageUpdated) {
163173 activeRuns.add(sid)
164174 }
165− if (event is SessionIdle && sid != _currentSession.value && activeRuns.remove(sid)) {
166− notifySessionCompleted(sid)
167− }
168175 }
169176 }
170177 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+1−1
@@ -235,7 +235,7 @@ fun ChatScreen(
235235 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
236236 putExtra(android.content.Intent.EXTRA_TEXT, md)
237237 }
238− shareLauncher.startActivity(android.content.Intent.createChooser(send, shareLabel))
238+ runCatching { shareLauncher.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
239239 },
240240 enabled = messages.isNotEmpty(),
241241 ) {
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+4−4
@@ -72,7 +72,7 @@ class ChatViewModel(
7272
7373 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
7474 container.activeConnection
75− .flatMapLatest { it?.events?.state ?: MutableStateFlow(EventStreamClient.ConnectionState.Disconnected) }
75+ .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
7676 .stateIn(
7777 scope = viewModelScope,
7878 started = SharingStarted.WhileSubscribed(5_000),
@@ -144,7 +144,7 @@ class ChatViewModel(
144144 // sending with no model just uses the server's default agent/model.
145145 viewModelScope.launch {
146146 container.activeConnection.collectLatest { conn ->
147− if (conn == null) return@collectLatest
147+ if (conn == null) { _models.value = emptyList(); _selectedModel.value = null; return@collectLatest }
148148 runCatching { conn.api.providers() }
149149 .onFailure { Log.w("ChatViewModel", "Failed to load model catalog", it) }
150150 .getOrNull()?.let { resp ->
@@ -157,7 +157,7 @@ class ChatViewModel(
157157 // Load the agent catalog (non-fatal).
158158 viewModelScope.launch {
159159 container.activeConnection.collectLatest { conn ->
160− if (conn == null) return@collectLatest
160+ if (conn == null) { _agents.value = emptyList(); return@collectLatest }
161161 runCatching { conn.api.agents() }
162162 .onFailure { Log.w("ChatViewModel", "Failed to load agent catalog", it) }
163163 .getOrNull()?.let { _agents.value = it }
@@ -166,7 +166,7 @@ class ChatViewModel(
166166 // Load the command catalog (non-fatal).
167167 viewModelScope.launch {
168168 container.activeConnection.collectLatest { conn ->
169− if (conn == null) return@collectLatest
169+ if (conn == null) { _commands.value = emptyList(); return@collectLatest }
170170 runCatching { conn.api.commands() }
171171 .onFailure { Log.w("ChatViewModel", "Failed to load command catalog", it) }
172172 .getOrNull()?.let { _commands.value = it }
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+1−1
@@ -59,7 +59,7 @@ fun MarkdownText(
5959 content = markdown,
6060 modifier = modifier.semantics(mergeDescendants = true) {
6161 contentDescription = markdown.take(500)
62− role = Role.Image
62+ role = Role.Button
6363 }.combinedClickable(
6464 onClick = {},
6565 onLongClick = { copyToClipboard(context, markdown) },
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+1−1
@@ -217,7 +217,7 @@ private fun FileRow(
217217 Row(
218218 modifier = Modifier
219219 .fillMaxWidth()
220− .clickable(onClick = onClick)
220+ .clickable(role = Role.Button, onClick = onClick)
221221 .semantics(mergeDescendants = true) { contentDescription = desc }
222222 .padding(horizontal = 16.dp, vertical = 14.dp),
223223 verticalAlignment = Alignment.CenterVertically,
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+3−1
@@ -40,6 +40,7 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
4040 fun clearTransientError() { _transientError.value = null }
4141
4242 private var searchJob: Job? = null
43+ private var openJob: Job? = null
4344
4445 init { open(""); loadStatus() }
4546
@@ -65,7 +66,8 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
6566 return
6667 }
6768 _state.value = _state.value.copy(path = path, loading = true, error = null)
68− viewModelScope.launch {
69+ openJob?.cancel()
70+ openJob = viewModelScope.launch {
6971 runCatching { client.listDirectory(path) }
7072 .onSuccess { entries ->
7173 _state.value = _state.value.copy(
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+1−1
@@ -75,7 +75,7 @@ fun FileViewScreen(
7575 putExtra(Intent.EXTRA_SUBJECT, filename)
7676 putExtra(Intent.EXTRA_TEXT, content)
7777 }
78− context.startActivity(Intent.createChooser(send, shareLabel))
78+ runCatching { context.startActivity(Intent.createChooser(send, shareLabel)) }
7979 }) {
8080 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
8181 }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+6−2
@@ -59,14 +59,18 @@ class ServerEditViewModel(
5959 val s = _state.value
6060 if (!s.canSave) return
6161 viewModelScope.launch {
62+ val existingLastUsed = if (s.id != null) {
63+ container.profileStore.profiles.first()
64+ .firstOrNull { it.id == s.id }?.lastUsed ?: 0L
65+ } else 0L
6266 container.profileStore.save(
6367 ServerProfile(
6468 id = s.id ?: UUID.randomUUID().toString(),
6569 label = s.label.trim(),
6670 baseUrl = s.baseUrl.trim(),
6771 username = s.username.trim().takeIf { it.isNotBlank() },
68− password = s.password.takeIf { it.isNotEmpty() },
69− lastUsed = 0,
72+ password = s.password.trim().takeIf { it.isNotEmpty() },
73+ lastUsed = existingLastUsed,
7074 ),
7175 )
7276 onDone()
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+2−1
@@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.StateFlow
1919import kotlinx.coroutines.flow.asStateFlow
2020import kotlinx.coroutines.flow.collectLatest
2121import kotlinx.coroutines.flow.flatMapLatest
22+import kotlinx.coroutines.flow.flowOf
2223import kotlinx.coroutines.flow.stateIn
2324import kotlinx.coroutines.flow.update
2425import kotlinx.coroutines.joinAll
@@ -51,7 +52,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
5152 @OptIn(ExperimentalCoroutinesApi::class)
5253 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
5354 container.activeConnection
54− .flatMapLatest { it?.events?.state ?: MutableStateFlow(EventStreamClient.ConnectionState.Disconnected) }
55+ .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
5556 .stateIn(
5657 scope = viewModelScope,
5758 started = SharingStarted.WhileSubscribed(5_000),
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+2−2
@@ -136,7 +136,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
136136 putExtra(Intent.EXTRA_SUBJECT, shareLabel)
137137 putExtra(Intent.EXTRA_TEXT, content)
138138 }
139− context.startActivity(Intent.createChooser(send, shareLabel))
139+ runCatching { context.startActivity(Intent.createChooser(send, shareLabel)) }
140140 }
141141 }) {
142142 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
@@ -170,7 +170,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
170170 putExtra(Intent.EXTRA_SUBJECT, shareLabel2)
171171 putExtra(Intent.EXTRA_TEXT, content.orEmpty())
172172 }
173− context.startActivity(Intent.createChooser(send, shareLabel2))
173+ runCatching { context.startActivity(Intent.createChooser(send, shareLabel2)) }
174174 }) { Text(stringResource(R.string.share)) }
175175 Spacer(Modifier.size(8.dp))
176176 TextButton(onClick = {
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+13−2
@@ -1,5 +1,7 @@
11package soy.iko.opencode.ui.settings
22
3+import android.content.pm.PackageManager
4+import android.os.Build
35import androidx.compose.foundation.clickable
46import androidx.compose.foundation.layout.Arrangement
57import androidx.compose.foundation.layout.Column
@@ -55,8 +57,17 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
5557 val context = LocalContext.current
5658 val unknownVersion = stringResource(R.string.unknown_version)
5759 val versionName = remember {
58− runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
59− .getOrNull() ?: unknownVersion
60+ runCatching {
61+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
62+ context.packageManager.getPackageInfo(
63+ context.packageName,
64+ PackageManager.PackageInfoFlags.of(0),
65+ ).versionName
66+ } else {
67+ @Suppress("DEPRECATION")
68+ context.packageManager.getPackageInfo(context.packageName, 0).versionName
69+ }
70+ }.getOrNull() ?: unknownVersion
6071 }
6172
6273 Scaffold(
app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.kt+2−2
@@ -130,7 +130,7 @@ class WithRetryTest {
130130 var calls = 0
131131 val start = testScheduler.currentTime
132132 runCatching {
133− withRetryInternal(maxAttempts = 3, initialDelayMs = 100L) {
133+ withRetryInternal(maxAttempts = 3, initialDelayMs = 100L, jitterFactor = 0.0) {
134134 calls++
135135 throw IOException("always")
136136 }
@@ -147,7 +147,7 @@ class WithRetryTest {
147147 var calls = 0
148148 var lastTime = testScheduler.currentTime
149149 runCatching {
150− withRetryInternal(maxAttempts = 4, initialDelayMs = 50L) {
150+ withRetryInternal(maxAttempts = 4, initialDelayMs = 50L, jitterFactor = 0.0) {
151151 calls++
152152 val now = testScheduler.currentTime
153153 if (calls > 1) delays.add(now - lastTime)