feat(intents): discover an app's intents from its activity resolver table (t16)

First half of the approved intents feature (docs/INTENTS_PLAN.md §2): the scan
that answers "what can this app actually be asked to do". No UI is wired here —
this is the discovery module, the manager path it rides on, and the tests.

THE DUMP IS FILTERED ON THE DEVICE. On the user's Shield `pm dump
com.netflix.ninja` is ~2241 lines of whole dumpsys for ONE app, so shipping it
over ADB to read a few dozen resolver lines repeats the mistake `| head -300`
made for app labels (t66 — where a truncation artefact was read as "this app has
no label"). The fetch now sends:

    pm dump <pkg> 2>/dev/null | sed -n "/Activity Resolver Table/,/Service Resolver Table/p" | head -n 400

with a bounded `grep -A 400 -m 1 -F 'Activity Resolver Table:'` fallback for a
device whose toolbox cannot do sed ranges, tried only when the primary answer did
not contain the section. The package id is the only caller-supplied text in
either command and is always shlex.quote'd, and the sed script is built from two
constants — tests drive eight hostile package ids (semicolons, &&, backticks,
$(), pipes, both quote kinds, a newline) and assert that the shell sees the
package as exactly one verbatim token and that no extra command token appears.

WHAT A CANDIDATE CARRIES: component, action, categories and the data
(scheme/authority/path, plus the matcher kind — `/title.*` is a GLOB, not a
path, and a caller that treats it as literal builds the wrong URI). A filter
carrying several schemes/authorities/paths expands to one candidate per
combination, because each is a concrete match, and dedup collapses the
coincidences. MIME keys are deliberately NOT modelled (they are not one of §3's
fields) — such a filter is still reported through its component, action and
categories rather than being invented into a URI.

RANK, NEVER HIDE. The four families the plan names as internal-looking noise
(DEBUG_*, androidx.*, com.google.firebase.*, ConstraintProxy*) are FLAGGED, not
deleted: they sort last so the useful rows come first, and the user still
decides. Ordering is total (flag, action, scheme, authority, path, component,
categories) so a re-scan is byte-identical, and the 200-candidate cap is applied
after dedup and DISCLOSED through truncated/found — a capped answer never looks
complete.

THE CACHE KEEPS AN ANSWER AND A FAILURE APART, structurally: put() is its only
writer, only a scan that RAN calls it, and it derives the entry's state — an
empty candidate list is INTENT_ENTRY_EMPTY, a real ANSWER the device gave, while
a scan whose transport failed caches nothing and is retried. A stale entry is
dropped on read rather than served. The scan itself has one status per outcome
(fresh / fresh_empty / cached / adb_disabled / adb_unavailable / unreadable /
invalid_package) so "ADB is off", "the device did not answer" and "this app
advertises nothing" can never be confused, and the not-answered statuses carry no
candidates at all — an ADB-down scan cannot look like an app with no intents.
ADB disabled and ADB unreachable are told apart from the entry's own option.
Discovery is EXPLICIT and single: one package per request, never a sweep over the
installed apps, and a test drives a watchdog cycle and asserts it starts no scan.
Concurrent requests for the same package share one dump.

THE FIXTURE IS A TRANSCRIPTION, NOT IMAGINATION. tests/fixtures/
pm_dump_netflix_resolver.txt reproduces the user's 2026-10-12 capture structure
(whole-dumpsys envelope, the activity table with its Schemes/Non-Data Actions
groups, the receiver/service/provider tables that bound it) and the test file's
docstring states line by line what is VERBATIM from the ticket/plan and what is
reconstructed — no real Shield was available, and the raw capture is not in this
repository (the same limit t13 recorded). The capture's own entries are each
pinned by an assertion: the cast entry, http/https with the four categories and
the five www.netflix.com path globs, nflx:, netflix:, MAIN with LEANBACK_LAUNCHER
+ LAUNCHER, NETFLIX_KEY_START, DIAL_START and the two DEBUG_* noise actions —
18 candidates, deduplicated, with the receiver/provider tables proven unread.

Evidence: 59 new tests in tests/test_intents.py (parser against the transcribed
capture, degradation on shapes never seen, the command shape and quoting safety,
the cache states and TTL, and the manager: one shell, cached, retried on failure,
fallback recovery, ADB-off versus ADB-down, the cap disclosed, the watchdog
silent, concurrent sharing). No device was available: the parser is proven
against the transcription, and every "runs/does not run" claim is an assertion on
the poller's recorded commands rather than on intentions.

Verify: suites 778/4 and 779/3; ruff clean; drift exit 0 (26 modules, 76 imports,
242 relative imports, 1417 statement blocks, 57 call arguments) with the
relative-import floor raised 195 -> 235 and the statement-block floor 1300 ->
1400 by the new module rather than dodged; the real-HA glob still runs green
(56 passed, 0 skipped).
This commit is contained in:
James Mackay committed 2026-10-12 12:27:01 +01:00
1 parent 14c168c25e
commit 91d9fb3883
6 files changed
+1869 -5

No files matched your search

+13 -5
View File
@@ -141,9 +141,9 @@ HA_CALL_OWNERS: dict[str, tuple[str, str]] = {
}
#: Non-vacuity floors — the ONE source of truth for the standalone entry point
#: and the pytest wrapper (t39 F1). They sit below the current walk (25 modules,
#: 76 imports, 206 relative imports, 57 checked arguments, 6 awaited HA calls,
#: 1322 statement blocks) so a broken or mis-targeted walk fails loudly instead of
#: and the pytest wrapper (t39 F1). They sit below the current walk (26 modules,
#: 76 imports, 242 relative imports, 57 checked arguments, 6 awaited HA calls,
#: 1417 statement blocks) so a broken or mis-targeted walk fails loudly instead of
#: printing zeros and exiting 0.
FLOORS: dict[str, int] = {
# t41: the sweep is recursive, so the floor sits above the top-level-only
@@ -154,7 +154,12 @@ FLOORS: dict[str, int] = {
# `ATTR_ACTIVITY`, the transport-state accessors and the shared app-directory
# helpers all arrive as imports, and none of them may vanish unnoticed.
"imports": 70,
"relative_imports": 195,
# t16: raised 190 -> 195 -> 235, again just under the walk. The intent
# discovery module adds the package's exports to `ha_adb_common`'s surface and
# is imported through it by the manager, so the relative-import count is exactly
# where a new module must show up — a floor left behind would let the manager's
# import of it disappear unnoticed.
"relative_imports": 235,
# t62 F2: raised from 20 to just under the current 46. A floor's job is to
# notice an API OWNER going away, not only a broken walk: at 20, deleting
# `async_show_menu` from HA_CALL_OWNERS left 40 checked arguments and still
@@ -177,7 +182,10 @@ FLOORS: dict[str, int] = {
# floor here means a pass that silently stopped parsing cannot look green.
# t15: raised 500 -> 1200 -> 1300, just under the walk, for the same reason the
# imports floor moved: the exhaustion pass covers the new surfaces too.
"statement_blocks": 1300,
# t16: raised 1200 -> 1300 -> 1400: the parser, the cache and the manager's
# scan path are new statement blocks, and the unreachable pass must keep
# covering them.
"statement_blocks": 1400,
}
#: The interpreter floor Home Assistant itself declares: PyPI metadata for