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
66d239ciko
app/src/main/java/soy/iko/opencode/data/model/Agent.kt+3
@@ -1,8 +1,10 @@
1package soy.iko.opencode.data.model
1package soy.iko.opencode.data.model
2
2
3+import androidx.compose.runtime.Immutable
3import kotlinx.serialization.Serializable
4import kotlinx.serialization.Serializable
4
5
5/** Entry from `GET /agent`. */
6/** Entry from `GET /agent`. */
7+@Immutable
6@Serializable
8@Serializable
7data class Agent(
9data class Agent(
8 val name: String,
10 val name: String,
@@ -16,6 +18,7 @@ data class Agent(
16}
18}
17
19
18/** Entry from `GET /command`. */
20/** Entry from `GET /command`. */
21+@Immutable
19@Serializable
22@Serializable
20data class Command(
23data class Command(
21 val name: String,
24 val name: String,
app/src/main/java/soy/iko/opencode/data/model/Common.kt+4
@@ -1,7 +1,9 @@
1package soy.iko.opencode.data.model
1package soy.iko.opencode.data.model
2
2
3+import androidx.compose.runtime.Immutable
3import kotlinx.serialization.Serializable
4import kotlinx.serialization.Serializable
4
5
6+@Immutable
5@Serializable
7@Serializable
6data class TimeInfo(
8data class TimeInfo(
7 val created: Long? = null,
9 val created: Long? = null,
@@ -9,6 +11,7 @@ data class TimeInfo(
9 val completed: Long? = null,
11 val completed: Long? = null,
10)
12)
11
13
14+@Immutable
12@Serializable
15@Serializable
13data class Tokens(
16data class Tokens(
14 val input: Long = 0,
17 val input: Long = 0,
@@ -17,6 +20,7 @@ data class Tokens(
17 val cache: Cache = Cache(),
20 val cache: Cache = Cache(),
18) {
21) {
19 @Serializable
22 @Serializable
23+ @Immutable
20 data class Cache(
24 data class Cache(
21 val read: Long = 0,
25 val read: Long = 0,
22 val write: Long = 0,
26 val write: Long = 0,
app/src/main/java/soy/iko/opencode/data/model/Files.kt+4
@@ -1,8 +1,10 @@
1package soy.iko.opencode.data.model
1package soy.iko.opencode.data.model
2
2
3+import androidx.compose.runtime.Immutable
3import kotlinx.serialization.Serializable
4import kotlinx.serialization.Serializable
4
5
5/** Entry from `GET /file?path=` (directory listing). */
6/** Entry from `GET /file?path=` (directory listing). */
7+@Immutable
6@Serializable
8@Serializable
7data class FileNode(
9data class FileNode(
8 val name: String = "",
10 val name: String = "",
@@ -13,6 +15,7 @@ data class FileNode(
13}
15}
14
16
15/** Response of `GET /file/content?path=`. */
17/** Response of `GET /file/content?path=`. */
18+@Immutable
16@Serializable
19@Serializable
17data class FileContent(
20data class FileContent(
18 val type: String? = null, // "text" | "binary"
21 val type: String? = null, // "text" | "binary"
@@ -25,6 +28,7 @@ data class FileContent(
25}
28}
26
29
27/** Entry from `GET /file/status` (VCS status). */
30/** Entry from `GET /file/status` (VCS status). */
31+@Immutable
28@Serializable
32@Serializable
29data class FileStatusEntry(
33data class FileStatusEntry(
30 val path: String = "",
34 val path: String = "",
app/src/main/java/soy/iko/opencode/data/model/Message.kt+2
@@ -1,5 +1,6 @@
1package soy.iko.opencode.data.model
1package soy.iko.opencode.data.model
2
2
3+import androidx.compose.runtime.Immutable
3import kotlinx.serialization.SerialName
4import kotlinx.serialization.SerialName
4import kotlinx.serialization.Serializable
5import kotlinx.serialization.Serializable
5import kotlinx.serialization.json.JsonObject
6import kotlinx.serialization.json.JsonObject
@@ -53,6 +54,7 @@ data class UnknownMessage(
53) : MessageInfo
54) : MessageInfo
54
55
55/** A message together with its parts — the shape returned by message endpoints and the prompt response. */
56/** A message together with its parts — the shape returned by message endpoints and the prompt response. */
57+@Immutable
56@Serializable
58@Serializable
57data class MessageWithParts(
59data class MessageWithParts(
58 val info: MessageInfo,
60 val info: MessageInfo,
app/src/main/java/soy/iko/opencode/data/model/Provider.kt+6
@@ -1,14 +1,17 @@
1package soy.iko.opencode.data.model
1package soy.iko.opencode.data.model
2
2
3+import androidx.compose.runtime.Immutable
3import kotlinx.serialization.Serializable
4import kotlinx.serialization.Serializable
4
5
5/** Response of `GET /config/providers`. */
6/** Response of `GET /config/providers`. */
7+@Immutable
6@Serializable
8@Serializable
7data class ProvidersResponse(
9data class ProvidersResponse(
8 val providers: List<Provider> = emptyList(),
10 val providers: List<Provider> = emptyList(),
9 val default: Map<String, String> = emptyMap(),
11 val default: Map<String, String> = emptyMap(),
10)
12)
11
13
14+@Immutable
12@Serializable
15@Serializable
13data class Provider(
16data class Provider(
14 val id: String,
17 val id: String,
@@ -18,6 +21,7 @@ data class Provider(
18 val displayName: String get() = name?.takeIf { it.isNotBlank() } ?: id
21 val displayName: String get() = name?.takeIf { it.isNotBlank() } ?: id
19}
22}
20
23
24+@Immutable
21@Serializable
25@Serializable
22data class ModelInfo(
26data class ModelInfo(
23 val id: String? = null,
27 val id: String? = null,
@@ -28,6 +32,7 @@ data class ModelInfo(
28}
32}
29
33
30/** A flattened provider/model selection used by the prompt body and the model picker. */
34/** A flattened provider/model selection used by the prompt body and the model picker. */
35+@Immutable
31@Serializable
36@Serializable
32data class ModelRef(
37data class ModelRef(
33 val providerID: String,
38 val providerID: String,
@@ -35,6 +40,7 @@ data class ModelRef(
35)
40)
36
41
37/** A single selectable entry in the model picker, flattened from [ProvidersResponse]. */
42/** A single selectable entry in the model picker, flattened from [ProvidersResponse]. */
43+@Immutable
38data class ModelOption(
44data class ModelOption(
39 val providerID: String,
45 val providerID: String,
40 val modelID: String,
46 val modelID: String,
app/src/main/java/soy/iko/opencode/data/model/ServerProfile.kt+3
@@ -1,9 +1,12 @@
1package soy.iko.opencode.data.model
1package soy.iko.opencode.data.model
2
2
3+import androidx.compose.runtime.Immutable
4+
3/**
5/**
4 * A saved opencode server connection. The [password] for HTTP Basic auth is resolved
6 * A saved opencode server connection. The [password] for HTTP Basic auth is resolved
5 * separately from EncryptedSharedPreferences and is null when not loaded/needed.
7 * separately from EncryptedSharedPreferences and is null when not loaded/needed.
6 */
8 */
9+@Immutable
7data class ServerProfile(
10data class ServerProfile(
8 val id: String,
11 val id: String,
9 val label: String,
12 val label: String,
app/src/main/java/soy/iko/opencode/data/model/Session.kt+2
@@ -1,8 +1,10 @@
1package soy.iko.opencode.data.model
1package soy.iko.opencode.data.model
2
2
3+import androidx.compose.runtime.Immutable
3import kotlinx.serialization.Serializable
4import kotlinx.serialization.Serializable
4
5
5/** A session as returned by `GET /session` and `POST /session`. */
6/** A session as returned by `GET /session` and `POST /session`. */
7+@Immutable
6@Serializable
8@Serializable
7data class Session(
9data class Session(
8 val id: String,
10 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) {
59 client.get("session").body()
59 client.get("session").body()
60 }
60 }
61
61
62− suspend fun createSession(title: String? = null): Session =
62+ suspend fun createSession(title: String? = null): Session = withRetry {
63 client.post("session") {
63 client.post("session") {
64 contentType(ContentType.Application.Json)
64 contentType(ContentType.Application.Json)
65 setBody(CreateSessionRequest(title = title))
65 setBody(CreateSessionRequest(title = title))
66 }.body()
66 }.body()
67+ }
67
68
68− suspend fun updateSession(id: String, title: String): Session =
69+ suspend fun updateSession(id: String, title: String): Session = withRetry {
69 client.patch("session/${encode(id)}") {
70 client.patch("session/${encode(id)}") {
70 contentType(ContentType.Application.Json)
71 contentType(ContentType.Application.Json)
71 setBody(UpdateSessionRequest(title = title))
72 setBody(UpdateSessionRequest(title = title))
72 }.body()
73 }.body()
74+ }
73
75
74 suspend fun deleteSession(id: String) {
76 suspend fun deleteSession(id: String) {
75 client.delete("session/${encode(id)}")
77 client.delete("session/${encode(id)}")
@@ -112,11 +114,12 @@ class OpencodeApiClient(private val client: HttpClient) {
112 command: String,
114 command: String,
113 arguments: String = "",
115 arguments: String = "",
114 agent: String? = null,
116 agent: String? = null,
115− ): MessageWithParts =
117+ ): MessageWithParts = withRetry {
116 client.post("session/${encode(sessionId)}/command") {
118 client.post("session/${encode(sessionId)}/command") {
117 contentType(ContentType.Application.Json)
119 contentType(ContentType.Application.Json)
118 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
120 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
119 }.body()
121 }.body()
122+ }
120
123
121 suspend fun providers(): ProvidersResponse = cacheMutex.withLock {
124 suspend fun providers(): ProvidersResponse = cacheMutex.withLock {
122 val now = System.currentTimeMillis()
125 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
7import kotlinx.coroutines.flow.StateFlow
7import kotlinx.coroutines.flow.StateFlow
8import kotlinx.coroutines.flow.asStateFlow
8import kotlinx.coroutines.flow.asStateFlow
9import kotlinx.coroutines.flow.update
9import kotlinx.coroutines.flow.update
10+import android.util.Log
10import kotlinx.coroutines.launch
11import kotlinx.coroutines.launch
11import kotlinx.coroutines.withContext
12import kotlinx.coroutines.withContext
12
13
@@ -58,9 +59,11 @@ class DraftStore(context: Context, scope: CoroutineScope) {
58 updated
59 updated
59 }
60 }
60 withContext(Dispatchers.IO) {
61 withContext(Dispatchers.IO) {
62+ runCatching {
61− prefs.edit().apply {
63+ prefs.edit().apply {
62− if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
64+ if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
63− }.apply()
65+ }.apply()
66+ }.onFailure { Log.w("DraftStore", "Failed to persist draft for $sessionId", it) }
64 }
67 }
65 }
68 }
66
69
@@ -72,7 +75,29 @@ class DraftStore(context: Context, scope: CoroutineScope) {
72 updated
75 updated
73 }
76 }
74 withContext(Dispatchers.IO) {
77 withContext(Dispatchers.IO) {
78+ runCatching {
75− prefs.edit().remove(sessionId).apply()
79+ prefs.edit().remove(sessionId).apply()
80+ }.onFailure { Log.w("DraftStore", "Failed to remove draft for $sessionId", it) }
76 }
81 }
77 }
82 }
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+ }
78}
103}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+5−4
@@ -56,6 +56,7 @@ import androidx.compose.runtime.getValue
56import androidx.compose.runtime.mutableStateOf
56import androidx.compose.runtime.mutableStateOf
57import androidx.compose.runtime.remember
57import androidx.compose.runtime.remember
58import androidx.compose.runtime.rememberCoroutineScope
58import androidx.compose.runtime.rememberCoroutineScope
59+import androidx.compose.runtime.saveable.rememberSaveable
59import androidx.compose.runtime.setValue
60import androidx.compose.runtime.setValue
60import androidx.compose.foundation.text.KeyboardActions
61import androidx.compose.foundation.text.KeyboardActions
61import androidx.compose.foundation.text.KeyboardOptions
62import androidx.compose.foundation.text.KeyboardOptions
@@ -136,10 +137,10 @@ fun ChatScreen(
136
137
137 val listState = rememberLazyListState()
138 val listState = rememberLazyListState()
138 val snackbar = remember { SnackbarHostState() }
139 val snackbar = remember { SnackbarHostState() }
139− var showModelPicker by remember { mutableStateOf(false) }
140+ var showModelPicker by rememberSaveable { mutableStateOf(false) }
140− var showAgentPicker by remember { mutableStateOf(false) }
141+ var showAgentPicker by rememberSaveable { mutableStateOf(false) }
141− var showCommandPicker by remember { mutableStateOf(false) }
142+ var showCommandPicker by rememberSaveable { mutableStateOf(false) }
142− var showExitConfirm by remember { mutableStateOf(false) }
143+ var showExitConfirm by rememberSaveable { mutableStateOf(false) }
143
144
144 // Keep the screen awake and hold a foreground priority while the agent is working,
145 // Keep the screen awake and hold a foreground priority while the agent is working,
145 // so backgrounding mid-run doesn't let Doze choke the SSE stream.
146 // 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(
327
327
328 override fun onCleared() {
328 override fun onCleared() {
329 super.onCleared()
329 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.
331 val pending = _draft.value
332 val pending = _draft.value
332 if (pending.isNotEmpty()) {
333 if (pending.isNotEmpty()) {
333− kotlinx.coroutines.runBlocking { container.draftStore.set(sessionId, pending) }
334+ container.draftStore.flushDraft(sessionId, pending)
334 }
335 }
335 }
336 }
336}
337}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+2
@@ -1,5 +1,6 @@
1package soy.iko.opencode.ui.file
1package soy.iko.opencode.ui.file
2
2
3+import androidx.compose.runtime.Immutable
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
5import androidx.lifecycle.viewModelScope
5import soy.iko.opencode.data.model.FileNode
6import soy.iko.opencode.data.model.FileNode
@@ -17,6 +18,7 @@ import kotlinx.coroutines.flow.asStateFlow
17import kotlinx.coroutines.flow.collectLatest
18import kotlinx.coroutines.flow.collectLatest
18import kotlinx.coroutines.launch
19import kotlinx.coroutines.launch
19
20
21+@Immutable
20data class FileBrowserState(
22data class FileBrowserState(
21 val path: String = "",
23 val path: String = "",
22 val entries: List<FileNode> = emptyList(),
24 val entries: List<FileNode> = emptyList(),
app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+2
@@ -1,5 +1,6 @@
1package soy.iko.opencode.ui.file
1package soy.iko.opencode.ui.file
2
2
3+import androidx.compose.runtime.Immutable
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
5import androidx.lifecycle.viewModelScope
5import soy.iko.opencode.data.model.FileContent
6import soy.iko.opencode.data.model.FileContent
@@ -13,6 +14,7 @@ import kotlinx.coroutines.flow.collectLatest
13import kotlinx.coroutines.launch
14import kotlinx.coroutines.launch
14import soy.iko.opencode.util.runCatchingCancellable
15import soy.iko.opencode.util.runCatchingCancellable
15
16
17+@Immutable
16data class FileViewState(
18data class FileViewState(
17 val loading: Boolean = true,
19 val loading: Boolean = true,
18 val content: FileContent? = null,
20 val content: FileContent? = null,
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+2
@@ -1,5 +1,6 @@
1package soy.iko.opencode.ui.server
1package soy.iko.opencode.ui.server
2
2
3+import androidx.compose.runtime.Immutable
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
5import androidx.lifecycle.viewModelScope
5import soy.iko.opencode.data.model.ServerProfile
6import soy.iko.opencode.data.model.ServerProfile
@@ -14,6 +15,7 @@ import soy.iko.opencode.R
14import soy.iko.opencode.util.runCatchingCancellable
15import soy.iko.opencode.util.runCatchingCancellable
15import java.util.UUID
16import java.util.UUID
16
17
18+@Immutable
17data class ServerEditState(
19data class ServerEditState(
18 val id: String? = null,
20 val id: String? = null,
19 val label: String = "",
21 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
38import androidx.compose.runtime.getValue
38import androidx.compose.runtime.getValue
39import androidx.compose.runtime.mutableStateOf
39import androidx.compose.runtime.mutableStateOf
40import androidx.compose.runtime.remember
40import androidx.compose.runtime.remember
41+import androidx.compose.runtime.saveable.rememberSaveable
41import androidx.compose.runtime.setValue
42import androidx.compose.runtime.setValue
42import androidx.compose.ui.Alignment
43import androidx.compose.ui.Alignment
43import androidx.compose.ui.Modifier
44import androidx.compose.ui.Modifier
@@ -68,7 +69,7 @@ fun ServerListScreen(
68 val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
69 val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
69 val haptics = LocalHapticFeedback.current
70 val haptics = LocalHapticFeedback.current
70 val snackbar = remember { SnackbarHostState() }
71 val snackbar = remember { SnackbarHostState() }
71− var pendingDelete by remember { mutableStateOf<ServerProfile?>(null) }
72+ var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
72 val connectedId = activeConnection?.profile?.id
73 val connectedId = activeConnection?.profile?.id
73
74
74 LaunchedEffect(error) {
75 LaunchedEffect(error) {
@@ -162,7 +163,7 @@ fun ServerListScreen(
162 IconButton(onClick = { onEditProfile(profile.id) }) {
163 IconButton(onClick = { onEditProfile(profile.id) }) {
163 Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
164 Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
164 }
165 }
165− IconButton(onClick = { pendingDelete = profile }) {
166+ IconButton(onClick = { pendingDeleteId = profile.id }) {
166 Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
167 Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
167 }
168 }
168 }
169 }
@@ -174,20 +175,20 @@ fun ServerListScreen(
174 }
175 }
175 }
176 }
176
177
177− pendingDelete?.let { profile ->
178+ profiles.find { it.id == pendingDeleteId }?.let { profile ->
178 AlertDialog(
179 AlertDialog(
179− onDismissRequest = { pendingDelete = null },
180+ onDismissRequest = { pendingDeleteId = null },
180 title = { Text(stringResource(R.string.remove_server_title)) },
181 title = { Text(stringResource(R.string.remove_server_title)) },
181 text = { Text(stringResource(R.string.remove_server_text, profile.displayLabel)) },
182 text = { Text(stringResource(R.string.remove_server_text, profile.displayLabel)) },
182 confirmButton = {
183 confirmButton = {
183 TextButton(onClick = {
184 TextButton(onClick = {
184 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
185 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
185− pendingDelete = null
186+ pendingDeleteId = null
186 vm.delete(profile)
187 vm.delete(profile)
187 }) { Text(stringResource(R.string.remove), color = MaterialTheme.colorScheme.error) }
188 }) { Text(stringResource(R.string.remove), color = MaterialTheme.colorScheme.error) }
188 },
189 },
189 dismissButton = {
190 dismissButton = {
190− TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.cancel)) }
191+ TextButton(onClick = { pendingDeleteId = null }) { Text(stringResource(R.string.cancel)) }
191 },
192 },
192 )
193 )
193 }
194 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+2
@@ -1,5 +1,6 @@
1package soy.iko.opencode.ui.session
1package soy.iko.opencode.ui.session
2
2
3+import androidx.compose.runtime.Immutable
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
5import androidx.lifecycle.viewModelScope
5import soy.iko.opencode.data.model.ServerProfile
6import soy.iko.opencode.data.model.ServerProfile
@@ -28,6 +29,7 @@ import kotlinx.coroutines.launch
28import kotlinx.coroutines.sync.Semaphore
29import kotlinx.coroutines.sync.Semaphore
29import kotlinx.coroutines.sync.withPermit
30import kotlinx.coroutines.sync.withPermit
30
31
32+@Immutable
31data class SessionListState(
33data class SessionListState(
32 val sessions: List<Session> = emptyList(),
34 val sessions: List<Session> = emptyList(),
33 val query: String = "",
35 val query: String = "",
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+13−3
@@ -1,6 +1,7 @@
1package soy.iko.opencode.ui.settings
1package soy.iko.opencode.ui.settings
2
2
3import android.content.Intent
3import android.content.Intent
4+import android.util.Log
4import androidx.compose.foundation.clickable
5import androidx.compose.foundation.clickable
5import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Column
@@ -33,6 +34,7 @@ import androidx.compose.runtime.getValue
33import androidx.compose.runtime.mutableStateOf
34import androidx.compose.runtime.mutableStateOf
34import androidx.compose.runtime.remember
35import androidx.compose.runtime.remember
35import androidx.compose.runtime.rememberCoroutineScope
36import androidx.compose.runtime.rememberCoroutineScope
37+import androidx.compose.runtime.saveable.rememberSaveable
36import androidx.compose.runtime.setValue
38import androidx.compose.runtime.setValue
37import kotlinx.coroutines.Dispatchers
39import kotlinx.coroutines.Dispatchers
38import kotlinx.coroutines.launch
40import kotlinx.coroutines.launch
@@ -57,7 +59,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
57 val context = LocalContext.current
59 val context = LocalContext.current
58 val logger = remember { CrashLogger.get(context) }
60 val logger = remember { CrashLogger.get(context) }
59 val reports by logger.reports.collectAsStateWithLifecycle()
61 val reports by logger.reports.collectAsStateWithLifecycle()
60− var viewing by remember { mutableStateOf<String?>(null) }
62+ var viewing by rememberSaveable { mutableStateOf<String?>(null) }
61 val shareScope = rememberCoroutineScope()
63 val shareScope = rememberCoroutineScope()
62 val shareLabel = stringResource(R.string.share)
64 val shareLabel = stringResource(R.string.share)
63
65
@@ -73,7 +75,10 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
73 actions = {
75 actions = {
74 if (reports.isNotEmpty()) {
76 if (reports.isNotEmpty()) {
75 IconButton(onClick = {
77 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+ }
77 }) {
82 }) {
78 Icon(Icons.Filled.DeleteSweep, contentDescription = stringResource(R.string.clear_all))
83 Icon(Icons.Filled.DeleteSweep, contentDescription = stringResource(R.string.clear_all))
79 }
84 }
@@ -139,6 +144,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
139 putExtra(Intent.EXTRA_TEXT, content)
144 putExtra(Intent.EXTRA_TEXT, content)
140 }
145 }
141 runCatching { context.startActivity(Intent.createChooser(send, shareLabel)) }
146 runCatching { context.startActivity(Intent.createChooser(send, shareLabel)) }
147+ .onFailure { Log.w("Diagnostics", "Failed to share crash report", it) }
142 }
148 }
143 }) {
149 }) {
144 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
150 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
@@ -173,10 +179,14 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
173 putExtra(Intent.EXTRA_TEXT, content.orEmpty())
179 putExtra(Intent.EXTRA_TEXT, content.orEmpty())
174 }
180 }
175 runCatching { context.startActivity(Intent.createChooser(send, shareLabel2)) }
181 runCatching { context.startActivity(Intent.createChooser(send, shareLabel2)) }
182+ .onFailure { Log.w("Diagnostics", "Failed to share crash report", it) }
176 }) { Text(stringResource(R.string.share)) }
183 }) { Text(stringResource(R.string.share)) }
177 Spacer(Modifier.size(8.dp))
184 Spacer(Modifier.size(8.dp))
178 TextButton(onClick = {
185 TextButton(onClick = {
186+ shareScope.launch(Dispatchers.IO) {
179− shareScope.launch(Dispatchers.IO) { runCatching { logger.deleteReport(reportName) } }
187+ runCatching { logger.deleteReport(reportName) }
188+ .onFailure { Log.w("Diagnostics", "Failed to delete crash report", it) }
189+ }
180 viewing = null
190 viewing = null
181 }) { Text(stringResource(R.string.delete)) }
191 }) { Text(stringResource(R.string.delete)) }
182 }
192 }