// 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 [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 print matching elements (no tap) fg print the foreground package acting launch launch an app and wait for it to be foreground tap find element by text, tap its centre (FR-4) type type into the focused field swipe [ms] scroll scroll the current list back press back home press home recipes recipes list the recipes in the recipes directory run [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 ") } return ensurePhone(r, func() error { h, err := ui.Dump(r) if err != nil { return err } matches := h.FindText(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 ") } 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 ") } 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 ") } 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 [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 ") } 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 }