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:
114
internal/ui/keyguard.go
Normal file
114
internal/ui/keyguard.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/artemis_control/internal/adb"
|
||||
)
|
||||
|
||||
// ErrLocked is returned when a secure keyguard is showing. Artemis never tries
|
||||
// to get past it: no PIN, pattern or password is stored, read or typed
|
||||
// (AGENTS.md section 13 Q2).
|
||||
var ErrLocked = errors.New("phone is locked; unlock it and retry")
|
||||
|
||||
// KeyguardState reports whether the lock screen is showing and, separately,
|
||||
// whether the device has a secure lock (PIN, pattern or password) at all.
|
||||
//
|
||||
// The two are independent: a phone with no PIN still shows a keyguard when it
|
||||
// wakes, but that one may be dismissed. A secure one may not.
|
||||
func KeyguardState(r *adb.Runner) (showing bool, secure bool, err error) {
|
||||
dump, err := r.Shell("dumpsys window")
|
||||
if err != nil {
|
||||
return false, false, fmt.Errorf("read window state: %w", err)
|
||||
}
|
||||
showing, err = keyguardShowing(dump)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
secure, err = userHasSecureLock(r)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return showing, secure, nil
|
||||
}
|
||||
|
||||
// keyguardShowing parses `dumpsys window` for the KeyguardServiceDelegate
|
||||
// block. An unparsable dump is an error: guessing here would mean acting on a
|
||||
// phone whose lock state is unknown.
|
||||
func keyguardShowing(dump string) (bool, error) {
|
||||
showing := false
|
||||
sawDelegate := false
|
||||
sawShowing := false
|
||||
inDelegate := false
|
||||
|
||||
for _, raw := range strings.Split(dump, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "KeyguardServiceDelegate"):
|
||||
inDelegate, sawDelegate = true, true
|
||||
continue
|
||||
case strings.HasPrefix(line, "isKeyguardShowing="):
|
||||
if strings.TrimPrefix(line, "isKeyguardShowing=") == "true" {
|
||||
showing = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !inDelegate {
|
||||
continue
|
||||
}
|
||||
// The delegate block is a flat list of key=value lines.
|
||||
if !strings.Contains(line, "=") {
|
||||
inDelegate = false
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "showing=") {
|
||||
showing = strings.TrimPrefix(line, "showing=") == "true"
|
||||
sawShowing = true
|
||||
}
|
||||
}
|
||||
|
||||
if !sawDelegate || !sawShowing {
|
||||
return false, fmt.Errorf("could not read the keyguard state from dumpsys window")
|
||||
}
|
||||
return showing, nil
|
||||
}
|
||||
|
||||
// userHasSecureLock asks the lock settings service whether the current user has
|
||||
// a secure lock screen. It never asks for or handles the credential itself.
|
||||
func userHasSecureLock(r *adb.Runner) (bool, error) {
|
||||
out, err := r.Shell("cmd lock_settings is-user-secure")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read lock state: %w", err)
|
||||
}
|
||||
switch strings.TrimSpace(out) {
|
||||
case "true":
|
||||
return true, nil
|
||||
case "false":
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("unexpected lock state %q from cmd lock_settings is-user-secure", strings.TrimSpace(out))
|
||||
}
|
||||
|
||||
// RequireUnlocked refuses to act while a secure keyguard is showing.
|
||||
//
|
||||
// A non-secure keyguard (the phone has no PIN at all) is dismissed, because
|
||||
// that screen guards nothing. A secure one is left alone: Artemis stops and
|
||||
// says so.
|
||||
func RequireUnlocked(r *adb.Runner) error {
|
||||
showing, secure, err := KeyguardState(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot confirm the phone is unlocked: %w", err)
|
||||
}
|
||||
if !showing {
|
||||
return nil
|
||||
}
|
||||
if secure {
|
||||
return ErrLocked
|
||||
}
|
||||
if _, err := r.Shell("wm dismiss-keyguard"); err != nil {
|
||||
return fmt.Errorf("dismiss the non-secure keyguard: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user