diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b0d1a7..a4e5d8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ Add entries under **Unreleased** in each pull request. The *Bump version* workfl ## [Unreleased] +### Added +- Crash reports include app and device details, heap use and uptime, the last 20 network calls (with errors and an excerpt of failed responses) and the last 50 `DeckLogger` entries, next to the breadcrumbs. Sizes are set with `CrashReporter.networkCallsInReport`, `logsInReport` and `stepsInReport`. +- `CrashReporter.addSection(title) { … }` adds your own context to every crash report. +- After a relaunch, an *App crashed last time* bar offers View, Share and dismiss, and the drawer header shows a *Crashed last time* chip (`CrashReporter.unseenCrash`, `CrashReporter.markSeen()`). +- On Android 11+, ANRs (with the system's thread dump) and native crashes are saved as crash reports on the next launch. +- `PreferenceFileRules` (`StoragePlugin(preferenceRules = …)`, `SharedPreferencesBrowser(rules = …)`) marks SharedPreferences files as app files, library files or hidden; testers can move a file between the lists from its page. +- `DeckHeaderChips.add(key) { … }` adds your own chips to the drawer header. +- `Pill` and `ConfirmDeleteButton` are public building blocks. + +### Changed +- The default keyboard shortcut is now **Ctrl + Shift + K**. The Android emulator keeps Ctrl + Shift + D for its D-pad panel, so the old default never reached the app there. + +### Fixed +- The *New version* chip no longer offers a build that isn't newer than the installed one (compared by version code). + ## [1.2.0] - 2026-11-04 ### Added diff --git a/README.md b/README.md index d3525f8..b1da5ad 100644 --- a/README.md +++ b/README.md @@ -114,12 +114,12 @@ OkHttpClient.Builder().apply { DebugTools.interceptors().forEach(::addIntercepto | How | Default | Change it | |---|---|---| | Two-finger swipe toward the drawer, anywhere on screen | On | `gestures = DeckGestures(twoFingerSwipe = false)` | -| **Ctrl + Shift + D** (emulator or hardware keyboard) | On | `DeckGestures(keyboardShortcut = DeckShortcut(...))`, or `null` to turn off | +| **Ctrl + Shift + K** (emulator or hardware keyboard) | On | `DeckGestures(keyboardShortcut = DeckShortcut(...))`, or `null` to turn off | | Floating bug button (drag it anywhere; it stays there) | On | `trigger = DeckTrigger.None` | | Code | — | `DevDeck.open()`, `DevDeck.close()`, `DevDeck.toggle()` | - The swipe is detected without taking touches, so taps, scrolls and system back keep working. -- On an emulator, a mouse can't do a two-finger swipe: use Ctrl + Shift + D or the floating button. +- On an emulator, a mouse can't do a two-finger swipe: use Ctrl + Shift + K or the floating button. Avoid Ctrl + Shift + letters the emulator keeps for its own panels (A–I, L–P, R–V). - If your app uses two-finger horizontal pans (maps, canvases), turn the swipe off. - To use your own icon on the floating button, pass an `ImageVector` or any composable: @@ -168,7 +168,7 @@ DevDeck.install( | `TimberLogsPlugin` | Logs | Every Timber call (`devdeck-timber`) | — | | `LogsPlugin` | Logs | Your `DeckLogger.log(...)` entries, one row per tag | ✓ | | `BreadcrumbsPlugin` | Logs | Screens, taps, requests and app events | ✓ | -| `CrashPlugin` | Logs | Saved crashes with stack traces | ✓ | +| `CrashPlugin` | Logs | Crash reports with the steps, network calls and logs before each crash; ANRs and native crashes on Android 11+ | ✓ | | `AppActionsPlugin` | App | Deep links, WebView debugging, restart, clear cache or data, kill | ✓ | | `UpdatesPlugin` | App | New test builds, release notes and install (`devdeck-appdistribution` for Firebase) | needs a source | | `UiToolsPlugin` | User Interface | Slow animations, keylines, screenshots, long screenshots, recording, gallery | ✓ | @@ -264,6 +264,22 @@ Log pages have level filters, search, an *All / By tag* view, pause, share and c **Breadcrumbs** record what led up to a problem: screens, app events, taps (`Modifier.breadcrumb("Place order")` labels Compose controls), requests, and your own steps (`Breadcrumbs.add(...)`). The last 50 are saved with every crash and the last 100 go into bug reports. +### Crash reports + +`CrashPlugin` saves a report when the app crashes, before the process dies. Each report has: + +- the stack trace and thread; +- app version, device, Android version, heap use and how long the app had been running; +- the last 50 breadcrumbs, 20 network calls (method, URL, status, duration, errors and an excerpt of failed responses, with secrets already masked) and 50 `DeckLogger` entries; +- any sections your app adds. + +After the relaunch, a small bar at the bottom says *App crashed last time*, with **View**, **Share** and dismiss, and the drawer header shows a *Crashed last time* chip. On Android 11+, ANRs (with the system's thread dump) and native crashes are picked up from the system on the next launch too. + +```kotlin +CrashReporter.addSection("User") { "id=${session.userId}, plan=${session.plan}" } // runs while crashing: keep it quick +CrashReporter.networkCallsInReport = 30 // also logsInReport, stepsInReport +``` + ### Screen capture, bug reports and info - **Screenshots and recordings:** under User Interface. While recording, a small floating bar shows the time with pause and stop, so the drawer can stay closed. DevDeck's own floating button and badges are left out of screenshots. @@ -293,9 +309,33 @@ val cachePlugin = deckPlugin(id = "cache", section = "System 📱") { | `SegmentedPills`, `FilterPill`, `FilterPillRow`, `ActionPill`, `ActionPillRow`, `SearchField` | View switchers, filters and search | | `BulletItem`, `InfoGroup`, `KeyValueItem`, `CodeBlock`, `TextItem` | Showing information | +| `Pill`, `ConfirmDeleteButton` | Tags like the network method and status, and a delete icon that asks for a second tap | + - Close or navigate from a plugin with `LocalDeckState.current?.close()` or `?.navigateTo(title) { … }`. - For setup work, implement `DeckPlugin` and override `onAttach(context)`. +### Header chips, crash report sections and preference files + +```kotlin +// A chip under the drawer title; return null while there is nothing to show. +DeckHeaderChips.add("environment") { if (isStaging) DeckHeaderChip("Staging", Color(0xFFF57C00)) else null } + +// Extra context in every crash report. +CrashReporter.addSection("Experiment") { experiments.activeNames().joinToString() } + +// Which SharedPreferences files are the app's, which are library files and which are never listed. +// Names are exact, or a prefix ending in *. +StoragePlugin( + preferenceRules = PreferenceFileRules( + app = setOf("com.google.maps.cache"), // looks like a library file, but it's yours + library = setOf("analytics_*"), // listed only after "Show library files" + hidden = setOf("secure_session"), // never listed + ), +) +``` + +Testers can also move a file between the app and library lists from its page (*Move to library files* / *Move to app files*); that choice is kept on the device. + ### Customizing ```kotlin diff --git a/devdeck/api/devdeck.api b/devdeck/api/devdeck.api index 2a37abe..0933ab0 100644 --- a/devdeck/api/devdeck.api +++ b/devdeck/api/devdeck.api @@ -63,6 +63,22 @@ public final class io/devdeck/DeckGestures$Companion { public final fun getDisabled ()Lio/devdeck/DeckGestures; } +public final class io/devdeck/DeckHeaderChip { + public static final field $stable I + public synthetic fun (Ljava/lang/String;Landroidx/compose/ui/graphics/Color;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Landroidx/compose/ui/graphics/Color;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getColor-QN2ZGVo ()Landroidx/compose/ui/graphics/Color; + public final fun getOnClick ()Lkotlin/jvm/functions/Function0; + public final fun getText ()Ljava/lang/String; +} + +public final class io/devdeck/DeckHeaderChips { + public static final field $stable I + public static final field INSTANCE Lio/devdeck/DeckHeaderChips; + public final fun add (Ljava/lang/String;Lkotlin/jvm/functions/Function2;)V + public final fun remove (Ljava/lang/String;)V +} + public final class io/devdeck/DeckPage { public static final field $stable I public final fun getTitle ()Ljava/lang/String; @@ -416,6 +432,10 @@ public final class io/devdeck/components/ConfirmButtonItemKt { public static final fun ConfirmButtonItem (Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Ljava/lang/String;Ljava/lang/String;ZLandroidx/compose/runtime/Composer;II)V } +public final class io/devdeck/components/ConfirmDeleteButtonKt { + public static final fun ConfirmDeleteButton (Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;ZLjava/lang/String;Landroidx/compose/runtime/Composer;II)V +} + public final class io/devdeck/components/DeckCardKt { public static final fun DeckCard (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V } @@ -461,6 +481,10 @@ public final class io/devdeck/components/PageLinkItemKt { public static final fun PageLinkItem (Ljava/lang/String;Landroidx/compose/ui/Modifier;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V } +public final class io/devdeck/components/PillKt { + public static final fun Pill-sW7UJKQ (Ljava/lang/String;JLandroidx/compose/ui/Modifier;ZLandroidx/compose/runtime/Composer;II)V +} + public final class io/devdeck/components/SearchFieldKt { public static final fun SearchField (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V } @@ -574,18 +598,25 @@ public final class io/devdeck/crash/CrashListKt { public final class io/devdeck/crash/CrashRecord { public static final field $stable I - public fun (Ljava/io/File;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/io/File;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V public synthetic fun (Ljava/io/File;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/io/File;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;)V + public synthetic fun (Ljava/io/File;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/io/File; public final fun component2 ()J public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; public final fun component5 ()Ljava/lang/String; public final fun component6 ()Ljava/lang/String; - public final fun copy (Ljava/io/File;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/devdeck/crash/CrashRecord; + public final fun component7 ()Ljava/util/List; + public final synthetic fun copy (Ljava/io/File;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/devdeck/crash/CrashRecord; + public final fun copy (Ljava/io/File;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;)Lio/devdeck/crash/CrashRecord; public static synthetic fun copy$default (Lio/devdeck/crash/CrashRecord;Ljava/io/File;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/devdeck/crash/CrashRecord; + public static synthetic fun copy$default (Lio/devdeck/crash/CrashRecord;Ljava/io/File;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)Lio/devdeck/crash/CrashRecord; public fun equals (Ljava/lang/Object;)Z public final fun getFile ()Ljava/io/File; + public final fun getReport ()Ljava/lang/String; + public final fun getSections ()Ljava/util/List; public final fun getStackTrace ()Ljava/lang/String; public final fun getSteps ()Ljava/lang/String; public final fun getThread ()Ljava/lang/String; @@ -598,11 +629,21 @@ public final class io/devdeck/crash/CrashRecord { public final class io/devdeck/crash/CrashReporter { public static final field $stable I public static final field INSTANCE Lio/devdeck/crash/CrashReporter; + public final fun addSection (Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V public final fun clear ()V public final fun crashes ()Ljava/util/List; + public final fun getLogsInReport ()I public final fun getMaxCrashes ()I + public final fun getNetworkCallsInReport ()I + public final fun getStepsInReport ()I + public final fun getUnseenCrash ()Lio/devdeck/crash/CrashRecord; public final fun install (Landroid/content/Context;)V + public final fun markSeen ()V + public final fun removeSection (Ljava/lang/String;)V + public final fun setLogsInReport (I)V public final fun setMaxCrashes (I)V + public final fun setNetworkCallsInReport (I)V + public final fun setStepsInReport (I)V } public final class io/devdeck/device/AppInfoKt { @@ -1183,8 +1224,10 @@ public final class io/devdeck/plugin/PerformancePlugin : io/devdeck/plugin/DeckP public final class io/devdeck/plugin/StoragePlugin : io/devdeck/plugin/DeckPlugin { public static final field $stable I public fun ()V - public fun (ZZLkotlin/jvm/functions/Function1;Ljava/lang/String;)V + public synthetic fun (ZZLkotlin/jvm/functions/Function1;Ljava/lang/String;)V public synthetic fun (ZZLkotlin/jvm/functions/Function1;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZZLkotlin/jvm/functions/Function1;Ljava/lang/String;Lio/devdeck/storage/PreferenceFileRules;)V + public synthetic fun (ZZLkotlin/jvm/functions/Function1;Ljava/lang/String;Lio/devdeck/storage/PreferenceFileRules;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun Content (Landroidx/compose/runtime/Composer;I)V public fun getId ()Ljava/lang/String; public fun getSection ()Ljava/lang/String; @@ -1342,8 +1385,27 @@ public final class io/devdeck/simulation/NetworkSimulatorSettingsKt { public static final fun NetworkSimulatorSettings (Landroidx/compose/ui/Modifier;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V } +public final class io/devdeck/storage/PreferenceFileRules { + public static final field $stable I + public fun ()V + public fun (Ljava/util/Set;Ljava/util/Set;Ljava/util/Set;)V + public synthetic fun (Ljava/util/Set;Ljava/util/Set;Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/util/Set; + public final fun component2 ()Ljava/util/Set; + public final fun component3 ()Ljava/util/Set; + public final fun copy (Ljava/util/Set;Ljava/util/Set;Ljava/util/Set;)Lio/devdeck/storage/PreferenceFileRules; + public static synthetic fun copy$default (Lio/devdeck/storage/PreferenceFileRules;Ljava/util/Set;Ljava/util/Set;Ljava/util/Set;ILjava/lang/Object;)Lio/devdeck/storage/PreferenceFileRules; + public fun equals (Ljava/lang/Object;)Z + public final fun getApp ()Ljava/util/Set; + public final fun getHidden ()Ljava/util/Set; + public final fun getLibrary ()Ljava/util/Set; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/devdeck/storage/SharedPreferencesBrowserKt { - public static final fun SharedPreferencesBrowser (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final synthetic fun SharedPreferencesBrowser (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun SharedPreferencesBrowser (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Lio/devdeck/storage/PreferenceFileRules;Landroidx/compose/runtime/Composer;II)V public static final fun SharedPreferencesFile (Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V } diff --git a/devdeck/src/main/kotlin/io/devdeck/DeckHeaderChips.kt b/devdeck/src/main/kotlin/io/devdeck/DeckHeaderChips.kt new file mode 100644 index 0000000..68ea3e0 --- /dev/null +++ b/devdeck/src/main/kotlin/io/devdeck/DeckHeaderChips.kt @@ -0,0 +1,36 @@ +package io.devdeck + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.ui.graphics.Color + +/** A chip in the drawer header, under the title. [color] tints it; null draws a neutral outlined chip. */ +@Immutable +public class DeckHeaderChip(public val text: String, public val color: Color? = null, public val onClick: (() -> Unit)? = null) + +/** + * Chips the app adds to the drawer header, next to DevDeck's own (recording, new version, simulations). Each chip is + * a composable that returns null while it has nothing to show, so it can follow Compose state: + * + * ``` + * DeckHeaderChips.add("environment") { if (isStaging) DeckHeaderChip("Staging", Color(0xFFF57C00)) else null } + * ``` + */ +public object DeckHeaderChips { + + private val entries = mutableStateListOf DeckHeaderChip?>>() + + /** Adds a chip, or replaces the one already added under [key]. */ + public fun add(key: String, chip: @Composable () -> DeckHeaderChip?) { + val index = entries.indexOfFirst { it.first == key } + if (index >= 0) entries[index] = key to chip else entries += key to chip + } + + public fun remove(key: String) { + entries.removeAll { it.first == key } + } + + @Composable + internal fun current(): List = entries.toList().mapNotNull { (_, chip) -> chip() } +} diff --git a/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt b/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt index 52aa77f..a94a6e6 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt @@ -68,6 +68,8 @@ import io.devdeck.capture.ScreenCapture import io.devdeck.capture.formatRecordingTime import io.devdeck.capture.rememberRecordingElapsed import io.devdeck.components.DeckCard +import io.devdeck.crash.CrashDetail +import io.devdeck.crash.CrashReporter import io.devdeck.device.appVersionInfo import io.devdeck.internal.DeckIcons import io.devdeck.plugin.UPDATES_PLUGIN_ID @@ -225,11 +227,12 @@ private fun DeckHeader(title: String, subtitle: String?, icon: (@Composable () - val updateColor = updateAvailableColor(LocalDeckColors.current?.isDark == true) val deckState = LocalDeckState.current val recordingElapsed = if (ScreenCapture.isRecording) rememberRecordingElapsed() else 0L + val appChips = DeckHeaderChips.current() val chips = buildList { if (ScreenCapture.isRecording) { val label = if (ScreenCapture.isRecordingPaused) "Recording paused" else "Recording" add( - HeaderChip( + DeckHeaderChip( "$label · ${formatRecordingTime(recordingElapsed)}", RecordingRed, onClick = { deckState?.pendingFocus = RECORDING_FOCUS }, @@ -237,11 +240,15 @@ private fun DeckHeader(title: String, subtitle: String?, icon: (@Composable () - ) } if (update != null) { - add(HeaderChip("New version · ${update.label}", updateColor, onClick = { deckState?.pendingFocus = UPDATES_PLUGIN_ID })) + add(DeckHeaderChip("New version · ${update.label}", updateColor, onClick = { deckState?.pendingFocus = UPDATES_PLUGIN_ID })) } - if (network.isActive) add(HeaderChip(network.summary, warning)) - if (heldBytes > 0) add(HeaderChip("Holding ${mb(heldBytes)}", warning)) - if (MemorySimulator.killOnBackground) add(HeaderChip("Kill when backgrounded", warning)) + if (network.isActive) add(DeckHeaderChip(network.summary, warning)) + if (heldBytes > 0) add(DeckHeaderChip("Holding ${mb(heldBytes)}", warning)) + if (MemorySimulator.killOnBackground) add(DeckHeaderChip("Kill when backgrounded", warning)) + CrashReporter.unseenCrash?.let { crash -> + add(DeckHeaderChip("Crashed last time", RecordingRed, onClick = { deckState?.navigateTo("Crash") { CrashDetail(crash) } })) + } + addAll(appChips) } if (chips.isNotEmpty()) { Row(Modifier.padding(top = 12.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically) { @@ -313,8 +320,6 @@ private fun CloseButton(onClose: () -> Unit) { // 40dp keeps the touch target close to the guideline while pulling the settings and close buttons together. private val HeaderButtonSize = 40.dp -private data class HeaderChip(val text: String, val accent: Color?, val onClick: (() -> Unit)? = null) - @Composable private fun StopRecordingButton(onClick: () -> Unit) { val shape = RoundedCornerShape(50) @@ -341,10 +346,10 @@ private fun StopRecordingButton(onClick: () -> Unit) { } @Composable -private fun Chip(chip: HeaderChip) { +private fun Chip(chip: DeckHeaderChip) { val shape = RoundedCornerShape(50) val card = LocalDeckColors.current?.card ?: MaterialTheme.colorScheme.surface - val accent = chip.accent + val accent = chip.color Text( text = chip.text, style = MaterialTheme.typography.labelMedium, diff --git a/devdeck/src/main/kotlin/io/devdeck/DeckShortcut.kt b/devdeck/src/main/kotlin/io/devdeck/DeckShortcut.kt index 500e6ce..f39f027 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DeckShortcut.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DeckShortcut.kt @@ -3,9 +3,13 @@ package io.devdeck import android.view.KeyEvent import androidx.compose.runtime.Immutable +/** + * The key combination that toggles the drawer. The default is Ctrl + Shift + K: the Android emulator keeps most other + * Ctrl + Shift + letter combinations (D opens its D-pad panel) and never passes them to the app. + */ @Immutable public data class DeckShortcut( - val keyCode: Int = KeyEvent.KEYCODE_D, + val keyCode: Int = KeyEvent.KEYCODE_K, val ctrl: Boolean = true, val shift: Boolean = true, val alt: Boolean = false, diff --git a/devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt b/devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt index 5b965c1..3d3c653 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.unit.min import androidx.core.content.edit import io.devdeck.capture.RecordingControls import io.devdeck.capture.ScreenCapture +import io.devdeck.crash.CrashPrompt import io.devdeck.internal.DeckIcons import io.devdeck.internal.DeckSettings import io.devdeck.performance.PerformanceMonitor @@ -128,6 +129,8 @@ internal fun DevDeckOverlay( if (!isVisible && !ScreenCapture.isCapturing) RecordingControls() + if (!isVisible && !ScreenCapture.isCapturing) CrashPrompt(state) + if (isVisible) { Box( Modifier diff --git a/devdeck/src/main/kotlin/io/devdeck/components/ConfirmDeleteButton.kt b/devdeck/src/main/kotlin/io/devdeck/components/ConfirmDeleteButton.kt index 508e9ef..f67b9f9 100644 --- a/devdeck/src/main/kotlin/io/devdeck/components/ConfirmDeleteButton.kt +++ b/devdeck/src/main/kotlin/io/devdeck/components/ConfirmDeleteButton.kt @@ -29,7 +29,7 @@ import kotlinx.coroutines.delay /** A delete icon button that asks for a second tap within a few seconds before running [onConfirm]. */ @Composable -internal fun ConfirmDeleteButton( +public fun ConfirmDeleteButton( contentDescription: String, onConfirm: () -> Unit, modifier: Modifier = Modifier, diff --git a/devdeck/src/main/kotlin/io/devdeck/components/Pill.kt b/devdeck/src/main/kotlin/io/devdeck/components/Pill.kt index bd450a7..9c4e422 100644 --- a/devdeck/src/main/kotlin/io/devdeck/components/Pill.kt +++ b/devdeck/src/main/kotlin/io/devdeck/components/Pill.kt @@ -15,8 +15,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.devdeck.internal.contentColorOn +/** A small monospace label, like the method and status tags on network calls: [filled], or outlined in [color]. */ @Composable -internal fun Pill(text: String, color: Color, filled: Boolean = true) { +public fun Pill(text: String, color: Color, modifier: Modifier = Modifier, filled: Boolean = true) { val shape = CircleShape Text( text = text, @@ -25,7 +26,7 @@ internal fun Pill(text: String, color: Color, filled: Boolean = true) { color = if (filled) contentColorOn(color) else color, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier + modifier = modifier .then(if (filled) Modifier.background(color, shape) else Modifier.border(1.dp, color, shape)) .padding(horizontal = 8.dp, vertical = 2.dp), ) diff --git a/devdeck/src/main/kotlin/io/devdeck/crash/CrashList.kt b/devdeck/src/main/kotlin/io/devdeck/crash/CrashList.kt index 9a7171d..48efd97 100644 --- a/devdeck/src/main/kotlin/io/devdeck/crash/CrashList.kt +++ b/devdeck/src/main/kotlin/io/devdeck/crash/CrashList.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.compose.runtime.rememberCoroutineScope @@ -73,20 +74,23 @@ public fun CrashList(modifier: Modifier = Modifier) { } @Composable -private fun CrashDetail(crash: CrashRecord) { +internal fun CrashDetail(crash: CrashRecord) { val context = LocalContext.current + LaunchedEffect(crash) { if (CrashReporter.unseenCrash?.file == crash.file) CrashReporter.markSeen() } Column(Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { BulletItem("Time", formatTime(crash.timestampMillis)) BulletItem("Thread", crash.thread) } - IconButton(onClick = { context.shareFiles(listOf(crash.file), text = crash.stackTrace, subject = crash.title) }) { + IconButton(onClick = { context.shareFiles(listOf(crash.file), text = crash.report, subject = crash.title) }) { Icon(DeckIcons.Share, contentDescription = "Share crash") } } - crash.steps?.let { CodeBlock(it, label = "Steps before the crash", collapsedLines = 12, copyable = true) } - CodeBlock(crash.stackTrace, label = "Stack trace", collapsedLines = 40, copyable = true) + CodeBlock(crash.stackTrace, label = "Stack trace", collapsedLines = 15, copyable = true) + crash.sections.ifEmpty { listOfNotNull(crash.steps?.let { CrashReporter.SECTION_STEPS to it }) }.forEach { (title, content) -> + CodeBlock(content, label = title, collapsedLines = 12, copyable = true) + } } } diff --git a/devdeck/src/main/kotlin/io/devdeck/crash/CrashPrompt.kt b/devdeck/src/main/kotlin/io/devdeck/crash/CrashPrompt.kt new file mode 100644 index 0000000..d9a5fa6 --- /dev/null +++ b/devdeck/src/main/kotlin/io/devdeck/crash/CrashPrompt.kt @@ -0,0 +1,75 @@ +package io.devdeck.crash + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.devdeck.DeckState +import io.devdeck.LocalDeckColors +import io.devdeck.capture.RecordingRed +import io.devdeck.internal.DeckIcons +import io.devdeck.internal.shareFiles + +/** After a relaunch, a small bar at the bottom says the app crashed last time, with View, Share and dismiss. */ +@Composable +internal fun CrashPrompt(state: DeckState, modifier: Modifier = Modifier) { + val crash = CrashReporter.unseenCrash ?: return + val context = LocalContext.current + val isDark = LocalDeckColors.current?.isDark == true + val background = if (isDark) Color(0xE6202124) else Color(0xEBFFFFFF) + val content = if (isDark) Color(0xFFE8EAED) else Color(0xFF202124) + val kind = crash.title.substringBefore(':').substringAfterLast('.') + Box(modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing).padding(bottom = 16.dp)) { + Row( + Modifier + .align(Alignment.BottomCenter) + .shadow(4.dp, CircleShape) + .background(background, CircleShape) + .padding(start = 14.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box(Modifier.size(8.dp).background(RecordingRed, CircleShape)) + Text( + if (kind == "ANR") "App froze last time (ANR)" else "App crashed last time", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium, + color = content, + modifier = Modifier.padding(start = 4.dp), + ) + TextButton(onClick = { + state.navigateToRoot() + state.navigateTo(kind) { CrashDetail(crash) } + state.open() + }) { Text("View") } + IconButton(onClick = { + context.shareFiles(listOf(crash.file), text = crash.report, subject = crash.title) + }, modifier = Modifier.size(36.dp)) { + Icon(DeckIcons.Share, contentDescription = "Share crash report", tint = content, modifier = Modifier.size(18.dp)) + } + IconButton(onClick = CrashReporter::markSeen, modifier = Modifier.size(36.dp)) { + Icon(DeckIcons.Close, contentDescription = "Dismiss", tint = content, modifier = Modifier.size(18.dp)) + } + } + } +} diff --git a/devdeck/src/main/kotlin/io/devdeck/crash/CrashReportFormat.kt b/devdeck/src/main/kotlin/io/devdeck/crash/CrashReportFormat.kt new file mode 100644 index 0000000..0d9cedd --- /dev/null +++ b/devdeck/src/main/kotlin/io/devdeck/crash/CrashReportFormat.kt @@ -0,0 +1,84 @@ +package io.devdeck.crash + +import io.devdeck.logs.LogEntry +import io.devdeck.network.NetworkCall +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** Plain-text pieces of a crash report. Lists arrive newest first and are written oldest first, ending at the crash. */ +internal object CrashReportFormat { + + fun networkCalls(calls: List): String? { + if (calls.isEmpty()) return null + val time = timeFormat() + return calls.asReversed().joinToString("\n") { call -> + buildString { + append(time.format(Date(call.startedAtMillis))).append(" ") + append(call.method.padEnd(METHOD_WIDTH)).append(' ') + append((call.responseCode?.toString() ?: if (call.error != null) "ERR" else "…").padEnd(STATUS_WIDTH)) + append((call.durationMillis?.let { "$it ms" } ?: "").padStart(DURATION_WIDTH)).append(" ") + append(call.url) + call.error?.let { append("\n Error: ").append(it.oneLine(MAX_ERROR_CHARS)) } + // Only failed calls carry a body excerpt; bodies were already redacted by NetworkLogger. + if (call.isFailure) { + call.responseBody?.takeIf { + it.isNotBlank() + }?.let { append("\n Response: ").append(it.oneLine(MAX_BODY_CHARS)) } + } + } + } + } + + fun logs(entries: List): String? { + if (entries.isEmpty()) return null + val time = timeFormat() + return entries.asReversed().joinToString("\n") { entry -> + buildString { + append( + time.format(Date(entry.timestampMillis)), + ).append(" [").append(entry.tag).append("] ").append(entry.message.oneLine(MAX_MESSAGE_CHARS)) + entry.payload?.takeIf { it.isNotBlank() }?.let { append("\n ").append(it.oneLine(MAX_PAYLOAD_CHARS)) } + } + } + } + + fun duration(millis: Long): String { + val seconds = millis / 1000 + return when { + seconds < 60 -> "$seconds s" + seconds < 3600 -> "${seconds / 60} min ${seconds % 60} s" + else -> "${seconds / 3600} h ${seconds % 3600 / 60} min" + } + } + + /** The signal that ended a native crash, such as "signal 11 (SIGSEGV)"; null when there's none. */ + fun signal(status: Int): String? = if (status <= 0) null else "signal $status" + (SIGNALS[status]?.let { " ($it)" } ?: "") + + /** ActivityManager importance as words; the numbers are stable platform constants. */ + fun importance(importance: Int): String = when { + importance <= 100 -> "in the foreground" + importance <= 200 -> "visible" + importance <= 300 -> "running a service" + else -> "in the background" + } + + fun megabytes(bytes: Long): String = String.format(Locale.US, "%.1f MB", bytes / (1024.0 * 1024.0)) + + private fun String.oneLine(max: Int): String { + val flat = replace(Regex("\\s+"), " ").trim() + return if (flat.length <= max) flat else flat.take(max) + "…" + } + + private fun timeFormat() = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + + private val SIGNALS = mapOf(4 to "SIGILL", 5 to "SIGTRAP", 6 to "SIGABRT", 7 to "SIGBUS", 8 to "SIGFPE", 11 to "SIGSEGV") + + private const val METHOD_WIDTH = 6 + private const val STATUS_WIDTH = 4 + private const val DURATION_WIDTH = 8 + private const val MAX_ERROR_CHARS = 300 + private const val MAX_BODY_CHARS = 500 + private const val MAX_MESSAGE_CHARS = 500 + private const val MAX_PAYLOAD_CHARS = 300 +} diff --git a/devdeck/src/main/kotlin/io/devdeck/crash/CrashReporter.kt b/devdeck/src/main/kotlin/io/devdeck/crash/CrashReporter.kt index 21ace1f..74d9cc2 100644 --- a/devdeck/src/main/kotlin/io/devdeck/crash/CrashReporter.kt +++ b/devdeck/src/main/kotlin/io/devdeck/crash/CrashReporter.kt @@ -1,18 +1,36 @@ package io.devdeck.crash +import android.app.ActivityManager +import android.app.ApplicationExitInfo import android.content.Context +import android.os.Build +import android.os.Process +import android.os.SystemClock +import androidx.annotation.RequiresApi import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.core.content.edit import io.devdeck.breadcrumbs.Breadcrumbs +import io.devdeck.device.appVersionInfo +import io.devdeck.internal.DeckSettings +import io.devdeck.logs.DeckLogger +import io.devdeck.network.NetworkLogger import java.io.File import java.io.PrintWriter import java.io.StringWriter import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.concurrent.thread +/** + * One saved crash. [sections] holds what was captured around it, in order: app and device details, the steps, + * network calls and logs before the crash, and any sections the app added with [CrashReporter.addSection]. + */ @Immutable public data class CrashRecord( val file: File, @@ -21,26 +39,100 @@ public data class CrashRecord( val title: String, val stackTrace: String, val steps: String? = null, -) + val sections: List> = emptyList(), +) { + @Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) + public constructor( + file: File, + timestampMillis: Long, + thread: String, + title: String, + stackTrace: String, + steps: String? = null, + ) : this(file, timestampMillis, thread, title, stackTrace, steps, emptyList()) + + @Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) + public fun copy( + file: File = this.file, + timestampMillis: Long = this.timestampMillis, + thread: String = this.thread, + title: String = this.title, + stackTrace: String = this.stackTrace, + steps: String? = this.steps, + ): CrashRecord = copy( + file = file, + timestampMillis = timestampMillis, + thread = thread, + title = title, + stackTrace = stackTrace, + steps = steps, + sections = sections, + ) + + /** The full report as text, the same as the saved file. */ + val report: String get() = runCatching { file.readText() }.getOrDefault(stackTrace) +} + +/** + * Saves uncaught exceptions to files before the app dies, with what led up to them, and shows them after the + * relaunch. On Android 11+ it also picks up ANRs and native crashes from the system on the next launch. + */ public object CrashReporter { public var maxCrashes: Int = 20 + /** How many of the latest network calls a report includes. */ + public var networkCallsInReport: Int = 20 + + /** How many of the latest DeckLogger entries a report includes. */ + public var logsInReport: Int = 50 + + /** How many of the latest breadcrumbs a report includes. */ + public var stepsInReport: Int = 50 + internal var version by mutableIntStateOf(0) private set + /** The newest crash the tester hasn't looked at yet, set on launch; null once seen or dismissed. */ + public var unseenCrash: CrashRecord? by mutableStateOf(null) + private set + private var directory: File? = null + private var appContext: Context? = null + private var deviceSummary: String = "" + private val customSections = CopyOnWriteArrayList String?>>() + + /** + * Adds a section to every crash report, such as the signed-in user or the current experiment. [content] runs on + * the crashing thread just before the app dies, so it must be quick and must not block; return null to skip it. + * Adding a section with the same [title] again replaces it. + */ + public fun addSection(title: String, content: () -> String?) { + customSections.removeAll { it.first == title } + customSections += title to content + } + + public fun removeSection(title: String) { + customSections.removeAll { it.first == title } + } public fun install(context: Context) { if (directory != null) return - val dir = File(context.filesDir, "devdeck/crashes").apply { mkdirs() } + val app = context.applicationContext + val dir = File(app.filesDir, "devdeck/crashes").apply { mkdirs() } directory = dir + appContext = app + deviceSummary = deviceSummary(app) val previous = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { thread, error -> runCatching { write(dir, thread, error) } previous?.uncaughtException(thread, error) } + thread(name = "DevDeck crash check", isDaemon = true) { + runCatching { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) importExitReasons(app, dir) } + runCatching { findUnseen(app) } + } } public fun crashes(): List = directory?.listFiles { file -> file.extension == "txt" } @@ -50,35 +142,164 @@ public object CrashReporter { public fun clear() { directory?.listFiles()?.forEach { it.delete() } + markSeen() version++ } + /** Stops offering [unseenCrash] after a relaunch; the crash stays in the list. */ + public fun markSeen() { + val crash = unseenCrash + unseenCrash = null + val context = appContext ?: return + if (crash != null) DeckSettings.of(context).edit { putLong(KEY_LAST_SEEN, crash.timestampMillis) } + } + + private fun findUnseen(context: Context) { + val lastSeen = DeckSettings.of(context).getLong(KEY_LAST_SEEN, 0L) + unseenCrash = crashes().firstOrNull { it.timestampMillis > lastSeen } + } + private fun write(dir: File, thread: Thread, error: Throwable) { val now = System.currentTimeMillis() val trace = StringWriter().also { error.printStackTrace(PrintWriter(it)) }.toString() - File(dir, "crash_${SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date(now))}.txt") - .writeText("$now\n${thread.name}\n$trace" + stepsSection()) + save(dir, now, thread.name, trace, captureSections(now)) + } + + private fun save(dir: File, timestampMillis: Long, thread: String, trace: String, sections: List>) { + val text = buildString { + append(timestampMillis).append('\n').append(thread).append('\n').append(trace.trimEnd()) + sections.forEach { (title, content) -> append("\n").append(marker(title)).append('\n').append(content.trimEnd()) } + append('\n') + } + File(dir, "crash_${SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date(timestampMillis))}.txt").writeText(text) dir.listFiles()?.sortedByDescending { it.lastModified() }?.drop(maxCrashes)?.forEach { it.delete() } } - private fun stepsSection(): String { - val steps = Breadcrumbs.recent(STEPS_IN_CRASH) - return if (steps.isEmpty()) "" else "\n$STEPS_MARKER\n${Breadcrumbs.format(steps)}" + // Each part is guarded on its own, so a failing one never stops the crash from being saved. + private fun captureSections(now: Long): List> { + val builtIn = listOf String?>>( + SECTION_APP to { appSection(now) }, + SECTION_STEPS to { Breadcrumbs.recent(stepsInReport).takeIf { it.isNotEmpty() }?.let(Breadcrumbs::format) }, + SECTION_NETWORK to { CrashReportFormat.networkCalls(NetworkLogger.calls.value.take(networkCallsInReport)) }, + SECTION_LOGS to { CrashReportFormat.logs(DeckLogger.entries.value.take(logsInReport)) }, + ) + return (builtIn + customSections).mapNotNull { (title, content) -> + runCatching { content() }.getOrNull()?.takeIf { it.isNotBlank() }?.let { title to it } + } + } + + private fun appSection(now: Long): String { + val runtime = Runtime.getRuntime() + val used = runtime.totalMemory() - runtime.freeMemory() + val uptime = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + CrashReportFormat.duration(SystemClock.elapsedRealtime() - Process.getStartElapsedRealtime()) + } else { + null + } + return buildString { + append(deviceSummary) + append( + "\nHeap: ", + ).append(CrashReportFormat.megabytes(used)).append(" used of ").append(CrashReportFormat.megabytes(runtime.maxMemory())) + if (uptime != null) append("\nRunning for: ").append(uptime) + append("\nTime: ").append(SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS Z", Locale.US).format(Date(now))) + } } - private fun read(file: File): CrashRecord? = runCatching { - val (body, steps) = file.readText().split("\n$STEPS_MARKER\n", limit = 2).let { it[0] to it.getOrNull(1) } - val lines = body.lines() - CrashRecord( + private fun deviceSummary(context: Context): String { + val version = appVersionInfo(context) + val memory = runCatching { + val info = ActivityManager.MemoryInfo() + (context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).getMemoryInfo(info) + CrashReportFormat.megabytes(info.totalMem) + }.getOrNull() + return buildString { + append("App: ").append(context.packageName) + if (version != null) append(' ').append(version.versionName).append(" (").append(version.versionCode).append(')') + append("\nDevice: ").append(Build.MANUFACTURER).append(' ').append(Build.MODEL) + append("\nAndroid: ").append(Build.VERSION.RELEASE).append(" (API ").append(Build.VERSION.SDK_INT).append(')') + if (memory != null) append("\nDevice memory: ").append(memory) + } + } + + /** Saves ANRs and native crashes the system recorded since the last check; Java crashes are already saved. */ + @RequiresApi(Build.VERSION_CODES.R) + private fun importExitReasons(context: Context, dir: File) { + val prefs = DeckSettings.of(context) + val lastImported = prefs.getLong(KEY_LAST_EXIT, 0L) + val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val exits = manager.getHistoricalProcessExitReasons(context.packageName, 0, MAX_EXITS) + .filter { it.timestamp > lastImported && it.reason in IMPORTED_REASONS } + exits.forEach { exit -> + val anr = exit.reason == ApplicationExitInfo.REASON_ANR + val title = if (anr) { + "ANR: " + (exit.description ?: "the app stopped responding") + } else { + "Native crash: " + (CrashReportFormat.signal(exit.status) ?: exit.description ?: "no details from the system") + } + val details = buildString { + append(title) + if (anr) { + append("\n\n").append(exit.readTrace() ?: "The system kept no ANR trace for this one.") + } else { + append("\n\nThe native stack trace is in the device's tombstone; pull it with `adb bugreport`.") + } + } + val app = buildString { + append(deviceSummary) + append("\nState: ").append(CrashReportFormat.importance(exit.importance)) + if (exit.pss > 0) append("\nMemory at exit (PSS): ").append(CrashReportFormat.megabytes(exit.pss * 1024)) + append("\nRecorded by the system; steps, network calls and logs are not available for this one.") + } + save(dir, exit.timestamp, "main", details, listOf(SECTION_APP to app)) + } + exits.maxOfOrNull { it.timestamp }?.let { newest -> prefs.edit { putLong(KEY_LAST_EXIT, newest) } } + if (exits.isNotEmpty()) version++ + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun ApplicationExitInfo.readTrace(): String? = runCatching { + traceInputStream?.bufferedReader()?.use { reader -> + val buffer = CharArray(MAX_TRACE_CHARS) + val read = reader.read(buffer) + if (read <= 0) null else String(buffer, 0, read) + if (reader.read() != -1) "\n… (trace cut short)" else "" + } + }.getOrNull() + + private fun read(file: File): CrashRecord? = runCatching { parse(file, file.readText()) }.getOrNull() + + /** Old files only have the steps section; they read the same way. */ + internal fun parse(file: File, text: String): CrashRecord { + val titles = SECTION_SPLIT.findAll(text).map { it.groupValues[1] }.toList() + val parts = text.split(SECTION_SPLIT) + val sections = titles.zip(parts.drop(1)) { title, content -> title to content.trimEnd() } + val lines = parts[0].lines() + return CrashRecord( file = file, timestampMillis = lines[0].toLong(), thread = lines[1], title = lines.getOrNull(2).orEmpty(), stackTrace = lines.drop(2).joinToString("\n").trimEnd(), - steps = steps?.trimEnd(), + steps = sections.firstOrNull { it.first == SECTION_STEPS }?.second, + sections = sections, ) - }.getOrNull() + } + + private fun marker(title: String) = "--- $title ---" - private const val STEPS_IN_CRASH = 50 - private const val STEPS_MARKER = "--- Steps before the crash ---" + internal const val SECTION_APP = "App and device" + internal const val SECTION_STEPS = "Steps before the crash" + internal const val SECTION_NETWORK = "Network calls before the crash" + internal const val SECTION_LOGS = "Logs before the crash" + + private val SECTION_SPLIT = Regex("\n--- (.+?) ---\n") + private val IMPORTED_REASONS = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + setOf(ApplicationExitInfo.REASON_ANR, ApplicationExitInfo.REASON_CRASH_NATIVE) + } else { + emptySet() + } + private const val MAX_EXITS = 10 + private const val MAX_TRACE_CHARS = 200_000 + private const val KEY_LAST_SEEN = "crash_last_seen" + private const val KEY_LAST_EXIT = "crash_last_exit_imported" } diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/CrashPlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/CrashPlugin.kt index cbe08e0..df80480 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/CrashPlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/CrashPlugin.kt @@ -19,9 +19,21 @@ public class CrashPlugin(private val testCrashButton: Boolean = false, override @Composable override fun Content() { val count = remember(CrashReporter.version) { CrashReporter.crashes().size } - PageLinkItem("Crash logs", subtitle = if (count == 0) "No crashes" else "$count saved") { CrashList() } + val unseen = CrashReporter.unseenCrash != null + PageLinkItem( + "Crash logs", + subtitle = when { + count == 0 -> "No crashes" + unseen -> "$count saved · new crash since the last launch" + else -> "$count saved" + }, + ) { CrashList() } if (testCrashButton) { ConfirmButtonItem(title = "Trigger test crash", onConfirm = { throw IllegalStateException("Test crash from the debug drawer") }) + // Blocks the main thread; tap the screen while it's frozen and Android reports an ANR after about 5 seconds. + ConfirmButtonItem(title = "Freeze the app (test ANR)", onConfirm = { Thread.sleep(TEST_FREEZE_MILLIS) }) } } } + +private const val TEST_FREEZE_MILLIS = 20_000L diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/StoragePlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/StoragePlugin.kt index e25f657..4f86b74 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/StoragePlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/StoragePlugin.kt @@ -3,6 +3,7 @@ package io.devdeck.plugin import androidx.compose.runtime.Composable import io.devdeck.components.ExpandableItem import io.devdeck.components.PageLinkItem +import io.devdeck.storage.PreferenceFileRules import io.devdeck.storage.SharedPreferencesBrowser import io.devdeck.storage.StorageUsage @@ -11,15 +12,24 @@ public class StoragePlugin( private val usage: Boolean = true, private val includePreferences: (String) -> Boolean = { true }, override val section: String? = "System 📱", + private val preferenceRules: PreferenceFileRules = PreferenceFileRules(), ) : DeckPlugin { + @Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) + public constructor( + sharedPreferences: Boolean = true, + usage: Boolean = true, + includePreferences: (String) -> Boolean = { true }, + section: String? = "System 📱", + ) : this(sharedPreferences, usage, includePreferences, section, PreferenceFileRules()) + override val id: String = "storage" @Composable override fun Content() { if (sharedPreferences) { PageLinkItem("Shared preferences", subtitle = "View, edit and delete keys") { - SharedPreferencesBrowser(include = includePreferences) + SharedPreferencesBrowser(include = includePreferences, rules = preferenceRules) } } if (usage) ExpandableItem("Storage usage") { StorageUsage() } diff --git a/devdeck/src/main/kotlin/io/devdeck/storage/PreferenceFileRules.kt b/devdeck/src/main/kotlin/io/devdeck/storage/PreferenceFileRules.kt new file mode 100644 index 0000000..260999f --- /dev/null +++ b/devdeck/src/main/kotlin/io/devdeck/storage/PreferenceFileRules.kt @@ -0,0 +1,92 @@ +package io.devdeck.storage + +import android.content.Context +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.edit +import io.devdeck.internal.DeckSettings + +/** + * Decides where each SharedPreferences file shows up in the browser. Each entry is a file name without `.xml`, or a + * prefix ending in `*` (`"analytics_*"`). + * + * - [app]: always listed with the app's own files, even if it looks like a library file. + * - [library]: listed only after *Show library files* is turned on. + * - [hidden]: never listed, for files nobody should edit from the drawer. + * + * Files that match none of these are sorted automatically: WebView, Firebase, Google Play services, AndroidX, DevDeck + * and package-style names from other organisations count as library files. Testers can also move a file between the + * two lists from its page; their choice wins over [app] and [library] but not over [hidden]. + */ +@Immutable +public data class PreferenceFileRules( + val app: Set = emptySet(), + val library: Set = emptySet(), + val hidden: Set = emptySet(), +) + +internal enum class PreferenceFileKind { App, Library, Hidden } + +internal fun PreferenceFileRules.kindOf(name: String, packageName: String, overrides: PreferenceOverrides.Snapshot): PreferenceFileKind = + when { + hidden.matches(name) -> PreferenceFileKind.Hidden + name in overrides.app -> PreferenceFileKind.App + name in overrides.library -> PreferenceFileKind.Library + app.matches(name) -> PreferenceFileKind.App + library.matches(name) -> PreferenceFileKind.Library + LibraryPreferences.isLibrary(name, packageName) -> PreferenceFileKind.Library + else -> PreferenceFileKind.App + } + +private fun Set.matches(name: String): Boolean = + any { pattern -> if (pattern.endsWith('*')) name.startsWith(pattern.dropLast(1)) else name == pattern } + +/** Files a tester moved between the app and library lists from the drawer, kept in DevDeck's settings. */ +internal object PreferenceOverrides { + + data class Snapshot(val app: Set = emptySet(), val library: Set = emptySet()) + + var current: Snapshot by mutableStateOf(Snapshot()) + private set + + /** The rules of the browser that was shown last, so a file's page sorts it the same way. */ + var rules: PreferenceFileRules = PreferenceFileRules() + + private var loaded = false + + /** The current overrides, read as Compose state so callers update when a file is moved. */ + fun observe(context: Context): Snapshot { + load(context) + return current + } + + private fun load(context: Context): Snapshot { + if (!loaded) { + loaded = true + val prefs = DeckSettings.of(context) + current = Snapshot( + app = prefs.getStringSet(KEY_APP, null).orEmpty().toSet(), + library = prefs.getStringSet(KEY_LIBRARY, null).orEmpty().toSet(), + ) + } + return current + } + + fun move(context: Context, name: String, to: PreferenceFileKind) { + val now = load(context) + current = when (to) { + PreferenceFileKind.App -> Snapshot(app = now.app + name, library = now.library - name) + PreferenceFileKind.Library -> Snapshot(app = now.app - name, library = now.library + name) + PreferenceFileKind.Hidden -> return + } + DeckSettings.of(context).edit { + putStringSet(KEY_APP, current.app) + putStringSet(KEY_LIBRARY, current.library) + } + } + + private const val KEY_APP = "preference_files_app" + private const val KEY_LIBRARY = "preference_files_library" +} diff --git a/devdeck/src/main/kotlin/io/devdeck/storage/SharedPreferencesBrowser.kt b/devdeck/src/main/kotlin/io/devdeck/storage/SharedPreferencesBrowser.kt index d683474..a88bece 100644 --- a/devdeck/src/main/kotlin/io/devdeck/storage/SharedPreferencesBrowser.kt +++ b/devdeck/src/main/kotlin/io/devdeck/storage/SharedPreferencesBrowser.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.unit.dp import androidx.core.content.edit import io.devdeck.LocalDeckState import io.devdeck.components.BulletItem +import io.devdeck.components.ButtonItem import io.devdeck.components.ConfirmButtonItem import io.devdeck.components.ConfirmDeleteButton import io.devdeck.components.InfoGroup @@ -42,8 +43,19 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @Composable public fun SharedPreferencesBrowser(modifier: Modifier = Modifier, include: (String) -> Boolean = { true }) { + SharedPreferencesBrowser(modifier, include, PreferenceFileRules()) +} + +/** Lists the app's SharedPreferences files; [rules] decide which ones count as library files or are never shown. */ +@Composable +public fun SharedPreferencesBrowser( + modifier: Modifier = Modifier, + include: (String) -> Boolean = { true }, + rules: PreferenceFileRules = PreferenceFileRules(), +) { val context = LocalContext.current val scope = rememberCoroutineScope() var version by remember { mutableIntStateOf(0) } @@ -52,9 +64,13 @@ public fun SharedPreferencesBrowser(modifier: Modifier = Modifier, include: (Str } // Library files (WebView, Firebase, DevDeck itself) are hidden unless asked for, so the app's own files stand out. var showLibraryFiles by rememberSaveable { mutableStateOf(false) } - val (libraryNames, appNames) = remember(loaded, include) { - loaded.orEmpty().filter(include).partition { LibraryPreferences.isLibrary(it, context.packageName) } + val overrides = PreferenceOverrides.observe(context) + PreferenceOverrides.rules = rules + val kinds = remember(loaded, include, rules, overrides) { + loaded.orEmpty().filter(include).groupBy { rules.kindOf(it, context.packageName, overrides) } } + val appNames = kinds[PreferenceFileKind.App].orEmpty() + val libraryNames = kinds[PreferenceFileKind.Library].orEmpty() val names = if (showLibraryFiles) appNames + libraryNames else appNames Column(modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) { @@ -119,6 +135,7 @@ public fun SharedPreferencesFile(name: String, modifier: Modifier = Modifier) { } } SectionDivider() + MoveFileButton(name) ConfirmButtonItem(title = "Clear all keys", onConfirm = { prefs.values.edit { clear() } prefs.refresh() @@ -126,6 +143,22 @@ public fun SharedPreferencesFile(name: String, modifier: Modifier = Modifier) { } } +/** Lets a tester correct the automatic sorting; the choice is kept in DevDeck's settings. */ +@Composable +private fun MoveFileButton(name: String) { + val context = LocalContext.current + val overrides = PreferenceOverrides.observe(context) + val isLibrary = remember(name, overrides) { + PreferenceOverrides.rules.kindOf(name, context.packageName, overrides) == + PreferenceFileKind.Library + } + ButtonItem( + title = if (isLibrary) "Move to app files" else "Move to library files", + subtitle = if (isLibrary) "List this file with the app's own files" else "List this file only when library files are shown", + onClick = { PreferenceOverrides.move(context, name, if (isLibrary) PreferenceFileKind.App else PreferenceFileKind.Library) }, + ) +} + @Composable private fun PreferenceEditor(name: String, key: String) { val prefs = rememberObservedPreferences(name) ?: return diff --git a/devdeck/src/main/kotlin/io/devdeck/updates/UpdateChecker.kt b/devdeck/src/main/kotlin/io/devdeck/updates/UpdateChecker.kt index e19e365..4c4a8f4 100644 --- a/devdeck/src/main/kotlin/io/devdeck/updates/UpdateChecker.kt +++ b/devdeck/src/main/kotlin/io/devdeck/updates/UpdateChecker.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.content.edit +import io.devdeck.device.appVersionInfo import io.devdeck.internal.DeckSettings import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -41,6 +42,9 @@ public object UpdateChecker { internal var notifier: ((AvailableUpdate) -> Unit)? = null internal var clock: () -> Long = System::currentTimeMillis + /** The running build's version code; a source's update with a version code at or below it is not offered. */ + internal var installedVersionCode: Long? = null + private var prefs: SharedPreferences? = null private val scope by lazy { CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) } private var job: Job? = null @@ -49,6 +53,7 @@ public object UpdateChecker { this.source = source this.checkIntervalMillis = checkIntervalMillis prefs = DeckSettings.of(context) + installedVersionCode = appVersionInfo(context)?.versionCode isSignedIn = source.isSignedIn() } @@ -106,7 +111,15 @@ public object UpdateChecker { val current = source ?: return status = UpdateStatus.Checking status = runCatching { current.checkForUpdate() }.fold( - onSuccess = { update -> if (update == null) UpdateStatus.UpToDate else UpdateStatus.Available(update) }, + onSuccess = { update -> + if (update == null || + !update.isNewerThan(installedVersionCode) + ) { + UpdateStatus.UpToDate + } else { + UpdateStatus.Available(update) + } + }, onFailure = { UpdateStatus.Failed(it.describe()) }, ) lastCheckedAtMillis = clock() @@ -129,6 +142,7 @@ public object UpdateChecker { notifier = null prefs = null clock = System::currentTimeMillis + installedVersionCode = null } private fun notifyOnce(update: AvailableUpdate) { @@ -147,3 +161,9 @@ public object UpdateChecker { internal fun isCheckDue(lastCheckedAtMillis: Long?, nowMillis: Long, intervalMillis: Long): Boolean = lastCheckedAtMillis == null || nowMillis - lastCheckedAtMillis >= intervalMillis + +/** Without both version codes there is nothing to compare, so the source's answer is trusted. */ +internal fun AvailableUpdate.isNewerThan(installedVersionCode: Long?): Boolean { + val offered = versionCode ?: return true + return installedVersionCode == null || offered > installedVersionCode +} diff --git a/devdeck/src/test/kotlin/io/devdeck/crash/CrashReportTest.kt b/devdeck/src/test/kotlin/io/devdeck/crash/CrashReportTest.kt new file mode 100644 index 0000000..8c261e5 --- /dev/null +++ b/devdeck/src/test/kotlin/io/devdeck/crash/CrashReportTest.kt @@ -0,0 +1,100 @@ +package io.devdeck.crash + +import io.devdeck.logs.LogEntry +import io.devdeck.network.NetworkCall +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class CrashReportTest { + + private val file = File("crash.txt") + + @Test + fun `reads files saved before sections were added`() { + val text = "1700000000000\nmain\njava.lang.IllegalStateException: boom\n\tat A.b(A.kt:1)\n" + + "--- Steps before the crash ---\n10:00:00.000 Tap Buy\n" + + val crash = CrashReporter.parse(file, text) + + assertEquals("java.lang.IllegalStateException: boom", crash.title) + assertEquals("java.lang.IllegalStateException: boom\n\tat A.b(A.kt:1)", crash.stackTrace) + assertEquals("10:00:00.000 Tap Buy", crash.steps) + assertEquals(listOf("Steps before the crash" to "10:00:00.000 Tap Buy"), crash.sections) + } + + @Test + fun `reads every section in order`() { + val text = "1700000000000\nmain\nANR: Input dispatching timed out\n\nfull trace\n" + + "--- App and device ---\nApp: com.example 1.0 (1)\n--- Network calls before the crash ---\nGET 200\n--- User ---\nid=42\n" + + val crash = CrashReporter.parse(file, text) + + assertEquals("ANR: Input dispatching timed out", crash.title) + assertEquals(listOf("App and device", "Network calls before the crash", "User"), crash.sections.map { it.first }) + assertEquals("id=42", crash.sections.last().second) + assertNull(crash.steps) + } + + @Test + fun `network calls are listed oldest first with errors and failed bodies`() { + val calls = listOf( + NetworkCall( + 2, + 2_000, + "POST", + "https://api.example.com/cart", + responseCode = 500, + durationMillis = 120, + responseBody = "{\n \"error\": \"down\"\n}", + ), + NetworkCall( + 1, + 1_000, + "GET", + "https://api.example.com/items", + responseCode = 200, + durationMillis = 80, + responseBody = "{\"items\":[]}", + ), + ) + + val text = CrashReportFormat.networkCalls(calls)!!.lines() + + assertTrue(text[0], text[0].contains("GET") && text[0].endsWith("https://api.example.com/items")) + assertTrue(text[1], text[1].contains("POST") && text[1].contains("500") && text[1].contains("120 ms")) + assertEquals(" Response: { \"error\": \"down\" }", text[2]) + assertEquals(3, text.size) + } + + @Test + fun `logs are listed oldest first and empty lists are skipped`() { + val logs = listOf(LogEntry(2, 2_000, "Cart", "Checkout failed", "code=7"), LogEntry(1, 1_000, "App", "Started", null)) + + val text = CrashReportFormat.logs(logs)!!.lines() + + assertTrue(text[0], text[0].endsWith("[App] Started")) + assertTrue(text[1], text[1].endsWith("[Cart] Checkout failed")) + assertEquals(" code=7", text[2]) + assertNull(CrashReportFormat.logs(emptyList())) + assertNull(CrashReportFormat.networkCalls(emptyList())) + } + + @Test + fun `native crash signals and process state read as words`() { + assertEquals("signal 11 (SIGSEGV)", CrashReportFormat.signal(11)) + assertEquals("signal 31", CrashReportFormat.signal(31)) + assertNull(CrashReportFormat.signal(0)) + assertEquals("in the foreground", CrashReportFormat.importance(100)) + assertEquals("in the background", CrashReportFormat.importance(400)) + } + + @Test + fun `durations read naturally`() { + assertEquals("42 s", CrashReportFormat.duration(42_000)) + assertEquals("3 min 5 s", CrashReportFormat.duration(185_000)) + assertEquals("2 h 1 min", CrashReportFormat.duration(7_260_000)) + } +} diff --git a/devdeck/src/test/kotlin/io/devdeck/storage/PreferenceFileRulesTest.kt b/devdeck/src/test/kotlin/io/devdeck/storage/PreferenceFileRulesTest.kt new file mode 100644 index 0000000..a7e7b02 --- /dev/null +++ b/devdeck/src/test/kotlin/io/devdeck/storage/PreferenceFileRulesTest.kt @@ -0,0 +1,39 @@ +package io.devdeck.storage + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PreferenceFileRulesTest { + + private val packageName = "com.example.shop" + private val none = PreferenceOverrides.Snapshot() + + @Test + fun `sorts files automatically without rules`() { + val rules = PreferenceFileRules() + assertEquals(PreferenceFileKind.App, rules.kindOf("user_session", packageName, none)) + assertEquals(PreferenceFileKind.Library, rules.kindOf("WebViewChromiumPrefs", packageName, none)) + } + + @Test + fun `rules move files between lists and hide them`() { + val rules = PreferenceFileRules( + app = setOf("com.google.maps.cache"), + library = setOf("analytics_*"), + hidden = setOf("secrets"), + ) + assertEquals(PreferenceFileKind.App, rules.kindOf("com.google.maps.cache", packageName, none)) + assertEquals(PreferenceFileKind.Library, rules.kindOf("analytics_events", packageName, none)) + assertEquals(PreferenceFileKind.App, rules.kindOf("analytics", packageName, none)) + assertEquals(PreferenceFileKind.Hidden, rules.kindOf("secrets", packageName, none)) + } + + @Test + fun `a tester's choice wins over rules but not over hidden`() { + val rules = PreferenceFileRules(library = setOf("analytics_*"), hidden = setOf("secrets")) + val overrides = PreferenceOverrides.Snapshot(app = setOf("analytics_events", "secrets"), library = setOf("user_session")) + assertEquals(PreferenceFileKind.App, rules.kindOf("analytics_events", packageName, overrides)) + assertEquals(PreferenceFileKind.Library, rules.kindOf("user_session", packageName, overrides)) + assertEquals(PreferenceFileKind.Hidden, rules.kindOf("secrets", packageName, overrides)) + } +} diff --git a/devdeck/src/test/kotlin/io/devdeck/updates/UpdateCheckerTest.kt b/devdeck/src/test/kotlin/io/devdeck/updates/UpdateCheckerTest.kt index c45870e..45ea4a3 100644 --- a/devdeck/src/test/kotlin/io/devdeck/updates/UpdateCheckerTest.kt +++ b/devdeck/src/test/kotlin/io/devdeck/updates/UpdateCheckerTest.kt @@ -31,6 +31,25 @@ class UpdateCheckerTest { assertEquals("v1.4.0 (412)", update.label) } + @Test + fun `a release that isn't newer than the installed build means up to date`() = runBlocking { + UpdateChecker.use(FakeSource(result = { AvailableUpdate("1.1.0", 10100) })) + UpdateChecker.installedVersionCode = 10200 + + UpdateChecker.refresh() + + assertEquals(UpdateStatus.UpToDate, UpdateChecker.status) + assertNull(UpdateChecker.availableUpdate) + } + + @Test + fun `newer means a higher version code, and a missing one is trusted`() { + assertTrue(AvailableUpdate("1.3.0", 10300).isNewerThan(10200)) + assertFalse(AvailableUpdate("1.2.0", 10200).isNewerThan(10200)) + assertTrue(AvailableUpdate("1.3.0").isNewerThan(10200)) + assertTrue(AvailableUpdate("1.3.0", 10300).isNewerThan(null)) + } + @Test fun `no release means up to date`() = runBlocking { UpdateChecker.use(FakeSource(result = { null })) diff --git a/sample/README.md b/sample/README.md index ce32276..7a35eb8 100644 --- a/sample/README.md +++ b/sample/README.md @@ -191,14 +191,14 @@ DevDeckHost( |---|---| | Drawer enabled | `enabled` | | Two-finger swipe to open | `DeckGestures(twoFingerSwipe)` | -| Ctrl + Shift + D shortcut | `DeckGestures(keyboardShortcut = DeckShortcut())` / `null` | +| Ctrl + Shift + K shortcut | `DeckGestures(keyboardShortcut = DeckShortcut())` / `null` | | Floating button | `DeckTrigger.FloatingButton()` / `DeckTrigger.None` | | Side | `DeckSide.Start` / `End` | | Open drawer | `deckState.open()` | ## Things to try -1. **Open the drawer** by tapping the floating bug button, tapping *Open drawer*, swiping left with two fingers anywhere on the screen, or pressing **Ctrl + Shift + D**. The shortcut is the easiest way on an emulator, because a mouse can't do a two-finger swipe. Sections start collapsed; tap a header such as *Logs 📚* to open it. +1. **Open the drawer** by tapping the floating bug button, tapping *Open drawer*, swiping left with two fingers anywhere on the screen, or pressing **Ctrl + Shift + K**. The shortcut is the easiest way on an emulator, because a mouse can't do a two-finger swipe. Sections start collapsed; tap a header such as *Logs 📚* to open it. 2. **Network:** tap every *Network demo* button (some of them several times), then open *Logs → Network activity*. - Try the *Errors* and *GraphQL* filters and search for `countries`. - Switch to **By endpoint** to see each API grouped with its call count and failures, then open a group for its stats and every call. diff --git a/sample/src/main/kotlin/io/devdeck/sample/SampleApp.kt b/sample/src/main/kotlin/io/devdeck/sample/SampleApp.kt index 98b07eb..f6998f9 100644 --- a/sample/src/main/kotlin/io/devdeck/sample/SampleApp.kt +++ b/sample/src/main/kotlin/io/devdeck/sample/SampleApp.kt @@ -1,11 +1,17 @@ package io.devdeck.sample import android.app.Application +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import io.devdeck.DeckHeaderChip +import io.devdeck.DeckHeaderChips import io.devdeck.DevDeck +import io.devdeck.crash.CrashReporter import io.devdeck.flags.FeatureFlagStore import io.devdeck.flags.SharedPreferencesFlagStore import io.devdeck.logs.DeckLogger import io.devdeck.plugin.DeckPlugin +import io.devdeck.sample.debugmenu.SampleEndpoints import io.devdeck.sample.debugmenu.sampleDeckStyle import io.devdeck.sample.debugmenu.samplePlugins import timber.log.Timber @@ -30,6 +36,12 @@ class SampleApp : Application() { style = sampleDeckStyle, includeActivity = { it is LegacyViewActivity || it is EndlessFeedActivity }, ) + // Extra context in every crash report, and a header chip that follows the selected endpoint. + CrashReporter.addSection("Endpoint") { SampleEndpoints.current(this).url } + DeckHeaderChips.add("endpoint") { + val endpoint = SampleEndpoints.current(LocalContext.current) + if (endpoint.name != "Production") DeckHeaderChip(endpoint.name, Color(0xFFF57C00)) else null + } DeckLogger.log("App started") Timber.i("Sample app started") } diff --git a/sample/src/main/kotlin/io/devdeck/sample/debugmenu/SamplePlugins.kt b/sample/src/main/kotlin/io/devdeck/sample/debugmenu/SamplePlugins.kt index 505ec3d..b68d208 100644 --- a/sample/src/main/kotlin/io/devdeck/sample/debugmenu/SamplePlugins.kt +++ b/sample/src/main/kotlin/io/devdeck/sample/debugmenu/SamplePlugins.kt @@ -21,6 +21,7 @@ import io.devdeck.plugin.PerformancePlugin import io.devdeck.plugin.StoragePlugin import io.devdeck.plugin.UiToolsPlugin import io.devdeck.plugin.UpdatesPlugin +import io.devdeck.storage.PreferenceFileRules import io.devdeck.timber.TimberLogsPlugin fun samplePlugins(context: Context, flagStore: FeatureFlagStore): List = listOf( @@ -39,7 +40,8 @@ fun samplePlugins(context: Context, flagStore: FeatureFlagStore): List