diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 2fe6cc0..2de3f4a 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -780,9 +780,20 @@ func clampGestureToSafeArea(fromX, fromY, toX, toY int, screen hierarchy.Bounds) if width <= 0 || height <= 0 { return fromX, fromY, toX, toY } - // The top margin clears the status bar; calibrated as a fraction of height - // so swipes from below ~8% of height no longer open the shade. + // Only the touch-down point opens the shade, and only when it lands in the + // top status strip; the margin clears it (calibrated as a fraction of + // height so origins below ~8% of height no longer open the shade). When the + // origin is too high, translate the whole segment down by the same delta + // rather than clamping the origin alone, which could push it past the + // destination and reverse the gesture (a near-top scroll would then flip + // direction). Side and bottom edge gestures are disabled by + // ForceThreeButtonNav, so only the top needs a margin; endpoints are + // otherwise just kept on screen. marginY := height / 12 + if shortfall := (screen.Top + marginY) - fromY; shortfall > 0 { + fromY += shortfall + toY += shortfall + } clamp := func(value, low, high int) int { if value < low { return low @@ -793,7 +804,7 @@ func clampGestureToSafeArea(fromX, fromY, toX, toY int, screen hierarchy.Bounds) return value } fromX = clamp(fromX, screen.Left, screen.Right) - fromY = clamp(fromY, screen.Top+marginY, screen.Bottom) + fromY = clamp(fromY, screen.Top, screen.Bottom) toX = clamp(toX, screen.Left, screen.Right) toY = clamp(toY, screen.Top, screen.Bottom) return fromX, fromY, toX, toY diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 2f97125..01bc390 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -909,6 +909,48 @@ func TestApplyAction_ScrollDirectionUsesInversion(t *testing.T) { } } +// TestApplyAction_ScrollNearTopKeepsDirectionAfterClamp pins the clamp fix +// against a scrollable container pinned in the top status strip: the safe-area +// clamp must translate the whole gesture down, not push the origin past the +// destination. With the old origin-only clamp the downward finger (scroll up) +// flipped to an upward finger, scrolling the opposite way. +func TestApplyAction_ScrollNearTopKeepsDirectionAfterClamp(t *testing.T) { + driverMock := mockdriver.New() + // A full-screen root establishes the 1080x2400 screen (marginY=200); the + // scrollable list sits entirely inside the top margin (y 20..180). + treeJSON := `{"attributes":{"bounds":"[0,0,1080,2400]"},"children":[ + {"attributes":{"resource-id":"com.fixture:id/toplist","scrollable":"true","bounds":"[0,20,1080,180]"},"children":[],"enabled":true} + ]}` + tree, err := hierarchy.Parse(treeJSON) + if err != nil { + t.Fatalf("parse tree: %v", err) + } + action := verifier.Action{Kind: verifier.ActionKindScroll, Direction: "up", On: "id:toplist"} + + if err := applyAction(context.Background(), driverMock, action, tree); err != nil { + t.Fatalf("apply action: %v", err) + } + var swipe *mockdriver.Action + for i := range driverMock.Actions() { + if driverMock.Actions()[i].Kind == mockdriver.ActionSwipe { + a := driverMock.Actions()[i] + swipe = &a + } + } + if swipe == nil { + t.Fatalf("expected a Swipe, got %v", driverMock.Actions()) + } + // The origin must clear the top margin (the clamp fired)... + if swipe.FromY != 200 { + t.Errorf("origin not pushed below the shade strip, got fromY=%d want 200", swipe.FromY) + } + // ...and "up" must still drag the finger DOWN (toY > fromY). The old clamp + // reversed this. + if swipe.ToY <= swipe.FromY { + t.Errorf("scroll up reversed by the clamp: from=%d to=%d (want toY > fromY)", swipe.FromY, swipe.ToY) + } +} + func TestApplyAction_ScrollScreenFallback(t *testing.T) { driverMock := mockdriver.New() treeJSON := `{"attributes":{"bounds":"[0,0,400,800]"},"children":[],"enabled":true}` @@ -1808,18 +1850,21 @@ func TestAwaitForeground_RelaunchesThenWaitsForWindow(t *testing.T) { // TestClampGestureToSafeArea_KeepsOriginBelowShadeStrip 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 pushed below the top -// margin. The side and bottom strips are NOT clamped: runs force 3-button -// navigation, which disables the back and home gestures, so (verified on device) -// those origins no longer drift. +// margin, and the WHOLE segment translates with it so the gesture direction is +// preserved (clamping the origin alone could push it past the destination and +// reverse a near-top scroll). The side and bottom strips are NOT clamped: runs +// force 3-button navigation, which disables the back and home gestures, so +// (verified on device) those origins no longer drift. func TestClampGestureToSafeArea_KeepsOriginBelowShadeStrip(t *testing.T) { screen := hierarchy.Bounds{Left: 0, Top: 0, Right: 1080, Bottom: 2400} // marginY = 2400/12 = 200. // The real shade-opening swipe captured on device: origin y=62 is pushed to - // the top margin (200); the in-range destination is untouched. + // the top margin (200), and the destination shifts down by the same 138 so + // the downward gesture stays downward (447 -> 585), not reversed. fromX, fromY, toX, toY := clampGestureToSafeArea(802, 62, 802, 447, screen) - if fromX != 802 || fromY != 200 || toX != 802 || toY != 447 { - t.Errorf("origin not pushed below the shade strip: from=(%d,%d) to=(%d,%d), want from=(802,200) to=(802,447)", fromX, fromY, toX, toY) + if fromX != 802 || fromY != 200 || toX != 802 || toY != 585 { + t.Errorf("segment not translated below the shade strip: from=(%d,%d) to=(%d,%d), want from=(802,200) to=(802,585)", fromX, fromY, toX, toY) } // A side origin (left/right edge) passes through untouched: 3-button nav