git.iko.soy/opencode-android
on master help sign in
Move crash report file I/O off the main thread, deduplicate session preview fetching, and tidy up exception handling and naming
commitc68ef0e33f0bccd42b8d287baed5c1f839307e84 authoriko <mail@iko.soy> dateTue 30 Jun 2026, 04:19 (3 months ago) parenta626ac6
6 files changed+33−21
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+2−2 app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+4−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+2−2 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+5−4 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+16−10 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+4−2
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+2−2
@@ -125,8 +125,8 @@ class EventStreamClient(
125125 }
126126 } catch (c: CancellationException) {
127127 throw c
128− } catch (t: Throwable) {
129− Log.w("EventStream", "SSE stream error, will retry", t)
128+ } catch (e: Exception) {
129+ Log.w("EventStream", "SSE stream error, will retry", e)
130130 }
131131 _state.value = ConnectionState.Disconnected
132132 if (!isActive) break
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+4−1
@@ -45,7 +45,10 @@ class CrashLogger private constructor(private val appContext: Context) {
4545 .onFailure { Log.e("CrashLogger", "Failed to write crash report", it) }
4646 previous?.uncaughtException(thread, throwable)
4747 }
48− refresh()
48+ // 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
50+ // whenever the scan completes, so the Diagnostics screen reflects the result.
51+ Thread { refresh() }.start()
4952 }
5053
5154 fun refresh() {
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+2−2
@@ -112,7 +112,7 @@ fun ChatScreen(
112112 val draft by vm.draft.collectAsStateWithLifecycle()
113113 val haptics = LocalHapticFeedback.current
114114 val scope = rememberCoroutineScope()
115− val shareLauncher = LocalContext.current
115+ val shareContext = LocalContext.current
116116 val shareLabel = stringResource(R.string.share)
117117 val defaultShareSubject = stringResource(R.string.share_subject)
118118
@@ -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− runCatching { shareLauncher.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
238+ runCatching { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
239239 },
240240 enabled = messages.isNotEmpty(),
241241 ) {
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+5−4
@@ -105,10 +105,11 @@ private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
105105 }
106106}
107107
108−private fun formatTokens(tokens: Tokens, format: String): String {
109− val dec = java.text.DecimalFormat.getNumberInstance(java.util.Locale.US)
110− return format.format(dec.format(tokens.input), dec.format(tokens.output))
111−}
108+private val tokenNumberFormat: java.text.NumberFormat =
109+ java.text.DecimalFormat.getNumberInstance(java.util.Locale.US)
110+
111+private fun formatTokens(tokens: Tokens, format: String): String =
112+ format.format(tokenNumberFormat.format(tokens.input), tokenNumberFormat.format(tokens.output))
112113
113114private fun formatCost(cost: Double): String =
114115 // Locale.US so the formatting is stable regardless of device locale (avoids
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+16−10
@@ -195,11 +195,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
195195 livePreviewJobs[sessionId] = viewModelScope.launch {
196196 val job = coroutineContext[kotlinx.coroutines.Job]
197197 try {
198− val preview = runCatching {
199− api.listMessages(sessionId).lastOrNull()?.let { msg ->
200− msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
201− }
202− }.getOrNull()?.takeIf { it.isNotBlank() }?.take(NetworkConfig.previewTextMaxLength) ?: return@launch
198+ val preview = fetchSessionPreview(api, sessionId) ?: return@launch
203199 _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
204200 } finally {
205201 if (job != null) livePreviewJobs.remove(sessionId, job)
@@ -218,11 +214,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
218214 sessions.take(NetworkConfig.maxPreviewSessions).map { session ->
219215 launch {
220216 semaphore.withPermit {
221− val preview = runCatching {
222− api.listMessages(session.id).lastOrNull()?.let { msg ->
223− msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
224− }
225− }.getOrNull()?.takeIf { it.isNotBlank() }?.take(NetworkConfig.previewTextMaxLength) ?: return@withPermit
217+ val preview = fetchSessionPreview(api, session.id) ?: return@withPermit
226218 _state.update { s -> s.copy(previews = s.previews + (session.id to preview)) }
227219 }
228220 }
@@ -291,3 +283,17 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
291283 }
292284 }
293285}
286+
287+/**
288+ * Fetch the last text part of a session's most recent message, truncated to
289+ * [NetworkConfig.previewTextMaxLength]. Returns null on failure or when the
290+ * last message has no text part.
291+ */
292+private suspend fun fetchSessionPreview(
293+ api: soy.iko.opencode.data.network.OpencodeApiClient,
294+ sessionId: String,
295+): String? = runCatching {
296+ api.listMessages(sessionId).lastOrNull()?.let { msg ->
297+ msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
298+ }
299+}.getOrNull()?.takeIf { it.isNotBlank() }?.take(NetworkConfig.previewTextMaxLength)
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+4−2
@@ -72,7 +72,9 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
7272 },
7373 actions = {
7474 if (reports.isNotEmpty()) {
75− IconButton(onClick = { logger.clearAll() }) {
75+ IconButton(onClick = {
76+ shareScope.launch(Dispatchers.IO) { logger.clearAll() }
77+ }) {
7678 Icon(Icons.Filled.DeleteSweep, contentDescription = stringResource(R.string.clear_all))
7779 }
7880 }
@@ -174,7 +176,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
174176 }) { Text(stringResource(R.string.share)) }
175177 Spacer(Modifier.size(8.dp))
176178 TextButton(onClick = {
177− logger.deleteReport(reportName)
179+ shareScope.launch(Dispatchers.IO) { logger.deleteReport(reportName) }
178180 viewing = null
179181 }) { Text(stringResource(R.string.delete)) }
180182 }