Initial commit: Null DRM Official
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.
This commit is contained in:
commit
2fa8f2435f
121 changed files with 17802 additions and 0 deletions
374
apps/pkg/adb/adb.go
Normal file
374
apps/pkg/adb/adb.go
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
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(")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue