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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ Add entries under **Unreleased** in each pull request. The *Bump version* workfl

## [Unreleased]

### Added
- `DeckGestures(oneFingerSwipe = true)` opens the drawer with a one-finger swipe in from its edge, next to the two-finger swipe; each can be turned on or off. A 200dp strip in the middle of that edge is excluded from Android's back gesture while the drawer is closed.

### Changed
- Fields in URLs and bodies are no longer masked by default (`NetworkLogger.redactedFields` is empty); auth, cookie, API-key and CSRF headers still are. To mask fields again, set `NetworkLogger.redactedFields = NetworkLogger.DEFAULT_REDACTED_FIELDS`.

### Fixed
- Debug apps could abort (`JNI DETECTED ERROR` / out of native memory) while DevDeck masked secrets in large response bodies. Kotlin's `Regex.replace` creates a matcher per match and on Android each one copies the whole body into native memory; redaction now uses one matcher per body, skips bodies without any secret field name, and runs once instead of inside the state update's retry loop. A 240K-character GraphQL body now takes tens of milliseconds instead of seconds.

## [1.2.1] - 2026-11-09

### Added
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,15 @@ 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)` |
| One-finger swipe in from the drawer's edge | Off | `gestures = DeckGestures(oneFingerSwipe = true)` |
| **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 + 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.
- The one-finger swipe starts within 24dp of the drawer's edge. To keep it from triggering Android's back gesture, a 200dp strip in the middle of that edge is excluded from back; turn it on only if your screens don't use that edge themselves. Both swipes can be on at once.
- To use your own icon on the floating button, pass an `ImageVector` or any composable:

```kotlin
Expand Down Expand Up @@ -210,7 +212,7 @@ val enabled: Boolean = flags[Flags.newCheckout]

- **Views:** *Timeline*, or *By endpoint* grouped by GraphQL operation or method and path, with counts, failures and timings.
- **Details:** overview, query params, headers and pretty-printed bodies, with Copy URL, Copy cURL and Share.
- **Redaction:** auth, cookie, API-key and CSRF headers, and passwords, tokens, secrets and API keys in URLs and bodies (`NetworkLogger.redactedHeaders` / `redactedFields`). Field names match regardless of case, `_` or `-`.
- **Redaction:** auth, cookie, API-key and CSRF headers are masked (`NetworkLogger.redactedHeaders`). Fields in URLs and bodies are shown as they are; to mask them too, set `NetworkLogger.redactedFields = NetworkLogger.DEFAULT_REDACTED_FIELDS` (passwords, tokens, secrets, API keys) or your own names. Field names match regardless of case, `_` or `-`.
- **Limits:** the last 300 calls (`maxCalls`), with per-body and total body caps (`maxBodyChars`, `maxTotalBodyChars`). Streaming and untyped bodies pass through uncaptured.

