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] = {}
@@ -24,6 +24,30 @@ from .installed_apps import (
parse_package_list,
)
from .intent import PlaybackIntent
from .intents import (
INTENT_CANDIDATE_LIMIT,
INTENT_ENTRY_CANDIDATES,
INTENT_ENTRY_EMPTY,
INTENT_SCAN_ADB_DISABLED,
INTENT_SCAN_ADB_UNAVAILABLE,
INTENT_SCAN_CACHED,
INTENT_SCAN_FRESH,
INTENT_SCAN_FRESH_EMPTY,
INTENT_SCAN_INVALID_PACKAGE,
INTENT_SCAN_TTL,
INTENT_SCAN_UNREADABLE,
INTERNAL_MARKERS,
IntentCandidate,
IntentScanCache,
IntentScanResult,
activity_resolver_section,
has_activity_resolver_table,
intent_scan_command,
intent_scan_fallback_command,
is_likely_internal,
parse_activity_resolver_table,
parse_intent_report,
)
from .keys import ensure_adb_key
from .logcat_events import foreground_from_logcat
from .media_session import (
@@ -41,8 +65,30 @@ __all__ = [
"ATTR_MEDIA_DAG_ROLE",
"ATTR_MEDIA_DAG_RUNNING_APPS",
"AdbClientBase",
"INTENT_CANDIDATE_LIMIT",
"INTENT_ENTRY_CANDIDATES",
"INTENT_ENTRY_EMPTY",
"INTENT_SCAN_ADB_DISABLED",
"INTENT_SCAN_ADB_UNAVAILABLE",
"INTENT_SCAN_CACHED",
"INTENT_SCAN_FRESH",
"INTENT_SCAN_FRESH_EMPTY",
"INTENT_SCAN_INVALID_PACKAGE",
"INTENT_SCAN_TTL",
"INTENT_SCAN_UNREADABLE",
"INTERNAL_MARKERS",
"IntentCandidate",
"IntentScanCache",
"IntentScanResult",
"MediaSessionInfo",
"PlaybackIntent",
"activity_resolver_section",
"has_activity_resolver_table",
"intent_scan_command",
"intent_scan_fallback_command",
"is_likely_internal",
"parse_activity_resolver_table",
"parse_intent_report",
"ensure_adb_key",
"parse_all_media_sessions",
"parse_app_label",
@@ -0,0 +1,692 @@
"""Intent discovery from an app's Activity Resolver Table (t16).
The approved intents feature (``docs/INTENTS_PLAN.md`` §2) starts here: the user
scans ONE installed app and we answer with the intents that app advertises. The
source of truth is the ``Activity Resolver Table:`` section Android prints in
``pm dump <package>``.
WHY THE DUMP IS FILTERED ON THE DEVICE. On the user's Shield (captured
2026-10-12) ``pm dump com.netflix.ninja`` is ~2241 lines of WHOLE dumpsys —
``DUMP OF SERVICE package:`` and then activity/meminfo/procstats/usagestats/
batterystats — for ONE app. Shipping that over ADB to read a few dozen resolver
lines is the same mistake ``| head -300`` made for app labels (t66, where the
truncation was misread as "this app has no label"), so the command built here
pipes the dump through ``sed`` ON THE DEVICE and only the resolver section
crosses the wire:
pm dump <pkg> 2>/dev/null \
| sed -n "/Activity Resolver Table/,/Service Resolver Table/p" \
| head -n <cap>
with a bounded ``grep -A … -m 1 -F`` fallback (``intent_scan_fallback_command``)
for a device whose toolbox cannot do sed ranges. The package id is always
``shlex.quote``d — a package name is the only thing in either command that could
carry shell metacharacters, and quoting it is what makes the command safe — and
everything else in the command is a fixed constant from this file.
WHAT THE CANDIDATES CARRY AND WHY. Per §3's data model: component, action,
categories, and the data (scheme, authority, path). A filter that carries several
schemes, authorities or paths is expanded into ONE CANDIDATE PER COMBINATION,
because each of those is a concrete match — a URI the app really accepts — and a
picker row that mixes two schemes cannot be turned into a URI at all. Candidates
are deduplicated on the whole tuple, so the expansion costs nothing when the
app's filters coincide.
WHAT THIS MODULE DOES NOT MODEL: MIME types. The resolver key of a MIME filter
(``video/*:``) is not one of §3's fields, and inventing a data URI out of it
would be worse than reporting the filter through its component, action and
categories — which is what happens. Nothing is dropped for being unmodelled.
MARK — READ THIS BEFORE TRUSTING A SHAPE. The parser is proven against a
TRANSCRIBED capture (``tests/fixtures/pm_dump_netflix_resolver.txt``), not
against a device: no real Shield was available, and the raw capture text is not
in this repository either. The entry/detail shapes below are what the capture
records (see the fixture's own notes for which lines are verbatim and which are
reconstructed), and every parser branch is deliberately tolerant of a shape we
have never seen: an unrecognised line degrades to "no candidate from this line",
never to a wrong candidate.
NOT TO BE CONFUSED WITH ``.intent``: that module holds ``PlaybackIntent``, the
optimistic-UI dataclass, not an Android intent. §7.4 of the plan suggests renaming
it to ``playback_intent.py``; that churn is not part of this unit, so the two names
sit side by side here and this note is the signpost.
"""
from __future__ import annotations
import re
import shlex
import time
from collections.abc import Iterator
from dataclasses import dataclass
#: ``Activity Resolver Table:`` — the section that opens the package's resolver
#: tables, and the two headings that END it on the observed capture (the receiver
#: table follows the activity one, then the service table).
ACTIVITY_TABLE_HEADER = "Activity Resolver Table:"
RECEIVER_TABLE_HEADER = "Receiver Resolver Table:"
SERVICE_TABLE_HEADER = "Service Resolver Table:"
PROVIDER_TABLE_HEADER = "Provider Resolver Table:"
#: The end of the activity section: whichever of these comes FIRST. Stopping at
#: the first one can only ever cut the section short, never extend it into
#: another table (the plan's sed uses the service heading; a capture where the
#: receiver table is absent still ends here).
_SECTION_ENDS = (
RECEIVER_TABLE_HEADER,
SERVICE_TABLE_HEADER,
PROVIDER_TABLE_HEADER,
)
#: The two patterns the ON-DEVICE sed range is built from. The colons are dropped
#: deliberately: the shipped command is the plan's own line
#: (``sed -n '/Activity Resolver Table/,/Service Resolver Table/p'``), which is
#: what has to be recognisable in a shell and in a review, and both patterns match
#: the same printed heading. They are constants — never caller text — so quoting
#: them in the command below cannot be broken from outside.
SED_RANGE_START = "Activity Resolver Table"
SED_RANGE_END = "Service Resolver Table"
#: How long a scan that RAN is reused, per package. An app's advertised intents
#: only change when the app does, so this is a cost bound rather than a staleness
#: guess: one shell an hour for a package the user keeps looking at.
INTENT_SCAN_TTL = 3600.0
#: The transfer bound of both commands (``head -n`` / ``grep -A``). A real
#: resolver section is tens of lines; this is what keeps a pathological table
#: from becoming a wall of text on the wire.
INTENT_SCAN_LINE_LIMIT = 400
#: The cap on returned candidates, applied AFTER dedup and ranking. The cap is
#: disclosed (``IntentParseResult.truncated`` / ``IntentScanResult.truncated``),
#: so a capped answer never looks complete.
INTENT_CANDIDATE_LIMIT = 200
#: One entry line: an optional group key, the component's hex id, the COMPONENT
#: and — for an activity filter — the filter's own id. Both shapes the capture
#: records match, with or without the group key in front:
#:
#: cast: deca7fa com.netflix.ninja/.MainActivity filter fc483a1
#: deca7fa com.netflix.ninja/.MainActivity filter fc483a2
_ENTRY_RE = re.compile(
r"^(?P<key>.*?)\s*(?P<hex_id>[0-9a-fA-F]{6,})\s+"
r"(?P<component>[A-Za-z_][\w.]*/[\w.$]+)"
r"(?:\s+filter\s+(?P<filter_id>[0-9a-fA-F]+))?\s*$"
)
#: A group heading on its own line (``Schemes:``, ``Non-Data Actions:``, and the
#: action-keyed groups inside it). Used for ONE fallback only: when a filter
#: carries no ``Action:`` detail, the key that grouped it is the action the
#: device printed — never a guess about a key that is not action-shaped.
_GROUP_KEY_RE = re.compile(r"^\s+(?P<key>[A-Za-z_][\w.$]*)\s*:\s*$")
#: A filter detail line. The five fields §3's model carries; ``Type:`` (a MIME
#: type) and anything else are read off the line and ignored — see the module
#: docstring on what is not modelled.
_DETAIL_RE = re.compile(
r"^\s*(?P<field>Action|Category|Scheme|Authority|Path)\s*:\s*(?P<value>.+?)\s*$"
)
#: ``Authority: "www.netflix.com": -1`` — the authority, not its weight.
_AUTHORITY_RE = re.compile(r'^"(?P<value>[^"]*)"\s*(?::\s*-?\d+)?$')
#: ``Path: "PatternMatcher{GLOB: /title.*}"`` — the matcher kind and its value.
#: Both separators Android prints are accepted (``GLOB: /x`` and ``LITERAL, /x``).
_PATH_MATCHER_RE = re.compile(
r"^PatternMatcher\{(?P<kind>[A-Za-z_]+)\s*[:,]?\s*(?P<value>.*)\}$"
)
#: The marker families ``docs/INTENTS_PLAN.md`` §2 names as internal-looking.
#: They are FLAGGED and never deleted: the user decides what is worth
#: registering, and a candidate we quietly removed is one they can never
#: register. Matching is on the action and the component (the capture's own
#: noise is action-shaped, e.g. ``com.netflix.action.DEBUG_PUSH_NOTIFICATION``).
INTERNAL_MARKERS = (
"DEBUG_",
"androidx.",
"com.google.firebase.",
"ConstraintProxy",
)
#: A cache entry's state (t16). ONLY a scan that RAN writes an entry, and the
#: entry then says which of these two answers it holds — the distinction the
#: discovery-cache defect taught us, made structural instead of remembered.
INTENT_ENTRY_CANDIDATES = "candidates"
INTENT_ENTRY_EMPTY = "empty"
#: A scan result's status. ``answered`` is True for the first three only: the
#: rest mean "we do not know", which is NOT the same as "this app advertises
#: nothing" and must never be shown as an empty list of intents.
INTENT_SCAN_FRESH = "fresh"
INTENT_SCAN_FRESH_EMPTY = "fresh_empty"
INTENT_SCAN_CACHED = "cached"
INTENT_SCAN_ADB_DISABLED = "adb_disabled"
INTENT_SCAN_ADB_UNAVAILABLE = "adb_unavailable"
INTENT_SCAN_UNREADABLE = "unreadable"
INTENT_SCAN_INVALID_PACKAGE = "invalid_package"
#: The statuses whose candidates are an ANSWER from the device.
_ANSWERED_STATUSES = (
INTENT_SCAN_FRESH,
INTENT_SCAN_FRESH_EMPTY,
INTENT_SCAN_CACHED,
)
def _unquote(value: str | None) -> str:
"""Strip one layer of matching quotes, if any (``"x"`` -> ``x``)."""
text = (value or "").strip()
if len(text) >= 2 and text[0] == text[-1] and text[0] in ("'", '"'):
return text[1:-1].strip()
return text
def _authority_value(raw: str) -> str | None:
"""The authority in an ``Authority:`` detail, or None.
Android prints ``Authority: "www.netflix.com": -1`` (quoted, then the filter's
score); a bare ``Authority: www.netflix.com`` is accepted too.
"""
text = (raw or "").strip()
match = _AUTHORITY_RE.match(text)
if match:
return match.group("value") or None
return _unquote(text) or None
def _path_value(raw: str) -> tuple[str | None, str | None]:
"""``(matcher_kind, path)`` from a ``Path:`` detail.
``"PatternMatcher{GLOB: /title.*}"`` -> ``("GLOB", "/title.*")``;
``"PatternMatcher{LITERAL, /browse}"`` -> ``("LITERAL", "/browse")``; anything
that is not a PatternMatcher is returned as a bare path with no kind.
"""
text = _unquote(raw)
match = _PATH_MATCHER_RE.match(text)
if match:
return match.group("kind").upper(), match.group("value").strip()
return None, text or None
def looks_like_action(key: str | None) -> bool:
"""Is a group key an ACTION rather than a sub-heading (t16)?
``android.intent.action.MAIN`` and ``com.netflix.action.DIAL_START`` are
action-shaped; ``Schemes``, ``Non-Data Actions`` and the ``cast:``/``http:``
scheme keys are not. Only used when a filter printed no ``Action:`` detail.
"""
text = (key or "").strip().rstrip(":")
return bool(text) and "." in text and " " not in text
def is_likely_internal(action: str | None, component: str | None = None) -> bool:
"""Does this candidate LOOK like the app's own internal plumbing (t16)?
A heuristic, and labelled as one: the four families the plan names are
matched anywhere in the action or the component, and the flag only moves a
candidate to the END of the ordering (``rank_candidates``) — it never removes
it, because the user is the one who decides what is worth registering.
"""
action_text = action or ""
component_text = component or ""
return any(
marker in action_text or marker in component_text
for marker in INTERNAL_MARKERS
)
@dataclass(frozen=True)
class IntentCandidate:
"""One intent an app advertises, as its resolver table describes it (t16).
``component`` is the ``package/Activity`` the filter belongs to; ``action``
is the intent action (never invented: see ``looks_like_action``); the data
fields are one concrete match, so ``scheme``/``authority``/``path`` together
describe a URI the app accepts. ``path_kind`` is Android's matcher
(``GLOB``/``LITERAL``/``PREFIX``) and is kept because ``/title.*`` is a glob,
not a path: a caller that treats it as literal builds the wrong URI.
"""
component: str
action: str | None = None
categories: tuple[str, ...] = ()
scheme: str | None = None
authority: str | None = None
path: str | None = None
path_kind: str | None = None
likely_internal: bool = False
@property
def key(self) -> tuple:
"""The dedup key: everything the candidate means, nothing about its row."""
return (
self.component,
self.action,
self.categories,
self.scheme,
self.authority,
self.path,
self.path_kind,
)
def as_dict(self) -> dict:
"""A plain dict, for the options page, diagnostics and JSON."""
return {
"component": self.component,
"action": self.action,
"categories": list(self.categories),
"scheme": self.scheme,
"authority": self.authority,
"path": self.path,
"path_kind": self.path_kind,
"likely_internal": self.likely_internal,
}
@dataclass(frozen=True)
class IntentParseResult:
"""What one capture yielded: the candidates, and whether the cap applied."""
candidates: tuple[IntentCandidate, ...] = ()
found: int = 0
truncated: bool = False
def has_activity_resolver_table(output: str | None) -> bool:
"""Does this capture carry the resolver section at all (t16)?
THE distinction this module exists to keep: a capture WITH the heading that
holds no entries is an ANSWER ("this app advertises nothing we can read"),
while a capture WITHOUT it is not an answer at all — the transport handed us
something else (an error line, a truncated answer, nothing). Only the former
may be cached (``IntentScanCache``); the latter must be retried.
"""
return bool(output) and ACTIVITY_TABLE_HEADER in output
def activity_resolver_section(output: str | None) -> str | None:
"""The ``Activity Resolver Table:`` section of a capture, or None.
Bounded by the FIRST of the receiver/service/provider headings, so a capture
that carries the whole dumpsys cannot leak another table's filters (a
receiver's actions are not an activity's intents). None when the section is
absent — see ``has_activity_resolver_table`` for why that is not "no intents".
"""
if not output:
return None
start = output.find(ACTIVITY_TABLE_HEADER)
if start < 0:
return None
body = output[start + len(ACTIVITY_TABLE_HEADER):]
end = len(body)
for heading in _SECTION_ENDS:
found = body.find(heading)
if 0 <= found < end:
end = found
return body[:end]
@dataclass
class _Entry:
"""A filter line being read: its component, its key, its own filter id."""
component: str
key: str | None = None
filter_id: str | None = None
@property
def group_action(self) -> str | None:
"""The group key as an action, when it is action-shaped (else None)."""
key = self.key
return key.rstrip(":") if looks_like_action(key) else None
def _iter_filters(section: str) -> Iterator[tuple[_Entry, list[tuple[str, str]]]]:
"""Yield ``(entry, details)`` for every filter line in the section.
Details belong to the filter line they follow, so the walk is line-order and
resets on each new entry. A group key on its own line is remembered for the
action fallback (``_Entry.group_action``).
"""
entry: _Entry | None = None
details: list[tuple[str, str]] = []
group_key: str | None = None
for line in section.splitlines():
match = _ENTRY_RE.match(line)
if match:
if entry is not None:
yield entry, details
key = (match.group("key") or "").strip() or group_key
entry = _Entry(
component=match.group("component"),
key=key,
filter_id=match.group("filter_id"),
)
details = []
continue
detail = _DETAIL_RE.match(line)
if detail is not None:
if entry is not None:
details.append((detail.group("field"), detail.group("value")))
continue
heading = _GROUP_KEY_RE.match(line)
if heading is not None:
group_key = heading.group("key")
if entry is not None:
yield entry, details
def _data_specs(details: list[tuple[str, str]]) -> list[tuple]:
"""The ``(scheme, authority, path, path_kind)`` combinations of one filter.
``Scheme:`` opens a group; the ``Authority:``/``Path:`` lines that follow it
belong to it (that is the nesting the capture prints). A filter with none of
the three yields a single all-None spec: "this filter matches on its action
alone", which is exactly what a Non-Data Action is.
"""
groups: list[list] = []
for field, raw in details:
if field == "Scheme":
groups.append([_unquote(raw) or None, [], []])
continue
if field not in ("Authority", "Path"):
continue
if not groups:
groups.append([None, [], []])
target = groups[-1]
if field == "Authority":
value = _authority_value(raw)
if value:
target[1].append(value)
else:
target[2].append(_path_value(raw))
specs: list[tuple] = []
for scheme, authorities, paths in groups:
for authority in authorities or [None]:
for path_kind, path in paths or [(None, None)]:
specs.append((scheme, authority, path, path_kind))
return specs
def _candidates_for(entry: _Entry, details: list[tuple[str, str]]) -> list[IntentCandidate]:
"""Every candidate one filter line yields (action x data combination)."""
actions: list[str | None] = []
categories: list[str] = []
for field, raw in details:
if field == "Action":
value = _unquote(raw)
if value and value not in actions:
actions.append(value)
elif field == "Category":
value = _unquote(raw)
if value and value not in categories:
categories.append(value)
if not actions:
# No Action: detail. The group key is the action ONLY when it is
# action-shaped; otherwise the candidate keeps action None rather than
# inventing one from a sub-heading.
actions = [entry.group_action]
specs = _data_specs(details) or [(None, None, None, None)]
candidates: list[IntentCandidate] = []
for action in actions:
for scheme, authority, path, path_kind in specs:
candidates.append(
IntentCandidate(
component=entry.component,
action=action,
categories=tuple(categories),
scheme=scheme,
authority=authority,
path=path,
path_kind=path_kind,
likely_internal=is_likely_internal(action, entry.component),
)
)
return candidates
def rank_candidates(
candidates: list[IntentCandidate] | tuple[IntentCandidate, ...],
*,
limit: int = INTENT_CANDIDATE_LIMIT,
) -> list[IntentCandidate]:
"""Deduplicate, order deterministically, and cap (t16).
Order: candidates a user is likely to want first (``likely_internal`` last),
then the dedup key itself — action, scheme, authority, path, component,
categories — so the order is total and identical on a re-scan, which is what
makes it safe for a UI to key rows by position.
The cap (``limit``, default ``INTENT_CANDIDATE_LIMIT``) keeps a pathological
table from becoming a wall of rows; it is applied AFTER dedup, drops the
least interesting rows first (internal-looking ones) and is DISCLOSED by the
caller (``IntentParseResult.truncated``), never silent.
"""
unique: dict[tuple, IntentCandidate] = {}
for candidate in candidates:
unique.setdefault(candidate.key, candidate)
ordered = sorted(
unique.values(),
key=lambda c: (
c.likely_internal,
c.action or "",
c.scheme or "",
c.authority or "",
c.path or "",
c.component,
c.categories,
),
)
return ordered[:limit]
def parse_intent_report(
output: str | None,
*,
limit: int = INTENT_CANDIDATE_LIMIT,
) -> IntentParseResult:
"""Parse a resolver capture into a report: candidates, count, capped or not.
An absent section yields an EMPTY report, not an answer — callers must ask
``has_activity_resolver_table`` (the manager does) before treating "no
candidates" as "this app advertises nothing".
"""
if not output:
return IntentParseResult()
section = activity_resolver_section(output)
if section is None:
return IntentParseResult()
raw: list[IntentCandidate] = []
for entry, details in _iter_filters(section):
raw.extend(_candidates_for(entry, details))
ranked = rank_candidates(raw, limit=limit)
deduped = len({candidate.key for candidate in raw})
return IntentParseResult(
candidates=tuple(ranked),
found=deduped,
truncated=deduped > len(ranked),
)
def parse_activity_resolver_table(
output: str | None,
*,
limit: int = INTENT_CANDIDATE_LIMIT,
) -> list[IntentCandidate]:
"""The candidates in a ``pm dump`` resolver capture (t16).
Deduplicated on (component, action, categories, scheme, authority, path),
deterministically ordered and capped — see ``rank_candidates``. An absent
section returns an empty list: this function reports WHAT IS THERE, and the
caller decides whether the capture was an answer at all (the manager checks
``has_activity_resolver_table`` first — a failed scan is not an answer and is
never cached as one).
"""
return list(parse_intent_report(output, limit=limit).candidates)
def intent_scan_command(package: str, *, line_limit: int = INTENT_SCAN_LINE_LIMIT) -> str:
"""The ON-DEVICE filter: only the resolver section crosses the wire (t16).
``pm dump <pkg>`` is a whole dumpsys (~2241 lines for one app on the user's
Shield), so the range is applied by the device's own ``sed`` and the answer is
additionally bounded, which is what makes the transfer proportional to the
resolver table rather than to the dump.
SAFETY: the package id is the only caller-supplied text in the command and it
is always ``shlex.quote``d; the sed script is built from the two constants
above (no interpolation of anything untrusted), so a package id containing
shell metacharacters cannot add a command — the fallback builder is quoted the
same way. A caller must still refuse an id that is not package-shaped: the
manager does (``is_launchable``).
"""
sed_range = f"/{SED_RANGE_START}/,/{SED_RANGE_END}/p"
return (
f"pm dump {shlex.quote(package)} 2>/dev/null | "
f'sed -n "{sed_range}" | '
f"head -n {int(line_limit)}"
)
def intent_scan_fallback_command(
package: str,
*,
line_limit: int = INTENT_SCAN_LINE_LIMIT,
) -> str:
"""The bounded ``grep -A`` fallback for a device without sed ranges (t16).
``pm dump <pkg> 2>/dev/null | grep -A <cap> -m 1 -F 'Activity Resolver Table:'``
— the same transfer bound as the primary command, applied with the one tool
every Android toolbox has. It is only ever a SECOND attempt: the manager runs
it when the primary answer did not contain the section at all, so a device
whose sed works never pays for it.
"""
return (
f"pm dump {shlex.quote(package)} 2>/dev/null | "
f"grep -A {int(line_limit)} -m 1 -F {shlex.quote(ACTIVITY_TABLE_HEADER)}"
)
@dataclass
class IntentCacheEntry:
"""One cached ANSWER: its state, its candidates and when it was written."""
state: str
candidates: tuple[IntentCandidate, ...]
stamp: float
truncated: bool = False
class IntentScanCache:
"""A per-package TTL cache of scans that RAN (t16).
The rule the discovery-cache defect taught us is structural here: ``put`` is
the ONLY writer, it is called only by a scan that ran, and it records which
of the two answers it holds — ``INTENT_ENTRY_CANDIDATES`` or
``INTENT_ENTRY_EMPTY``. A scan whose transport failed never reaches ``put``,
so nothing is cached and the next request retries instead of serving a
fabricated "this app advertises nothing" for the whole TTL.
A stale entry is DROPPED when it is read (never served), so callers see
either an answer inside its TTL or nothing at all.
"""
def __init__(self, *, ttl: float = INTENT_SCAN_TTL, clock=time.monotonic):
self._ttl = ttl
self._clock = clock
self._entries: dict[str, IntentCacheEntry] = {}
@property
def ttl(self) -> float:
return self._ttl
def get(self, package: str) -> IntentCacheEntry | None:
"""The fresh entry for a package, or None (dropping it when stale)."""
entry = self._entries.get(package)
if entry is None:
return None
if self._clock() - entry.stamp > self._ttl:
self._entries.pop(package, None)
return None
return entry
def put(
self,
package: str,
candidates: list[IntentCandidate] | tuple[IntentCandidate, ...],
*,
truncated: bool = False,
) -> IntentCacheEntry:
"""Record a scan that RAN: its candidates, or the empty ANSWER.
The state is derived, not passed: an empty candidate list means the
device answered with none (``INTENT_ENTRY_EMPTY``) — an answer worth
caching — while a non-empty list is ``INTENT_ENTRY_CANDIDATES``.
"""
saved = tuple(candidates)
entry = IntentCacheEntry(
state=INTENT_ENTRY_CANDIDATES if saved else INTENT_ENTRY_EMPTY,
candidates=saved,
stamp=self._clock(),
truncated=truncated,
)
self._entries[package] = entry
return entry
def state(self, package: str | None) -> str | None:
"""The cached entry's state, or None when no fresh answer is cached."""
if not package:
return None
entry = self.get(package)
return entry.state if entry is not None else None
def age(self, package: str) -> float | None:
"""How long ago the fresh entry was written, or None."""
entry = self.get(package)
return None if entry is None else self._clock() - entry.stamp
def clear(self) -> None:
"""Forget every entry (tests, and a device switch)."""
self._entries.clear()
@dataclass(frozen=True)
class IntentScanResult:
"""The answer to one scan request (t16), status included.
``candidates`` is only ever an ANSWER when ``answered`` is True. Every other
status means we do not know, which is why they all carry no candidates: an
ADB-down scan must not look like an app with no intents.
"""
package: str
status: str
candidates: tuple[IntentCandidate, ...] = ()
entry_state: str | None = None
age_seconds: float | None = None
truncated: bool = False
@property
def answered(self) -> bool:
"""Did the DEVICE answer? False means "unknown", never "no intents"."""
return self.status in _ANSWERED_STATUSES
@property
def has_candidates(self) -> bool:
"""Did the device answer WITH intents?"""
return bool(self.candidates)
def as_dict(self) -> dict:
"""A plain dict, for the options page, diagnostics and JSON."""
return {
"package": self.package,
"status": self.status,
"answered": self.answered,
"entry_state": self.entry_state,
"age_seconds": self.age_seconds,
"truncated": self.truncated,
"candidates": [candidate.as_dict() for candidate in self.candidates],
}