"""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 ``. 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 2>/dev/null \ | sed -n "/Activity Resolver Table/,/Service Resolver Table/p" \ | head -n 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.*?)\s*(?P[0-9a-fA-F]{6,})\s+" r"(?P[A-Za-z_][\w.]*/[\w.$]+)" r"(?:\s+filter\s+(?P[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[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*(?PAction|Category|Scheme|Authority|Path)\s*:\s*(?P.+?)\s*$" ) #: ``Authority: "www.netflix.com": -1`` — the authority, not its weight. _AUTHORITY_RE = re.compile(r'^"(?P[^"]*)"\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[A-Za-z_]+)\s*[:,]?\s*(?P.*)\}$" ) #: 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 `` 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 2>/dev/null | grep -A -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], }