```kotlin
Expand Down Expand Up @@ -270,7 +272,7 @@ Log pages have level filters, search, an *All / By tag* view, pause, share and c

- 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;
- the last 50 breadcrumbs, 20 network calls (method, URL, status, duration, errors and an excerpt of failed responses, masked the same way as the network inspector) 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.
Expand Down Expand Up @@ -383,7 +385,7 @@ style = DeckStyle(titleIcon = null)
## Security

- **Keep DevDeck out of store builds.** It shows network traffic, logs, preferences and files, and can change app state.
- **Secrets are masked** in the network inspector, but your own logs, logcat lines and preference values are shown as they are.
- **Auth headers are masked** in the network inspector. Request and response bodies, URLs, your own logs, logcat lines and preference values are shown as they are, unless you turn on `NetworkLogger.redactedFields`.
- **Nothing leaves the device on its own.** Data is only shared when a tester taps *Share* or sends a bug report.
- **Found a vulnerability?** Report it privately, as described in [SECURITY.md](SECURITY.md).

Expand Down
11 changes: 9 additions & 2 deletions devdeck/api/devdeck.api
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,21 @@ public final class io/devdeck/DeckGestures {
public static final field $stable I
public static final field Companion Lio/devdeck/DeckGestures$Companion;
public fun <init> ()V
public fun <init> (ZLio/devdeck/DeckShortcut;Z)V
public synthetic fun <init> (ZLio/devdeck/DeckShortcut;Z)V
public synthetic fun <init> (ZLio/devdeck/DeckShortcut;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (ZLio/devdeck/DeckShortcut;ZZ)V
public synthetic fun <init> (ZLio/devdeck/DeckShortcut;ZZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Z
public final fun component2 ()Lio/devdeck/DeckShortcut;
public final fun component3 ()Z
public final fun copy (ZLio/devdeck/DeckShortcut;Z)Lio/devdeck/DeckGestures;
public final fun component4 ()Z
public final synthetic fun copy (ZLio/devdeck/DeckShortcut;Z)Lio/devdeck/DeckGestures;
public final fun copy (ZLio/devdeck/DeckShortcut;ZZ)Lio/devdeck/DeckGestures;
public static synthetic fun copy$default (Lio/devdeck/DeckGestures;ZLio/devdeck/DeckShortcut;ZILjava/lang/Object;)Lio/devdeck/DeckGestures;
public static synthetic fun copy$default (Lio/devdeck/DeckGestures;ZLio/devdeck/DeckShortcut;ZZILjava/lang/Object;)Lio/devdeck/DeckGestures;
public fun equals (Ljava/lang/Object;)Z
public final fun getKeyboardShortcut ()Lio/devdeck/DeckShortcut;
public final fun getOneFingerSwipe ()Z
public final fun getSwipeToClose ()Z
public final fun getTwoFingerSwipe ()Z
public fun hashCode ()I
Expand Down Expand Up @@ -952,6 +958,7 @@ public final class io/devdeck/network/NetworkLogger {
public final fun fail (JLjava/lang/String;Ljava/lang/Long;)V
public static synthetic fun fail$default (Lio/devdeck/network/NetworkLogger;JLjava/lang/String;Ljava/lang/Long;ILjava/lang/Object;)V
public final fun getCalls ()Lkotlinx/coroutines/flow/StateFlow;
public final fun getDEFAULT_REDACTED_FIELDS ()Ljava/util/Set;
public final fun getMaxBodyChars ()I
public final fun getMaxCalls ()I
public final fun getMaxTotalBodyChars ()I
Expand Down
35 changes: 34 additions & 1 deletion devdeck/src/main/kotlin/io/devdeck/DeckGestures.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,46 @@ package io.devdeck

import androidx.compose.runtime.Immutable

/**
* How the drawer opens and closes.
*
* - [twoFingerSwipe]: a two-finger swipe towards the drawer anywhere on screen.
* - [oneFingerSwipe]: a one-finger swipe in from the drawer's edge. Off by default because it competes with the
* app's own edge gestures; when on, a 200dp strip in the middle of that edge is excluded from Android's back
* gesture so the swipe reaches the app.
* - [keyboardShortcut]: toggles the drawer from a hardware keyboard or the emulator; null turns it off.
* - [swipeToClose]: dragging the open drawer closes it.
*/
@Immutable
public data class DeckGestures(
val twoFingerSwipe: Boolean = true,
val keyboardShortcut: DeckShortcut? = DeckShortcut(),
val swipeToClose: Boolean = true,
val oneFingerSwipe: Boolean = false,
) {

@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN)
public constructor(
twoFingerSwipe: Boolean = true,
keyboardShortcut: DeckShortcut? = DeckShortcut(),
swipeToClose: Boolean = true,
) : this(twoFingerSwipe, keyboardShortcut, swipeToClose, oneFingerSwipe = false)

@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN)
public fun copy(
twoFingerSwipe: Boolean = this.twoFingerSwipe,
keyboardShortcut: DeckShortcut? = this.keyboardShortcut,
swipeToClose: Boolean = this.swipeToClose,
): DeckGestures = copy(
twoFingerSwipe = twoFingerSwipe,
keyboardShortcut = keyboardShortcut,
swipeToClose = swipeToClose,
oneFingerSwipe = oneFingerSwipe,
)

internal val anySwipe: Boolean get() = twoFingerSwipe || oneFingerSwipe

public companion object {
public val Disabled: DeckGestures = DeckGestures(twoFingerSwipe = false, keyboardShortcut = null)
public val Disabled: DeckGestures = DeckGestures(twoFingerSwipe = false, keyboardShortcut = null, oneFingerSwipe = false)
}
}
14 changes: 11 additions & 3 deletions devdeck/src/main/kotlin/io/devdeck/DevDeck.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import androidx.core.view.doOnAttach
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import io.devdeck.gesture.EDGE_WIDTH
import io.devdeck.gesture.EdgeSwipeTracker
import io.devdeck.gesture.SWIPE_DISTANCE
import io.devdeck.gesture.TwoFingerSwipeTracker
import io.devdeck.gesture.activePointers
Expand Down Expand Up @@ -164,13 +166,19 @@ public object DevDeck {
val observer = activity.inputObserver() ?: return
if (observer.hasGlobalListeners) return
observer.hasGlobalListeners = true
val tracker = TwoFingerSwipeTracker(SWIPE_DISTANCE.value * activity.resources.displayMetrics.density)
val density = activity.resources.displayMetrics.density
val tracker = TwoFingerSwipeTracker(SWIPE_DISTANCE.value * density)
val edgeTracker = EdgeSwipeTracker(SWIPE_DISTANCE.value * density, EDGE_WIDTH.value * density)
observer.touchListeners += { event ->
val active = config
if (active != null && isEnabled && active.gestures.twoFingerSwipe && !state.isOpen && !HostedActivities.contains(activity)) {
if (active != null && isEnabled && active.gestures.anySwipe && !state.isOpen && !HostedActivities.contains(activity)) {
val rtl = activity.resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL
val drawerOnRight = (active.side == DeckSide.End) != rtl
if (tracker.update(event.activePointers(), towardsLeft = drawerOnRight)) state.open()
val pointers = event.activePointers()
val width = activity.window?.decorView?.width?.toFloat() ?: 0f
val twoFinger = active.gestures.twoFingerSwipe && tracker.update(pointers, towardsLeft = drawerOnRight)
val oneFinger = active.gestures.oneFingerSwipe && edgeTracker.update(pointers, towardsLeft = drawerOnRight, widthPx = width)
if (twoFinger || oneFinger) state.open()
}
}
observer.keyListeners += { event ->
Expand Down
11 changes: 9 additions & 2 deletions devdeck/src/main/kotlin/io/devdeck/DevDeckHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import io.devdeck.gesture.deckSwipes
import io.devdeck.gesture.inputObserver
import io.devdeck.gesture.twoFingerSwipe
import io.devdeck.internal.findActivity
import io.devdeck.plugin.DeckPlugin
import io.devdeck.plugin.DeckPluginContent
Expand All @@ -32,7 +32,14 @@ public fun DevDeckHost(
val isRight = (side == DeckSide.End) == (LocalLayoutDirection.current == LayoutDirection.Ltr)
val openDeck = remember(state) { { state.open() } }
HostActivityEffect(if (enabled) gestures.keyboardShortcut else null, state)
Box(modifier.twoFingerSwipe(enabled && gestures.twoFingerSwipe, towardsLeft = isRight, onSwipe = openDeck)) {
Box(
modifier.deckSwipes(
enabled && gestures.twoFingerSwipe,
enabled && gestures.oneFingerSwipe,
towardsLeft = isRight,
onSwipe = openDeck,
),
) {
content()
DevDeckOverlay(
drawerContent = drawerContent,
Expand Down
15 changes: 15 additions & 0 deletions devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.systemGestureExclusion
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
Expand Down Expand Up @@ -53,6 +55,8 @@ import androidx.core.content.edit
import io.devdeck.capture.RecordingControls
import io.devdeck.capture.ScreenCapture
import io.devdeck.crash.CrashPrompt
import io.devdeck.gesture.EDGE_EXCLUSION_HEIGHT
import io.devdeck.gesture.EDGE_WIDTH
import io.devdeck.internal.DeckIcons
import io.devdeck.internal.DeckSettings
import io.devdeck.performance.PerformanceMonitor
Expand Down Expand Up @@ -117,6 +121,17 @@ internal fun DevDeckOverlay(
onDragStopped = { velocity -> state.onDragStopped(direction * velocity / panelWidthPx) },
)

// Lets a one-finger swipe in from the drawer's edge reach the app instead of Android's back gesture.
if (gestures.oneFingerSwipe && !isVisible) {
Box(
Modifier
.align(if (isRight) AbsoluteAlignment.CenterRight else AbsoluteAlignment.CenterLeft)
.width(EDGE_WIDTH)
.height(EDGE_EXCLUSION_HEIGHT)
.systemGestureExclusion(),
)
}

if (UiTools.isKeylineOverlayEnabled) {
KeylineOverlay()
}
Expand Down
36 changes: 36 additions & 0 deletions devdeck/src/main/kotlin/io/devdeck/gesture/DeckSwipeModifier.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package io.devdeck.gesture

import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp

/** Watches touches without consuming them and calls [onSwipe] for an enabled opening swipe. */
internal fun Modifier.deckSwipes(twoFinger: Boolean, oneFinger: Boolean, towardsLeft: Boolean, onSwipe: () -> Unit): Modifier =
if (!twoFinger && !oneFinger) {
this
} else {
pointerInput(twoFinger, oneFinger, towardsLeft, onSwipe) {
val twoFingerTracker = TwoFingerSwipeTracker(SWIPE_DISTANCE.toPx())
val edgeTracker = EdgeSwipeTracker(SWIPE_DISTANCE.toPx(), EDGE_WIDTH.toPx())
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
val pointers = event.changes
.filter { it.pressed }
.map { TouchPoint(it.id.value, it.position.x, it.position.y) }
val twoFingerSwipe = twoFinger && twoFingerTracker.update(pointers, towardsLeft)
val edgeSwipe = oneFinger && edgeTracker.update(pointers, towardsLeft, size.width.toFloat())
if (twoFingerSwipe || edgeSwipe) onSwipe()
}
}
}
}

internal val SWIPE_DISTANCE = 72.dp

/** How far from the drawer's edge a one-finger swipe may start. */
internal val EDGE_WIDTH = 24.dp

/** Height of the strip on the drawer's edge that Android's back gesture leaves to the app (the system caps it at 200dp). */
internal val EDGE_EXCLUSION_HEIGHT = 200.dp
41 changes: 41 additions & 0 deletions devdeck/src/main/kotlin/io/devdeck/gesture/EdgeSwipeTracker.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package io.devdeck.gesture

import kotlin.math.abs

/**
* Detects a one-finger swipe that starts within [edgeWidthPx] of the drawer's edge and moves towards the middle.
* A second finger cancels it, so a two-finger swipe never also counts as an edge swipe.
*/
internal class EdgeSwipeTracker(private val minDistancePx: Float, private val edgeWidthPx: Float) {

private var start: TouchPoint? = null
private var consumed = false

/** [towardsLeft] means the drawer is on the right, so the swipe starts at the right edge of [widthPx]. */
fun update(pointers: List<TouchPoint>, towardsLeft: Boolean, widthPx: Float): Boolean {
if (pointers.isEmpty()) {
start = null
consumed = false
return false
}
if (pointers.size > 1) consumed = true
if (consumed) return false
val pointer = pointers.single()
val origin = start ?: pointer.also {
val fromEdge = if (towardsLeft) widthPx - it.x else it.x
// A touch that doesn't begin at the edge can't become an edge swipe until every finger lifts.
if (fromEdge > edgeWidthPx) consumed = true
start = it
}
if (consumed || pointer.id != origin.id) return false
val dx = pointer.x - origin.x
val directional = if (towardsLeft) -dx else dx
val swiped = directional >= minDistancePx && directional >= abs(pointer.y - origin.y) * HORIZONTAL_RATIO
if (swiped) consumed = true
return swiped
}

private companion object {
const val HORIZONTAL_RATIO = 1.5f
}
}

This file was deleted.

Loading
Loading