package adb import ( "bytes" "fmt" "os" "os/exec" "regexp" "strconv" "strings" "time" ) // Client wraps the adb CLI. When Serial is set, every invocation uses -s SERIAL. type Client struct { Bin string Serial string } func New() *Client { bin := "adb" if p, err := exec.LookPath("adb"); err == nil { bin = p } return &Client{Bin: bin} } // WithSerial returns a copy that targets one device. func (c *Client) WithSerial(serial string) *Client { out := *c out.Serial = strings.TrimSpace(serial) return &out } func (c *Client) prefix(args []string) []string { if c.Serial == "" { return args } return append([]string{"-s", c.Serial}, args...) } func (c *Client) Run(args ...string) (string, string, error) { cmd := exec.Command(c.Bin, c.prefix(args)...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() return stdout.String(), stderr.String(), err } func (c *Client) Out(args ...string) string { out, _, _ := c.Run(args...) return strings.TrimSpace(out) } func (c *Client) EnsureDevice() error { state := c.Out("get-state") if state != "device" { if c.Serial != "" { return fmt.Errorf("adb device %s not ready (state=%q)", c.Serial, state) } return fmt.Errorf("adb device not ready (state=%q)", state) } return nil } // Device is one row from `adb devices -l`. type Device struct { Serial string State string Model string Product string USB string } // ListDevices returns every adb device row (any state). Uses a serial-less client. func ListDevices(bin string) ([]Device, error) { if bin == "" { bin = New().Bin } cmd := exec.Command(bin, "devices", "-l") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("adb devices: %w (%s)", err, strings.TrimSpace(stderr.String())) } var out []Device for _, line := range strings.Split(stdout.String(), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "List of devices") { continue } fields := strings.Fields(line) if len(fields) < 2 { continue } d := Device{Serial: fields[0], State: fields[1]} for _, f := range fields[2:] { if strings.HasPrefix(f, "model:") { d.Model = strings.TrimPrefix(f, "model:") } if strings.HasPrefix(f, "product:") { d.Product = strings.TrimPrefix(f, "product:") } if strings.HasPrefix(f, "usb:") { d.USB = strings.TrimPrefix(f, "usb:") } } out = append(out, d) } return out, nil } // PackageInstalled is true when `pm path ` returns a path. Never installs. func (c *Client) PackageInstalled(pkg string) bool { pkg = strings.TrimSpace(pkg) if pkg == "" { return false } out := c.Out("shell", "pm", "path", pkg) return strings.Contains(out, "package:") } // ForceStop stops the package so the phone is free for the next job. func (c *Client) ForceStop(pkg string) { if pkg == "" { return } _, _ = c.Shell("am", "force-stop", pkg) } // EnsureAwake wakes the screen. Call this BEFORE launching the target app. // It must not press HOME or swipe after the app is open — dumpsys always // contains "StatusBar", and a HOME key looks like "the app just exits". func (c *Client) EnsureAwake() { _, _ = c.Shell("input", "keyevent", "KEYCODE_WAKEUP") time.Sleep(250 * time.Millisecond) _, _ = c.Shell("wm", "dismiss-keyguard") if currentFocusIsLockOrShade(c.Out("shell", "dumpsys", "window")) { _, _ = c.Shell("input", "swipe", "540", "2000", "540", "600", "300") time.Sleep(400 * time.Millisecond) c.DismissShadeIfFocused() } } func currentFocusIsLockOrShade(dumpsys string) bool { for _, line := range strings.Split(dumpsys, "\n") { line = strings.TrimSpace(line) if !strings.Contains(line, "mCurrentFocus=") { continue } low := strings.ToLower(line) return strings.Contains(low, "notificationshade") || strings.Contains(low, "keyguard") || strings.Contains(low, "lockscreen") } return false } // DismissShadeIfFocused presses BACK only when the notification shade has focus. func (c *Client) DismissShadeIfFocused() { if currentFocusIsLockOrShade(c.Out("shell", "dumpsys", "window")) { _, _ = c.Shell("input", "keyevent", "KEYCODE_BACK") time.Sleep(200 * time.Millisecond) } } func (c *Client) Shell(args ...string) (string, error) { all := append([]string{"shell"}, args...) out, errOut, err := c.Run(all...) if err != nil { if strings.TrimSpace(errOut) != "" { return out, fmt.Errorf("%w: %s", err, strings.TrimSpace(errOut)) } return out, err } return out, nil } func (c *Client) Push(local, remote string) error { _, errOut, err := c.Run("push", local, remote) if err != nil { return fmt.Errorf("adb push: %w (%s)", err, strings.TrimSpace(errOut)) } return nil } func (c *Client) Pull(remote, local string) error { _, errOut, err := c.Run("pull", remote, local) if err != nil { return fmt.Errorf("adb pull: %w (%s)", err, strings.TrimSpace(errOut)) } return nil } func (c *Client) Tap(x, y int) error { _, err := c.Shell("input", "tap", strconv.Itoa(x), strconv.Itoa(y)) return err } // Swipe performs an input swipe over durationMs milliseconds. func (c *Client) Swipe(x1, y1, x2, y2, durationMs int) error { if durationMs <= 0 { durationMs = 300 } _, err := c.Shell("input", "swipe", strconv.Itoa(x1), strconv.Itoa(y1), strconv.Itoa(x2), strconv.Itoa(y2), strconv.Itoa(durationMs)) return err } // Node is one uiautomator element. type Node struct { Text string Desc string Class string ResourceID string Clickable bool X1, Y1 int X2, Y2 int } func (n Node) CX() int { return (n.X1 + n.X2) / 2 } func (n Node) CY() int { return (n.Y1 + n.Y2) / 2 } func (n Node) Area() int { return (n.X2 - n.X1) * (n.Y2 - n.Y1) } var ( reNode = regexp.MustCompile(`]+>`) reAttr = func(k string) *regexp.Regexp { return regexp.MustCompile(k + `="([^"]*)"`) } reBounds = regexp.MustCompile(`\[(\d+),(\d+)\]\[(\d+),(\d+)\]`) ) func parseNodes(xml string) []Node { var out []Node for _, m := range reNode.FindAllString(xml, -1) { attr := func(k string) string { mm := reAttr(k).FindStringSubmatch(m) if len(mm) < 2 { return "" } return mm[1] } b := attr("bounds") bm := reBounds.FindStringSubmatch(b) if len(bm) != 5 { continue } x1, _ := strconv.Atoi(bm[1]) y1, _ := strconv.Atoi(bm[2]) x2, _ := strconv.Atoi(bm[3]) y2, _ := strconv.Atoi(bm[4]) out = append(out, Node{ Text: attr("text"), Desc: attr("content-desc"), Class: attr("class"), ResourceID: attr("resource-id"), Clickable: attr("clickable") == "true", X1: x1, Y1: y1, X2: x2, Y2: y2, }) } return out } // DumpUI pulls a uiautomator hierarchy. func (c *Client) DumpUI(cacheDir string) ([]Node, error) { remote := "/sdcard/streamd_uidump.xml" if _, err := c.Shell("uiautomator", "dump", remote); err != nil { // dump often returns exit 0 with message; ignore soft failures } if err := os.MkdirAll(cacheDir, 0o755); err != nil { return nil, err } local := cacheDir + string(os.PathSeparator) + "uidump.xml" if err := c.Pull(remote, local); err != nil { return nil, err } raw, err := os.ReadFile(local) if err != nil { return nil, err } return parseNodes(string(raw)), nil } // FindLargest returns the largest node matching any text/desc regex. func FindLargest(nodes []Node, textPats, descPats []*regexp.Regexp) *Node { var hits []Node for _, n := range nodes { ok := false if n.Text != "" { for _, p := range textPats { if p.MatchString(n.Text) { ok = true break } } } if n.Desc != "" { for _, p := range descPats { if p.MatchString(n.Desc) { ok = true break } } } if ok { hits = append(hits, n) } } if len(hits) == 0 { return nil } best := hits[0] for _, h := range hits[1:] { if h.Area() > best.Area() { best = h } } return &best } // FindSmallest prefers tiny chips over hero buttons. func FindSmallest(nodes []Node, descPats []*regexp.Regexp) *Node { var hits []Node for _, n := range nodes { if n.Desc == "" { continue } for _, p := range descPats { if p.MatchString(n.Desc) { hits = append(hits, n) break } } } if len(hits) == 0 { return nil } best := hits[0] for _, h := range hits[1:] { if h.Area() < best.Area() { best = h } } return &best } // WaitFor polls DumpUI until a match appears or timeout. func (c *Client) WaitFor(cacheDir string, textPats, descPats []*regexp.Regexp, timeout time.Duration) (*Node, error) { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { nodes, err := c.DumpUI(cacheDir) if err == nil { if n := FindLargest(nodes, textPats, descPats); n != nil { return n, nil } } time.Sleep(800 * time.Millisecond) } return nil, fmt.Errorf("ui node not found within %s", timeout) } // PlaybackActive is true when the package media session is PLAYING. func (c *Client) PlaybackActive(pkg string) bool { out := c.Out("shell", "dumpsys", "media_session") if !strings.Contains(out, pkg) { return false } return strings.Contains(out, "state=PLAYING") || strings.Contains(out, "PLAYING(") }