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

@@ -45,6 +45,7 @@ from .const import (
APP_LIST_TTL,
APP_SWEEP_CONCURRENCY,
APP_SWEEP_SPACING,
CONF_ADB_ENABLED,
CONF_APP_NAME_MAP,
CONF_LOGCAT_EVENTS,
DEFAULT_APP_DIRECTORY,
@@ -59,13 +60,26 @@ from .const import (
SYSTEM_MEDIA_PACKAGES,
)
from .ha_adb_common import (
INTENT_SCAN_ADB_DISABLED,
INTENT_SCAN_ADB_UNAVAILABLE,
INTENT_SCAN_CACHED,
INTENT_SCAN_FRESH,
INTENT_SCAN_FRESH_EMPTY,
INTENT_SCAN_INVALID_PACKAGE,
INTENT_SCAN_UNREADABLE,
IntentScanCache,
IntentScanResult,
MediaSessionInfo,
foreground_from_logcat,
has_activity_resolver_table,
icon_for_package,
installed_packages,
intent_scan_command,
intent_scan_fallback_command,
is_launchable,
parse_app_label,
parse_audio_playback,
parse_intent_report,
resolve_package_name,
)
@@ -292,6 +306,16 @@ class ShieldConnectionManager:
self._logcat_ladder_sleep = asyncio.sleep
# Injectable clock keeps the TTL expiry tests deterministic.
self._clock = clock
# t16: intent discovery. ONE package per explicit request — never a sweep
# over the installed apps (that is ~2241 dump lines each) — cached with
# INTENT_SCAN_TTL. The cache is the module's, so the "a scan that RAN and
# found nothing is an answer, a scan whose transport failed is not" rule
# lives in one place (IntentScanCache.put is its only writer).
self._intents = IntentScanCache(clock=self._clock)
#: t16: scans in flight, keyed by package, so a second request for the
#: same app awaits the dump already being read instead of paying for a
#: second one.
self._intent_scans: dict[str, asyncio.Task] = {}
self._tasks: list[asyncio.Task] = []
self._stopping = False
self._failures = 0
@@ -1503,6 +1527,149 @@ class ShieldConnectionManager:
self._create_task(self._fetch_app_label(package), f"app_label_{package}")
)
# ── Intent discovery (t16) ──
@property
def intent_cache(self) -> IntentScanCache:
"""The per-package intent-scan cache (t16) — tests, and the options page."""
return self._intents
def intent_scan_state(self, package: str | None) -> str | None:
"""Which cached ANSWER holds for a package: "candidates", "empty", None (t16).
None means no answer is cached: the package was never scanned, or the
last scan could not deliver one (a failed scan is never cached, so the
next request retries instead of serving "this app advertises nothing").
"""
return self._intents.state(package)
def _adb_disabled(self) -> bool:
"""Is ADB switched OFF for this entry (t16)?
The config option is the honest signal: no poller can mean "off" OR
"enabled but not connected yet", and those are different statuses with
different fixes (turn ADB on / pair it, versus wait or reconnect).
"""
options = getattr(self.entry, "options", None) or {}
return not options.get(CONF_ADB_ENABLED, False)
async def async_scan_intents(
self, package: str, force: bool = False
) -> IntentScanResult:
"""Scan ONE app's advertised intents over ADB, on demand (t16).
EXPLICIT AND SINGLE (the plan's ruling): one package per call, triggered
by the user scanning that app — never a sweep over the installed apps,
which on the user's Shield would be ~2241 dump lines each. The caller
gets an :class:`IntentScanResult` whose STATUS says what happened, so
"ADB is off", "the device did not answer" and "this app advertises no
intents" can never be confused for one another.
Cached per package with ``INTENT_SCAN_TTL`` unless ``force``: a cached
answer is returned as ``cached`` with its age and its entry state, and
only a scan that RAN is ever cached (see :class:`IntentScanCache`).
"""
name = (package or "").strip()
if not is_launchable(name):
# A malformed id never reaches a shell, and never looks like an
# answer: the same rule the sweep applies to the rows it keeps.
return IntentScanResult(package=name, status=INTENT_SCAN_INVALID_PACKAGE)
if not force:
cached = self._intents.get(name)
if cached is not None:
return IntentScanResult(
package=name,
status=INTENT_SCAN_CACHED,
candidates=cached.candidates,
entry_state=cached.state,
age_seconds=self._intents.age(name),
truncated=cached.truncated,
)
if self._poller is None:
status = (
INTENT_SCAN_ADB_DISABLED
if self._adb_disabled()
else INTENT_SCAN_ADB_UNAVAILABLE
)
_LOGGER.debug("intent scan for %s not attempted (%s)", name, status)
return IntentScanResult(package=name, status=status)
if not self.connected:
_LOGGER.debug(
"intent scan for %s not attempted (ADB connected: no) — the "
"device cannot be asked right now",
name,
)
return IntentScanResult(package=name, status=INTENT_SCAN_ADB_UNAVAILABLE)
running = self._intent_scans.get(name)
if running is not None and not running.done():
# One dump per package at a time: a second request awaits the scan
# already in flight rather than paying for a second one.
return await asyncio.shield(running)
task = self._create_task(self._scan_intents(name), f"intents_{name}")
self._intent_scans[name] = task
try:
return await task
finally:
if self._intent_scans.get(name) is task:
self._intent_scans.pop(name, None)
async def _scan_intents(self, package: str) -> IntentScanResult:
"""The scan itself: the on-device filter, the bounded fallback, the cache (t16).
The status vocabulary is the point. A capture that carries the resolver
section is an ANSWER — cached even when it holds no candidates — while
anything else (a shell that raised, no output, an error line, a filtered
answer we cannot read) is NOT an answer, is NOT cached, and is retried.
That is the discovery-cache defect's rule applied to intents, and it is
why ``has_activity_resolver_table`` is asked before the parser is
believed.
"""
poller = self._poller
if poller is None: # pragma: no cover - the caller checked this
return IntentScanResult(package=package, status=INTENT_SCAN_ADB_UNAVAILABLE)
delivered_something = False
for command in (
intent_scan_command(package),
intent_scan_fallback_command(package),
):
try:
answer = await poller.shell(command)
except Exception as err: # noqa: BLE001 - the shell's failure IS the finding
_LOGGER.debug("intent scan for %s failed (%s)", package, err)
continue
if not answer or not answer.strip():
continue
delivered_something = True
if not has_activity_resolver_table(answer):
# The device answered, but not with the resolver section (a
# "Unable to find package" line, a truncated answer): worth one
# bounded fallback attempt, and never a cached answer.
continue
report = parse_intent_report(answer)
entry = self._intents.put(
package, report.candidates, truncated=report.truncated
)
_LOGGER.debug(
"intent scan for %s answered: %d candidate(s), state=%s, "
"truncated=%s",
package,
len(entry.candidates),
entry.state,
entry.truncated,
)
return IntentScanResult(
package=package,
status=INTENT_SCAN_FRESH if report.candidates else INTENT_SCAN_FRESH_EMPTY,
candidates=entry.candidates,
entry_state=entry.state,
age_seconds=0.0,
truncated=entry.truncated,
)
return IntentScanResult(
package=package,
status=INTENT_SCAN_UNREADABLE if delivered_something else INTENT_SCAN_ADB_UNAVAILABLE,
)
def last_known_map(self) -> dict[str, MediaSessionInfo]:
"""TTL-filtered snapshot of the per-package last-known cache."""
result: dict[str, MediaSessionInfo] = {}