// Command artemis drives an Android phone over ADB wireless. // // This is the Phase 2 surface: act primitives on the command line. 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 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 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) ` 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 "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 { return ui.Back(r) }) case "home": err = ensurePhone(r, func() error { return ui.Home(r) }) 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() } 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") 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 := ui.Wake(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 := ui.Wake(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 { 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 { 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 { 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 := ui.Wake(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 }