Skip to content

Commit 03d1105

Browse files
Fix scroll and scrollUntilVisible on Android
Use ADB input swipe for reliable scrolling instead of Appium gestures, which are unreliable on many Android devices/emulators. Log a warning when falling back to the Appium scroll path due to missing ADB. Also distinguish "element not found" errors from infrastructure failures in scrollUntilVisible so connection errors are propagated immediately instead of being silently swallowed until all scrolls are exhausted. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a281e9a commit 03d1105

4 files changed

Lines changed: 312 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111
- `runFlow: when` conditions with variable expressions (e.g., `${output.element.id}`) were never expanded, causing conditions to always evaluate as false and silently skip conditional blocks
1212
- iOS real device: `acceptAlertButtonSelector` matched "Don't Allow" instead of "Allow" — `CONTAINS[c] 'Allow'` matched both buttons, causing WDA to reject permission dialogs. Changed to `BEGINSWITH[c] 'Allow'` with `OK` fallback for older iOS versions
13+
- Android: `scroll` and `scrollUntilVisible` did not scroll — Appium `/appium/gestures/scroll` endpoint is unreliable on many devices. Replaced with ADB `input swipe` for direct OS-level input injection (falls back to Appium if ADB is unavailable). Also added on-screen bounds verification to prevent false positives from off-screen elements in the Android view hierarchy
1314

1415
## [1.0.7] - 2026-02-20
1516

pkg/driver/uiautomator2/commands.go

Lines changed: 95 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package uiautomator2
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"strconv"
78
"strings"
@@ -395,51 +396,72 @@ func (d *Driver) scroll(step *flow.ScrollStep) *core.CommandResult {
395396
direction = "down"
396397
}
397398

398-
// Get screen size for dynamic scroll area
399+
// Get screen size for scroll coordinates
399400
width, height, err := d.screenSize()
400401
if err != nil {
401402
return errorResult(err, "Failed to get screen size")
402403
}
403404

404-
// Use most of screen for scroll area (leave margins)
405-
area := uiautomator2.NewRect(0, height/8, width, height*3/4)
406-
407-
// /appium/gestures/scroll already uses scroll semantics — no inversion needed
408-
if err := d.client.ScrollInArea(area, direction, 0.5, 0); err != nil {
405+
// Use ADB input swipe for reliable scrolling
406+
if err := d.scrollBySwipe(direction, width, height); err != nil {
409407
return errorResult(err, fmt.Sprintf("Failed to scroll: %v", err))
410408
}
411409

412410
return successResult(fmt.Sprintf("Scrolled %s", direction), nil)
413411
}
414412

