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:
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] = {}
|
||||
|
||||
Reference in new issue
Block a user