Capture, decrypt, and restream toolkit with compiled-in app modules (RTE, TG4, BBC), on-device MITM proxy, streamd control plane, and www. BBC module.yaml is published (clear streams); other module values stay local.
374 lines
9 KiB
Go
374 lines
9 KiB
Go
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 <pkg>` 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(`<node [^>]+>`)
|
|
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(")
|
|
}
|