Files
artemis_control/cmd/artemis/main.go
Sam Rolfe be22eba386 fix: address the Maps search box by id, and re-prove Phase 5
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.
2026-10-17 09:12:01 +11:00

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
}