feat: detect a wedged app with expect_change

An app can stop consuming input while it keeps drawing: Android delivers
the tap, the app never acts on it. Phase 3 hit this three times against
Spotify and the recipe failed on the label it was waiting for, which names
the wrong problem.

The new `expect_change <duration>` step fingerprints the screen (foreground
package plus every visible label and its bounds) and fails when the screen
does not change after the previous step, with the distinct reason
"the app stopped consuming input". It supports `retry N`, which re-presses
the step above a bounded number of times, so a dropped tap is still
recovered and a wedged app still ends the run (FR-11).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-16 15:56:59 +11:00
parent 36b4dd2391
commit 43cd4282d0
6 changed files with 214 additions and 22 deletions

84
internal/ui/change.go Normal file
View File

@@ -0,0 +1,84 @@
package ui
import (
"fmt"
"sort"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/artemis_control/internal/adb"
)
// ScreenSignature returns a fingerprint of what is on screen: the foreground
// package plus every visible label and the bounds it sits in.
//
// Two dumps of an unchanged screen produce the same signature, and any change a
// human would see — new text, different text, an element moved or a different
// app in front — produces a different one. It is the raw material for wedge
// detection: if an input is delivered and the signature never moves, the app
// never acted on that input.
//
// The foreground package is best effort. If it cannot be read the labels alone
// still make a usable fingerprint, because a screen that stops changing is
// exactly the failure this guards against.
func ScreenSignature(r *adb.Runner) (string, error) {
h, err := Dump(r)
if err != nil {
return "", err
}
fg, _ := ForegroundPackage(r)
return h.Signature(fg), nil
}
// Signature is the pure half of ScreenSignature: a stable fingerprint of one
// parsed dump. Labels are sorted so that the order uiautomator happened to
// report them in never looks like a change, and bounds are included so that an
// element moving is a change even when the words stay the same.
func (h *Hierarchy) Signature(foreground string) string {
parts := make([]string, 0, len(h.TextNodes()))
h.Walk(func(n *Node) {
label := strings.TrimSpace(n.Text)
if label == "" {
label = strings.TrimSpace(n.ContentDesc)
}
if label == "" {
return
}
parts = append(parts, strings.TrimSpace(n.Bounds)+"|"+label)
})
sort.Strings(parts)
return foreground + "\n" + strings.Join(parts, "\n")
}
// ExpectChange waits until the screen no longer looks like it did when the
// function was called, and returns the signatures before and after.
//
// This is the wedged-app check. An app that has stopped consuming input still
// draws the screen it had, so a delivered tap changes nothing. A timeout here
// means the last input was accepted by Android but never acted on by the app,
// which the recipe reports as "the app stopped consuming input" instead of
// blaming the label it was waiting for (AGENTS.md section 11 Phase 5).
func ExpectChange(r *adb.Runner, timeout time.Duration) (before, after string, err error) {
if timeout <= 0 {
timeout = 10 * time.Second
}
before, err = ScreenSignature(r)
if err != nil {
return "", "", err
}
deadline := time.Now().Add(timeout)
for {
sig, derr := ScreenSignature(r)
if derr != nil {
return before, "", derr
}
if sig != before {
return before, sig, nil
}
if !time.Now().Before(deadline) {
return before, before, fmt.Errorf("the screen did not change within %s", timeout)
}
time.Sleep(PollInterval)
}
}

View File

@@ -0,0 +1,68 @@
package ui
import (
"strings"
"testing"
)
// TestSignatureIsStable guards the wedged-app check: two reads of an unchanged
// screen must produce the same signature, or every screen would look like it
// changed and no wedge could ever be detected.
func TestSignatureIsStable(t *testing.T) {
h := parseSample(t)
first := h.Signature("com.android.settings")
second := h.Signature("com.android.settings")
if first != second {
t.Fatalf("signature is not stable:\n%q\n%q", first, second)
}
if !strings.Contains(first, "com.android.settings") {
t.Fatalf("signature %q does not name the foreground package", first)
}
}
// TestSignatureSeesRealChanges checks the three things a human would call a
// change: different text, a moved element, and a different app in front.
func TestSignatureSeesRealChanges(t *testing.T) {
h := parseSample(t)
base := h.Signature("com.android.settings")
if h.Signature("com.spotify.music") == base {
t.Fatal("a different foreground package must change the signature")
}
renamed, err := parseHierarchy([]byte(strings.Replace(sampleDump, "Connected", "Disconnected", 1)))
if err != nil {
t.Fatalf("parseHierarchy: %v", err)
}
if renamed.Signature("com.android.settings") == base {
t.Fatal("different visible text must change the signature")
}
moved, err := parseHierarchy([]byte(strings.Replace(sampleDump, "[221,217][453,277]", "[221,300][453,360]", 1)))
if err != nil {
t.Fatalf("parseHierarchy: %v", err)
}
if moved.Signature("com.android.settings") == base {
t.Fatal("an element that moved must change the signature")
}
}
// TestSignatureIgnoresNodeOrder keeps the fingerprint about the screen, not the
// order uiautomator happened to walk it in.
func TestSignatureIgnoresNodeOrder(t *testing.T) {
a, err := parseHierarchy([]byte(`<hierarchy rotation="0">` +
`<node index="0" text="Alpha" bounds="[0,0][10,10]"/>` +
`<node index="1" text="Beta" bounds="[0,10][10,20]"/></hierarchy>`))
if err != nil {
t.Fatalf("parseHierarchy: %v", err)
}
b, err := parseHierarchy([]byte(`<hierarchy rotation="0">` +
`<node index="0" text="Beta" bounds="[0,10][10,20]"/>` +
`<node index="1" text="Alpha" bounds="[0,0][10,10]"/></hierarchy>`))
if err != nil {
t.Fatalf("parseHierarchy: %v", err)
}
if a.Signature("app.one") != b.Signature("app.one") {
t.Fatal("node order must not change the signature")
}
}