Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ Add entries under **Unreleased** in each pull request. The *Bump version* workfl

## [Unreleased]

### Added
- `DeckTrigger.FloatingButton(icon = Icons.Default.Build)` sets the floating button's icon from an `ImageVector`; the composable `icon` slot is now documented too.
- `DeckStyle(titleIcon = …)` replaces the bug icon next to the drawer title; `titleIcon = null` shows the title without an icon.

### Changed
- The SharedPreferences browser lists only the app's own files. Files written by libraries and the system (WebView, Firebase, Google Play services, AndroidX, DevDeck) are behind a *Show library files* switch.

## [1.1.0] - 2026-10-27

### Added
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ OkHttpClient.Builder().apply { DebugTools.interceptors().forEach(::addIntercepto
- 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.
- 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:

```kotlin
trigger = DeckTrigger.FloatingButton(icon = Icons.Default.Build)
trigger = DeckTrigger.FloatingButton(icon = { Icon(painterResource(R.drawable.ic_debug), contentDescription = "Open debug menu") })
```

The drawer header shows the app version, plus chips for a new test build, an active recording (with a *Stop* button) and any network or memory simulation.

Expand Down Expand Up @@ -168,7 +174,7 @@ DevDeck.install(
| `UiToolsPlugin` | User Interface | Slow animations, keylines, screenshots, long screenshots, recording, gallery | ✓ |
| `AppearancePlugin` | User Interface | Dark mode and app language overrides, system language and display settings | ✓ |
| `PerformancePlugin` | System | Frame rate, jank and memory overlay, memory details | ✓ |
| `StoragePlugin` | System | SharedPreferences browser and editor, storage usage | ✓ |
| `StoragePlugin` | System | SharedPreferences browser and editor (the app's own files first; library files such as WebView or Firebase behind a switch), storage usage | ✓ |
| `DevicePlugin` | System | Permissions, developer options | ✓ |
| `InfoPlugin` | Info | App info, device info, shareable snapshot, bug report, version | ✓ |

Expand Down Expand Up @@ -307,6 +313,13 @@ DevDeck.updateConfig { it.copy(plugins = it.plugins + cachePlugin) }

`DeckStyle` also sets the panel width and colors; `collapsibleSections = false` shows every section open, and `oneSectionAtATime = true` closes the others when one opens.

The icon next to the title is `titleIcon`: pass your own composable, or `null` to show the title without an icon. `Icon` picks up the tile's colour. The floating button's icon is set on the trigger (see [Opening the drawer](#opening-the-drawer)).

```kotlin
style = DeckStyle(titleIcon = { Icon(painterResource(R.drawable.ic_logo), contentDescription = null) })
style = DeckStyle(titleIcon = null)
```

**Payment screens:** DevDeck skips Adyen, Primer, Netcetera 3DS, Cardinal Commerce, Stripe, Braintree, Checkout.com and PayPal activities automatically (`DeckExclusions.PAYMENT_SDKS`). Add your own with `excludedActivityPrefixes`, and set `DevDeck.isEnabled = false` during 3DS inside your own screens.

---
Expand Down
10 changes: 9 additions & 1 deletion devdeck/api/devdeck.api
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,14 @@ public final class io/devdeck/DeckStateKt {
public final class io/devdeck/DeckStyle {
public static final field $stable I
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;FFJJJLandroidx/compose/material3/ColorScheme;Landroidx/compose/material3/ColorScheme;IZZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;FFJJJLandroidx/compose/material3/ColorScheme;Landroidx/compose/material3/ColorScheme;IZZLkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;FFJJJLandroidx/compose/material3/ColorScheme;Landroidx/compose/material3/ColorScheme;IZZLkotlin/jvm/functions/Function2;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;FFJJJLandroidx/compose/material3/ColorScheme;Landroidx/compose/material3/ColorScheme;IZZLkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Ljava/lang/String;
public final fun component10 ()I
public final fun component11 ()Z
public final fun component12 ()Z
public final fun component13 ()Lkotlin/jvm/functions/Function2;
public final fun component2 ()Ljava/lang/String;
public final fun component3-D9Ej5fM ()F
public final fun component4 ()F
Expand All @@ -145,8 +148,10 @@ public final class io/devdeck/DeckStyle {
public final fun component7-0d7_KjU ()J
public final fun component8 ()Landroidx/compose/material3/ColorScheme;
public final fun component9 ()Landroidx/compose/material3/ColorScheme;
public final fun copy-JPBzAow (Ljava/lang/String;Ljava/lang/String;FFJJJLandroidx/compose/material3/ColorScheme;Landroidx/compose/material3/ColorScheme;IZZ)Lio/devdeck/DeckStyle;
public final synthetic fun copy-JPBzAow (Ljava/lang/String;Ljava/lang/String;FFJJJLandroidx/compose/material3/ColorScheme;Landroidx/compose/material3/ColorScheme;IZZ)Lio/devdeck/DeckStyle;
public static synthetic fun copy-JPBzAow$default (Lio/devdeck/DeckStyle;Ljava/lang/String;Ljava/lang/String;FFJJJLandroidx/compose/material3/ColorScheme;Landroidx/compose/material3/ColorScheme;IZZILjava/lang/Object;)Lio/devdeck/DeckStyle;
public final fun copy-WDFtIgw (Ljava/lang/String;Ljava/lang/String;FFJJJLandroidx/compose/material3/ColorScheme;Landroidx/compose/material3/ColorScheme;IZZLkotlin/jvm/functions/Function2;)Lio/devdeck/DeckStyle;
public static synthetic fun copy-WDFtIgw$default (Lio/devdeck/DeckStyle;Ljava/lang/String;Ljava/lang/String;FFJJJLandroidx/compose/material3/ColorScheme;Landroidx/compose/material3/ColorScheme;IZZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/devdeck/DeckStyle;
public fun equals (Ljava/lang/Object;)Z
public final fun getAnimationDurationMillis ()I
public final fun getCardColor-0d7_KjU ()J
Expand All @@ -159,6 +164,7 @@ public final class io/devdeck/DeckStyle {
public final fun getScrimColor-0d7_KjU ()J
public final fun getSubtitle ()Ljava/lang/String;
public final fun getTitle ()Ljava/lang/String;
public final fun getTitleIcon ()Lkotlin/jvm/functions/Function2;
public final fun getWidthFraction ()F
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
Expand All @@ -172,6 +178,8 @@ public final class io/devdeck/DeckTrigger$FloatingButton : io/devdeck/DeckTrigge
public fun <init> ()V
public fun <init> (Landroidx/compose/ui/Alignment;ZLkotlin/jvm/functions/Function2;)V
public synthetic fun <init> (Landroidx/compose/ui/Alignment;ZLkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/Alignment;Z)V
public synthetic fun <init> (Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/Alignment;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Landroidx/compose/ui/Alignment;
public final fun component2 ()Z
public final fun component3 ()Lkotlin/jvm/functions/Function2;
Expand Down
19 changes: 11 additions & 8 deletions devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
Expand Down Expand Up @@ -143,7 +144,7 @@ private fun DeckPageContent(
) {
Column(Modifier.fillMaxSize().background(background).windowInsetsPadding(WindowInsets.safeDrawing.only(insetSides))) {
if (page == null) {
DeckHeader(title = style.title, subtitle = style.subtitle, onClose = state::close)
DeckHeader(title = style.title, subtitle = style.subtitle, icon = style.titleIcon, onClose = state::close)
} else {
DeckPageBar(title = page.title, onBack = { state.navigateBack() }, onClose = state::close)
}
Expand Down Expand Up @@ -172,7 +173,7 @@ private const val PAGE_TRANSITION_MILLIS = 280
private const val FOCUS_TIMEOUT_MILLIS = 2_000L

@Composable
private fun DeckHeader(title: String, subtitle: String?, onClose: () -> Unit) {
private fun DeckHeader(title: String, subtitle: String?, icon: (@Composable () -> Unit)?, onClose: () -> Unit) {
val context = LocalContext.current
val version = remember(context) { appVersionInfo(context)?.let { "${it.versionName} (${it.versionCode})" } }
val warning = LocalDeckColors.current?.warning ?: MaterialTheme.colorScheme.error
Expand All @@ -181,13 +182,15 @@ private fun DeckHeader(title: String, subtitle: String?, onClose: () -> Unit) {
val heldBytes = MemorySimulator.heldBytes
Column(Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier.size(44.dp).background(MaterialTheme.colorScheme.primary, RoundedCornerShape(14.dp)),
contentAlignment = Alignment.Center,
) {
Icon(DeckIcons.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary)
if (icon != null) {
Box(
Modifier.size(44.dp).background(MaterialTheme.colorScheme.primary, RoundedCornerShape(14.dp)),
contentAlignment = Alignment.Center,
) {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onPrimary, content = icon)
}
Spacer(Modifier.width(12.dp))
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = title,
Expand Down
62 changes: 61 additions & 1 deletion devdeck/src/main/kotlin/io/devdeck/DeckStyle.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
package io.devdeck

import androidx.compose.material3.ColorScheme
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.devdeck.internal.DeckIcons

/**
* How the drawer looks. [titleIcon] is drawn in the tile next to the title, tinted for that tile (use `Icon` so it
* picks up the colour); pass `null` to show the title without an icon.
*/
@Immutable
public data class DeckStyle(
val title: String = "Debug menu",
Expand All @@ -20,4 +27,57 @@ public data class DeckStyle(
val animationDurationMillis: Int = 250,
val collapsibleSections: Boolean = true,
val oneSectionAtATime: Boolean = false,
)
val titleIcon: (@Composable () -> Unit)? = DefaultTitleIcon,
) {

@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN)
public constructor(
title: String = "Debug menu",
subtitle: String? = null,
maxWidth: Dp = 360.dp,
widthFraction: Float = 0.85f,
scrimColor: Color = Color.Black.copy(alpha = 0.4f),
containerColor: Color = Color.Unspecified,
cardColor: Color = Color.Unspecified,
lightColorScheme: ColorScheme? = null,
darkColorScheme: ColorScheme? = null,
animationDurationMillis: Int = 250,
collapsibleSections: Boolean = true,
oneSectionAtATime: Boolean = false,
) : this(
title, subtitle, maxWidth, widthFraction, scrimColor, containerColor, cardColor, lightColorScheme, darkColorScheme,
animationDurationMillis, collapsibleSections, oneSectionAtATime, DefaultTitleIcon,
)

@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN)
public fun copy(
title: String = this.title,
subtitle: String? = this.subtitle,
maxWidth: Dp = this.maxWidth,
widthFraction: Float = this.widthFraction,
scrimColor: Color = this.scrimColor,
containerColor: Color = this.containerColor,
cardColor: Color = this.cardColor,
lightColorScheme: ColorScheme? = this.lightColorScheme,
darkColorScheme: ColorScheme? = this.darkColorScheme,
animationDurationMillis: Int = this.animationDurationMillis,
collapsibleSections: Boolean = this.collapsibleSections,
oneSectionAtATime: Boolean = this.oneSectionAtATime,
): DeckStyle = copy(
title = title,
subtitle = subtitle,
maxWidth = maxWidth,
widthFraction = widthFraction,
scrimColor = scrimColor,
containerColor = containerColor,
cardColor = cardColor,
lightColorScheme = lightColorScheme,
darkColorScheme = darkColorScheme,
animationDurationMillis = animationDurationMillis,
collapsibleSections = collapsibleSections,
oneSectionAtATime = oneSectionAtATime,
titleIcon = titleIcon,
)
}

private val DefaultTitleIcon: @Composable () -> Unit = { Icon(DeckIcons.BugReport, contentDescription = null) }
18 changes: 17 additions & 1 deletion devdeck/src/main/kotlin/io/devdeck/DeckTrigger.kt
Original file line number Diff line number Diff line change
@@ -1,17 +1,33 @@
package io.devdeck

import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.vector.ImageVector

@Immutable
public sealed interface DeckTrigger {

public data object None : DeckTrigger

/**
* A round button that opens the drawer. [icon] replaces the default bug icon with any composable, for example
* `{ Icon(painterResource(R.drawable.ic_debug), contentDescription = "Open debug menu") }`.
*/
public data class FloatingButton(
val alignment: Alignment = Alignment.CenterEnd,
val draggable: Boolean = true,
val icon: (@Composable () -> Unit)? = null,
) : DeckTrigger
) : DeckTrigger {

/** Shows [icon] (for example `Icons.Default.Build`) instead of the default bug icon. */
public constructor(
icon: ImageVector,
alignment: Alignment = Alignment.CenterEnd,
draggable: Boolean = true,
) : this(alignment, draggable, { Icon(icon, contentDescription = OPEN_DESCRIPTION) })
}
}

internal const val OPEN_DESCRIPTION = "Open debug menu"
2 changes: 1 addition & 1 deletion devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ private fun FloatingTrigger(trigger: DeckTrigger.FloatingButton, onClick: () ->
.size(TriggerSize),
) {
Box(contentAlignment = Alignment.Center) {
trigger.icon?.invoke() ?: Icon(DeckIcons.BugReport, contentDescription = "Open debug menu")
trigger.icon?.invoke() ?: Icon(DeckIcons.BugReport, contentDescription = OPEN_DESCRIPTION)
}
}
if (UpdateChecker.availableUpdate != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package io.devdeck.storage

/**
* Tells the app's own SharedPreferences files apart from the ones libraries and the system write into the same folder
* (WebView, Firebase, Google Play services, AndroidX, DevDeck itself).
*/
internal object LibraryPreferences {

/** True when [name] belongs to a library rather than to the app with package [packageName]. */
fun isLibrary(name: String, packageName: String): Boolean {
if (LIBRARY_PREFIXES.any { name.startsWith(it) }) return true
// Files named like a package (com.google.foo, androidx.work.util) belong to whoever owns that package.
if (!QUALIFIED_NAME.matches(name)) return false
return !name.startsWith(organisation(packageName))
}

/** The first two segments of the package, so com.example.app and com.example.app.debug both own com.example.*. */
private fun organisation(packageName: String): String = packageName.split('.').take(2).joinToString(".", postfix = ".")

private val QUALIFIED_NAME = Regex("^[a-z][a-z0-9_]*(\\.[A-Za-z0-9_]+)+.*")

private val LIBRARY_PREFIXES = listOf(
"devdeck_",
"androidx.",
"android.",
"com.android.",
"com.google.",
"WebView",
"Firebase",
"PersistedInstallation",
"com.facebook.",
"com.crashlytics",
"com.datadog.",
"io.sentry",
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
Expand Down Expand Up @@ -48,7 +50,12 @@ public fun SharedPreferencesBrowser(modifier: Modifier = Modifier, include: (Str
val loaded by produceState<List<String>?>(null, context, version) {
value = withContext(Dispatchers.IO) { StorageFiles.sharedPreferencesNames(context) }
}
val names = remember(loaded, include) { loaded.orEmpty().filter(include) }
// 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 names = if (showLibraryFiles) appNames + libraryNames else appNames
Column(modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Text(
Expand All @@ -74,6 +81,15 @@ public fun SharedPreferencesBrowser(modifier: Modifier = Modifier, include: (Str
}
if (loaded != null && names.isEmpty()) TextItem("No SharedPreferences files")
InfoGroup(dividerThreshold = 2) { names.forEach { name -> key(name, version) { PreferenceFileRow(name) } } }
if (libraryNames.isNotEmpty()) {
SectionDivider()
SwitchItem(
title = "Show library files",
subtitle = "${libraryNames.size} from libraries and the system, such as WebView or Firebase",
checked = showLibraryFiles,
onCheckedChange = { showLibraryFiles = it },
)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package io.devdeck.storage

import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class LibraryPreferencesTest {

private fun isLibrary(name: String) = LibraryPreferences.isLibrary(name, packageName = "com.example.shop.debug")

@Test
fun `keeps the app's own files`() {
listOf("user_session", "settings", "onboarding", "com.example.shop.debug_preferences", "com.example.cart").forEach {
assertFalse(it, isLibrary(it))
}
}

@Test
fun `hides library and system files`() {
listOf(
"WebViewChromiumPrefs",
"WebViewProfilePrefsDefault",
"FirebaseHeartBeatW0RFRkFVTFRd",
"PersistedInstallation.W0RFRkFVTFRd",
"com.google.android.gms.measurement.prefs",
"com.google.firebase.crashlytics",
"androidx.work.util",
"devdeck_settings",
"com.facebook.sdk.appEventPreferences",
"io.sentry.prefs",
"org.example.analytics",
).forEach { assertTrue(it, isLibrary(it)) }
}
}
Loading
Loading