feat: Phase 3 recipes file, spotify_play, and locked-screen refusal
Phase 3 of AGENTS.md section 11.
Recipes file (FR-5)
- recipes/ holds one hand-editable file per task; recipes/spotify_play.recipe
is the first one.
- internal/recipe parses directives, guards, parameters and steps, and runs
them with a per-step and a whole-run timeout (FR-11).
- Steps name text ("tap the element whose text is Search"), never coordinates.
- guards name screens Artemis must never tap on, so a signed-out or
first-run Spotify fails with its own reason (FR-8).
- Every run appends task, params, steps, result and duration to
logs/runs.jsonl (FR-10).
spotify_play
- launch, search, play, then wait for the transport button to say "Pause".
- internal/ui/rows.go adds tap_row: a result row is a clickable container with
a title and a subtitle, so a one-line search suggestion is not a result, and
the tap lands on the row's centre rather than on its "More options" button.
This fixes a real wrong tap: a plain text tap hit a 126x12 pixel
"More options for song Honky Tonk Women - Live" button.
Locked screen (AGENTS.md section 13 Q2, decided)
- Artemis fails loudly on a secure keyguard and never types a PIN. No secret is
stored. A non-secure keyguard is still dismissed.
- internal/ui/keyguard.go reads the KeyguardServiceDelegate block plus
cmd lock_settings is-user-secure.
- Every acting command wakes the screen before it checks the keyguard: a dozing
phone reports showing=false, because the lock screen is not drawn yet.
- AGENTS.md records the decision and that storing a PIN for auto-unlock is
rejected for now.
Verified live: the phone locked itself mid-session, and keyguard, tap and
run spotify_play all refused with "phone is locked; unlock it and retry".
The live end-to-end playback run still needs an unlocked phone.
This commit is contained in:
@@ -128,6 +128,17 @@ func TapText(r *adb.Runner, query string, exact bool) (*Node, error) {
|
||||
if len(matches) == 0 {
|
||||
return nil, fmt.Errorf("element with text %q not found on screen; refusing to tap blindly", query)
|
||||
}
|
||||
return TapNode(r, matches)
|
||||
}
|
||||
|
||||
// TapNode taps the centre of the most tappable node among matches.
|
||||
//
|
||||
// Callers that already hold a hierarchy use this instead of TapText, so the
|
||||
// coordinates come from the dump they just read rather than an older one.
|
||||
func TapNode(r *adb.Runner, matches []*Node) (*Node, error) {
|
||||
if len(matches) == 0 {
|
||||
return nil, fmt.Errorf("no element was matched, refusing to tap blindly")
|
||||
}
|
||||
node, err := pickBestMatch(matches)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -170,6 +181,24 @@ func WaitForText(r *adb.Runner, query string, exact bool, timeout time.Duration)
|
||||
return nil, fmt.Errorf("text %q did not appear within %s", query, timeout)
|
||||
}
|
||||
|
||||
// WaitForNoText polls the screen until no element matches query. Recipes use it
|
||||
// to wait for one list to be replaced by another, for example a search result
|
||||
// list replacing the "Recents" list.
|
||||
func WaitForNoText(r *adb.Runner, query string, exact bool, timeout time.Duration) error {
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
h, err := Dump(r)
|
||||
if err == nil && len(h.FindText(query, exact)) == 0 {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("text %q was still on screen after %s", query, timeout)
|
||||
}
|
||||
|
||||
// ScreenSize reads the physical screen size.
|
||||
func ScreenSize(r *adb.Runner) (width, height int, err error) {
|
||||
out, err := r.Shell("wm size")
|
||||
@@ -247,6 +276,10 @@ func Scroll(r *adb.Runner, direction string) error {
|
||||
}
|
||||
|
||||
// Wake makes sure the screen is on before any action (FR-7).
|
||||
//
|
||||
// It dismisses the keyguard only when that keyguard is not secure. A secure
|
||||
// keyguard (PIN, pattern or password) is left exactly as it is: Artemis never
|
||||
// tries to get past it (AGENTS.md section 13 Q2).
|
||||
func Wake(r *adb.Runner) error {
|
||||
if _, err := r.Shell("input keyevent " + strconv.Itoa(KeyWakeup)); err != nil {
|
||||
return fmt.Errorf("wake keyevent: %w", err)
|
||||
@@ -255,8 +288,11 @@ func Wake(r *adb.Runner) error {
|
||||
for time.Now().Before(deadline) {
|
||||
out, err := r.Shell("dumpsys power | grep mWakefulness=")
|
||||
if err == nil && strings.Contains(out, "mWakefulness=Awake") {
|
||||
// Dismiss a non-secure keyguard; harmless when the phone is unlocked.
|
||||
_, _ = r.Shell("wm dismiss-keyguard")
|
||||
// Only a non-secure keyguard may be dismissed. If the state cannot
|
||||
// be read we leave the keyguard alone rather than guess.
|
||||
if showing, secure, kerr := KeyguardState(r); kerr == nil && (!showing || !secure) {
|
||||
_, _ = r.Shell("wm dismiss-keyguard")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
Reference in New Issue
Block a user