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.
360 lines
8.9 KiB
Go
360 lines
8.9 KiB
Go
// Package uiflow holds the phone-UI primitives app modules drive playback with:
|
|
// wait for a node, tap a node, wait for playback, find scrollable live cards.
|
|
// It knows nothing about any particular app — every threshold and pattern is an
|
|
// argument.
|
|
package uiflow
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"drmdecryption/adb"
|
|
)
|
|
|
|
// Pick selects among several matching nodes.
|
|
type Pick string
|
|
|
|
const (
|
|
PickLargest Pick = "largest"
|
|
PickSmallest Pick = "smallest"
|
|
PickFirst Pick = "first"
|
|
)
|
|
|
|
// Match describes which UI node to look for. An empty Match never matches.
|
|
type Match struct {
|
|
TextRE []string
|
|
DescRE []string
|
|
TextContains []string
|
|
DescContains []string
|
|
ResourceContains []string
|
|
Pick Pick
|
|
MinArea int
|
|
}
|
|
|
|
type compiled struct {
|
|
textRE, descRE []*regexp.Regexp
|
|
m Match
|
|
}
|
|
|
|
// CompileRes compiles case-insensitive patterns, skipping empties.
|
|
func CompileRes(pats []string) []*regexp.Regexp {
|
|
out := make([]*regexp.Regexp, 0, len(pats))
|
|
for _, p := range pats {
|
|
if p == "" {
|
|
continue
|
|
}
|
|
out = append(out, regexp.MustCompile("(?i)"+p))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (m Match) compile() compiled {
|
|
return compiled{textRE: CompileRes(m.TextRE), descRE: CompileRes(m.DescRE), m: m}
|
|
}
|
|
|
|
func (c compiled) empty() bool {
|
|
return len(c.textRE) == 0 && len(c.descRE) == 0 &&
|
|
len(c.m.TextContains) == 0 && len(c.m.DescContains) == 0 && len(c.m.ResourceContains) == 0
|
|
}
|
|
|
|
func (c compiled) matches(n adb.Node) bool {
|
|
if c.empty() {
|
|
return false
|
|
}
|
|
if n.Text != "" {
|
|
for _, p := range c.textRE {
|
|
if p.MatchString(n.Text) {
|
|
return true
|
|
}
|
|
}
|
|
for _, s := range c.m.TextContains {
|
|
if s != "" && strings.Contains(strings.ToLower(n.Text), strings.ToLower(s)) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
if n.Desc != "" {
|
|
for _, p := range c.descRE {
|
|
if p.MatchString(n.Desc) {
|
|
return true
|
|
}
|
|
}
|
|
for _, s := range c.m.DescContains {
|
|
if s != "" && strings.Contains(strings.ToLower(n.Desc), strings.ToLower(s)) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
for _, s := range c.m.ResourceContains {
|
|
if s != "" && strings.Contains(n.ResourceID, s) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func pickNode(hits []adb.Node, pick Pick, minArea int) *adb.Node {
|
|
if len(hits) == 0 {
|
|
return nil
|
|
}
|
|
var best *adb.Node
|
|
switch pick {
|
|
case PickSmallest:
|
|
best = &hits[0]
|
|
for i := range hits {
|
|
if hits[i].Area() < best.Area() {
|
|
best = &hits[i]
|
|
}
|
|
}
|
|
case PickFirst:
|
|
best = &hits[0]
|
|
default: // largest
|
|
best = &hits[0]
|
|
for i := range hits {
|
|
if hits[i].Area() > best.Area() {
|
|
best = &hits[i]
|
|
}
|
|
}
|
|
}
|
|
if minArea > 0 && best.Area() < minArea {
|
|
return nil
|
|
}
|
|
return best
|
|
}
|
|
|
|
// Find polls the UI until a node matches, returning it.
|
|
func Find(c *adb.Client, cacheDir string, m Match, timeout time.Duration) (*adb.Node, error) {
|
|
cm := m.compile()
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
nodes, err := c.DumpUI(cacheDir)
|
|
if err != nil {
|
|
time.Sleep(time.Second)
|
|
continue
|
|
}
|
|
var hits []adb.Node
|
|
for _, n := range nodes {
|
|
if cm.matches(n) {
|
|
hits = append(hits, n)
|
|
}
|
|
}
|
|
if n := pickNode(hits, m.Pick, m.MinArea); n != nil {
|
|
return n, nil
|
|
}
|
|
time.Sleep(time.Second)
|
|
}
|
|
return nil, fmt.Errorf("ui node not found within %s", timeout)
|
|
}
|
|
|
|
// WaitUI waits for a node to exist without tapping it.
|
|
func WaitUI(c *adb.Client, cacheDir string, m Match, timeout time.Duration) error {
|
|
_, err := Find(c, cacheDir, m, timeout)
|
|
return err
|
|
}
|
|
|
|
// TapUI waits for a node and taps its centre.
|
|
func TapUI(c *adb.Client, cacheDir string, m Match, timeout time.Duration) error {
|
|
n, err := Find(c, cacheDir, m, timeout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("[*] tap_ui %q/%q @ %d,%d\n", n.Text, n.Desc, n.CX(), n.CY())
|
|
return c.Tap(n.CX(), n.CY())
|
|
}
|
|
|
|
// PlaybackOpts tunes WaitPlayback. The UI signals are a fallback for players
|
|
// whose audio session does not show up in dumpsys.
|
|
type PlaybackOpts struct {
|
|
Timeout time.Duration
|
|
SoftFail bool
|
|
OrDescContains []string
|
|
OrResourceContains []string
|
|
}
|
|
|
|
// WaitPlayback blocks until the package is playing audio, or a UI signal says the
|
|
// player is up. With SoftFail a timeout is logged and tolerated.
|
|
func WaitPlayback(c *adb.Client, cacheDir, pkg string, opt PlaybackOpts) error {
|
|
if opt.Timeout <= 0 {
|
|
opt.Timeout = 45 * time.Second
|
|
}
|
|
deadline := time.Now().Add(opt.Timeout)
|
|
for time.Now().Before(deadline) {
|
|
if c.PlaybackActive(pkg) {
|
|
fmt.Printf("[+] %s is PLAYING\n", pkg)
|
|
return nil
|
|
}
|
|
if len(opt.OrDescContains) > 0 || len(opt.OrResourceContains) > 0 {
|
|
if nodes, err := c.DumpUI(cacheDir); err == nil {
|
|
for _, n := range nodes {
|
|
desc := strings.ToLower(n.Desc)
|
|
for _, d := range opt.OrDescContains {
|
|
if d != "" && strings.Contains(desc, strings.ToLower(d)) {
|
|
fmt.Printf("[+] UI signal %q\n", d)
|
|
return nil
|
|
}
|
|
}
|
|
for _, r := range opt.OrResourceContains {
|
|
if r != "" && strings.Contains(n.ResourceID, r) {
|
|
fmt.Printf("[+] resource signal %q\n", r)
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
time.Sleep(time.Second)
|
|
}
|
|
if opt.SoftFail {
|
|
fmt.Printf("[!] timed out waiting for playback; continuing capture\n")
|
|
return nil
|
|
}
|
|
return fmt.Errorf("timed out waiting for playback")
|
|
}
|
|
|
|
// CardOpts describes what a content card looks like in a given app: a clickable
|
|
// row at least MinH tall and MinW wide, below MinY, whose description/text is not
|
|
// chrome. Callers supply the denylists; nothing is hardcoded.
|
|
type CardOpts struct {
|
|
MinY, MinH, MinW int
|
|
DescDeny []string
|
|
TextDeny []string
|
|
// ScrollMax limits swipe attempts when the wanted index is off-screen.
|
|
ScrollMax int
|
|
// Swipe coordinates used to scroll the card list.
|
|
SwipeX, SwipeFromY, SwipeToY, SwipeMS int
|
|
}
|
|
|
|
// WithDefaults fills unset geometry with values that suit a typical 1080x1920
|
|
// phone card list.
|
|
func (o CardOpts) WithDefaults() CardOpts {
|
|
if o.MinY <= 0 {
|
|
o.MinY = 300
|
|
}
|
|
if o.MinH <= 0 {
|
|
o.MinH = 250
|
|
}
|
|
if o.MinW <= 0 {
|
|
o.MinW = 600
|
|
}
|
|
if o.ScrollMax <= 0 {
|
|
o.ScrollMax = 8
|
|
}
|
|
if o.SwipeX == 0 {
|
|
o.SwipeX = 540
|
|
}
|
|
if o.SwipeFromY == 0 {
|
|
o.SwipeFromY = 1700
|
|
}
|
|
if o.SwipeToY == 0 {
|
|
o.SwipeToY = 700
|
|
}
|
|
if o.SwipeMS == 0 {
|
|
o.SwipeMS = 350
|
|
}
|
|
return o
|
|
}
|
|
|
|
// Cards returns the content cards visible in a UI dump, top to bottom.
|
|
func Cards(nodes []adb.Node, opt CardOpts) []adb.Node {
|
|
opt = opt.WithDefaults()
|
|
var cards []adb.Node
|
|
for _, n := range nodes {
|
|
if !n.Clickable {
|
|
continue
|
|
}
|
|
h := n.Y2 - n.Y1
|
|
w := n.X2 - n.X1
|
|
if n.Y1 < opt.MinY || h < opt.MinH || w < opt.MinW {
|
|
continue
|
|
}
|
|
if containsAny(n.Desc, opt.DescDeny) {
|
|
continue
|
|
}
|
|
if containsAnyFold(n.Text, opt.TextDeny) {
|
|
continue
|
|
}
|
|
cards = append(cards, n)
|
|
}
|
|
sortByTop(cards)
|
|
return cards
|
|
}
|
|
|
|
// WaitCard polls for the index-th content card, scrolling when it is not yet on
|
|
// screen (each scroll consumes one index, matching how the list advances).
|
|
func WaitCard(c *adb.Client, cacheDir string, index int, timeout time.Duration, opt CardOpts) (*adb.Node, error) {
|
|
opt = opt.WithDefaults()
|
|
deadline := time.Now().Add(timeout)
|
|
target := index
|
|
var last int
|
|
scrolled := 0
|
|
for time.Now().Before(deadline) {
|
|
nodes, err := c.DumpUI(cacheDir)
|
|
if err == nil {
|
|
cards := Cards(nodes, opt)
|
|
last = len(cards)
|
|
if target >= 0 && target < len(cards) {
|
|
card := cards[target]
|
|
return &card, nil
|
|
}
|
|
if target >= last && last > 0 && scrolled < opt.ScrollMax {
|
|
fmt.Printf("[*] card need local #%d (%d on screen) — scrolling\n", target, last)
|
|
_ = c.Swipe(opt.SwipeX, opt.SwipeFromY, opt.SwipeX, opt.SwipeToY, opt.SwipeMS)
|
|
scrolled++
|
|
if target > 0 {
|
|
target--
|
|
}
|
|
time.Sleep(1200 * time.Millisecond)
|
|
continue
|
|
}
|
|
}
|
|
time.Sleep(800 * time.Millisecond)
|
|
}
|
|
return nil, fmt.Errorf("only saw %d cards (need index %d)", last, index)
|
|
}
|
|
|
|
// TapCard waits for and taps the index-th content card.
|
|
func TapCard(c *adb.Client, cacheDir string, index int, timeout time.Duration, opt CardOpts) error {
|
|
card, err := WaitCard(c, cacheDir, index, timeout, opt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("[*] tap card #%d @ %d,%d\n", index, card.CX(), card.CY())
|
|
return c.Tap(card.CX(), card.CY())
|
|
}
|
|
|
|
// MonkeyLaunch starts an app through its launcher intent.
|
|
func MonkeyLaunch(c *adb.Client, pkg string) error {
|
|
_, _, err := c.Run("shell", "monkey", "-p", pkg, "-c", "android.intent.category.LAUNCHER", "1")
|
|
return err
|
|
}
|
|
|
|
func containsAny(s string, needles []string) bool {
|
|
for _, n := range needles {
|
|
if n != "" && strings.Contains(s, n) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsAnyFold(s string, needles []string) bool {
|
|
ls := strings.ToLower(s)
|
|
for _, n := range needles {
|
|
if n != "" && strings.Contains(ls, strings.ToLower(n)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func sortByTop(cards []adb.Node) {
|
|
for i := 0; i < len(cards); i++ {
|
|
for j := i + 1; j < len(cards); j++ {
|
|
if cards[j].Y1 < cards[i].Y1 {
|
|
cards[i], cards[j] = cards[j], cards[i]
|
|
}
|
|
}
|
|
}
|
|
}
|