// 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] } } } }