feat(intents): discover an app's intents from its activity resolver table (t16)
First half of the approved intents feature (docs/INTENTS_PLAN.md §2): the scan
that answers "what can this app actually be asked to do". No UI is wired here —
this is the discovery module, the manager path it rides on, and the tests.
THE DUMP IS FILTERED ON THE DEVICE. On the user's Shield `pm dump
com.netflix.ninja` is ~2241 lines of whole dumpsys for ONE app, so shipping it
over ADB to read a few dozen resolver lines repeats the mistake `| head -300`
made for app labels (t66 — where a truncation artefact was read as "this app has
no label"). The fetch now sends:
pm dump <pkg> 2>/dev/null | sed -n "/Activity Resolver Table/,/Service Resolver Table/p" | head -n 400
with a bounded `grep -A 400 -m 1 -F 'Activity Resolver Table:'` fallback for a
device whose toolbox cannot do sed ranges, tried only when the primary answer did
not contain the section. The package id is the only caller-supplied text in
either command and is always shlex.quote'd, and the sed script is built from two
constants — tests drive eight hostile package ids (semicolons, &&, backticks,
$(), pipes, both quote kinds, a newline) and assert that the shell sees the
package as exactly one verbatim token and that no extra command token appears.
WHAT A CANDIDATE CARRIES: component, action, categories and the data
(scheme/authority/path, plus the matcher kind — `/title.*` is a GLOB, not a
path, and a caller that treats it as literal builds the wrong URI). A filter
carrying several schemes/authorities/paths expands to one candidate per
combination, because each is a concrete match, and dedup collapses the
coincidences. MIME keys are deliberately NOT modelled (they are not one of §3's
fields) — such a filter is still reported through its component, action and
categories rather than being invented into a URI.
RANK, NEVER HIDE. The four families the plan names as internal-looking noise
(DEBUG_*, androidx.*, com.google.firebase.*, ConstraintProxy*) are FLAGGED, not
deleted: they sort last so the useful rows come first, and the user still
decides. Ordering is total (flag, action, scheme, authority, path, component,
categories) so a re-scan is byte-identical, and the 200-candidate cap is applied
after dedup and DISCLOSED through truncated/found — a capped answer never looks
complete.
THE CACHE KEEPS AN ANSWER AND A FAILURE APART, structurally: put() is its only
writer, only a scan that RAN calls it, and it derives the entry's state — an
empty candidate list is INTENT_ENTRY_EMPTY, a real ANSWER the device gave, while
a scan whose transport failed caches nothing and is retried. A stale entry is
dropped on read rather than served. The scan itself has one status per outcome
(fresh / fresh_empty / cached / adb_disabled / adb_unavailable / unreadable /
invalid_package) so "ADB is off", "the device did not answer" and "this app
advertises nothing" can never be confused, and the not-answered statuses carry no
candidates at all — an ADB-down scan cannot look like an app with no intents.
ADB disabled and ADB unreachable are told apart from the entry's own option.
Discovery is EXPLICIT and single: one package per request, never a sweep over the
installed apps, and a test drives a watchdog cycle and asserts it starts no scan.
Concurrent requests for the same package share one dump.
THE FIXTURE IS A TRANSCRIPTION, NOT IMAGINATION. tests/fixtures/
pm_dump_netflix_resolver.txt reproduces the user's 2026-10-12 capture structure
(whole-dumpsys envelope, the activity table with its Schemes/Non-Data Actions
groups, the receiver/service/provider tables that bound it) and the test file's
docstring states line by line what is VERBATIM from the ticket/plan and what is
reconstructed — no real Shield was available, and the raw capture is not in this
repository (the same limit t13 recorded). The capture's own entries are each
pinned by an assertion: the cast entry, http/https with the four categories and
the five www.netflix.com path globs, nflx:, netflix:, MAIN with LEANBACK_LAUNCHER
+ LAUNCHER, NETFLIX_KEY_START, DIAL_START and the two DEBUG_* noise actions —
18 candidates, deduplicated, with the receiver/provider tables proven unread.
Evidence: 59 new tests in tests/test_intents.py (parser against the transcribed
capture, degradation on shapes never seen, the command shape and quoting safety,
the cache states and TTL, and the manager: one shell, cached, retried on failure,
fallback recovery, ADB-off versus ADB-down, the cap disclosed, the watchdog
silent, concurrent sharing). No device was available: the parser is proven
against the transcription, and every "runs/does not run" claim is an assertion on
the poller's recorded commands rather than on intentions.
Verify: suites 778/4 and 779/3; ruff clean; drift exit 0 (26 modules, 76 imports,
242 relative imports, 1417 statement blocks, 57 call arguments) with the
relative-import floor raised 195 -> 235 and the statement-block floor 1300 ->
1400 by the new module rather than dodged; the real-HA glob still runs green
(56 passed, 0 skipped).
This commit is contained in:
1 parent
14c168c25e
commit
91d9fb3883
6 files changed
+1869
-5
No files matched your search
@@ -0,0 +1,850 @@
|
||||
"""tests/test_intents.py — intent discovery from the resolver table (t16).
|
||||
|
||||
THE FIXTURE IS A TRANSCRIPTION, NOT IMAGINATION. ``tests/fixtures/
|
||||
pm_dump_netflix_resolver.txt`` reproduces the ``pm dump com.netflix.ninja``
|
||||
capture the user took on 2026-10-12 (the structure is recorded in
|
||||
``docs/INTENTS_PLAN.md`` §1 and the ticket). No real Shield was available while
|
||||
this was written, and the raw ~2241-line capture text is NOT in this repository
|
||||
either (the same limit t13 recorded for its own Netflix fixture), so the fixture
|
||||
is a faithful reproduction of the RECORDED structure rather than the capture
|
||||
itself. Being precise about that, because a parser "proven" against invented
|
||||
output is worth nothing:
|
||||
|
||||
* VERBATIM (quoted in the ticket/plan, reproduced character for character as
|
||||
they appear there): the section heading ``Activity Resolver Table:``; the
|
||||
bound ``Receiver Resolver Table:`` / ``Service Resolver Table:``; the entry
|
||||
form ``deca7fa com.netflix.ninja/.MainActivity filter fc483a1`` (hex id,
|
||||
component, ``filter`` id); the detail lines ``Action:``, ``Category:``,
|
||||
``Scheme:``, ``Authority: "www.netflix.com": -1`` and
|
||||
``Path: "PatternMatcher{GLOB: /title.*}"``; the cast entry
|
||||
(``com.google.cast.action.START`` + ``DEFAULT``); the ``http``/``https``
|
||||
entries (``android.intent.action.VIEW`` + ``DEFAULT`` + ``LEANBACK_LAUNCHER``
|
||||
+ ``LAUNCHER`` + ``BROWSABLE``) with the ``www.netflix.com`` authority and the
|
||||
``/title.*``, ``/watch.*``, ``/browse``, ``/home``, ``/deeplink.*`` paths; the
|
||||
``nflx:``/``netflix:`` schemes; the ``Non-Data Actions:`` group with
|
||||
``android.intent.action.MAIN`` (``LEANBACK_LAUNCHER`` + ``LAUNCHER``),
|
||||
``com.netflix.action.NETFLIX_KEY_START`` and ``com.netflix.action.DIAL_START``;
|
||||
and the noise actions ``com.netflix.action.DEBUG_PUSH_NOTIFICATION`` /
|
||||
``com.netflix.action.DEBUG_FORCE_EXIT``.
|
||||
* RECONSTRUCTED from the recorded structure (NOT claimed as capture text): the
|
||||
whole-dumpsys envelope lines (``DUMP OF SERVICE package:``/``activity``/
|
||||
``meminfo``/``procstats``/``usagestats``/``batterystats``), the exit order of
|
||||
the receiver/service/provider tables, the package block
|
||||
(``nonLocalizedLabel=null``, ``labelRes=0x7f0f0d31`` — the shapes t13's fixture
|
||||
already records), and the exact INDENTATION (the capture is not in the repo, so
|
||||
the nesting depth is the shape ``IntentResolver`` documents).
|
||||
* THE SIZE. The capture is ~2241 lines; the fixture is 101, so the "only the
|
||||
section crosses the wire" test PADS the fixture to the observed size with
|
||||
filler (``_whole_dump``) rather than pretending a 135-line file is a 2241-line
|
||||
dump. The capture's other 2000+ lines are four service sections and per-package
|
||||
blocks, none of which the resolver run reads.
|
||||
* Filler is always labelled, and one test below ("the file is pure capture
|
||||
text") asserts the fixture holds no prose of ours.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from nsh.connection import ShieldConnectionManager
|
||||
from nsh.const import CONF_ADB_ENABLED
|
||||
from nsh.ha_adb_common 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_UNREADABLE,
|
||||
INTERNAL_MARKERS,
|
||||
IntentScanCache,
|
||||
activity_resolver_section,
|
||||
has_activity_resolver_table,
|
||||
intent_scan_command,
|
||||
intent_scan_fallback_command,
|
||||
is_likely_internal,
|
||||
parse_activity_resolver_table,
|
||||
parse_intent_report,
|
||||
)
|
||||
|
||||
FIXTURE = Path(__file__).resolve().parent / "fixtures" / "pm_dump_netflix_resolver.txt"
|
||||
NETFLIX = "com.netflix.ninja"
|
||||
NETFLIX_DUMP = FIXTURE.read_text(encoding="utf-8")
|
||||
|
||||
#: The components the capture's ACTIVITY table names (both real: the app's main
|
||||
#: activity and its debug receiver).
|
||||
MAIN_ACTIVITY = "com.netflix.ninja/.MainActivity"
|
||||
DEBUG_RECEIVER = "com.netflix.ninja/.debug.DebugReceiver"
|
||||
|
||||
#: The five paths on the http/https filter, with the matcher kind the capture
|
||||
#: prints next to each (a GLOB is not a path — see the candidate docstring).
|
||||
NETFLIX_PATHS = (
|
||||
("/title.*", "GLOB"),
|
||||
("/watch.*", "GLOB"),
|
||||
("/browse", "LITERAL"),
|
||||
("/home", "LITERAL"),
|
||||
("/deeplink.*", "GLOB"),
|
||||
)
|
||||
|
||||
#: The 18 candidates the capture's activity table yields: 5 paths x 2 schemes for
|
||||
#: the VIEW filter, the cast/netflix/nflx entries, and the five Non-Data Actions.
|
||||
EXPECTED_CANDIDATES = 18
|
||||
|
||||
|
||||
def _whole_dump(padding_lines: int = 2200) -> str:
|
||||
"""The fixture padded to the OBSERVED capture size (~2241 lines).
|
||||
|
||||
The filler is explicitly not capture text: it is what the four trailing
|
||||
service sections and the per-package blocks look like at scale, and it exists
|
||||
so the transfer test measures the real problem rather than a toy one. Lines
|
||||
are shaped so a naive parser could pick them up if the on-device filter were
|
||||
missing (they look like resolver entries for another package).
|
||||
"""
|
||||
filler = "\n".join(
|
||||
f" Package [com.example.filler{n}] (7f0f{n:04d}):\n"
|
||||
f" versionName=1.0.{n} codePath=/data/app/com.example.filler{n}\n"
|
||||
f"Activity Resolver Table:\n"
|
||||
f" Schemes:\n"
|
||||
f" custom{n}: deca7fa com.example.filler{n}/.OtherActivity "
|
||||
f"filter fc{n:04d}\n"
|
||||
f" Action: \"com.example.filler{n}.ACTION\"\n"
|
||||
for n in range(padding_lines // 6)
|
||||
)
|
||||
return f"{NETFLIX_DUMP}\n{filler}\n"
|
||||
|
||||
|
||||
def _on_device_sed_range(dump: str) -> str:
|
||||
"""What the WIRE carries once the device applies the sed range (t16).
|
||||
|
||||
A fake that handed the parser the whole dump would only prove the parser
|
||||
tolerates it — not that the manager never had to ship it. This emulates the
|
||||
shipped pipeline: the sed range first, then the ``head -n`` bound.
|
||||
"""
|
||||
start = dump.find("Activity Resolver Table:")
|
||||
assert start >= 0
|
||||
end = len(dump)
|
||||
for heading in ("Receiver Resolver Table:", "Service Resolver Table:"):
|
||||
found = dump.find(heading, start)
|
||||
if 0 <= found < end:
|
||||
end = found
|
||||
section = dump[start:end]
|
||||
lines = section.splitlines()[:400]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
class _IntentsPoller:
|
||||
"""A poller whose shell answers the resolver commands like the device would.
|
||||
|
||||
``answers`` maps a substring of the command to the answer, so a test can put
|
||||
a specific shape on the wire (the section, an error line, nothing at all) and
|
||||
know which command produced it. Every command is recorded: the cost claims in
|
||||
this file are assertions on that list, not on intentions.
|
||||
"""
|
||||
|
||||
def __init__(self, *, answers=None, default=None, raise_on=None, connected=True):
|
||||
self.answers = dict(answers or {})
|
||||
self.default = default
|
||||
self.raise_on = raise_on
|
||||
self.connected = connected
|
||||
self.shell_calls: list[str] = []
|
||||
|
||||
async def shell(self, command: str):
|
||||
self.shell_calls.append(command)
|
||||
for needle, answer in self.answers.items():
|
||||
if needle in command:
|
||||
if isinstance(answer, Exception):
|
||||
raise answer
|
||||
return answer
|
||||
if self.raise_on is not None and self.raise_on in command:
|
||||
raise RuntimeError("adb shell failed")
|
||||
return self.default
|
||||
|
||||
@property
|
||||
def section_commands(self) -> list[str]:
|
||||
"""The commands that actually asked for the resolver section."""
|
||||
return [cmd for cmd in self.shell_calls if "Activity Resolver Table" in cmd]
|
||||
|
||||
|
||||
def _entry(*, adb_enabled=True):
|
||||
return SimpleNamespace(
|
||||
options={CONF_ADB_ENABLED: adb_enabled},
|
||||
data={},
|
||||
title="Shield",
|
||||
entry_id="e1",
|
||||
)
|
||||
|
||||
|
||||
def _manager(poller, *, adb_enabled=True):
|
||||
return ShieldConnectionManager(
|
||||
hass=None, entry=_entry(adb_enabled=adb_enabled), poller=poller
|
||||
)
|
||||
|
||||
|
||||
# ── the fixture itself ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTheFixtureIsHonest:
|
||||
"""The fixture is a transcription — and it has to LOOK like one."""
|
||||
|
||||
def test_it_is_a_whole_dumpsys_not_just_the_section(self):
|
||||
# the shape the ticket records: the package dump, then the four trailing
|
||||
# service sections. A fixture that held only the resolver table would
|
||||
# make the on-device filter look pointless.
|
||||
assert "DUMP OF SERVICE package:" in NETFLIX_DUMP
|
||||
for service in ("activity", "meminfo", "procstats", "usagestats", "batterystats"):
|
||||
assert f"DUMP OF SERVICE {service}:" in NETFLIX_DUMP, service
|
||||
|
||||
def test_it_holds_no_prose_of_ours(self):
|
||||
# the honesty notes live HERE, not in the capture: a fixture with our
|
||||
# commentary inside it could not be compared with real device output
|
||||
assert "t16" not in NETFLIX_DUMP
|
||||
assert "NOTE" not in NETFLIX_DUMP
|
||||
assert "reconstruct" not in NETFLIX_DUMP.lower()
|
||||
|
||||
def test_the_section_bounds_the_capture_records_are_present(self):
|
||||
assert "Receiver Resolver Table:" in NETFLIX_DUMP
|
||||
assert "Service Resolver Table:" in NETFLIX_DUMP
|
||||
assert NETFLIX_DUMP.count("Activity Resolver Table:") == 2 # + the package block
|
||||
|
||||
def test_the_fixture_stays_a_trimmed_reproduction(self):
|
||||
# the docstring states the fixture's size; keeping it small is what makes
|
||||
# the padding note in _whole_dump honest rather than rhetorical
|
||||
assert 80 < len(NETFLIX_DUMP.splitlines()) < 200
|
||||
def test_the_capture_is_far_bigger_than_its_resolver_section(self):
|
||||
whole = _whole_dump()
|
||||
filtered = _on_device_sed_range(whole)
|
||||
|
||||
assert len(whole.splitlines()) > 2000, "the observed size is ~2241 lines"
|
||||
assert len(filtered.splitlines()) < 120, "only the resolver section crosses"
|
||||
assert "com.example.filler1" not in filtered, "the filter really filters"
|
||||
|
||||
|
||||
# ── the parser ────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def candidates():
|
||||
"""The capture's candidates, parsed once for the whole module."""
|
||||
return parse_activity_resolver_table(NETFLIX_DUMP)
|
||||
|
||||
|
||||
class TestTheParserFindsTheCapturedEntries:
|
||||
"""Every entry the ticket names is found, with the fields it carries."""
|
||||
|
||||
def test_the_cast_entry(self, candidates):
|
||||
cast = [
|
||||
c
|
||||
for c in candidates
|
||||
if c.action == "com.google.cast.action.START" and c.scheme == "cast"
|
||||
]
|
||||
assert len(cast) == 1
|
||||
assert cast[0].component == MAIN_ACTIVITY
|
||||
assert cast[0].categories == ("android.intent.category.DEFAULT",)
|
||||
|
||||
def test_the_http_and_https_entries(self, candidates):
|
||||
for scheme in ("http", "https"):
|
||||
view = [
|
||||
c
|
||||
for c in candidates
|
||||
if c.action == "android.intent.action.VIEW" and c.scheme == scheme
|
||||
]
|
||||
assert len(view) == len(NETFLIX_PATHS), scheme
|
||||
assert {c.authority for c in view} == {"www.netflix.com"}
|
||||
assert {c.component for c in view} == {MAIN_ACTIVITY}
|
||||
# the categories the capture prints on both, in capture order
|
||||
assert view[0].categories == (
|
||||
"android.intent.category.DEFAULT",
|
||||
"android.intent.category.BROWSABLE",
|
||||
"android.intent.category.LEANBACK_LAUNCHER",
|
||||
"android.intent.category.LAUNCHER",
|
||||
)
|
||||
|
||||
def test_the_path_globs_are_kept_with_their_matcher_kind(self, candidates):
|
||||
view = [
|
||||
c
|
||||
for c in candidates
|
||||
if c.action == "android.intent.action.VIEW" and c.scheme == "https"
|
||||
]
|
||||
assert {(c.path, c.path_kind) for c in view} == set(NETFLIX_PATHS)
|
||||
|
||||
def test_the_custom_schemes(self, candidates):
|
||||
by_scheme = {c.scheme for c in candidates}
|
||||
assert {"netflix", "nflx"} <= by_scheme
|
||||
for scheme in ("netflix", "nflx"):
|
||||
entry = [c for c in candidates if c.scheme == scheme]
|
||||
assert len(entry) == 1
|
||||
assert entry[0].action == "android.intent.action.VIEW"
|
||||
assert entry[0].authority is None and entry[0].path is None
|
||||
|
||||
def test_the_non_data_actions(self, candidates):
|
||||
by_action = {c.action for c in candidates if not c.scheme}
|
||||
assert {
|
||||
"android.intent.action.MAIN",
|
||||
"com.netflix.action.NETFLIX_KEY_START",
|
||||
"com.netflix.action.DIAL_START",
|
||||
} <= by_action
|
||||
main = [c for c in candidates if c.action == "android.intent.action.MAIN"]
|
||||
assert len(main) == 1
|
||||
assert main[0].categories == (
|
||||
"android.intent.category.LEANBACK_LAUNCHER",
|
||||
"android.intent.category.LAUNCHER",
|
||||
)
|
||||
assert main[0].scheme is None and main[0].authority is None
|
||||
|
||||
def test_the_authority_keeps_its_name_and_drops_its_weight(self, candidates):
|
||||
assert "www.netflix.com" in {c.authority for c in candidates}
|
||||
assert not any(
|
||||
c.authority and ":" in c.authority for c in candidates
|
||||
), "the filter's score (-1) is not part of the authority"
|
||||
|
||||
def test_the_two_components_are_the_captures(self, candidates):
|
||||
assert {c.component for c in candidates} == {MAIN_ACTIVITY, DEBUG_RECEIVER}
|
||||
|
||||
def test_the_count_is_exactly_the_capture_18(self, candidates):
|
||||
assert len(candidates) == EXPECTED_CANDIDATES
|
||||
|
||||
def test_deduplication_is_real(self, candidates):
|
||||
keys = [c.key for c in candidates]
|
||||
assert len(set(keys)) == len(keys) == EXPECTED_CANDIDATES
|
||||
|
||||
def test_the_receiver_service_and_provider_tables_are_not_read(self, candidates):
|
||||
text = " ".join(f"{c.component} {c.action}" for c in candidates)
|
||||
for foreign in (
|
||||
"androidx.work.impl.diagnostics.DiagnosticsReceiver",
|
||||
"com.google.firebase.messaging.FirebaseMessagingService",
|
||||
"com.google.firebase.iid.FirebaseInstanceIdService",
|
||||
"androidx.startup.InitializationProvider",
|
||||
"com.netflix.ninja/.push.DebugPushReceiver",
|
||||
):
|
||||
assert foreign not in text, foreign
|
||||
|
||||
|
||||
class TestRankingAndHonesty:
|
||||
"""Order, the cap, and the noise flag — never a silent deletion."""
|
||||
|
||||
def test_the_order_is_deterministic(self):
|
||||
first = parse_activity_resolver_table(NETFLIX_DUMP)
|
||||
second = parse_activity_resolver_table(NETFLIX_DUMP)
|
||||
|
||||
assert [c.key for c in first] == [c.key for c in second]
|
||||
|
||||
def test_noise_is_flagged_and_kept(self):
|
||||
candidates = parse_activity_resolver_table(NETFLIX_DUMP)
|
||||
debug = [c for c in candidates if "DEBUG_" in (c.action or "")]
|
||||
|
||||
assert len(debug) == 2, "both DEBUG_* actions from the capture are present"
|
||||
assert all(c.likely_internal for c in debug), "and both are flagged"
|
||||
assert candidates[-2:] == sorted(
|
||||
debug, key=lambda c: c.action or ""
|
||||
), "flagged candidates are ranked last, not hidden"
|
||||
|
||||
def test_useful_candidates_come_first(self):
|
||||
candidates = parse_activity_resolver_table(NETFLIX_DUMP)
|
||||
|
||||
assert candidates[0].action == "android.intent.action.MAIN"
|
||||
assert not candidates[0].likely_internal
|
||||
|
||||
def test_the_cap_is_applied_after_dedup_and_disclosed(self):
|
||||
report = parse_intent_report(NETFLIX_DUMP, limit=3)
|
||||
|
||||
assert len(report.candidates) == 3
|
||||
assert report.found == EXPECTED_CANDIDATES
|
||||
assert report.truncated is True
|
||||
|
||||
def test_without_a_cap_nothing_is_truncated(self):
|
||||
report = parse_intent_report(NETFLIX_DUMP)
|
||||
|
||||
assert report.found == len(report.candidates) == EXPECTED_CANDIDATES
|
||||
assert report.truncated is False
|
||||
assert INTENT_CANDIDATE_LIMIT > EXPECTED_CANDIDATES
|
||||
|
||||
def test_the_four_marker_families_are_the_plans(self):
|
||||
# the ticket's list, pinned so a marker cannot be quietly dropped
|
||||
assert INTERNAL_MARKERS == (
|
||||
"DEBUG_",
|
||||
"androidx.",
|
||||
"com.google.firebase.",
|
||||
"ConstraintProxy",
|
||||
)
|
||||
|
||||
def test_the_other_marker_families_are_flagged_not_dropped(self):
|
||||
"""The three families NOT in this capture's activity table.
|
||||
|
||||
On the capture they appear in the receiver/provider tables (which the
|
||||
parser does not read), so they are proven on a labelled SYNTHETIC section
|
||||
rather than smuggled into the transcription.
|
||||
"""
|
||||
section = (
|
||||
"Activity Resolver Table:\n"
|
||||
" Non-Data Actions:\n"
|
||||
" androidx.work.DiagnosticsReportReceiver:\n"
|
||||
" deca7fa com.netflix.ninja/androidx.work.impl.\n"
|
||||
"DiagnosticsReceiver filter fc483b1\n"
|
||||
" Action: \"androidx.work.Diagnostics.REPORT\"\n"
|
||||
" com.google.firebase.MESSAGING_EVENT:\n"
|
||||
" deca7fa com.netflix.ninja/.PushService filter fc483b2\n"
|
||||
" Action: \"com.google.firebase.MESSAGING_EVENT\"\n"
|
||||
" com.netflix.action.NORMAL:\n"
|
||||
" deca7fa com.netflix.ninja/.MainActivity filter fc483b3\n"
|
||||
" Action: \"ConstraintProxy.Normal\"\n"
|
||||
"Service Resolver Table:\n"
|
||||
)
|
||||
candidates = parse_activity_resolver_table(section)
|
||||
|
||||
assert len(candidates) == 3, "nothing was deleted"
|
||||
assert all(c.likely_internal for c in candidates)
|
||||
assert is_likely_internal("com.netflix.action.NORMAL", None) is False
|
||||
assert is_likely_internal(None, "com.x/ConstraintProxy$Foo") is True
|
||||
|
||||
|
||||
class TestDegradation:
|
||||
"""A shape we have never seen degrades to NO candidate, never a wrong one."""
|
||||
|
||||
def test_an_absent_section_is_not_an_answer(self):
|
||||
for output in (None, "", "Unable to find package: com.netflix.ninja\n", " random"):
|
||||
assert has_activity_resolver_table(output) is False
|
||||
assert parse_activity_resolver_table(output) == []
|
||||
|
||||
def test_a_section_with_no_entries_is_an_answer(self):
|
||||
empty = (
|
||||
"Activity Resolver Table:\n"
|
||||
"Service Resolver Table:\n"
|
||||
" Non-Data Actions:\n"
|
||||
" com.x.ACTION:\n"
|
||||
" deca7fa com.x/.Y filter fc1\n"
|
||||
" Action: \"com.x.ACTION\"\n"
|
||||
)
|
||||
# the section IS there and holds nothing: an answer, and the service
|
||||
# table's filter must not leak into it
|
||||
assert has_activity_resolver_table(empty) is True
|
||||
assert parse_activity_resolver_table(empty) == []
|
||||
|
||||
def test_a_group_key_is_the_action_only_when_it_is_action_shaped(self):
|
||||
action_keyed = (
|
||||
"Activity Resolver Table:\n"
|
||||
" Non-Data Actions:\n"
|
||||
" com.netflix.action.KEY_ONLY:\n"
|
||||
" deca7fa com.netflix.ninja/.MainActivity filter fc1\n"
|
||||
"Service Resolver Table:\n"
|
||||
)
|
||||
scheme_keyed = (
|
||||
"Activity Resolver Table:\n"
|
||||
" Schemes:\n"
|
||||
" cast: deca7fa com.netflix.ninja/.MainActivity filter fc2\n"
|
||||
"Service Resolver Table:\n"
|
||||
)
|
||||
|
||||
assert [c.action for c in parse_activity_resolver_table(action_keyed)] == [
|
||||
"com.netflix.action.KEY_ONLY"
|
||||
]
|
||||
# "cast" is a scheme key, not an action: no action is invented from it
|
||||
assert [c.action for c in parse_activity_resolver_table(scheme_keyed)] == [None]
|
||||
assert [c.scheme for c in parse_activity_resolver_table(scheme_keyed)] == [None]
|
||||
|
||||
def test_a_filter_without_a_component_yields_nothing(self):
|
||||
noise = (
|
||||
"Activity Resolver Table:\n"
|
||||
" Schemes:\n"
|
||||
" https:\n"
|
||||
" Action: \"android.intent.action.VIEW\"\n"
|
||||
" Scheme: \"acme\"\n"
|
||||
"Service Resolver Table:\n"
|
||||
)
|
||||
# no entry line at all -> nothing to attach those details to
|
||||
assert parse_activity_resolver_table(noise) == []
|
||||
|
||||
def test_the_section_helper_stops_at_the_first_table(self):
|
||||
section = activity_resolver_section(NETFLIX_DUMP)
|
||||
|
||||
assert section is not None
|
||||
assert section.startswith("\n Schemes:")
|
||||
assert "Receiver Resolver Table:" not in section
|
||||
assert activity_resolver_section("nothing here") is None
|
||||
|
||||
|
||||
# ── the command shape and its safety ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestTheOnDeviceFilter:
|
||||
"""The dump is filtered on the device — and the command is safe."""
|
||||
|
||||
def test_the_primary_command_is_a_device_side_range(self):
|
||||
command = intent_scan_command(NETFLIX)
|
||||
|
||||
assert command.startswith("pm dump ")
|
||||
assert "sed -n" in command
|
||||
# the plan's own line, recognisable in a review
|
||||
assert "/Activity Resolver Table/,/Service Resolver Table/p" in command
|
||||
assert "head -n 400" in command
|
||||
assert "head -300" not in command, "the t66 truncation mistake, not repeated"
|
||||
assert "2>/dev/null" in command
|
||||
|
||||
def test_the_fallback_command_is_a_bounded_grep(self):
|
||||
command = intent_scan_fallback_command(NETFLIX)
|
||||
|
||||
assert "grep -A 400 -m 1 -F" in command
|
||||
assert "'Activity Resolver Table:'" in command
|
||||
assert "sed" not in command
|
||||
|
||||
def test_both_commands_quote_the_package(self):
|
||||
for command in (intent_scan_command(NETFLIX), intent_scan_fallback_command(NETFLIX)):
|
||||
assert f"pm dump {shlex.quote(NETFLIX)} " in command
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"package",
|
||||
[
|
||||
"com.evil; touch /tmp/owned",
|
||||
"com.evil && reboot",
|
||||
"com.evil`id`",
|
||||
"com.evil$(rm -rf /)",
|
||||
"com.evil|cat /etc/passwd",
|
||||
"com.evil'quote",
|
||||
'com.evil"quote',
|
||||
"com.evil\nreboot",
|
||||
],
|
||||
)
|
||||
def test_a_hostile_package_cannot_add_a_command(self, package):
|
||||
for command in (intent_scan_command(package), intent_scan_fallback_command(package)):
|
||||
tokens = shlex.split(command)
|
||||
# the shell sees the package as exactly ONE token, verbatim: nothing
|
||||
# it contains can become a command, an operator or a redirection
|
||||
assert tokens[2] == package, tokens
|
||||
assert tokens[0] == "pm" and tokens[1] == "dump"
|
||||
for forbidden in ("touch", "rm", "reboot", "id", "cat", "bash", "sh"):
|
||||
assert forbidden not in tokens, (package, forbidden, tokens)
|
||||
# ...and it only ever appears inside a quoted word of the raw command
|
||||
# ...and the package is quoted by shlex, byte for byte
|
||||
assert f"pm dump {shlex.quote(package)} " in command
|
||||
|
||||
def test_the_transfer_is_proportional_to_the_section(self):
|
||||
whole = _whole_dump()
|
||||
assert len(whole.splitlines()) > 2000
|
||||
|
||||
# the emulated device-side filter, then the parser
|
||||
filtered = _on_device_sed_range(whole)
|
||||
candidates = parse_activity_resolver_table(filtered)
|
||||
|
||||
assert len(candidates) == EXPECTED_CANDIDATES
|
||||
assert len(filtered) < len(whole) / 10, "the wire carries the section, not the dump"
|
||||
|
||||
|
||||
# ── the cache: an answer versus a failure ─────────────────────────────────
|
||||
|
||||
|
||||
class TestTheCacheKeepsTheTwoApart:
|
||||
"""Only a scan that RAN writes an entry — and the entry says which answer."""
|
||||
|
||||
def test_a_scan_that_found_candidates_says_so(self):
|
||||
clock = [1000.0]
|
||||
cache = IntentScanCache(clock=lambda: clock[0])
|
||||
candidates = parse_activity_resolver_table(NETFLIX_DUMP)
|
||||
|
||||
entry = cache.put(NETFLIX, candidates)
|
||||
|
||||
assert entry.state == INTENT_ENTRY_CANDIDATES
|
||||
assert cache.state(NETFLIX) == INTENT_ENTRY_CANDIDATES
|
||||
assert len(cache.get(NETFLIX).candidates) == EXPECTED_CANDIDATES
|
||||
|
||||
def test_a_scan_that_ran_and_found_nothing_is_an_answer(self):
|
||||
cache = IntentScanCache()
|
||||
|
||||
entry = cache.put(NETFLIX, [])
|
||||
|
||||
assert entry.state == INTENT_ENTRY_EMPTY
|
||||
assert entry.candidates == ()
|
||||
assert cache.state(NETFLIX) == INTENT_ENTRY_EMPTY, (
|
||||
"an empty answer is cached: the device answered and the answer is 'none'"
|
||||
)
|
||||
|
||||
def test_a_failed_scan_is_not_cached_at_all(self):
|
||||
cache = IntentScanCache()
|
||||
|
||||
# a failure never reaches put() — that is the whole design — so there is
|
||||
# nothing to read back, not even an empty answer
|
||||
assert cache.get(NETFLIX) is None
|
||||
assert cache.state(NETFLIX) is None
|
||||
|
||||
def test_a_stale_entry_is_dropped_rather_than_served(self):
|
||||
clock = [1000.0]
|
||||
cache = IntentScanCache(ttl=60.0, clock=lambda: clock[0])
|
||||
cache.put(NETFLIX, parse_activity_resolver_table(NETFLIX_DUMP))
|
||||
|
||||
clock[0] += 61.0
|
||||
|
||||
assert cache.get(NETFLIX) is None
|
||||
assert cache.state(NETFLIX) is None
|
||||
|
||||
def test_the_ttl_is_the_module_constant(self):
|
||||
assert IntentScanCache().ttl > 0
|
||||
|
||||
def test_the_cap_travels_with_the_entry(self):
|
||||
clock = [1000.0]
|
||||
cache = IntentScanCache(clock=lambda: clock[0])
|
||||
report = parse_intent_report(NETFLIX_DUMP, limit=2)
|
||||
|
||||
entry = cache.put(NETFLIX, report.candidates, truncated=report.truncated)
|
||||
|
||||
assert entry.truncated is True
|
||||
assert len(entry.candidates) == 2
|
||||
|
||||
|
||||
# ── the manager: one package, on demand, over ADB ─────────────────────────
|
||||
|
||||
|
||||
class TestTheManagerScan:
|
||||
"""The scan is explicit, single, cached, and honest about failure."""
|
||||
|
||||
async def test_a_scan_returns_the_candidates_and_caches_them(self):
|
||||
poller = _IntentsPoller(default=NETFLIX_DUMP)
|
||||
manager = _manager(poller)
|
||||
|
||||
first = await manager.async_scan_intents(NETFLIX)
|
||||
|
||||
assert first.status == INTENT_SCAN_FRESH
|
||||
assert first.answered is True
|
||||
assert len(first.candidates) == EXPECTED_CANDIDATES
|
||||
assert first.entry_state == INTENT_ENTRY_CANDIDATES
|
||||
assert len(poller.section_commands) == 1, "one shell for one app"
|
||||
|
||||
second = await manager.async_scan_intents(NETFLIX)
|
||||
|
||||
assert second.status == INTENT_SCAN_CACHED
|
||||
assert second.age_seconds is not None and second.age_seconds >= 0
|
||||
assert [c.key for c in second.candidates] == [c.key for c in first.candidates]
|
||||
assert len(poller.shell_calls) == 1, "the second request cost nothing"
|
||||
assert manager.intent_scan_state(NETFLIX) == INTENT_ENTRY_CANDIDATES
|
||||
|
||||
async def test_force_rescans(self):
|
||||
poller = _IntentsPoller(default=NETFLIX_DUMP)
|
||||
manager = _manager(poller)
|
||||
await manager.async_scan_intents(NETFLIX)
|
||||
|
||||
again = await manager.async_scan_intents(NETFLIX, force=True)
|
||||
|
||||
assert again.status == INTENT_SCAN_FRESH
|
||||
assert len(poller.section_commands) == 2
|
||||
|
||||
async def test_an_empty_answer_is_cached_and_is_not_a_failure(self):
|
||||
empty = "Activity Resolver Table:\nService Resolver Table:\n"
|
||||
poller = _IntentsPoller(default=empty)
|
||||
manager = _manager(poller)
|
||||
|
||||
first = await manager.async_scan_intents(NETFLIX)
|
||||
|
||||
assert first.status == INTENT_SCAN_FRESH_EMPTY
|
||||
assert first.answered is True, "the device answered: it advertises nothing"
|
||||
assert first.candidates == ()
|
||||
assert first.entry_state == INTENT_ENTRY_EMPTY
|
||||
|
||||
second = await manager.async_scan_intents(NETFLIX)
|
||||
|
||||
assert second.status == INTENT_SCAN_CACHED
|
||||
assert second.entry_state == INTENT_ENTRY_EMPTY
|
||||
assert len(poller.shell_calls) == 1, "a definitive empty answer is not re-asked"
|
||||
|
||||
async def test_a_shell_that_raises_is_not_cached_and_is_retried(self):
|
||||
poller = _IntentsPoller(raise_on="Activity Resolver Table")
|
||||
manager = _manager(poller)
|
||||
|
||||
first = await manager.async_scan_intents(NETFLIX)
|
||||
|
||||
assert first.status == INTENT_SCAN_ADB_UNAVAILABLE
|
||||
assert first.answered is False
|
||||
assert first.candidates == ()
|
||||
assert manager.intent_scan_state(NETFLIX) is None, "a failure is not an answer"
|
||||
|
||||
second = await manager.async_scan_intents(NETFLIX)
|
||||
|
||||
assert second.status == INTENT_SCAN_ADB_UNAVAILABLE
|
||||
assert len(poller.shell_calls) > 1, "the next request really retried"
|
||||
|
||||
async def test_no_output_is_not_an_answer(self):
|
||||
poller = _IntentsPoller(default="")
|
||||
manager = _manager(poller)
|
||||
|
||||
result = await manager.async_scan_intents(NETFLIX)
|
||||
|
||||
assert result.status == INTENT_SCAN_ADB_UNAVAILABLE
|
||||
assert result.answered is False
|
||||
assert manager.intent_scan_state(NETFLIX) is None
|
||||
|
||||
async def test_an_unreadable_answer_falls_back_then_says_so(self):
|
||||
poller = _IntentsPoller(
|
||||
answers={
|
||||
"sed -n": "Unable to find package: com.netflix.ninja\n",
|
||||
"grep": "",
|
||||
}
|
||||
)
|
||||
manager = _manager(poller)
|
||||
|
||||
result = await manager.async_scan_intents(NETFLIX)
|
||||
|
||||
assert result.status == INTENT_SCAN_UNREADABLE
|
||||
assert result.answered is False
|
||||
assert result.candidates == ()
|
||||
assert len(poller.shell_calls) == 2, "the bounded grep fallback was tried"
|
||||
assert "grep" in poller.shell_calls[-1]
|
||||
assert manager.intent_scan_state(NETFLIX) is None
|
||||
|
||||
async def test_the_fallback_recovers_a_device_without_sed_ranges(self):
|
||||
poller = _IntentsPoller(
|
||||
answers={
|
||||
"sed -n": "", # a toolbox that cannot do ranges answers nothing
|
||||
"grep": NETFLIX_DUMP,
|
||||
}
|
||||
)
|
||||
manager = _manager(poller)
|
||||
|
||||
result = await manager.async_scan_intents(NETFLIX)
|
||||
|
||||
assert result.status == INTENT_SCAN_FRESH
|
||||
assert len(result.candidates) == EXPECTED_CANDIDATES
|
||||
assert len(poller.shell_calls) == 2
|
||||
assert manager.intent_scan_state(NETFLIX) == INTENT_ENTRY_CANDIDATES
|
||||
|
||||
async def test_adb_disabled_and_unreachable_are_told_apart(self):
|
||||
disabled = _manager(None, adb_enabled=False)
|
||||
enabled_but_passive = _manager(None, adb_enabled=True)
|
||||
disconnected = _manager(_IntentsPoller(default=NETFLIX_DUMP, connected=False))
|
||||
disconnected._poller.connected = False
|
||||
|
||||
for manager, expected in (
|
||||
(disabled, INTENT_SCAN_ADB_DISABLED),
|
||||
(enabled_but_passive, INTENT_SCAN_ADB_UNAVAILABLE),
|
||||
(disconnected, INTENT_SCAN_ADB_UNAVAILABLE),
|
||||
):
|
||||
result = await manager.async_scan_intents(NETFLIX)
|
||||
assert result.status == expected
|
||||
assert result.answered is False
|
||||
assert result.candidates == (), "no empty list pretending there are none"
|
||||
|
||||
assert disconnected._poller.shell_calls == [], "nothing was asked"
|
||||
|
||||
async def test_a_malformed_package_never_reaches_a_shell(self):
|
||||
poller = _IntentsPoller(default=NETFLIX_DUMP)
|
||||
manager = _manager(poller)
|
||||
|
||||
for bad in ("", " ", "notapackage", "not a package; rm -rf /"):
|
||||
result = await manager.async_scan_intents(bad)
|
||||
assert result.status == INTENT_SCAN_INVALID_PACKAGE
|
||||
assert result.answered is False
|
||||
|
||||
assert poller.shell_calls == []
|
||||
|
||||
async def test_one_request_scans_one_package_and_nothing_else(self):
|
||||
poller = _IntentsPoller(
|
||||
answers={
|
||||
NETFLIX: NETFLIX_DUMP,
|
||||
"com.other.app": NETFLIX_DUMP,
|
||||
}
|
||||
)
|
||||
manager = _manager(poller)
|
||||
|
||||
await manager.async_scan_intents(NETFLIX)
|
||||
|
||||
assert len(poller.shell_calls) == 1
|
||||
assert NETFLIX in poller.shell_calls[0]
|
||||
assert "com.other" not in poller.shell_calls[0]
|
||||
|
||||
await manager.async_scan_intents("com.other.app")
|
||||
|
||||
assert len(poller.shell_calls) == 2, "the second package is its own scan"
|
||||
assert manager.intent_scan_state(NETFLIX) == INTENT_ENTRY_CANDIDATES
|
||||
assert manager.intent_scan_state("com.other.app") == INTENT_ENTRY_CANDIDATES
|
||||
|
||||
async def test_concurrent_requests_for_one_package_share_a_dump(self):
|
||||
release = asyncio.Event()
|
||||
|
||||
class _SlowPoller(_IntentsPoller):
|
||||
async def shell(self, command):
|
||||
await release.wait()
|
||||
return await super().shell(command)
|
||||
|
||||
poller = _SlowPoller(default=NETFLIX_DUMP)
|
||||
manager = _manager(poller)
|
||||
|
||||
first = asyncio.create_task(manager.async_scan_intents(NETFLIX))
|
||||
await asyncio.sleep(0) # let the first scan reach the shell
|
||||
second = asyncio.create_task(manager.async_scan_intents(NETFLIX))
|
||||
await asyncio.sleep(0)
|
||||
release.set()
|
||||
one, two = await asyncio.gather(first, second)
|
||||
|
||||
assert len(poller.section_commands) == 1, "one dump, two callers"
|
||||
assert [c.key for c in one.candidates] == [c.key for c in two.candidates]
|
||||
|
||||
async def test_the_watchdog_never_scans_intents(self):
|
||||
"""Discovery is EXPLICIT: no cycle of the manager may start a scan."""
|
||||
poller = _IntentsPoller(default=NETFLIX_DUMP)
|
||||
manager = _manager(poller)
|
||||
|
||||
await manager._watchdog_cycle(0.0)
|
||||
|
||||
assert poller.section_commands == [], "the watchdog must not scan"
|
||||
assert manager.intent_scan_state(NETFLIX) is None
|
||||
|
||||
async def test_the_result_serialises_for_the_ui(self):
|
||||
poller = _IntentsPoller(default=NETFLIX_DUMP)
|
||||
manager = _manager(poller)
|
||||
|
||||
payload = (await manager.async_scan_intents(NETFLIX)).as_dict()
|
||||
|
||||
assert payload["status"] == INTENT_SCAN_FRESH
|
||||
assert payload["answered"] is True
|
||||
assert payload["truncated"] is False
|
||||
assert len(payload["candidates"]) == EXPECTED_CANDIDATES
|
||||
assert payload["candidates"][0]["component"] == MAIN_ACTIVITY
|
||||
|
||||
async def test_the_ttl_expires_in_the_manager_too(self):
|
||||
"""The manager's cache is the module's, on the manager's clock (t16)."""
|
||||
now = [1000.0]
|
||||
poller = _IntentsPoller(default=NETFLIX_DUMP)
|
||||
manager = ShieldConnectionManager(
|
||||
hass=None,
|
||||
entry=_entry(),
|
||||
poller=poller,
|
||||
clock=lambda: now[0],
|
||||
)
|
||||
|
||||
await manager.async_scan_intents(NETFLIX)
|
||||
cached = await manager.async_scan_intents(NETFLIX)
|
||||
assert cached.status == INTENT_SCAN_CACHED
|
||||
|
||||
now[0] += manager.intent_cache.ttl + 1.0
|
||||
|
||||
assert manager.intent_scan_state(NETFLIX) is None, "a stale answer is dropped"
|
||||
again = await manager.async_scan_intents(NETFLIX)
|
||||
assert again.status == INTENT_SCAN_FRESH
|
||||
assert len(poller.section_commands) == 2, "and the device is asked again"
|
||||
|
||||
async def test_an_enormous_table_is_capped_and_says_so(self):
|
||||
"""The cap is disclosed end to end, never a silently shortened answer."""
|
||||
entries = "".join(
|
||||
f" com.example.action{n:03d}:\n"
|
||||
f" deca7fa com.example.app/.Activity{n:03d} filter fc{n:04d}\n"
|
||||
f" Action: \"com.example.action{n:03d}\"\n"
|
||||
for n in range(INTENT_CANDIDATE_LIMIT + 25)
|
||||
)
|
||||
section = (
|
||||
"Activity Resolver Table:\n"
|
||||
" Non-Data Actions:\n"
|
||||
f"{entries}"
|
||||
"Service Resolver Table:\n"
|
||||
)
|
||||
poller = _IntentsPoller(default=section)
|
||||
manager = _manager(poller)
|
||||
|
||||
result = await manager.async_scan_intents(NETFLIX)
|
||||
|
||||
assert result.status == INTENT_SCAN_FRESH
|
||||
assert result.answered is True
|
||||
assert len(result.candidates) == INTENT_CANDIDATE_LIMIT
|
||||
assert result.truncated is True, "a capped answer never looks complete"
|
||||
assert manager.intent_cache.get(NETFLIX).truncated is True
|
||||
Reference in new issue
Block a user