413+
// isElementNotFoundError returns true if the error indicates the element was simply
414+
// not found (expected during scrolling). Returns false for infrastructure errors
415+
// (connection refused, request failures, etc.) which should be propagated immediately.
416+
func isElementNotFoundError(err error) bool {
417+
if errors.Is(err, context.DeadlineExceeded) {
418+
return true
419+
}
420+
msg := strings.ToLower(err.Error())
421+
notFoundPhrases := []string{"not found", "no elements match", "no such element", "could not be located", "context deadline exceeded"}
422+
for _, phrase := range notFoundPhrases {
423+
if strings.Contains(msg, phrase) {
424+
return true
425+
}
426+
}
427+
return false
428+
}
429+
415430
func (d *Driver) scrollUntilVisible(step *flow.ScrollUntilVisibleStep) *core.CommandResult {
416431
direction := strings.ToLower(step.Direction)
417432
if direction == "" {
418433
direction = "down"
419434
}
420435

421-
maxScrolls := 10
436+
maxScrolls := step.MaxScrolls
437+
if maxScrolls <= 0 {
438+
maxScrolls = 10
439+
}
422440

423-
// Get screen size for dynamic scroll area
441+
// Get screen size for scroll coordinates
424442
width, height, err := d.screenSize()
425443
if err != nil {
426444
return errorResult(err, "Failed to get screen size")
427445
}
428446

429-
// Use most of screen for scroll area (leave margins)
430-
area := uiautomator2.NewRect(0, height/8, width, height*3/4)
431-
432447
for i := 0; i < maxScrolls; i++ {
433448
// Try to find element (short timeout - includes page source fallback)
434449
_, info, err := d.findElement(step.Element, true, 1000)
435450
if err == nil && info != nil {
436-
// Element found - return success
437-
return successResult(fmt.Sprintf("Element found after %d scrolls", i), info)
451+
// On Android, UIAutomator can find elements that exist in the view hierarchy
452+
// but are off-screen (e.g., in ScrollView). Verify the element is actually
453+
// visible on screen by checking its bounds overlap with the viewport.
454+
if isElementOnScreen(info, width, height) {
455+
return successResult(fmt.Sprintf("Element found after %d scrolls", i), info)
456+
}
457+
// Element exists in hierarchy but is off-screen - continue scrolling
458+
} else if err != nil && info == nil && !isElementNotFoundError(err) {
459+
return errorResult(err, "Failed to find element")
438460
}
439461

440-
// /appium/gestures/scroll already uses scroll semantics — no inversion needed
441-
if err := d.client.ScrollInArea(area, direction, 0.3, 0); err != nil {
442-
return errorResult(err, fmt.Sprintf("Failed to scroll: %v", err))
462+
// Use ADB input swipe for reliable scrolling (Appium gestures/scroll is unreliable)
463+
if err := d.scrollBySwipe(direction, width, height); err != nil {
464+
return errorResult(err, "Failed to scroll")
443465
}
444466

445467
time.Sleep(300 * time.Millisecond)
@@ -448,6 +470,63 @@ func (d *Driver) scrollUntilVisible(step *flow.ScrollUntilVisibleStep) *core.Com
448470
return errorResult(fmt.Errorf("element not found"), fmt.Sprintf("Element not found after %d scrolls", maxScrolls))
449471
}
450472

473+
// scrollBySwipe performs a scroll gesture using ADB input swipe for reliability.
474+
// Falls back to Appium gestures/scroll if ADB is not available.
475+
func (d *Driver) scrollBySwipe(direction string, screenWidth, screenHeight int) error {
476+
centerX := screenWidth / 2
477+
startY := screenHeight * 3 / 5
478+
endY := screenHeight * 2 / 5
479+
durationMs := 300
480+
481+
// Calculate swipe coordinates based on direction
482+
// Swipe direction is opposite of scroll direction:
483+
// scroll DOWN (see content below) = swipe finger UP
484+
// scroll UP (see content above) = swipe finger DOWN
485+
var fromX, fromY, toX, toY int
486+
switch direction {
487+
case "up":
488+
fromX, fromY = centerX, endY
489+
toX, toY = centerX, startY
490+
case "down":
491+
fromX, fromY = centerX, startY
492+
toX, toY = centerX, endY
493+
case "left":
494+
centerY := screenHeight / 2
495+
fromX, fromY = screenWidth*2/5, centerY
496+
toX, toY = screenWidth*3/5, centerY
497+
case "right":
498+
centerY := screenHeight / 2
499+
fromX, fromY = screenWidth*3/5, centerY
500+
toX, toY = screenWidth*2/5, centerY
501+
default:
502+
fromX, fromY = centerX, startY
503+
toX, toY = centerX, endY
504+
}
505+
506+
// Prefer ADB shell for reliable input injection
507+
if d.device != nil {
508+
cmd := fmt.Sprintf("input swipe %d %d %d %d %d", fromX, fromY, toX, toY, durationMs)
509+
_, err := d.device.Shell(cmd)
510+
return err
511+
}
512+
513+
// Fallback to Appium gestures if no ADB access — this path is unreliable
514+
// on many Android devices/emulators, so log a warning to aid debugging.
515+
logger.Warn("ADB not available, falling back to Appium scroll (may be unreliable)")
516+
area := uiautomator2.NewRect(0, screenHeight/8, screenWidth, screenHeight*3/4)
517+
return d.client.ScrollInArea(area, direction, 0.5, 0)
518+
}
519+
520+
// isElementOnScreen checks if an element's bounds overlap with the visible screen area.
521+
// Returns false if bounds have no area (zero width or height) or are entirely off-screen.
522+
func isElementOnScreen(info *core.ElementInfo, screenWidth, screenHeight int) bool {
523+
b := info.Bounds
524+
if b.Width == 0 || b.Height == 0 {
525+
return false
526+
}
527+
return b.X+b.Width > 0 && b.X < screenWidth && b.Y+b.Height > 0 && b.Y < screenHeight
528+
}
529+
451530
func (d *Driver) swipe(step *flow.SwipeStep) *core.CommandResult {
452531
// Check if coordinate-based swipe (percentage or absolute)
453532
if step.Start != "" && step.End != "" {

pkg/driver/uiautomator2/commands_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package uiautomator2
22

33
import (
4+
"context"
45
"errors"
56
"fmt"
67
"net/http"
@@ -4067,6 +4068,34 @@ func TestLaunchAppViaShellAmStartErrorWithArgs(t *testing.T) {
40674068
// Verify MockUIA2Client satisfies UIA2Client at compile time.
40684069
var _ UIA2Client = (*MockUIA2Client)(nil)
40694070

4071+
func TestIsElementNotFoundError(t *testing.T) {
4072+
tests := []struct {
4073+
name string
4074+
err error
4075+
expected bool
4076+
}{
4077+
{"context deadline exceeded", context.DeadlineExceeded, true},
4078+
{"wrapped deadline exceeded", fmt.Errorf("element 'x' not found: %w", context.DeadlineExceeded), true},
4079+
{"element not found", fmt.Errorf("element not found"), true},
4080+
{"no elements match", fmt.Errorf("no elements match selector"), true},
4081+
{"no such element", fmt.Errorf("no such element: An element could not be located"), true},
4082+
{"could not be located", fmt.Errorf("An element could not be located on the page"), true},
4083+
{"appium deadline with no such element", fmt.Errorf("context deadline exceeded: no such element: An element could not be located on the page using the given search parameters"), true},
4084+
{"connection refused", fmt.Errorf("connection refused"), false},
4085+
{"send request failed", fmt.Errorf("send request failed"), false},
4086+
{"EOF", fmt.Errorf("unexpected EOF"), false},
4087+
}
4088+
4089+
for _, tt := range tests {
4090+
t.Run(tt.name, func(t *testing.T) {
4091+
got := isElementNotFoundError(tt.err)
4092+
if got != tt.expected {
4093+
t.Errorf("isElementNotFoundError(%q) = %v, want %v", tt.err, got, tt.expected)
4094+
}
4095+
})
4096+
}
4097+
}
4098+
40704099
// Verify uiautomator2.DeviceInfo is used correctly.
40714100
var _ = &uiautomator2.DeviceInfo{}
40724101

0 commit comments

Comments
 (0)