From 9c9d3c934e52c3d94c42deef07f6c6bc0502d6c0 Mon Sep 17 00:00:00 2001 From: PJ Date: Wed, 10 Jun 2026 15:43:53 +0530 Subject: [PATCH] fix(runner): clamp swipe/scroll origin out of edge gesture zones A gesture starting in the top status-bar strip pulls down the notification shade; the bottom and side strips are the home and back gestures. Any of them drags the fuzzer out of the app. Swipe and scroll origins are now clamped into a safe inner area sized from the maximum element extent (the Android hierarchy root reports zero bounds, so the extent is the reliable screen size). Calibrated on device: origins below ~7% of height no longer open the shade. --- internal/runner/runner.go | 56 +++++++++++++++++++++++++++++++++- internal/runner/runner_test.go | 48 +++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 253b5af..36392f0 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -537,6 +537,7 @@ func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.A return drv.LongPress(ctx, x, y) case verifier.ActionKindScroll: fromX, fromY, toX, toY := scrollEndpoints(action, tree) + fromX, fromY, toX, toY = clampGestureToSafeArea(fromX, fromY, toX, toY, screenBounds(tree)) duration := time.Duration(action.DurationMillis) * time.Millisecond if duration <= 0 { duration = 300 * time.Millisecond @@ -581,7 +582,8 @@ func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.A if duration <= 0 { duration = 250 * time.Millisecond } - return drv.Swipe(ctx, action.FromX, action.FromY, action.ToX, action.ToY, duration) + fromX, fromY, toX, toY := clampGestureToSafeArea(action.FromX, action.FromY, action.ToX, action.ToY, screenBounds(tree)) + return drv.Swipe(ctx, fromX, fromY, toX, toY, duration) case verifier.ActionKindPressKey: if action.Key == "" { return nil @@ -707,6 +709,58 @@ func scrollEndpoints(action verifier.Action, tree *hierarchy.Tree) (fromX, fromY // scrollBounds returns the container bounds for an authored Scroll: the node // named by On when it resolves, otherwise the root (whole-screen) bounds. +// screenBounds returns the device screen rectangle as the maximum extent across +// all elements. The hierarchy root often reports zero bounds on Android, so the +// extent (driven by full-screen containers and the navigation bar) is the +// reliable screen size. Returns a zero rectangle when unknown. +func screenBounds(tree *hierarchy.Tree) hierarchy.Bounds { + if tree == nil { + return hierarchy.Bounds{} + } + var bounds hierarchy.Bounds + for _, element := range tree.Elements { + if element.Bounds.Right > bounds.Right { + bounds.Right = element.Bounds.Right + } + if element.Bounds.Bottom > bounds.Bottom { + bounds.Bottom = element.Bounds.Bottom + } + } + return bounds +} + +// clampGestureToSafeArea keeps a swipe's origin out of the system gesture zones +// at the screen edges. A gesture that starts in the top strip pulls down the +// notification shade; the bottom strip is the home gesture and the side strips +// are the back gesture. Any of these drags the fuzzer out of the app. The +// destination only needs to stay on screen. With an unknown screen size the +// coordinates pass through unchanged. +func clampGestureToSafeArea(fromX, fromY, toX, toY int, screen hierarchy.Bounds) (int, int, int, int) { + width, height := screen.Width(), screen.Height() + if width <= 0 || height <= 0 { + return fromX, fromY, toX, toY + } + // Margins are a fraction of each axis so they scale across devices and + // densities. The vertical margin clears the status bar (calibrated: swipes + // from below ~7% of height no longer open the shade) and the home gesture. + marginX := width / 20 + marginY := height / 12 + clamp := func(value, low, high int) int { + if value < low { + return low + } + if value > high { + return high + } + return value + } + fromX = clamp(fromX, screen.Left+marginX, screen.Right-marginX) + fromY = clamp(fromY, screen.Top+marginY, screen.Bottom-marginY) + toX = clamp(toX, screen.Left, screen.Right) + toY = clamp(toY, screen.Top, screen.Bottom) + return fromX, fromY, toX, toY +} + func scrollBounds(action verifier.Action, tree *hierarchy.Tree) hierarchy.Bounds { if tree == nil { return hierarchy.Bounds{} diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 8d5ed6d..f447170 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -1807,6 +1807,54 @@ func TestAwaitForeground_RelaunchesThenWaitsForWindow(t *testing.T) { } } +// TestClampGestureToSafeArea_KeepsOriginOutOfGestureZones locks the swipe fix: +// a gesture origin in the top status-bar strip pulls down the notification +// shade and drags the fuzzer out of the app. The origin must be pulled into the +// safe inner area while the destination only stays on screen. +func TestClampGestureToSafeArea_KeepsOriginOutOfGestureZones(t *testing.T) { + screen := hierarchy.Bounds{Left: 0, Top: 0, Right: 1080, Bottom: 2400} + // marginX = 1080/20 = 54, marginY = 2400/12 = 200. + + // The real shade-opening swipe captured on device: origin y=62. + fromX, fromY, toX, toY := clampGestureToSafeArea(802, 62, 802, 447, screen) + if fromY < 200 { + t.Errorf("origin Y %d still inside the status-bar strip (need >= 200)", fromY) + } + if fromX != 802 || toX != 802 || toY != 447 { + t.Errorf("unexpected clamp of in-range coords: from=(%d,%d) to=(%d,%d)", fromX, fromY, toX, toY) + } + + // Top-left corner origin pulled inside both margins. + fromX, fromY, _, _ = clampGestureToSafeArea(0, 0, 540, 1200, screen) + if fromX != 54 || fromY != 200 { + t.Errorf("corner origin not clamped to (54,200), got (%d,%d)", fromX, fromY) + } + + // Unknown screen size leaves coordinates untouched. + fromX, fromY, toX, toY = clampGestureToSafeArea(802, 62, 802, 447, hierarchy.Bounds{}) + if fromX != 802 || fromY != 62 || toX != 802 || toY != 447 { + t.Error("coordinates must pass through unchanged when screen size is unknown") + } +} + +// TestScreenBounds_UsesMaxExtentNotRoot guards the screen-size source: the +// Android hierarchy root reports zero bounds, so the screen rectangle must come +// from the maximum element extent or the gesture clamp silently no-ops. +func TestScreenBounds_UsesMaxExtentNotRoot(t *testing.T) { + tree := &hierarchy.Tree{ + Root: &hierarchy.Node{Element: hierarchy.Element{Bounds: hierarchy.Bounds{}}}, + Elements: []*hierarchy.Element{ + {Bounds: hierarchy.Bounds{}}, + {Bounds: hierarchy.Bounds{Left: 0, Top: 0, Right: 1080, Bottom: 2160}}, + {Bounds: hierarchy.Bounds{Left: 0, Top: 2268, Right: 1080, Bottom: 2400}}, + }, + } + got := screenBounds(tree) + if got.Right != 1080 || got.Bottom != 2400 { + t.Fatalf("screenBounds = %+v, want right=1080 bottom=2400", got) + } +} + // TestEnsureForeground_DismissesSystemOverlay locks the shade fix: when the app // is still the resumed activity but a system overlay (notification shade) holds // the focused window, the guard must dismiss it with back rather than relaunch