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
c68ef0eiko
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+2−2
@@ -125,8 +125,8 @@ class EventStreamClient(
125 }
125 }
126 } catch (c: CancellationException) {
126 } catch (c: CancellationException) {
127 throw c
127 throw c
128− } catch (t: Throwable) {
128+ } catch (e: Exception) {
129− Log.w("EventStream", "SSE stream error, will retry", t)
129+ Log.w("EventStream", "SSE stream error, will retry", e)
130 }
130 }
131 _state.value = ConnectionState.Disconnected
131 _state.value = ConnectionState.Disconnected
132 if (!isActive) break
132 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) {
45 .onFailure { Log.e("CrashLogger", "Failed to write crash report", it) }
45 .onFailure { Log.e("CrashLogger", "Failed to write crash report", it) }
46 previous?.uncaughtException(thread, throwable)
46 previous?.uncaughtException(thread, throwable)
47 }
47 }
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.
48− refresh()
51+ Thread { refresh() }.start()
49 }
52 }
50
53
51 fun refresh() {
54 fun refresh() {
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+2−2
@@ -112,7 +112,7 @@ fun ChatScreen(
112 val draft by vm.draft.collectAsStateWithLifecycle()
112 val draft by vm.draft.collectAsStateWithLifecycle()
113 val haptics = LocalHapticFeedback.current
113 val haptics = LocalHapticFeedback.current
114 val scope = rememberCoroutineScope()
114 val scope = rememberCoroutineScope()
115− val shareLauncher = LocalContext.current
115+ val shareContext = LocalContext.current
116 val shareLabel = stringResource(R.string.share)
116 val shareLabel = stringResource(R.string.share)
117 val defaultShareSubject = stringResource(R.string.share_subject)
117 val defaultShareSubject = stringResource(R.string.share_subject)
118
118
@@ -235,7 +235,7 @@ fun ChatScreen(
235 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
235 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
236 putExtra(android.content.Intent.EXTRA_TEXT, md)
236 putExtra(android.content.Intent.EXTRA_TEXT, md)
237 }
237 }
238− runCatching { shareLauncher.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
238+ runCatching { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
239 },
239 },
240 enabled = messages.isNotEmpty(),
240 enabled = messages.isNotEmpty(),
241 ) {
241 ) {
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) {
105 }
105 }
106}
106}
107
107
108−private fun formatTokens(tokens: Tokens, format: String): String {
108+private val tokenNumberFormat: java.text.NumberFormat =
109− val dec = java.text.DecimalFormat.getNumberInstance(java.util.Locale.US)
109+ java.text.DecimalFormat.getNumberInstance(java.util.Locale.US)
110+
111+private fun formatTokens(tokens: Tokens, format: String): String =
110− return format.format(dec.format(tokens.input), dec.format(tokens.output))
112+ format.format(tokenNumberFormat.format(tokens.input), tokenNumberFormat.format(tokens.output))
111−}
112
113
113private fun formatCost(cost: Double): String =
114private fun formatCost(cost: Double): String =
114 // Locale.US so the formatting is stable regardless of device locale (avoids
115 // 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() {
195 livePreviewJobs[sessionId] = viewModelScope.launch {
195 livePreviewJobs[sessionId] = viewModelScope.launch {
196 val job = coroutineContext[kotlinx.coroutines.Job]
196 val job = coroutineContext[kotlinx.coroutines.Job]
197 try {
197 try {
198− val preview = runCatching {
198+ val preview = fetchSessionPreview(api, sessionId) ?: return@launch
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
203 _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
199 _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
204 } finally {
200 } finally {
205 if (job != null) livePreviewJobs.remove(sessionId, job)
201 if (job != null) livePreviewJobs.remove(sessionId, job)
@@ -218,11 +214,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
218 sessions.take(NetworkConfig.maxPreviewSessions).map { session ->
214 sessions.take(NetworkConfig.maxPreviewSessions).map { session ->
219 launch {
215 launch {
220 semaphore.withPermit {
216 semaphore.withPermit {
221− val preview = runCatching {
217+ val preview = fetchSessionPreview(api, session.id) ?: return@withPermit
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
226 _state.update { s -> s.copy(previews = s.previews + (session.id to preview)) }
218 _state.update { s -> s.copy(previews = s.previews + (session.id to preview)) }
227 }
219 }
228 }
220 }
@@ -291,3 +283,17 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
291 }
283 }
292 }
284 }
293}
285}
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) {
72 },
72 },
73 actions = {
73 actions = {
74 if (reports.isNotEmpty()) {
74 if (reports.isNotEmpty()) {
75− IconButton(onClick = { logger.clearAll() }) {
75+ IconButton(onClick = {
76+ shareScope.launch(Dispatchers.IO) { logger.clearAll() }
77+ }) {
76 Icon(Icons.Filled.DeleteSweep, contentDescription = stringResource(R.string.clear_all))
78 Icon(Icons.Filled.DeleteSweep, contentDescription = stringResource(R.string.clear_all))
77 }
79 }
78 }
80 }
@@ -174,7 +176,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
174 }) { Text(stringResource(R.string.share)) }
176 }) { Text(stringResource(R.string.share)) }
175 Spacer(Modifier.size(8.dp))
177 Spacer(Modifier.size(8.dp))
176 TextButton(onClick = {
178 TextButton(onClick = {
177− logger.deleteReport(reportName)
179+ shareScope.launch(Dispatchers.IO) { logger.deleteReport(reportName) }
178 viewing = null
180 viewing = null
179 }) { Text(stringResource(R.string.delete)) }
181 }) { Text(stringResource(R.string.delete)) }
180 }
182 }