1package soy.iko.opencode.ui.settings
3import android.content.ActivityNotFoundException
4import android.content.Intent
6import androidx.compose.foundation.background
7import androidx.compose.foundation.clickable
8import androidx.compose.foundation.layout.Arrangement
9import androidx.compose.foundation.layout.Box
10import androidx.compose.foundation.layout.Column
11import androidx.compose.foundation.layout.Row
12import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.imePadding
15import androidx.compose.foundation.layout.padding
16import androidx.compose.foundation.lazy.LazyColumn
17import androidx.compose.foundation.lazy.items
18import androidx.compose.foundation.rememberScrollState
19import androidx.compose.foundation.text.selection.SelectionContainer
20import androidx.compose.foundation.verticalScroll
21import androidx.compose.material.icons.Icons
22import androidx.compose.material.icons.filled.BugReport
23import androidx.compose.material.icons.filled.Close
24import androidx.compose.material.icons.filled.ContentCopy
25import androidx.activity.compose.BackHandler
26import androidx.activity.compose.rememberLauncherForActivityResult
27import androidx.compose.material.icons.filled.Delete
28import androidx.compose.material.icons.filled.DeleteSweep
29import androidx.compose.material.icons.filled.IosShare
30import androidx.compose.material.icons.filled.MoreVert
31import androidx.compose.material.icons.filled.Search
32import androidx.compose.material.icons.filled.Share
33import androidx.compose.material3.AlertDialog
34import androidx.compose.material3.CircularProgressIndicator
35import androidx.compose.material3.DropdownMenu
36import androidx.compose.material3.DropdownMenuItem
37import androidx.compose.material3.ExperimentalMaterial3Api
38import androidx.compose.material3.HorizontalDivider
39import androidx.compose.material3.Icon
40import androidx.compose.material3.IconButton
41import androidx.compose.material3.MaterialTheme
42import androidx.compose.material3.OutlinedTextField
43import androidx.compose.material3.Scaffold
44import androidx.compose.material3.SnackbarDuration
45import androidx.compose.material3.SnackbarHost
46import androidx.compose.material3.SnackbarHostState
47import androidx.compose.material3.SnackbarResult
48import androidx.compose.material3.Surface
49import androidx.compose.material3.SwipeToDismissBox
50import androidx.compose.material3.SwipeToDismissBoxValue
51import androidx.compose.material3.Text
52import androidx.compose.material3.TextButton
53import androidx.compose.material3.TopAppBar
54import androidx.compose.material3.rememberSwipeToDismissBoxState
55import androidx.compose.runtime.Composable
56import androidx.compose.runtime.CompositionLocalProvider
57import androidx.compose.runtime.LaunchedEffect
58import androidx.compose.runtime.getValue
59import androidx.compose.runtime.mutableIntStateOf
60import androidx.compose.runtime.mutableStateOf
61import androidx.compose.runtime.remember
62import androidx.compose.runtime.rememberCoroutineScope
63import androidx.compose.runtime.saveable.rememberSaveable
64import androidx.compose.runtime.setValue
65import kotlinx.coroutines.Dispatchers
66import kotlinx.coroutines.channels.BufferOverflow
67import kotlinx.coroutines.coroutineScope
68import kotlinx.coroutines.delay
69import kotlinx.coroutines.flow.MutableSharedFlow
70import kotlinx.coroutines.flow.collectLatest
71import kotlinx.coroutines.launch
72import kotlinx.coroutines.withContext
73import soy.iko.opencode.util.runCatchingCancellable
74import soy.iko.opencode.ui.components.AppTopBar
75import soy.iko.opencode.ui.components.EmptyState
76import soy.iko.opencode.ui.components.copyToClipboard
77import soy.iko.opencode.ui.components.reducedMotionAnimateItem
78import androidx.compose.ui.Alignment
79import androidx.compose.ui.Modifier
80import androidx.compose.ui.draw.clip
81import androidx.compose.ui.platform.LocalContext
82import androidx.compose.ui.platform.LocalHapticFeedback
83import androidx.compose.ui.res.stringResource
84import androidx.compose.ui.text.font.FontFamily
85import androidx.compose.ui.semantics.Role
86import androidx.compose.ui.semantics.contentDescription
87import androidx.compose.ui.semantics.heading
88import androidx.compose.ui.semantics.semantics
89import androidx.compose.ui.unit.dp
90import androidx.compose.ui.window.Dialog
91import androidx.compose.ui.window.DialogProperties
92import androidx.lifecycle.compose.collectAsStateWithLifecycle
93import soy.iko.opencode.R
94import soy.iko.opencode.data.network.NetworkConfig
95import soy.iko.opencode.data.repo.CrashLogger
96import soy.iko.opencode.ui.components.LocalRelativeTimeTick
97import soy.iko.opencode.ui.components.rememberRelativeTime
98import soy.iko.opencode.ui.components.rememberRelativeTimeTick
100@OptIn(ExperimentalMaterial3Api::class)
102fun DiagnosticsScreen(onBack: () -> Unit) {
103 val context = LocalContext.current
104 val logger = remember { CrashLogger.get(context) }
105 val reports by logger.reports.collectAsStateWithLifecycle()
106 var viewing by rememberSaveable { mutableStateOf<String?>(null) }
107 var showClearAll by rememberSaveable { mutableStateOf(false) }
108 var pendingReportDelete by rememberSaveable { mutableStateOf<String?>(null) }
109 // Optional text filter over the crash report previews/file names. Only surfaced once there
110 // are enough reports that scrolling is slower than typing.
111 var crashQuery by rememberSaveable { mutableStateOf("") }
112 val shareScope = rememberCoroutineScope()
113 val haptics = LocalHapticFeedback.current
114 val snackbar = remember { SnackbarHostState() }
115 // Separate host for transient error messages (share failures, etc.) so a transient error
116 // can't preempt an undo snackbar's full window — the undo and error channels don't share
117 // a queue, so a share error can't cancel an in-flight undo and strand a pending delete.
118 val errorSnackbar = remember { SnackbarHostState() }
119 val undoLabel = stringResource(R.string.undo)
120 val reportDeletedLabel = stringResource(R.string.report_deleted)
121 val reportsClearedLabel = stringResource(R.string.reports_cleared)
122 // Deferred report deletions are owned by the CrashLogger (its process-lived scope),
123 // so they survive both rotation and navigating away from this screen. The screen only
124 // shows the Undo snackbar and asks the logger to cancel a pending delete on undo.
125 val shareLabel = stringResource(R.string.share)
126 val shareSubject = stringResource(R.string.crash_report_share_subject)
127 val timeTick = rememberRelativeTimeTick()
129 // "Export all": let the user save every crash report into a single text file they pick via
130 // SAF (CreateDocument). Concatenating avoids needing a zip dependency; each report is
131 // delimited so a single file stays readable and shareable for support/debugging.
132 val exportAllLauncher = rememberLauncherForActivityResult(
133 androidx.activity.result.contract.ActivityResultContracts.CreateDocument("text/plain"),
135 if (uri == null) return@rememberLauncherForActivityResult
137 val ok = runCatchingCancellable {
138 withContext(Dispatchers.IO) {
139 val all = reports.joinToString("\n\n${"=".repeat(60)}\n\n") { report ->
140 "### ${report.fileName}\n\n" + logger.readReport(report.fileName).orEmpty()
142 context.contentResolver.openOutputStream(uri)?.use { it.write(all.toByteArray()) }
145 if (ok) snackbar.showSnackbar(context.getString(R.string.export_all))
149 // Report-delete undo events, shown via a single collectLatest collector rather than a
150 // per-confirm launch: a serialized showSnackbar would queue each report's undo behind the
151 // previous one's full window. collectLatest instead cancels the current snackbar and shows
152 // the newest immediately. Because the actual delete is scheduled at confirm time (on the
153 // CrashLogger's own scope, so it still commits if the user navigates away during the undo
154 // window), a superseded report would otherwise be deleted with no reachable Undo; the
155 // collector withdraws the previously-pending report's scheduled delete when a newer one
156 // supersedes its snackbar, keeping every delete undoable for its whole window.
157 val reportDeleteEvents = remember {
158 MutableSharedFlow<String>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
160 LaunchedEffect(Unit) {
161 var pending: String? = null
162 reportDeleteEvents.collectLatest { name ->
163 // Only reached when a newer delete arrives (collectLatest supersedes the prior
164 // snackbar) — navigating away tears down this collector without a new emission, so
165 // that report's delete still commits. cancelScheduledDelete no-ops if it already fired.
166 pending?.let { logger.cancelScheduledDelete(it) }
169 val dismisser = launch {
170 delay(NetworkConfig.undoReportDeleteDelayMs)
171 snackbar.currentSnackbarData?.dismiss()
173 val result = snackbar.showSnackbar(
174 message = reportDeletedLabel,
175 actionLabel = undoLabel,
176 duration = SnackbarDuration.Indefinite,
179 if (result == SnackbarResult.ActionPerformed) {
180 logger.cancelScheduledDelete(name)
187 // Clear-all undo: defers the bulk delete for the same window as a single-report delete,
188 // so the previously-irreversible "Clear all" is now recoverable.
189 val clearAllEvents = remember {
190 MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
192 LaunchedEffect(Unit) {
193 clearAllEvents.collectLatest {
194 logger.scheduleClearAll(NetworkConfig.undoReportDeleteDelayMs)
196 val dismisser = launch {
197 delay(NetworkConfig.undoReportDeleteDelayMs)
198 snackbar.currentSnackbarData?.dismiss()
200 val result = snackbar.showSnackbar(
201 message = reportsClearedLabel,
202 actionLabel = undoLabel,
203 duration = SnackbarDuration.Indefinite,
206 if (result == SnackbarResult.ActionPerformed) logger.cancelScheduledClearAll()
214 title = stringResource(R.string.diagnostics),
217 if (reports.isNotEmpty()) {
218 IconButton(onClick = {
220 exportAllLauncher.launch("opencode-crash-reports.txt")
223 Icon(Icons.Filled.IosShare, contentDescription = stringResource(R.string.export_all))
225 IconButton(onClick = {
228 Icon(Icons.Filled.DeleteSweep, contentDescription = stringResource(R.string.clear_all))
235 // Stack both hosts; the undo snackbar takes priority (rendered first), the error
236 // host renders below it so both can be visible simultaneously without preempting.
238 SnackbarHost(snackbar)
239 SnackbarHost(errorSnackbar)
243 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
244 Column(modifier = Modifier.fillMaxSize().imePadding().padding(padding)) {
246 stringResource(R.string.diagnostics_desc),
247 style = MaterialTheme.typography.bodySmall,
248 color = MaterialTheme.colorScheme.onSurfaceVariant,
249 modifier = Modifier.padding(16.dp),
252 stringResource(R.string.crash_reports),
253 style = MaterialTheme.typography.titleSmall,
254 color = MaterialTheme.colorScheme.primary,
256 .padding(start = 16.dp, bottom = 8.dp)
257 .semantics { heading() },
259 if (reports.isEmpty()) {
261 icon = Icons.Filled.BugReport,
262 title = stringResource(R.string.no_crash_reports),
263 description = stringResource(R.string.no_crash_reports_desc),
264 modifier = Modifier.fillMaxWidth().padding(24.dp),
267 val filtered = remember(reports, crashQuery) { filterCrashReports(reports, crashQuery) }
268 LazyColumn(modifier = Modifier.fillMaxSize()) {
269 crashSearchHeader(reports = reports, filtered = filtered, crashQuery = crashQuery) {
272 items(filtered, key = { it.fileName }) { report ->
273 // rememberSaveable so an open row menu survives a rotation; BackHandler
274 // so system back closes it instead of navigating away, matching the
275 // session/server/file list dropdown back-handling.
276 var menuExpanded by rememberSaveable(report.fileName) { mutableStateOf(false) }
277 BackHandler(enabled = menuExpanded) { menuExpanded = false }
278 // Swipe end-to-start reveals a delete affordance. The delete is
279 // deferred for an undo window (matching the session/server lists);
280 // confirmValueChange snaps back so the row remains while the undo
281 // snackbar is shown, and is removed only when the window expires.
282 val swipeState = rememberSwipeToDismissBoxState(
283 confirmValueChange = {
284 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
285 pendingReportDelete = report.fileName
291 enableDismissFromStartToEnd = false,
292 modifier = reducedMotionAnimateItem(),
293 backgroundContent = {
297 .clip(MaterialTheme.shapes.medium)
298 .background(MaterialTheme.colorScheme.errorContainer)
299 .padding(horizontal = 20.dp),
300 contentAlignment = Alignment.CenterEnd,
303 Icons.Filled.DeleteSweep,
304 contentDescription = stringResource(R.string.delete),
305 tint = MaterialTheme.colorScheme.onErrorContainer,
313 .clickable(role = Role.Button) { viewing = report.fileName }
314 .padding(horizontal = 16.dp, vertical = 12.dp),
315 verticalAlignment = Alignment.CenterVertically,
316 horizontalArrangement = Arrangement.spacedBy(12.dp),
319 Icons.Filled.BugReport,
320 contentDescription = null,
321 tint = MaterialTheme.colorScheme.error,
323 Column(modifier = Modifier.weight(1f)) {
324 Text(report.preview, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
326 rememberRelativeTime(report.timestamp),
327 style = MaterialTheme.typography.bodySmall,
328 color = MaterialTheme.colorScheme.onSurfaceVariant,
331 IconButton(onClick = {
333 val content = withContext(Dispatchers.IO) {
334 logger.readReport(report.fileName).orEmpty()
336 val send = Intent(Intent.ACTION_SEND).apply {
338 putExtra(Intent.EXTRA_SUBJECT, shareSubject)
339 putExtra(Intent.EXTRA_TEXT, content)
341 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
343 Log.w("Diagnostics", "Failed to share crash report", it)
344 // FIX 20: "no app to share" only fits ActivityNotFoundException; other
345 // failures (e.g. an oversized report exceeding the Binder limit →
346 // TransactionTooLargeException) get a generic toast, not that misleading one.
347 val msg = if (it is ActivityNotFoundException) R.string.no_share_app else R.string.error_generic
348 shareScope.launch { errorSnackbar.showSnackbar(context.getString(msg)) }
352 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
355 IconButton(onClick = { menuExpanded = true }) {
357 Icons.Filled.MoreVert,
358 contentDescription = stringResource(R.string.more_options),
361 DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
363 leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) },
366 stringResource(R.string.delete),
367 color = MaterialTheme.colorScheme.error,
372 pendingReportDelete = report.fileName
387 val reportName = viewing
388 if (reportName != null) {
389 var reportContent by remember(reportName) { mutableStateOf<String?>(null) }
390 var loadFailed by remember(reportName) { mutableStateOf(false) }
391 // A retry trigger: bumping this re-runs the load LaunchedEffect (keyed on it) and
392 // clears the failure state, so a transient read error is recoverable without closing
393 // and reopening the report — matching the file viewer's error-retry pattern.
394 var retryKey by remember(reportName) { mutableIntStateOf(0) }
395 LaunchedEffect(reportName, retryKey) {
396 if (retryKey > 0) { reportContent = null; loadFailed = false }
397 val loaded = withContext(Dispatchers.IO) {
398 // runCatchingCancellable (not runCatching) so dismissing the dialog or
399 // switching reports mid-read lets the CancellationException propagate
400 // instead of being swallowed and setting state on a torn-down effect.
401 runCatchingCancellable { logger.readReport(reportName) }.getOrNull()
403 if (loaded != null) reportContent = loaded else loadFailed = true
405 val content = reportContent
407 onDismissRequest = { viewing = null },
408 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
410 Surface(modifier = Modifier.fillMaxSize()) {
414 title = { Text(stringResource(R.string.crash_report)) },
416 IconButton(onClick = { viewing = null }) {
417 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
422 // Disable Copy until the report has loaded so it can't copy an empty body.
423 enabled = content != null,
425 copyToClipboard(context, context.getString(R.string.crash_report), content.orEmpty())
427 ) { Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy)) }
429 // Disable Share until the report has loaded — otherwise tapping it
430 // while the async read is still in flight (content == null) shares an
431 // empty body instead of the report.
432 enabled = content != null,
434 val send = Intent(Intent.ACTION_SEND).apply {
436 putExtra(Intent.EXTRA_SUBJECT, shareSubject)
437 putExtra(Intent.EXTRA_TEXT, content.orEmpty())
439 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
441 Log.w("Diagnostics", "Failed to share crash report", it)
442 // FIX 20: "no app to share" only fits ActivityNotFoundException; other failures
443 // (e.g. an oversized report exceeding the Binder limit → TransactionTooLargeException)
444 // get a generic toast, not that misleading one.
445 val msg = if (it is ActivityNotFoundException) R.string.no_share_app else R.string.error_generic
446 shareScope.launch { errorSnackbar.showSnackbar(context.getString(msg)) }
449 ) { Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share)) }
450 IconButton(onClick = {
451 // Confirm before deleting a single report, matching clear-all.
452 pendingReportDelete = reportName
453 }) { Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete)) }
459 content != null -> SelectionContainer {
460 // Wrap in SelectionContainer so a user can select/copy just the
461 // relevant stack snippet instead of the whole-report Copy button.
464 style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
467 .verticalScroll(rememberScrollState())
473 modifier = Modifier.fillMaxSize().padding(padding),
474 contentAlignment = Alignment.Center,
477 horizontalAlignment = Alignment.CenterHorizontally,
478 verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
481 stringResource(R.string.report_load_failed),
482 color = MaterialTheme.colorScheme.error,
484 TextButton(onClick = { retryKey++ }) {
485 Text(stringResource(R.string.retry))
490 val loadingLabel = stringResource(R.string.loading)
492 modifier = Modifier.fillMaxSize().padding(padding),
493 contentAlignment = Alignment.Center,
495 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
504 pendingReportDelete?.let { name ->
506 onDismissRequest = { pendingReportDelete = null },
507 title = { Text(stringResource(R.string.delete_report_title)) },
508 text = { Text(stringResource(R.string.delete_report_text)) },
510 TextButton(onClick = {
511 pendingReportDelete = null
513 // Defer the actual delete so the Undo snackbar can cancel it. The
514 // deletion is owned by the CrashLogger's own scope, so it commits even
515 // if the user navigates away during the undo window. The Undo snackbar
516 // is shown by the single collectLatest collector above (not a per-confirm
517 // launch) so rapid consecutive deletes don't produce a dead Undo button.
518 logger.scheduleDelete(name, NetworkConfig.undoReportDeleteDelayMs)
519 reportDeleteEvents.tryEmit(name)
520 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
523 TextButton(onClick = { pendingReportDelete = null }) { Text(stringResource(R.string.cancel)) }
530 onDismissRequest = { showClearAll = false },
531 title = { Text(stringResource(R.string.clear_all)) },
532 text = { Text(stringResource(R.string.clear_all_confirm)) },
534 TextButton(onClick = {
536 shareScope.launch { clearAllEvents.emit(Unit) }
537 }) { Text(stringResource(R.string.clear_all), color = MaterialTheme.colorScheme.error) }
540 TextButton(onClick = { showClearAll = false }) { Text(stringResource(R.string.cancel)) }
546/** Filter crash reports by preview/file name. Extracted from [DiagnosticsScreen] to keep its
547 * cyclomatic complexity under the detekt threshold. Empty query returns all reports. */
548private fun filterCrashReports(
549 reports: List<CrashLogger.CrashReport>,
551): List<CrashLogger.CrashReport> {
553 if (q.isEmpty()) return reports
554 return reports.filter {
555 it.preview.contains(q, ignoreCase = true) || it.fileName.contains(q, ignoreCase = true)
559/** The diagnostics filter field with a clear button. Extracted from [DiagnosticsScreen] to keep
560 * its cyclomatic complexity under the detekt threshold. */
562private fun CrashQueryField(query: String, onQueryChange: (String) -> Unit) {
563 val keyboard = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
566 onValueChange = onQueryChange,
567 modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
568 placeholder = { Text(stringResource(R.string.search_crash_reports)) },
570 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
572 if (query.isNotEmpty()) {
573 IconButton(onClick = { onQueryChange("") }) {
574 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
578 // Match the session/file/server search fields: ImeAction.Search dismisses the keyboard
579 // on submit so the IME doesn't keep covering the crash list after filtering.
580 keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
581 imeAction = androidx.compose.ui.text.input.ImeAction.Search,
583 keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { keyboard?.hide() }),
587/** Conditionally emits the search field and the no-match placeholder into the crash list.
588 * Extracted from [DiagnosticsScreen] to keep its cyclomatic complexity under the threshold. */
589private fun androidx.compose.foundation.lazy.LazyListScope.crashSearchHeader(
590 reports: List<CrashLogger.CrashReport>,
591 filtered: List<CrashLogger.CrashReport>,
593 onQueryChange: (String) -> Unit,
595 if (reports.size >= NetworkConfig.diagnosticsSearchThreshold) {
596 item(key = "__search") {
597 CrashQueryField(query = crashQuery, onQueryChange = onQueryChange)
600 if (crashQuery.isNotBlank() && filtered.isEmpty()) {
601 item(key = "__no_match") {
603 icon = Icons.Filled.Search,
604 title = stringResource(R.string.no_crash_matches),
605 modifier = Modifier.fillMaxWidth().padding(24.dp),
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list