From 07b000dbd521ba9780c29d6ffe25b2d9499633ae Mon Sep 17 00:00:00 2001 From: Abhishek Bansal Date: Tue, 10 Nov 2026 16:23:43 +0530 Subject: [PATCH] Fix redaction running debug apps out of memory; opt-in field masking; one-finger edge swipe Redaction crash - Kotlin's Regex.replace creates a new Matcher for every match, and on Android each Matcher copies the whole input into native memory. Masking a large GraphQL body made thousands of 500 KB copies, which aborted debug apps (CheckJNI) or got them killed for low memory. - NetworkRedaction now uses one Matcher per body and skips bodies that contain none of the field names; NetworkLogger.complete masks once, outside the StateFlow update that can retry. - Emulator stress test (4 threads x 5 bodies of 240K chars): 7.1 s per body, 703 MB native heap and a LOW_MEMORY kill before; 27-124 ms and 21.6 MB after. Masking - NetworkLogger.redactedFields is empty by default; auth, cookie, API-key and CSRF headers are still masked. NetworkLogger.DEFAULT_REDACTED_FIELDS turns field masking back on. Gestures - DeckGestures(oneFingerSwipe = true) opens the drawer with a one-finger swipe in from its edge (off by default); a 200dp strip of that edge is excluded from the system back gesture while the drawer is closed. The old DeckGestures constructor and copy are kept as hidden overloads. --- CHANGELOG.md | 9 +++ README.md | 8 ++- devdeck/api/devdeck.api | 11 +++- .../main/kotlin/io/devdeck/DeckGestures.kt | 35 ++++++++++- devdeck/src/main/kotlin/io/devdeck/DevDeck.kt | 14 ++++- .../src/main/kotlin/io/devdeck/DevDeckHost.kt | 11 +++- .../main/kotlin/io/devdeck/DevDeckOverlay.kt | 15 +++++ .../io/devdeck/gesture/DeckSwipeModifier.kt | 36 +++++++++++ .../io/devdeck/gesture/EdgeSwipeTracker.kt | 41 +++++++++++++ .../devdeck/gesture/TwoFingerSwipeModifier.kt | 25 -------- .../io/devdeck/network/NetworkLogger.kt | 17 +++++- .../io/devdeck/network/NetworkRedaction.kt | 52 ++++++++++++---- .../devdeck/gesture/EdgeSwipeTrackerTest.kt | 60 +++++++++++++++++++ .../devdeck/network/NetworkRedactionTest.kt | 36 ++++++++++- sample/README.md | 1 + .../io/devdeck/sample/ui/DrawerControls.kt | 1 + .../io/devdeck/sample/ui/DrawerOptions.kt | 2 + 17 files changed, 322 insertions(+), 52 deletions(-) create mode 100644 devdeck/src/main/kotlin/io/devdeck/gesture/DeckSwipeModifier.kt create mode 100644 devdeck/src/main/kotlin/io/devdeck/gesture/EdgeSwipeTracker.kt delete mode 100644 devdeck/src/main/kotlin/io/devdeck/gesture/TwoFingerSwipeModifier.kt create mode 100644 devdeck/src/test/kotlin/io/devdeck/gesture/EdgeSwipeTrackerTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 912c26b..fa83b00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index b1da5ad..5b1ed06 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ 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()` | @@ -121,6 +122,7 @@ 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 + 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 @@ -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 @@ -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. @@ -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). diff --git a/devdeck/api/devdeck.api b/devdeck/api/devdeck.api index 0933ab0..ebd4701 100644 --- a/devdeck/api/devdeck.api +++ b/devdeck/api/devdeck.api @@ -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 ()V - public fun (ZLio/devdeck/DeckShortcut;Z)V + public synthetic fun (ZLio/devdeck/DeckShortcut;Z)V public synthetic fun (ZLio/devdeck/DeckShortcut;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZLio/devdeck/DeckShortcut;ZZ)V + public synthetic fun (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 @@ -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 diff --git a/devdeck/src/main/kotlin/io/devdeck/DeckGestures.kt b/devdeck/src/main/kotlin/io/devdeck/DeckGestures.kt index 01e8390..e1cbd28 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DeckGestures.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DeckGestures.kt @@ -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) } } diff --git a/devdeck/src/main/kotlin/io/devdeck/DevDeck.kt b/devdeck/src/main/kotlin/io/devdeck/DevDeck.kt index ba061ce..d857506 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DevDeck.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DevDeck.kt @@ -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 @@ -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 -> diff --git a/devdeck/src/main/kotlin/io/devdeck/DevDeckHost.kt b/devdeck/src/main/kotlin/io/devdeck/DevDeckHost.kt index 909068a..5a788a4 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DevDeckHost.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DevDeckHost.kt @@ -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 @@ -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, diff --git a/devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt b/devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt index 3d3c653..ed6337f 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt @@ -17,6 +17,7 @@ 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 @@ -24,6 +25,7 @@ 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 @@ -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 @@ -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() } diff --git a/devdeck/src/main/kotlin/io/devdeck/gesture/DeckSwipeModifier.kt b/devdeck/src/main/kotlin/io/devdeck/gesture/DeckSwipeModifier.kt new file mode 100644 index 0000000..6b8893b --- /dev/null +++ b/devdeck/src/main/kotlin/io/devdeck/gesture/DeckSwipeModifier.kt @@ -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 diff --git a/devdeck/src/main/kotlin/io/devdeck/gesture/EdgeSwipeTracker.kt b/devdeck/src/main/kotlin/io/devdeck/gesture/EdgeSwipeTracker.kt new file mode 100644 index 0000000..586f210 --- /dev/null +++ b/devdeck/src/main/kotlin/io/devdeck/gesture/EdgeSwipeTracker.kt @@ -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, 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 + } +} diff --git a/devdeck/src/main/kotlin/io/devdeck/gesture/TwoFingerSwipeModifier.kt b/devdeck/src/main/kotlin/io/devdeck/gesture/TwoFingerSwipeModifier.kt deleted file mode 100644 index deb495d..0000000 --- a/devdeck/src/main/kotlin/io/devdeck/gesture/TwoFingerSwipeModifier.kt +++ /dev/null @@ -1,25 +0,0 @@ -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 - -internal fun Modifier.twoFingerSwipe(enabled: Boolean, towardsLeft: Boolean, onSwipe: () -> Unit): Modifier = if (!enabled) { - this -} else { - pointerInput(towardsLeft, onSwipe) { - val tracker = TwoFingerSwipeTracker(SWIPE_DISTANCE.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) } - if (tracker.update(pointers, towardsLeft)) onSwipe() - } - } - } -} - -internal val SWIPE_DISTANCE = 72.dp diff --git a/devdeck/src/main/kotlin/io/devdeck/network/NetworkLogger.kt b/devdeck/src/main/kotlin/io/devdeck/network/NetworkLogger.kt index e910f1e..65c5f82 100644 --- a/devdeck/src/main/kotlin/io/devdeck/network/NetworkLogger.kt +++ b/devdeck/src/main/kotlin/io/devdeck/network/NetworkLogger.kt @@ -29,7 +29,15 @@ public object NetworkLogger { "X-XSRF-Token", ) - public var redactedFields: Set = setOf( + /** + * Field names whose values are masked in URLs and bodies. Empty by default: DevDeck runs in debug builds and + * masking every body costs time on large responses. Turn it on with + * `NetworkLogger.redactedFields = NetworkLogger.DEFAULT_REDACTED_FIELDS`, or your own names. + */ + public var redactedFields: Set = emptySet() + + /** Common secret field names, for [redactedFields]. */ + public val DEFAULT_REDACTED_FIELDS: Set = setOf( "password", "passwd", "token", @@ -83,12 +91,15 @@ public object NetworkLogger { durationMillis: Long? = null, protocol: String? = null, ) { + // Redacted once, outside update(): its lambda can run again when calls finish at the same time. + val safeHeaders = redact(headers) + val safeBody = body?.let { NetworkRedaction.body(it, redactedFields) }?.truncate() update(id) { it.copy( responseCode = code, responseMessage = message, - responseHeaders = redact(headers), - responseBody = body?.let { NetworkRedaction.body(it, redactedFields) }?.truncate(), + responseHeaders = safeHeaders, + responseBody = safeBody, responseContentType = contentType, responseSize = size, durationMillis = durationMillis, diff --git a/devdeck/src/main/kotlin/io/devdeck/network/NetworkRedaction.kt b/devdeck/src/main/kotlin/io/devdeck/network/NetworkRedaction.kt index 5ba902f..3d51f56 100644 --- a/devdeck/src/main/kotlin/io/devdeck/network/NetworkRedaction.kt +++ b/devdeck/src/main/kotlin/io/devdeck/network/NetworkRedaction.kt @@ -1,6 +1,8 @@ package io.devdeck.network import java.net.URLDecoder +import java.util.regex.Matcher +import java.util.regex.Pattern internal object NetworkRedaction { @@ -21,17 +23,15 @@ internal object NetworkRedaction { fun body(body: String, keys: Set): String { if (keys.isEmpty() || body.isEmpty()) return body val normalized = normalizeAll(keys) - val json = JSON_STRING.replace(body) { match -> - val separator = match.groups[2]?.value - if (separator != null && normalize(match.groupValues[1]) in normalized) { - "\"${match.groupValues[1]}\"$separator\"$MASK\"" - } else { - match.value - } + // Most bodies contain no secret field at all; one cheap pass skips the regex work for them. + if (!mayContainAny(body, normalized)) return body + val json = JSON_STRING.replaceEach(body) { match -> + val separator = match.group(2) + if (separator != null && normalize(match.group(1)) in normalized) "\"${match.group(1)}\"$separator\"$MASK\"" else null } - return FORM_FIELD.replace(json) { match -> - val name = match.groupValues[2] - if (normalize(decode(name)) in normalized) "${match.groupValues[1]}$name=$MASK" else match.value + return FORM_FIELD.replaceEach(json) { match -> + val name = match.group(2) + if (normalize(decode(name)) in normalized) "${match.group(1)}$name=$MASK" else null } } @@ -47,15 +47,43 @@ internal object NetworkRedaction { private fun normalizeAll(keys: Set): Set = keys.mapTo(HashSet()) { normalize(it) } + // URL-encoded form names (`pass%5Fword`) only match after decoding, so those bodies always get the full pass. + private fun mayContainAny(body: String, normalizedKeys: Set): Boolean { + if ('%' in body) return true + val flat = normalize(body) + return normalizedKeys.any { it in flat } + } + + /** + * Replaces matches with [transform]'s result, or keeps them when it returns null. Uses one Matcher for the whole + * input: Kotlin's Regex.replace makes a new Matcher per match, and on Android each one copies the whole input + * into native memory, which ran large bodies out of memory. + */ + private inline fun Pattern.replaceEach(input: String, transform: (Matcher) -> String?): String { + val matcher = matcher(input) + if (!matcher.find()) return input + val out = StringBuilder(input.length) + var last = 0 + var replaced = false + do { + val replacement = transform(matcher) ?: continue + out.append(input, last, matcher.start()).append(replacement) + last = matcher.end() + replaced = true + } while (matcher.find()) + if (!replaced) return input + return out.append(input, last, input.length).toString() + } + private fun decode(value: String): String = runCatching { URLDecoder.decode(value, "UTF-8") }.getOrDefault(value) // Consumes every JSON string in order so keys and values stay aligned; groups 2 and 3 match only for `"key": scalar`. private const val STRING_BODY = "[^\"\\\\]*+(?:\\\\.[^\"\\\\]*+)*+" private val JSON_STRING = - Regex("\"($STRING_BODY)\"(?:(\\s*:\\s*)(\"$STRING_BODY\"|-?\\d[\\d.eE+-]*|true|false|null))?") + Pattern.compile("\"($STRING_BODY)\"(?:(\\s*:\\s*)(\"$STRING_BODY\"|-?\\d[\\d.eE+-]*|true|false|null))?") - private val FORM_FIELD = Regex("(^|&)([^=&\\s]+)=[^&\\s]*") + private val FORM_FIELD = Pattern.compile("(^|&)([^=&\\s]+)=[^&\\s]*") const val MASK = "██" } diff --git a/devdeck/src/test/kotlin/io/devdeck/gesture/EdgeSwipeTrackerTest.kt b/devdeck/src/test/kotlin/io/devdeck/gesture/EdgeSwipeTrackerTest.kt new file mode 100644 index 0000000..d33ab7b --- /dev/null +++ b/devdeck/src/test/kotlin/io/devdeck/gesture/EdgeSwipeTrackerTest.kt @@ -0,0 +1,60 @@ +package io.devdeck.gesture + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class EdgeSwipeTrackerTest { + + private val width = 1000f + + private fun tracker() = EdgeSwipeTracker(minDistancePx = 100f, edgeWidthPx = 30f) + + private fun finger(x: Float, y: Float = 500f, id: Long = 1) = listOf(TouchPoint(id, x, y)) + + @Test + fun `a swipe in from the right edge opens a drawer on the right`() { + val tracker = tracker() + assertFalse(tracker.update(finger(990f), towardsLeft = true, widthPx = width)) + assertFalse(tracker.update(finger(940f), towardsLeft = true, widthPx = width)) + assertTrue(tracker.update(finger(880f), towardsLeft = true, widthPx = width)) + assertFalse("fires once per gesture", tracker.update(finger(800f), towardsLeft = true, widthPx = width)) + } + + @Test + fun `a swipe in from the left edge opens a drawer on the left`() { + val tracker = tracker() + tracker.update(finger(10f), towardsLeft = false, widthPx = width) + assertTrue(tracker.update(finger(150f), towardsLeft = false, widthPx = width)) + } + + @Test + fun `swipes that start away from the edge are ignored until the finger lifts`() { + val tracker = tracker() + tracker.update(finger(700f), towardsLeft = true, widthPx = width) + assertFalse(tracker.update(finger(400f), towardsLeft = true, widthPx = width)) + + tracker.update(emptyList(), towardsLeft = true, widthPx = width) + tracker.update(finger(995f), towardsLeft = true, widthPx = width) + assertTrue(tracker.update(finger(850f), towardsLeft = true, widthPx = width)) + } + + @Test + fun `mostly vertical drags and second fingers don't count`() { + val vertical = tracker() + vertical.update(finger(990f, y = 100f), towardsLeft = true, widthPx = width) + assertFalse(vertical.update(finger(870f, y = 400f), towardsLeft = true, widthPx = width)) + + val twoFingers = tracker() + twoFingers.update(finger(990f), towardsLeft = true, widthPx = width) + twoFingers.update(finger(980f) + finger(500f, id = 2), towardsLeft = true, widthPx = width) + assertFalse(twoFingers.update(finger(850f), towardsLeft = true, widthPx = width)) + } + + @Test + fun `swiping away from the drawer doesn't open it`() { + val tracker = tracker() + tracker.update(finger(10f), towardsLeft = true, widthPx = width) + assertFalse(tracker.update(finger(200f), towardsLeft = true, widthPx = width)) + } +} diff --git a/devdeck/src/test/kotlin/io/devdeck/network/NetworkRedactionTest.kt b/devdeck/src/test/kotlin/io/devdeck/network/NetworkRedactionTest.kt index c942a87..be2f668 100644 --- a/devdeck/src/test/kotlin/io/devdeck/network/NetworkRedactionTest.kt +++ b/devdeck/src/test/kotlin/io/devdeck/network/NetworkRedactionTest.kt @@ -2,6 +2,7 @@ package io.devdeck.network import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test @@ -36,7 +37,7 @@ class NetworkRedactionTest { fun `matches camelCase and dashed variants of secret keys`() { val body = NetworkRedaction.body( """{"accessToken":"a1","refreshToken":"r1","idToken":"i1","clientSecret":"c1","apiKey":"k1","user":"sam"}""", - NetworkLogger.redactedFields, + NetworkLogger.DEFAULT_REDACTED_FIELDS, ) listOf("a1", "r1", "i1", "c1", "k1").forEach { assertFalse(body, body.contains("\"$it\"")) } @@ -85,4 +86,37 @@ class NetworkRedactionTest { val url = "https://api.example.com/orders?page=1" assertEquals(url, NetworkRedaction.url(url, keys)) } + + @Test + fun `masks every secret in a large body with many strings`() { + // About the size of a captured GraphQL page: thousands of strings, one secret per item. + val body = buildString { + append("{\"data\":[") + var i = 0 + while (length < 240_000) { + if (i > 0) append(',') + append("{\"id\":\"p$i\",\"name\":\"Sneaker $i\",\"access_token\":\"t$i\"}") + i++ + } + append("]}") + } + + val redacted = NetworkRedaction.body(body, keys) + + assertFalse(Regex("\"t\\d+\"").containsMatchIn(redacted)) + assertEquals(Regex("access_token").findAll(body).count(), Regex("\"access_token\":\"██\"").findAll(redacted).count()) + assertTrue(redacted.contains("\"name\":\"Sneaker 0\"")) + } + + @Test + fun `returns bodies without secret fields untouched`() { + val body = """{"data":{"products":[{"id":"1","name":"Sneaker"}]}}""" + + assertSame(body, NetworkRedaction.body(body, keys)) + } + + @Test + fun `still masks url-encoded form field names`() { + assertEquals("user=sam&pass%77ord=██", NetworkRedaction.body("user=sam&pass%77ord=s3cret", keys)) + } } diff --git a/sample/README.md b/sample/README.md index 7a35eb8..e033801 100644 --- a/sample/README.md +++ b/sample/README.md @@ -191,6 +191,7 @@ DevDeckHost( |---|---| | Drawer enabled | `enabled` | | Two-finger swipe to open | `DeckGestures(twoFingerSwipe)` | +| One-finger swipe from the edge | `DeckGestures(oneFingerSwipe)` | | Ctrl + Shift + K shortcut | `DeckGestures(keyboardShortcut = DeckShortcut())` / `null` | | Floating button | `DeckTrigger.FloatingButton()` / `DeckTrigger.None` | | Side | `DeckSide.Start` / `End` | diff --git a/sample/src/main/kotlin/io/devdeck/sample/ui/DrawerControls.kt b/sample/src/main/kotlin/io/devdeck/sample/ui/DrawerControls.kt index d176ba8..cb1cdd1 100644 --- a/sample/src/main/kotlin/io/devdeck/sample/ui/DrawerControls.kt +++ b/sample/src/main/kotlin/io/devdeck/sample/ui/DrawerControls.kt @@ -23,6 +23,7 @@ fun DrawerControls(options: DrawerOptions, modifier: Modifier = Modifier) { Text("Drawer controls", style = MaterialTheme.typography.titleMedium) ControlSwitch("Drawer enabled", options.enabled) { options.enabled = it } ControlSwitch("Two-finger swipe to open", options.twoFingerSwipe) { options.twoFingerSwipe = it } + ControlSwitch("One-finger swipe from the edge", options.oneFingerSwipe) { options.oneFingerSwipe = it } ControlSwitch("Ctrl + Shift + K shortcut", options.keyboardShortcut) { options.keyboardShortcut = it } ControlSwitch("Floating button", options.floatingButton) { options.floatingButton = it } ControlSwitch("Custom floating button icon", options.customIcon, enabled = options.floatingButton) { options.customIcon = it } diff --git a/sample/src/main/kotlin/io/devdeck/sample/ui/DrawerOptions.kt b/sample/src/main/kotlin/io/devdeck/sample/ui/DrawerOptions.kt index 52b5410..0287708 100644 --- a/sample/src/main/kotlin/io/devdeck/sample/ui/DrawerOptions.kt +++ b/sample/src/main/kotlin/io/devdeck/sample/ui/DrawerOptions.kt @@ -19,6 +19,7 @@ import io.devdeck.sample.R class DrawerOptions { var enabled by mutableStateOf(true) var twoFingerSwipe by mutableStateOf(true) + var oneFingerSwipe by mutableStateOf(false) var keyboardShortcut by mutableStateOf(true) var floatingButton by mutableStateOf(true) var customIcon by mutableStateOf(false) @@ -29,6 +30,7 @@ class DrawerOptions { val gestures: DeckGestures get() = DeckGestures( twoFingerSwipe = twoFingerSwipe, + oneFingerSwipe = oneFingerSwipe, keyboardShortcut = if (keyboardShortcut) DeckShortcut() else null, )