The Maps omnibox hint rotates: it read "Search here" in the Phase 5 run and "Try petrol stations, ATMs" in the AUD-128 re-verification, so the recipe's text match on it failed. The resource id does not rotate. - internal/ui: add Find/FindID and the "id:" selector; Find dispatches an id: selector to the resource id and everything else to text as before. - internal/recipe: wait/tap/wait_gone/tap_if and the tap helper use Find, so a recipe may name a resource id. Guards stay text-only. - recipes/maps_navigate.recipe: wait/tap id:search_omnibox_text_box. - tests for the id selector; docs and README updated.
432 lines
10 KiB
Go
432 lines
10 KiB
Go
// Command artemis drives an Android phone over ADB wireless.
|
|
//
|
|
// This is the command line surface: connection, act primitives, and the named
|
|
// recipes in recipes/. The Hub's HTTP action API arrives in Phase 6 and reuses
|
|
// these packages.
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"time"
|
|
|
|
"gitea.lab.audasmedia.com.au/sam/artemis_control/internal/adb"
|
|
"gitea.lab.audasmedia.com.au/sam/artemis_control/internal/ui"
|
|
)
|
|
|
|
const usage = `artemis — drive the phone's screen over ADB wireless
|
|
|
|
usage: artemis <command> [args]
|
|
|
|
connection
|
|
connect [host] connect to the phone (scans ports if host given)
|
|
devices list adb devices
|
|
wake wake the screen
|
|
keyguard report the lock-screen state
|
|
|
|
reading
|
|
dump print visible text nodes with bounds
|
|
find <text> print matching elements (no tap)
|
|
fg print the foreground package
|
|
|
|
acting
|
|
launch <package> launch an app and wait for it to be foreground
|
|
tap <text> find element by text, tap its centre (FR-4)
|
|
type <text> type into the focused field
|
|
swipe <x1> <y1> <x2> <y2> [ms]
|
|
scroll <up|down> scroll the current list
|
|
back press back
|
|
home press home
|
|
|
|
recipes
|
|
recipes list the recipes in the recipes directory
|
|
run <task> [k=v ...] run a named recipe, e.g. run spotify_play query="..."
|
|
|
|
proof
|
|
demo Phase 2 DoD: launch an app, tap a named element
|
|
|
|
environment
|
|
ARTEMIS_PHONE phone tailscale IP (default 100.101.49.17)
|
|
ARTEMIS_ADB_SERIAL pin a specific adb serial
|
|
ARTEMIS_ADB path to the adb binary (default: PATH)
|
|
ARTEMIS_RECIPES recipes directory (default: recipes)
|
|
ARTEMIS_LOG run log file (default: logs/runs.jsonl)
|
|
`
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
fmt.Fprint(os.Stderr, usage)
|
|
os.Exit(2)
|
|
}
|
|
cmd := os.Args[1]
|
|
args := os.Args[2:]
|
|
|
|
r := adb.New(os.Getenv("ARTEMIS_ADB_SERIAL"))
|
|
|
|
var err error
|
|
switch cmd {
|
|
case "connect":
|
|
err = cmdConnect(r, args)
|
|
case "devices":
|
|
err = cmdDevices(r)
|
|
case "wake":
|
|
err = cmdWake(r)
|
|
case "keyguard":
|
|
err = cmdKeyguard(r)
|
|
case "dump":
|
|
err = cmdDump(r)
|
|
case "find":
|
|
err = cmdFind(r, args)
|
|
case "fg":
|
|
err = cmdFG(r)
|
|
case "launch":
|
|
err = cmdLaunch(r, args)
|
|
case "tap":
|
|
err = cmdTap(r, args)
|
|
case "type":
|
|
err = cmdType(r, args)
|
|
case "swipe":
|
|
err = cmdSwipe(r, args)
|
|
case "scroll":
|
|
err = cmdScroll(r, args)
|
|
case "back":
|
|
err = ensurePhone(r, func() error {
|
|
if err := wakeAndGuard(r); err != nil {
|
|
return err
|
|
}
|
|
return ui.Back(r)
|
|
})
|
|
case "home":
|
|
err = ensurePhone(r, func() error {
|
|
if err := wakeAndGuard(r); err != nil {
|
|
return err
|
|
}
|
|
return ui.Home(r)
|
|
})
|
|
case "recipes":
|
|
err = cmdRecipes(args)
|
|
case "run":
|
|
err = cmdRun(r, args)
|
|
case "demo":
|
|
err = cmdDemo(r)
|
|
case "-h", "--help", "help":
|
|
fmt.Print(usage)
|
|
return
|
|
default:
|
|
err = fmt.Errorf("unknown command %q\n\n%s", cmd, usage)
|
|
}
|
|
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "artemis: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func phoneHost() string {
|
|
if h := os.Getenv("ARTEMIS_PHONE"); h != "" {
|
|
return h
|
|
}
|
|
return "100.101.49.17"
|
|
}
|
|
|
|
// ensurePhone makes sure we have a live serial before running fn.
|
|
func ensurePhone(r *adb.Runner, fn func() error) error {
|
|
if r.Serial != "" && r.DeviceOnline(r.Serial) {
|
|
return fn()
|
|
}
|
|
serial, err := r.ConnectAuto(phoneHost())
|
|
if err != nil {
|
|
return fmt.Errorf("phone is not reachable: %w", err)
|
|
}
|
|
fmt.Printf("connected to %s\n", serial)
|
|
return fn()
|
|
}
|
|
|
|
// wakeAndGuard wakes the screen and refuses to act while a secure keyguard is
|
|
// showing. Artemis has no PIN and never types one (AGENTS.md section 13 Q2).
|
|
//
|
|
// Waking first matters. A dozing phone reports `showing=false` because the lock
|
|
// screen is not drawn yet, so the keyguard state is only trustworthy once the
|
|
// screen is on.
|
|
func wakeAndGuard(r *adb.Runner) error {
|
|
if err := ui.Wake(r); err != nil {
|
|
return err
|
|
}
|
|
return ui.RequireUnlocked(r)
|
|
}
|
|
|
|
func cmdConnect(r *adb.Runner, args []string) error {
|
|
if len(args) > 0 {
|
|
msg, err := r.Connect(args[0])
|
|
if msg != "" {
|
|
fmt.Println(msg)
|
|
}
|
|
return err
|
|
}
|
|
serial, err := r.ConnectAuto(phoneHost())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("connected to %s\n", serial)
|
|
return nil
|
|
}
|
|
|
|
func cmdDevices(r *adb.Runner) error {
|
|
devices, err := r.Devices()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(devices) == 0 {
|
|
fmt.Println("no devices")
|
|
return nil
|
|
}
|
|
for _, d := range devices {
|
|
fmt.Printf("%s\t%s\tmodel=%s device=%s\n", d.Serial, d.State, d.Model, d.Device)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func cmdWake(r *adb.Runner) error {
|
|
return ensurePhone(r, func() error {
|
|
if err := ui.Wake(r); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("screen awake")
|
|
showing, secure, err := ui.KeyguardState(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if showing && secure {
|
|
fmt.Println("phone is locked (secure keyguard); Artemis will not act until it is unlocked")
|
|
return nil
|
|
}
|
|
fmt.Println("phone is unlocked")
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// cmdKeyguard reports the lock-screen state without touching it.
|
|
func cmdKeyguard(r *adb.Runner) error {
|
|
return ensurePhone(r, func() error {
|
|
showing, secure, err := ui.KeyguardState(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("keyguard showing=%t secure=%t\n", showing, secure)
|
|
if showing && secure {
|
|
return ui.ErrLocked
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func cmdDump(r *adb.Runner) error {
|
|
return ensurePhone(r, func() error {
|
|
if err := ui.Wake(r); err != nil {
|
|
return err
|
|
}
|
|
h, err := ui.Dump(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
nodes := h.TextNodes()
|
|
fmt.Printf("%d text nodes\n", len(nodes))
|
|
for _, n := range nodes {
|
|
label := n.Text
|
|
if label == "" {
|
|
label = n.ContentDesc
|
|
}
|
|
fmt.Printf("[%s] %s bounds=%s\n", shortClass(n.Class), label, n.Bounds)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func cmdFind(r *adb.Runner, args []string) error {
|
|
if len(args) != 1 {
|
|
return fmt.Errorf("usage: artemis find <text|id:resource_id>")
|
|
}
|
|
return ensurePhone(r, func() error {
|
|
h, err := ui.Dump(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
matches := h.Find(args[0], false)
|
|
if len(matches) == 0 {
|
|
return fmt.Errorf("no element matching %q", args[0])
|
|
}
|
|
for _, n := range matches {
|
|
cx, cy, _ := n.Center()
|
|
fmt.Printf("%s centre=(%d,%d)\n", n.Label(), cx, cy)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func cmdFG(r *adb.Runner) error {
|
|
return ensurePhone(r, func() error {
|
|
fg, err := ui.ForegroundPackage(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(fg)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func cmdLaunch(r *adb.Runner, args []string) error {
|
|
if len(args) != 1 {
|
|
return fmt.Errorf("usage: artemis launch <package>")
|
|
}
|
|
return ensurePhone(r, func() error {
|
|
if err := wakeAndGuard(r); err != nil {
|
|
return err
|
|
}
|
|
start := time.Now()
|
|
if err := ui.Launch(r, args[0]); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("launched %s in %s\n", args[0], time.Since(start).Round(time.Millisecond))
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func cmdTap(r *adb.Runner, args []string) error {
|
|
if len(args) != 1 {
|
|
return fmt.Errorf("usage: artemis tap <text>")
|
|
}
|
|
return ensurePhone(r, func() error {
|
|
if err := wakeAndGuard(r); err != nil {
|
|
return err
|
|
}
|
|
node, err := ui.TapText(r, args[0], false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cx, cy, _ := node.Center()
|
|
fmt.Printf("tapped %q at (%d,%d) — %s\n", args[0], cx, cy, node.Label())
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func cmdType(r *adb.Runner, args []string) error {
|
|
if len(args) != 1 {
|
|
return fmt.Errorf("usage: artemis type <text>")
|
|
}
|
|
return ensurePhone(r, func() error {
|
|
if err := wakeAndGuard(r); err != nil {
|
|
return err
|
|
}
|
|
return ui.Type(r, args[0])
|
|
})
|
|
}
|
|
|
|
func cmdSwipe(r *adb.Runner, args []string) error {
|
|
if len(args) < 4 || len(args) > 5 {
|
|
return fmt.Errorf("usage: artemis swipe <x1> <y1> <x2> <y2> [ms]")
|
|
}
|
|
nums := make([]int, 4)
|
|
for i, a := range args[:4] {
|
|
v, err := strconv.Atoi(a)
|
|
if err != nil {
|
|
return fmt.Errorf("swipe coordinate %q is not a number", a)
|
|
}
|
|
nums[i] = v
|
|
}
|
|
ms := 300
|
|
if len(args) == 5 {
|
|
v, err := strconv.Atoi(args[4])
|
|
if err != nil {
|
|
return fmt.Errorf("swipe duration %q is not a number", args[4])
|
|
}
|
|
ms = v
|
|
}
|
|
return ensurePhone(r, func() error {
|
|
if err := wakeAndGuard(r); err != nil {
|
|
return err
|
|
}
|
|
return ui.Swipe(r, nums[0], nums[1], nums[2], nums[3], ms)
|
|
})
|
|
}
|
|
|
|
func cmdScroll(r *adb.Runner, args []string) error {
|
|
if len(args) != 1 {
|
|
return fmt.Errorf("usage: artemis scroll <up|down>")
|
|
}
|
|
return ensurePhone(r, func() error {
|
|
if err := wakeAndGuard(r); err != nil {
|
|
return err
|
|
}
|
|
return ui.Scroll(r, args[0])
|
|
})
|
|
}
|
|
|
|
// cmdDemo is the Phase 2 Definition of Done, runnable on demand:
|
|
// Artemis opens a named app and taps a named element on it.
|
|
func cmdDemo(r *adb.Runner) error {
|
|
return ensurePhone(r, func() error {
|
|
if err := wakeAndGuard(r); err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Println("step 1: launch", ui.PackageSettings)
|
|
if err := ui.Launch(r, ui.PackageSettings); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Launching resumes the last Settings page, so back out to the home
|
|
// screen first to make the demo repeatable.
|
|
const homeMarker = "Search Settings"
|
|
for attempt := 0; attempt < 4; attempt++ {
|
|
if _, err := ui.WaitForText(r, homeMarker, false, 3*time.Second); err == nil {
|
|
break
|
|
}
|
|
if attempt == 3 {
|
|
return fmt.Errorf("Settings home (%q) never appeared", homeMarker)
|
|
}
|
|
if err := ui.Back(r); err != nil {
|
|
return err
|
|
}
|
|
time.Sleep(700 * time.Millisecond)
|
|
}
|
|
|
|
// A stable, top-level Settings entry on the Pixel 8a.
|
|
const target = "Network and internet"
|
|
fmt.Printf("step 2: tap %q\n", target)
|
|
node, err := ui.TapText(r, target, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cx, cy, _ := node.Center()
|
|
fmt.Printf(" tapped %s at (%d,%d)\n", node.Label(), cx, cy)
|
|
|
|
// Prove the tap navigated: this text only exists on the target page.
|
|
const proof = "Aeroplane mode"
|
|
fmt.Printf("step 3: wait for %q\n", proof)
|
|
if _, err := ui.WaitForText(r, proof, false, 10*time.Second); err != nil {
|
|
return fmt.Errorf("tap did not open the expected page: %w", err)
|
|
}
|
|
|
|
fg, err := ui.ForegroundPackage(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("step 4: foreground package is", fg)
|
|
if fg != ui.PackageSettings {
|
|
return fmt.Errorf("expected to still be in %s after the tap, got %s", ui.PackageSettings, fg)
|
|
}
|
|
fmt.Println("demo OK")
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func shortClass(class string) string {
|
|
for i := len(class) - 1; i >= 0; i-- {
|
|
if class[i] == '.' {
|
|
return class[i+1:]
|
|
}
|
|
}
|
|
return class
|
|
}
|