git.iko.soy/opencode-android
on master help sign in
Add @Immutable annotations, rememberSaveable for dialog state, withRetry on mutating API calls, DraftStore error handling, and replace runBlocking with synchronous draft flush
commit66d239c1cd956d529829ab52b009c24698516903 authoriko <mail@iko.soy> dateTue 30 Jun 2026, 06:47 (3 months ago) parentbe4df91
17 files changed+95−22
app/src/main/java/soy/iko/opencode/data/model/Agent.kt+3 app/src/main/java/soy/iko/opencode/data/model/Common.kt+4 app/src/main/java/soy/iko/opencode/data/model/Files.kt+4 app/src/main/java/soy/iko/opencode/data/model/Message.kt+2 app/src/main/java/soy/iko/opencode/data/model/Provider.kt+6 app/src/main/java/soy/iko/opencode/data/model/ServerProfile.kt+3 app/src/main/java/soy/iko/opencode/data/model/Session.kt+2 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+6−3 app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+29−4 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+5−4 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+3−2 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+2 app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+2 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+2 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+7−6 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+2 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+13−3
app/src/main/java/soy/iko/opencode/data/model/Agent.kt+3
@@ -1,8 +1,10 @@
11package soy.iko.opencode.data.model
22
3+import androidx.compose.runtime.Immutable
34import kotlinx.serialization.Serializable
45
56/** Entry from `GET /agent`. */
7+@Immutable
68@Serializable
79data class Agent(
810 val name: String,
@@ -16,6 +18,7 @@ data class Agent(
1618}
1719
1820/** Entry from `GET /command`. */
21+@Immutable
1922@Serializable
2023data class Command(
2124 val name: String,
app/src/main/java/soy/iko/opencode/data/model/Common.kt+4
@@ -1,7 +1,9 @@
11package soy.iko.opencode.data.model
22
3+import androidx.compose.runtime.Immutable
34import kotlinx.serialization.Serializable
45
6+@Immutable
57@Serializable
68data class TimeInfo(
79 val created: Long? = null,
@@ -9,6 +11,7 @@ data class TimeInfo(
911 val completed: Long? = null,
1012)
1113
14+@Immutable
1215@Serializable
1316data class Tokens(
1417 val input: Long = 0,
@@ -17,6 +20,7 @@ data class Tokens(
1720 val cache: Cache = Cache(),
1821) {
1922 @Serializable
23+ @Immutable
2024 data class Cache(
2125 val read: Long = 0,
2226 val write: Long = 0,
app/src/main/java/soy/iko/opencode/data/model/Files.kt+4
@@ -1,8 +1,10 @@
11package soy.iko.opencode.data.model
22
3+import androidx.compose.runtime.Immutable
34import kotlinx.serialization.Serializable
45
56/** Entry from `GET /file?path=` (directory listing). */
7+@Immutable
68@Serializable
79data class FileNode(
810 val name: String = "",
@@ -13,6 +15,7 @@ data class FileNode(
1315}
1416
1517/** Response of `GET /file/content?path=`. */
18+@Immutable
1619@Serializable
1720data class FileContent(
1821 val type: String? = null, // "text" | "binary"
@@ -25,6 +28,7 @@ data class FileContent(
2528}
2629
2730/** Entry from `GET /file/status` (VCS status). */
31+@Immutable
2832@Serializable
2933data class FileStatusEntry(
3034 val path: String = "",
app/src/main/java/soy/iko/opencode/data/model/Message.kt+2
@@ -1,5 +1,6 @@
11package soy.iko.opencode.data.model
22
3+import androidx.compose.runtime.Immutable
34import kotlinx.serialization.SerialName
45import kotlinx.serialization.Serializable
56import kotlinx.serialization.json.JsonObject
@@ -53,6 +54,7 @@ data class UnknownMessage(
5354) : MessageInfo
5455
5556/** A message together with its parts — the shape returned by message endpoints and the prompt response. */
57+@Immutable
5658@Serializable
5759data class MessageWithParts(
5860 val info: MessageInfo,
app/src/main/java/soy/iko/opencode/data/model/Provider.kt+6
@@ -1,14 +1,17 @@
11package soy.iko.opencode.data.model
22
3+import androidx.compose.runtime.Immutable
34import kotlinx.serialization.Serializable
45
56/** Response of `GET /config/providers`. */
7+@Immutable
68@Serializable
79data class ProvidersResponse(
810 val providers: List<Provider> = emptyList(),
911 val default: Map<String, String> = emptyMap(),
1012)
1113
14+@Immutable
1215@Serializable
1316data class Provider(
1417 val id: String,
@@ -18,6 +21,7 @@ data class Provider(
1821 val displayName: String get() = name?.takeIf { it.isNotBlank() } ?: id
1922}
2023
24+@Immutable
2125@Serializable
2226data class ModelInfo(
2327 val id: String? = null,
@@ -28,6 +32,7 @@ data class ModelInfo(
2832}
2933
3034/** A flattened provider/model selection used by the prompt body and the model picker. */
35+@Immutable
3136@Serializable
3237data class ModelRef(
3338 val providerID: String,
@@ -35,6 +40,7 @@ data class ModelRef(
3540)
3641
3742/** A single selectable entry in the model picker, flattened from [ProvidersResponse]. */
43+@Immutable
3844data class ModelOption(
3945 val providerID: String,
4046 val modelID: String,
app/src/main/java/soy/iko/opencode/data/model/ServerProfile.kt+3
@@ -1,9 +1,12 @@
11package soy.iko.opencode.data.model
22
3+import androidx.compose.runtime.Immutable
4+
35/**
46 * A saved opencode server connection. The [password] for HTTP Basic auth is resolved
57 * separately from EncryptedSharedPreferences and is null when not loaded/needed.
68 */
9+@Immutable
710data class ServerProfile(
811 val id: String,
912 val label: String,
app/src/main/java/soy/iko/opencode/data/model/Session.kt+2
@@ -1,8 +1,10 @@
11package soy.iko.opencode.data.model
22
3+import androidx.compose.runtime.Immutable
34import kotlinx.serialization.Serializable
45
56/** A session as returned by `GET /session` and `POST /session`. */
7+@Immutable
68@Serializable
79data class Session(
810 val id: String,
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+6−3
@@ -59,17 +59,19 @@ class OpencodeApiClient(private val client: HttpClient) {
5959 client.get("session").body()
6060 }
6161
62− suspend fun createSession(title: String? = null): Session =
62+ suspend fun createSession(title: String? = null): Session = withRetry {
6363 client.post("session") {
6464 contentType(ContentType.Application.Json)
6565 setBody(CreateSessionRequest(title = title))
6666 }.body()
67+ }
6768
68− suspend fun updateSession(id: String, title: String): Session =
69+ suspend fun updateSession(id: String, title: String): Session = withRetry {
6970 client.patch("session/${encode(id)}") {
7071 contentType(ContentType.Application.Json)
7172 setBody(UpdateSessionRequest(title = title))
7273 }.body()
74+ }
7375
7476 suspend fun deleteSession(id: String) {
7577 client.delete("session/${encode(id)}")
@@ -112,11 +114,12 @@ class OpencodeApiClient(private val client: HttpClient) {
112114 command: String,
113115 arguments: String = "",
114116 agent: String? = null,
115− ): MessageWithParts =
117+ ): MessageWithParts = withRetry {
116118 client.post("session/${encode(sessionId)}/command") {
117119 contentType(ContentType.Application.Json)
118120 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
119121 }.body()
122+ }
120123
121124 suspend fun providers(): ProvidersResponse = cacheMutex.withLock {
122125 val now = System.currentTimeMillis()
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+29−4
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
77import kotlinx.coroutines.flow.StateFlow
88import kotlinx.coroutines.flow.asStateFlow
99import kotlinx.coroutines.flow.update
10+import android.util.Log
1011import kotlinx.coroutines.launch
1112import kotlinx.coroutines.withContext
1213
@@ -58,9 +59,11 @@ class DraftStore(context: Context, scope: CoroutineScope) {
5859 updated
5960 }
6061 withContext(Dispatchers.IO) {
61− prefs.edit().apply {
62− if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
63− }.apply()
62+ runCatching {
63+ prefs.edit().apply {
64+ if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
65+ }.apply()
66+ }.onFailure { Log.w("DraftStore", "Failed to persist draft for $sessionId", it) }
6467 }
6568 }
6669
@@ -72,7 +75,29 @@ class DraftStore(context: Context, scope: CoroutineScope) {
7275 updated
7376 }
7477 withContext(Dispatchers.IO) {
75− prefs.edit().remove(sessionId).apply()
78+ runCatching {
79+ prefs.edit().remove(sessionId).apply()
80+ }.onFailure { Log.w("DraftStore", "Failed to remove draft for $sessionId", it) }
7681 }
7782 }
83+
84+ /**
85+ * Synchronously persist a draft using [commit] instead of [apply]. Intended for
86+ * [ViewModel.onCleared] where the viewModelScope is already cancelled and a
87+ * suspending write would require [runBlocking], risking an ANR. Safe to call
88+ * from the main thread — [commit] blocks briefly on disk I/O but the data is
89+ * small (a single key-value pair).
90+ */
91+ fun flushDraft(sessionId: String, text: String) {
92+ _drafts.update { current ->
93+ val updated = current.toMutableMap()
94+ if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
95+ updated
96+ }
97+ runCatching {
98+ prefs.edit().apply {
99+ if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
100+ }.commit()
101+ }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
102+ }
78103}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+5−4
@@ -56,6 +56,7 @@ import androidx.compose.runtime.getValue
5656import androidx.compose.runtime.mutableStateOf
5757import androidx.compose.runtime.remember
5858import androidx.compose.runtime.rememberCoroutineScope
59+import androidx.compose.runtime.saveable.rememberSaveable
5960import androidx.compose.runtime.setValue
6061import androidx.compose.foundation.text.KeyboardActions
6162import androidx.compose.foundation.text.KeyboardOptions
@@ -136,10 +137,10 @@ fun ChatScreen(
136137
137138 val listState = rememberLazyListState()
138139 val snackbar = remember { SnackbarHostState() }
139− var showModelPicker by remember { mutableStateOf(false) }
140− var showAgentPicker by remember { mutableStateOf(false) }
141− var showCommandPicker by remember { mutableStateOf(false) }
142− var showExitConfirm by remember { mutableStateOf(false) }
140+ var showModelPicker by rememberSaveable { mutableStateOf(false) }
141+ var showAgentPicker by rememberSaveable { mutableStateOf(false) }
142+ var showCommandPicker by rememberSaveable { mutableStateOf(false) }
143+ var showExitConfirm by rememberSaveable { mutableStateOf(false) }
143144
144145 // Keep the screen awake and hold a foreground priority while the agent is working,
145146 // so backgrounding mid-run doesn't let Doze choke the SSE stream.
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+3−2
@@ -327,10 +327,11 @@ class ChatViewModel(
327327
328328 override fun onCleared() {
329329 super.onCleared()
330− // Flush any pending debounced draft so it survives navigation.
330+ // Flush any pending debounced draft so it survives navigation. Use a synchronous
331+ // commit instead of runBlocking so the main thread isn't held hostage by disk I/O.
331332 val pending = _draft.value
332333 if (pending.isNotEmpty()) {
333− kotlinx.coroutines.runBlocking { container.draftStore.set(sessionId, pending) }
334+ container.draftStore.flushDraft(sessionId, pending)
334335 }
335336 }
336337}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+2
@@ -1,5 +1,6 @@
11package soy.iko.opencode.ui.file
22
3+import androidx.compose.runtime.Immutable
34import androidx.lifecycle.ViewModel
45import androidx.lifecycle.viewModelScope
56import soy.iko.opencode.data.model.FileNode
@@ -17,6 +18,7 @@ import kotlinx.coroutines.flow.asStateFlow
1718import kotlinx.coroutines.flow.collectLatest
1819import kotlinx.coroutines.launch
1920
21+@Immutable
2022data class FileBrowserState(
2123 val path: String = "",
2224 val entries: List<FileNode> = emptyList(),
app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+2
@@ -1,5 +1,6 @@
11package soy.iko.opencode.ui.file
22
3+import androidx.compose.runtime.Immutable
34import androidx.lifecycle.ViewModel
45import androidx.lifecycle.viewModelScope
56import soy.iko.opencode.data.model.FileContent
@@ -13,6 +14,7 @@ import kotlinx.coroutines.flow.collectLatest
1314import kotlinx.coroutines.launch
1415import soy.iko.opencode.util.runCatchingCancellable
1516
17+@Immutable
1618data class FileViewState(
1719 val loading: Boolean = true,
1820 val content: FileContent? = null,
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+2
@@ -1,5 +1,6 @@
11package soy.iko.opencode.ui.server
22
3+import androidx.compose.runtime.Immutable
34import androidx.lifecycle.ViewModel
45import androidx.lifecycle.viewModelScope
56import soy.iko.opencode.data.model.ServerProfile
@@ -14,6 +15,7 @@ import soy.iko.opencode.R
1415import soy.iko.opencode.util.runCatchingCancellable
1516import java.util.UUID
1617
18+@Immutable
1719data class ServerEditState(
1820 val id: String? = null,
1921 val label: String = "",
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+7−6
@@ -38,6 +38,7 @@ import androidx.compose.runtime.LaunchedEffect
3838import androidx.compose.runtime.getValue
3939import androidx.compose.runtime.mutableStateOf
4040import androidx.compose.runtime.remember
41+import androidx.compose.runtime.saveable.rememberSaveable
4142import androidx.compose.runtime.setValue
4243import androidx.compose.ui.Alignment
4344import androidx.compose.ui.Modifier
@@ -68,7 +69,7 @@ fun ServerListScreen(
6869 val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
6970 val haptics = LocalHapticFeedback.current
7071 val snackbar = remember { SnackbarHostState() }
71− var pendingDelete by remember { mutableStateOf<ServerProfile?>(null) }
72+ var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
7273 val connectedId = activeConnection?.profile?.id
7374
7475 LaunchedEffect(error) {
@@ -162,7 +163,7 @@ fun ServerListScreen(
162163 IconButton(onClick = { onEditProfile(profile.id) }) {
163164 Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
164165 }
165− IconButton(onClick = { pendingDelete = profile }) {
166+ IconButton(onClick = { pendingDeleteId = profile.id }) {
166167 Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
167168 }
168169 }
@@ -174,20 +175,20 @@ fun ServerListScreen(
174175 }
175176 }
176177
177− pendingDelete?.let { profile ->
178+ profiles.find { it.id == pendingDeleteId }?.let { profile ->
178179 AlertDialog(
179− onDismissRequest = { pendingDelete = null },
180+ onDismissRequest = { pendingDeleteId = null },
180181 title = { Text(stringResource(R.string.remove_server_title)) },
181182 text = { Text(stringResource(R.string.remove_server_text, profile.displayLabel)) },
182183 confirmButton = {
183184 TextButton(onClick = {
184185 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
185− pendingDelete = null
186+ pendingDeleteId = null
186187 vm.delete(profile)
187188 }) { Text(stringResource(R.string.remove), color = MaterialTheme.colorScheme.error) }
188189 },
189190 dismissButton = {
190− TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.cancel)) }
191+ TextButton(onClick = { pendingDeleteId = null }) { Text(stringResource(R.string.cancel)) }
191192 },
192193 )
193194 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+2
@@ -1,5 +1,6 @@
11package soy.iko.opencode.ui.session
22
3+import androidx.compose.runtime.Immutable
34import androidx.lifecycle.ViewModel
45import androidx.lifecycle.viewModelScope
56import soy.iko.opencode.data.model.ServerProfile
@@ -28,6 +29,7 @@ import kotlinx.coroutines.launch
2829import kotlinx.coroutines.sync.Semaphore
2930import kotlinx.coroutines.sync.withPermit
3031
32+@Immutable
3133data class SessionListState(
3234 val sessions: List<Session> = emptyList(),
3335 val query: String = "",
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+13−3
@@ -1,6 +1,7 @@
11package soy.iko.opencode.ui.settings
22
33import android.content.Intent
4+import android.util.Log
45import androidx.compose.foundation.clickable
56import androidx.compose.foundation.layout.Arrangement
67import androidx.compose.foundation.layout.Column
@@ -33,6 +34,7 @@ import androidx.compose.runtime.getValue
3334import androidx.compose.runtime.mutableStateOf
3435import androidx.compose.runtime.remember
3536import androidx.compose.runtime.rememberCoroutineScope
37+import androidx.compose.runtime.saveable.rememberSaveable
3638import androidx.compose.runtime.setValue
3739import kotlinx.coroutines.Dispatchers
3840import kotlinx.coroutines.launch
@@ -57,7 +59,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
5759 val context = LocalContext.current
5860 val logger = remember { CrashLogger.get(context) }
5961 val reports by logger.reports.collectAsStateWithLifecycle()
60− var viewing by remember { mutableStateOf<String?>(null) }
62+ var viewing by rememberSaveable { mutableStateOf<String?>(null) }
6163 val shareScope = rememberCoroutineScope()
6264 val shareLabel = stringResource(R.string.share)
6365
@@ -73,7 +75,10 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
7375 actions = {
7476 if (reports.isNotEmpty()) {
7577 IconButton(onClick = {
76− shareScope.launch(Dispatchers.IO) { runCatching { logger.clearAll() } }
78+ shareScope.launch(Dispatchers.IO) {
79+ runCatching { logger.clearAll() }
80+ .onFailure { Log.w("Diagnostics", "Failed to clear crash reports", it) }
81+ }
7782 }) {
7883 Icon(Icons.Filled.DeleteSweep, contentDescription = stringResource(R.string.clear_all))
7984 }
@@ -139,6 +144,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
139144 putExtra(Intent.EXTRA_TEXT, content)
140145 }
141146 runCatching { context.startActivity(Intent.createChooser(send, shareLabel)) }
147+ .onFailure { Log.w("Diagnostics", "Failed to share crash report", it) }
142148 }
143149 }) {
144150 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
@@ -173,10 +179,14 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
173179 putExtra(Intent.EXTRA_TEXT, content.orEmpty())
174180 }
175181 runCatching { context.startActivity(Intent.createChooser(send, shareLabel2)) }
182+ .onFailure { Log.w("Diagnostics", "Failed to share crash report", it) }
176183 }) { Text(stringResource(R.string.share)) }
177184 Spacer(Modifier.size(8.dp))
178185 TextButton(onClick = {
179− shareScope.launch(Dispatchers.IO) { runCatching { logger.deleteReport(reportName) } }
186+ shareScope.launch(Dispatchers.IO) {
187+ runCatching { logger.deleteReport(reportName) }
188+ .onFailure { Log.w("Diagnostics", "Failed to delete crash report", it) }
189+ }
180190 viewing = null
181191 }) { Text(stringResource(R.string.delete)) }
182192 }