The t18 review's three findings, closed with the reviewer's own repro.
F1 (HIGH — the harm was real shells, not a wrong label). `async_scan_intents`
decided between `adb_disabled` and `adb_unavailable` INSIDE `if self._poller is
None:`, so a manager that HELD a poller fell through to the shell loop: with
`adb_enabled: False` and a connected poller the review measured status `fresh`,
one candidate CACHED, and `pm dump com.x …` really sent. The option is a fact
about the ENTRY, so it is now checked once at the top and returns before
anything else — no cache read, no cache write, no shell. The no-poller branch
keeps the OTHER honest answer: with the option ON and nobody to ask, the status
is `adb_unavailable` ("wait or reconnect"), never `adb_disabled` ("go turn the
option on"), because the two have different fixes. Five tests pin it, including
a cache-leak case (a manager with a cached answer whose option is then switched
off answers `adb_disabled`, not the cache) and both statuses with a FORCED scan.
F2 (low, but it fabricated a value that looked real). A capture cut mid-detail
produced `Scheme: "ht` → scheme `"ht` and `Authority: "www.exam` → that
authority, and `_candidate_data` would then prefill the form with a URL no app
can match. Not an injection (everything stays quoted when used), which is
exactly why nothing caught it. `_unquote` now DROPS a value that opens a quote
and never closes it (a lone quote too), so the callers turn it into None: the
candidate survives with its intact action/component and simply carries no
scheme/authority/path — and therefore prefills NO data instead of a broken URL.
A complete quoted value is still read (the anti-overcorrection pin), and the
consequence is pinned at the consumption end too, through the real options-flow
helper.
F3 (low). The floor comment now answers the review in writing, where the next
reader looks: the t18 review's measurement (awaited_calls 6 against a floor of 5,
three other counters within 2) is named, and the verdict is recorded — those ARE
the policy margins (2 for owner-counters because an owner really can be
refactored away, 1 for awaited_calls because six sites cannot carry a percentage
band). The counters were genuinely updated, not dodged: raised to the real sweep
when the code grew (20→24, 70→75, 235→275, 55→63, 1400→1620) and re-derived
twice since (t20 1620→1615, t21 1615→1617 for this unit's blocks).
EVIDENCE THAT THE TESTS PIN THE FIX (not merely pass): the same files were run
against the PRE-fix source (git archive HEAD, a throwaway copy under /tmp) — 9 of
the 11 new tests FAIL there, including the reviewer's repro, which fails with
`assert 'fresh' == 'adb_disabled'`. The two that pass are the deliberate
anti-overcorrection pins: a COMPLETE quoted value is still parsed, and the
no-poller control keeps its own status.
NO REGRESSION: 900 passed / 5 skipped, 901 / 4 with androidtvremote2; the
injection proof, the fixture's candidates with dedup/flagging/order/bound
disclosures, both cache outcomes, the registration round trip through the real
FlowManager, button lifecycle and defaults, the YAML seed rule and §7.1
(supports_response optional, a real return_response call, a no-response call) all
still pass. The strings files are byte-identical (untouched here). Real-HA pattern
collected and RAN 3 files → 74 passed / 0 skipped. Drift exit 0 (1650 blocks).
NOT VERIFIED HERE: the device side remains a scripted double — no real Shield was
asked for its resolver table, so the shell COUNT and the status vocabulary are
what is proven, not real firmware behaviour. No browser either: the form prefill
is asserted on the values the helper returns.
706 lines
28 KiB
Python
706 lines
28 KiB
Python
"""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; DROP an unterminated one (t21/F2).
|
|
|
|
``"x"`` -> ``x``. A value that OPENS a quote and never closes it is not a
|
|
value we know: the capture was cut mid-detail (``Scheme: "ht``,
|
|
``Authority: "www.exam``, ``Path: "PatternMatcher{GLOB: /title``), and
|
|
believing the leftover produced a scheme, host or pattern that LOOKS real
|
|
while no app can match it — `_candidate_data` would then prefill the form
|
|
with a URL that cannot work. An unterminated value is therefore dropped
|
|
(empty here; the callers turn that into None), so a corrupt detail is never
|
|
mistaken for a real one. It was never an injection either way (every value is
|
|
quoted when it is used), which is exactly why the failure was invisible.
|
|
"""
|
|
text = (value or "").strip()
|
|
if text[:1] in ("'", '"'):
|
|
if len(text) >= 2 and text[-1] == text[0]:
|
|
return text[1:-1].strip()
|
|
return ""
|
|
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],
|
|
}
|