Files
ha-nvidia-shield/tests/test_installed_apps.py
T
James Mackay de1c34777d fix(adb): ADB-off is asked by every device path, not inferred from a missing poller (t23)
The t22 review's O1, and the same bug class as t21's F1 — so it gets the same
structural treatment instead of a note.

WHAT WAS MEASURED. With a poller injected into an entry whose own option said
`adb_enabled: False`, the installed-apps sweep sent 4 real shells (the reviewer;
3 on this test's double, which yields one launchable package) and a label probe
sent 1 more. Both gated on `self._poller is None` — and the poller's absence is a
property of the SETUP code (`_build_poller_factory` returns None when ADB is off),
which is precisely the indirect guarantee the t21 repair had already shown to be
insufficient. Production was unreachable only through that setup path.

THE FIX. `_adb_disabled()` is now asked directly by every path in the manager
that can reach the device, and `_adb_active()` (option ON *and* a poller) is the
shared predicate for the ones that also need a transport. The `available` property
deliberately keeps its transport meaning (diagnostics reads it as "ADB is
configured"); a new docstring says so, because overloading it is how the gate got
lost in the first place.

Gated paths, and what each asks now:
  * intent scan — the option (t21, unchanged) AND the private shell site itself
  * installed-apps sweep — the option first, then poller+connected
  * installed-apps refresh (public entry) — returns the last table, starts nothing
  * app-label lookup / drain / the ONE probe site (`_dump_label`) — the option; a
    lookup returns None and queues NOTHING (no request, no task, no cache write)
  * ADB text entry, media keys, bring-to-foreground, digit and enter keys — `_adb_active()`
  * the heartbeat `_refresh` — the option: no wake probe, no media dump, no
    running-apps poll, no foreground fallback, no label drain, no logcat ensure
  * the watchdog — the option BEFORE the poller factory, so not even a factory can
    build a transport; and `_watchdog_cycle` refuses a direct call too (a TCP
    connect is device traffic)
  * the logcat stream — the option at the ensure gate, the option in
    `_open_logcat_stream`, and the ladder exits WITHOUT counting a failure (a local
    option must not auto-disable the option for the session)
  * the public `adb_poller` accessor — None while the option is off, which closes
    the shell paths that live OUTSIDE this module (the `adb_command` service, the
    screen-capture image and the intent shell) without reaching into them: all
    three already refuse on None with their own message
  * `test_connect` (the pairing probe) stays ungated on purpose: it runs BEFORE an
    entry exists, and the setup menu only offers that step when the ADB option is
    on (config_flow.py). `async_stop`/`_note_failure` disconnect calls are teardown,
    not traffic.

PROOF, BY TRAFFIC. tests/test_adb_off_gates_every_device_path.py drives every
audited path — as a parametrized AUDIT table plus one full cycle on one manager —
with `adb_enabled: False` and a double that WOULD answer everything, and asserts
the poller's complete traffic list (shells, polls, commands, connects) is EMPTY,
plus that nothing was cached (no intent answer, no app table, no stamp, no queue,
no store field, no wake reading). It also proves the watchdog never builds a
poller and never connects, that a direct cycle does not connect either, that the
option is a CHECK and not a latch (the same manager resumes the sweep, the scan,
the label queue, the media key, the digit key and the accessor when the option
flips back on), and that ADB-off and no-poller stay told apart.

PRE-FIX PROOF: the same file against `git archive HEAD` FAILS 16 of 23 tests, and
the failures print the measured traffic — the sweep's 3 shells, the label probe's
`pm dump … | grep …`, `input text hello`, `media:play`, `foreground:<pkg>`,
`digit:5`, `enter`, and 4 polls per heartbeat cycle.

HARNESS TRUTHFULNESS. The suites' managers were built with `entry=None`, which
means ADB is OFF (fail closed, matching `__init__.py`). Those harnesses now pass a
realistic entry whose option says ADB is on — they model production instead of the
one combination the gates block. No production behaviour changed for an ADB-on
entry: 923 passed / 5 skipped, up from 900 by this file.

Floors re-derived from the new walk (1661 blocks → 1627, same 2% band): raised,
because that is what the re-measurement says. Drift exit 0. Strings files
byte-identical.

NOT VERIFIED HERE: the device side is a scripted double, so what is proven is that
NOTHING WAS SENT, not what firmware would do; no real Shield was involved.
2026-10-12 13:32:34 +01:00

1023 lines
44 KiB
Python

"""t57: installed-app discovery — parsers, the bounded sweep, and the surfaces.
No real Shield was available, so the command OUTPUT here is the part that is
fixture-based, not observed: the shapes are the documented Android ones
(``pm list packages`` prints ``package:<id>``; ``resolve-activity --brief``
prints the verbose intent then ``package/activity``; ``pm dump`` prints
``application-label: '...'``). The parsers are deliberately tolerant because
that is the property that matters when a real device's output differs in
whitespace or ordering, and every tolerance is pinned below.
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).resolve().parent))
import entity_stublib # noqa: E402
import pytest # noqa: E402
from entity_stublib import load_entity_modules # noqa: E402
@pytest.fixture(autouse=True)
def scoped_entity_stubs():
"""Container for the HA stubs + loaded entity modules (t7 discipline).
Without the teardown the stubs this file installs leak into whichever test
file runs next — tests/test_harness_scoping.py exists because that mistake
is invisible until an unrelated file fails.
"""
snapshot = entity_stublib.snapshot_modules()
entity_stublib.install()
yield load_entity_modules()
entity_stublib.restore_modules(snapshot)
from nsh.connection import ShieldConnectionManager # noqa: E402
from nsh.const import ( # noqa: E402
APP_ICON_FALLBACKS,
APP_LABEL_EXIT_MARKER,
APP_LABEL_GREP_SHAPES,
APP_LIST_TTL,
CONF_ADB_ENABLED,
CONF_APP_NAME_MAP,
)
from nsh.ha_adb_common import ( # noqa: E402
icon_for_package,
installed_packages,
is_launchable,
parse_active_launcher,
parse_package_list,
)
# ── realistic device output ────────────────────────────────────────────────
#: `pm list packages -3` on the user's Shield (t66). These are the apps the
#: report listed, which is also why they are the curated table's rows: on THIS
#: device the firmware names none of them, so the table — not the device — is where
#: the names come from. `org.xbmc.kodi` and `com.example.handmade` are kept as the
#: packages NOTHING can name (no device label, no curated row), so the fall-through
#: to "" is pinned on the same list.
USER_PACKAGES = (
"package:ar.tvplayer.tv\n"
"package:be.digitalia.mediasession2mqtt\n"
"package:com.amazon.amazonvideo.livingroom\n"
"package:com.esaba.downloader\n"
"package:com.github.damontecres.wholphin\n"
"package:com.google.android.sambadocumentsprovider\n"
"package:com.google.android.speech.pumpkin\n"
"package:com.google.android.tts\n"
"package:com.google.android.tvlauncher\n"
"package:com.google.android.youtube.tv\n"
"package:com.hyperion.grabber\n"
"package:com.lonelycatgames.Xplore\n"
"package:com.nendo.argosy\n"
"package:com.netflix.ninja\n"
"package:com.plexapp.mediaserver.smb\n"
"package:com.retroarch\n"
"package:com.spocky.projengmenu\n"
"package:dev.jdtech.jellyfin\n"
"package:dev.trooped.tvquickbars\n"
"package:flar2.homebutton\n"
"package:tv.pluto.android\n"
"package:org.xbmc.kodi\n"
"package:com.example.handmade\n"
)
#: `cmd package resolve-activity --brief -c android.intent.category.HOME` —
#: the verbose header then the component, exactly as Android prints it.
LAUNCHER_RESOLVED = (
"priority=0 preferredOrder=0 match=0x108000 specificIndex=-1 isDefault=false\n"
"com.google.android.tvlauncher/com.google.android.apps.tv.launcherx.MainActivity\n"
)
#: `pm dump` for an app whose manifest and locale variants both exist. The
#: parser takes the first default-locale label and ignores the localized ones.
DUMP_TIVIMATE = (
"Package [ar.tvplayer.tv] (7f1a2b3c):\n"
" application-label:'TiviMate'\n"
" application-label-de:'TiviMate DE'\n"
" application-label-fr:'TiviMate FR'\n"
" versionName=4.7.0\n"
)
#: A dump that RAN and carries no label at all — several system/OEM packages
#: look like this. It is a definitive negative, not a failed fetch.
DUMP_LABEL_LESS = (
"Package [com.example.handmade] (aa11bb22):\n"
" versionName=1.0\n"
" codePath=/data/app/com.example.handmade\n"
)
#: The REAL device shape, trimmed: `pm dump com.netflix.ninja` on the user's Shield
#: (captured 2026-10-12; the observed structure is recorded in
#: ``docs/INTENTS_PLAN.md`` §1). It is the evidence behind this work — the raw
#: ~2241-line capture text is NOT in the repository, so this is a faithful
#: reproduction of the recorded structure, not the capture itself.
#:
#: WHAT IS REPRODUCED: the whole-dumpsys envelope (`DUMP OF SERVICE package:`,
#: then `activity`, `meminfo`, `procstats`, `usagestats`, `batterystats`), the
#: package section opening with `Activity Resolver Table:` followed by the
#: receiver/service tables, Netflix's real entries (`cast:`, `http`/`https` with
#: `www.netflix.com` path globs, `nflx:`, `netflix:`, `MAIN` +
#: `LEANBACK_LAUNCHER`) and the capture's own noise (`com.netflix.action.DEBUG_*`,
#: `androidx.*`, `com.google.firebase.*`, `ConstraintProxy*`) — and, the point of
#: the whole exercise, NO `application-label` line anywhere: grepping the capture
#: for `application-label` found nothing.
#:
#: (`nonLocalizedLabel=null`, `labelRes=0x7f0f0d31`) — the capture's record covers
#: the resolver tables, not this block. They are here because they are exactly the
#: shapes the on-device search must NOT read as a name: `null` is not a name, and a
#: resource id is not a name either. The bodies of the four trailing service
#: sections are trimmed to one line each (the capture is ~2241 lines; this is ~80).
DUMP_NETFLIX_REAL = (
"DUMP OF SERVICE package:\n"
"Activity Resolver Table:\n"
" Full MIME Types:\n"
" video/*: 7f0f4c12 com.netflix.ninja/.ui.MainActivity filter 7f0f0a11\n"
" Action: \"android.intent.action.VIEW\"\n"
" Category: \"android.intent.category.DEFAULT\"\n"
" Category: \"android.intent.category.BROWSABLE\"\n"
" Scheme: \"https\"\n"
" Authority: \"www.netflix.com\": -1\n"
" Path: \"PatternMatcher{LITERAL,/title}\"\n"
" Scheme: \"http\"\n"
" Authority: \"www.netflix.com\": -1\n"
" nflx: 7f0f4c12 com.netflix.ninja/.ui.MainActivity filter 7f0f0a12\n"
" Action: \"android.intent.action.VIEW\"\n"
" Category: \"android.intent.category.DEFAULT\"\n"
" Scheme: \"nflx\"\n"
" netflix: 7f0f4c12 com.netflix.ninja/.ui.MainActivity filter 7f0f0a13\n"
" Action: \"android.intent.action.VIEW\"\n"
" Scheme: \"netflix\"\n"
" Non-Data Actions:\n"
" android.intent.action.MAIN:\n"
" 7f0f4c12 com.netflix.ninja/.ui.MainActivity filter 7f0f0a14\n"
" Action: \"android.intent.action.MAIN\"\n"
" Category: \"android.intent.category.LEANBACK_LAUNCHER\"\n"
" cast:\n"
" 7f0f4c12 com.netflix.ninja/.ui.MainActivity filter 7f0f0a15\n"
" Action: \"android.intent.action.VIEW\"\n"
" Scheme: \"cast\"\n"
" com.netflix.action.DEBUG_FORCE_EXIT:\n"
" 7f0f4c12 com.netflix.ninja/.DebugReceiver filter 7f0f0a16\n"
" Action: \"com.netflix.action.DEBUG_FORCE_EXIT\"\n"
"Receiver Resolver Table:\n"
" Non-Data Actions:\n"
" androidx.work.DiagnosticsReportReceiver:\n"
" 7f0f4c12 com.netflix.ninja/androidx.work.impl.diagnostics.DiagnosticsReceiver\n"
" com.google.firebase.MESSAGING_EVENT:\n"
" 7f0f4c12 com.netflix.ninja/com.google.firebase.messaging.FirebaseMessagingService\n"
" com.netflix.action.DEBUG_PUSH_NOTIFICATION:\n"
" 7f0f4c12 com.netflix.ninja/.push.DebugPushReceiver\n"
"Service Resolver Table:\n"
" Non-Data Actions:\n"
" com.google.firebase.INSTANCE_ID_EVENT:\n"
" 7f0f4c12 com.netflix.ninja/com.google.firebase.iid.FirebaseInstanceIdService\n"
"Provider Resolver Table:\n"
" Authorities:\n"
" com.netflix.ninja.androidx-startup:\n"
" 7f0f4c12 com.netflix.ninja/androidx.startup.InitializationProvider\n"
"Permissions:\n"
" Permission [com.netflix.ninja.C2D_MESSAGE]:\n"
" sourcePackage=com.netflix.ninja\n"
"Packages:\n"
" Package [com.netflix.ninja] (7f1a2b3c):\n"
" userId=10123\n"
" pkg=Package{7f1a2b3c com.netflix.ninja}\n"
# the two INFERRED lines (see the note above): the shapes the search must skip
" nonLocalizedLabel=null\n"
" labelRes=0x7f0f0d31\n"
" versionCode=61102 minSdk=21 targetSdk=29\n"
" versionName=6.11.2 build 3413\n"
" codePath=/data/app/com.netflix.ninja-1/base.apk\n"
" ConstraintProxy.BatteryBroadcastReceiver=\n"
" com.netflix.ninja/androidx.work.impl.constraints.trackers."
"ConstraintProxy$BatteryChargingProxy\n"
" Activity Resolver Table: (nothing else listed for this package)\n"
"DUMP OF SERVICE activity:\n"
" ACTIVITY MANAGER PENDING INTENTS (dumpsys activity pending)\n"
" (nothing)\n"
"DUMP OF SERVICE meminfo:\n"
" Applications Memory Usage (in Kilobytes):\n"
"DUMP OF SERVICE procstats:\n"
" Process Stats:\n"
"DUMP OF SERVICE usagestats:\n"
" In-memory rollup:\n"
"DUMP OF SERVICE batterystats:\n"
" Battery Stats:\n"
)
def _on_device_grep(dump: str) -> str:
"""What the WIRE carries once the fetch greps on the device (t66).
The real command is ``pm dump <pkg> | grep -E <shapes> | head -40``, so the
2241-line dump never crosses ADB: only the matching lines do, plus the exit
marker the manager relies on. A fake that returned the whole dump would only
prove that the PARSER tolerates it — not that the manager never had to.
"""
shapes = APP_LABEL_GREP_SHAPES.split("|")
hits = [line for line in dump.splitlines() if any(s in line for s in shapes)]
return "\n".join(hits[:40]) + f"\n{APP_LABEL_EXIT_MARKER}0\n"
class _DiscoveryPoller:
"""A poller whose shell answers the discovery commands from fixtures."""
def __init__(self, *, packages=USER_PACKAGES, launcher=LAUNCHER_RESOLVED,
dumps=None, failure=False, on_device_grep=False):
self.connected = True
self.connected_override = True
self.packages = packages
self.launcher = launcher
self.dumps = dict(dumps or {})
self.failure = failure
#: t66: True = the fixture values are RAW device dumps and the fake applies
#: the on-device grep before answering, like the real command does.
self.on_device_grep = on_device_grep
self.shell_calls: list[str] = []
@property
def is_connected(self):
return self.connected
async def shell(self, cmd: str):
self.shell_calls.append(cmd)
if self.failure:
return None
if "pm list packages" in cmd:
return self.packages
if "resolve-activity" in cmd:
return self.launcher
if "pm dump" in cmd:
import shlex
for package, dump in self.dumps.items():
if shlex.quote(package) in cmd or package in cmd:
return _on_device_grep(dump) if self.on_device_grep else dump
return "__NSH_EXIT:0\n" # ran, no label
return None
def _entry(*, adb_enabled=True, **options):
"""A realistic entry: the ADB option is ON unless a test says otherwise (t23)."""
return SimpleNamespace(
options={CONF_ADB_ENABLED: adb_enabled, **options},
data={},
title="Shield",
entry_id="e1",
)
def _manager(poller, *, adb_enabled=True):
return ShieldConnectionManager(
hass=None, entry=_entry(adb_enabled=adb_enabled), poller=poller
)
class TestPackageListParsing:
"""`pm list packages -3`."""
def test_real_output_parses_one_package_per_line(self):
# the order of `pm list packages -3` is preserved, one id per line
assert parse_package_list(USER_PACKAGES) == [
line.removeprefix("package:") for line in USER_PACKAGES.splitlines()
]
def test_noise_and_duplicates_are_tolerated(self):
noisy = (
"adb: connecting to 10.0.0.9:5555\n"
"package:com.a\n"
"\n"
"package:com.a\n"
"package:com.b \n"
"some warning line\n"
)
assert parse_package_list(noisy) == ["com.a", "com.b"]
def test_empty_and_none_are_an_empty_list(self):
assert parse_package_list(None) == []
assert parse_package_list("") == []
def test_a_package_id_is_sanity_checked(self):
assert is_launchable("com.example.app")
assert is_launchable("ar.tvplayer.tv")
assert not is_launchable("")
assert not is_launchable("notapackage")
assert not is_launchable("com..b")
class TestLauncherParsing:
"""`cmd package resolve-activity --brief -c ...HOME`."""
def test_the_component_after_the_verbose_header_is_the_launcher(self):
assert parse_active_launcher(LAUNCHER_RESOLVED) == "com.google.android.tvlauncher"
def test_activity_only_output_still_parses(self):
assert parse_active_launcher("com.google.android.tvlauncher/.MainActivity") == (
"com.google.android.tvlauncher"
)
def test_nothing_resolved_is_none(self):
assert parse_active_launcher("No activity found") is None
assert parse_active_launcher(None) is None
def test_the_tv_settings_entry_point_is_not_a_launcher(self):
# a HOME resolution can land on the settings app's entry point; that is
# not the app a user opens
output = "com.android.tv.settings/.MainActivity"
assert parse_active_launcher(output) is None
class TestInstalledPackagesMerge:
"""User apps plus the active launcher, de-duplicated."""
def test_launcher_is_added_last(self):
# t66: the user's own list contains the launcher, so the append-last
# property is pinned on a list that does NOT already have it
user_only = USER_PACKAGES.replace("package:com.google.android.tvlauncher\n", "")
packages = installed_packages(user_only, LAUNCHER_RESOLVED)
assert packages[-1] == "com.google.android.tvlauncher"
assert packages.count("com.google.android.tvlauncher") == 1
assert packages[0] == "ar.tvplayer.tv"
def test_a_launcher_already_listed_keeps_its_place(self):
packages = installed_packages(USER_PACKAGES, LAUNCHER_RESOLVED)
assert packages.count("com.google.android.tvlauncher") == 1
assert packages == parse_package_list(USER_PACKAGES)
def test_a_third_party_launcher_is_not_duplicated(self):
user = "package:com.example.launcher\npackage:com.a\n"
launcher = "com.example.launcher/.MainActivity"
assert installed_packages(user, launcher) == ["com.example.launcher", "com.a"]
def test_no_launcher_still_yields_the_user_apps(self):
assert installed_packages(USER_PACKAGES, None) == parse_package_list(USER_PACKAGES)
class TestIconHeuristic:
"""Icons are a curated map plus a generic fallback — and always a string."""
def test_a_known_package_gets_its_curated_icon(self):
assert icon_for_package("com.netflix.ninja", APP_ICON_FALLBACKS) == "mdi:netflix"
def test_an_unknown_package_gets_the_generic_icon(self):
assert icon_for_package("com.example.handmade", APP_ICON_FALLBACKS) == "mdi:television"
def test_the_result_is_always_a_string(self):
for package in ("", None, "com.example.handmade", "ar.tvplayer.tv"):
icon = icon_for_package(package, APP_ICON_FALLBACKS)
assert isinstance(icon, str) and icon
class TestDiscoverySweep:
"""The bounded sweep on the connection manager."""
async def test_a_sweep_lists_labels_and_icons_every_app(self):
poller = _DiscoveryPoller(dumps={
"ar.tvplayer.tv": DUMP_TIVIMATE,
"com.example.handmade": DUMP_LABEL_LESS,
})
manager = _manager(poller)
rows = await manager.async_refresh_installed_apps()
packages = [row["package"] for row in rows]
assert "ar.tvplayer.tv" in packages
assert "com.netflix.ninja" in packages
assert "com.google.android.tvlauncher" in packages # the launcher is there
by_pkg = {row["package"]: row for row in rows}
assert by_pkg["ar.tvplayer.tv"]["name"] == "TiviMate" # from the dump
assert by_pkg["ar.tvplayer.tv"]["icon"] == "mdi:television-classic"
# t66: nothing can name `com.example.handmade` (the dump carries no label
# and it is not in the curated table), so its name is "" — the id is
# already published once in `package`, and a name that repeats it is the
# `pkg=pkg` row the user reported from the apps editor. The row itself is
# still offered: it must not vanish from the table.
assert by_pkg["com.example.handmade"]["name"] == ""
assert by_pkg["com.example.handmade"]["package"] == "com.example.handmade"
# every row has a string name ("" when nothing names it) and a real icon
for row in rows:
assert isinstance(row["name"], str)
assert isinstance(row["icon"], str) and row["icon"]
async def test_the_live_bug_the_discovered_list_is_a_superset(self):
"""The complaint in one assertion: every app the user has is offered, and
each one is NAMED — on this device none of them is named by the firmware.
"""
from nsh.const import DEFAULT_APP_DIRECTORY
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
manager = _manager(poller)
rows = await manager.async_refresh_installed_apps()
packages = {row["package"] for row in rows}
by_pkg = {row["package"]: row for row in rows}
# the curated table is exactly the app set the report listed
assert set(DEFAULT_APP_DIRECTORY) <= packages
# ...and every one of those apps reads by its NAME, from the curated map,
# with the device naming nothing (t66)
for package, name in DEFAULT_APP_DIRECTORY.items():
assert name
assert by_pkg[package]["name"], package
if package != "ar.tvplayer.tv": # this one's dump DOES carry a label
assert by_pkg[package]["name"] == name, package
assert by_pkg["ar.tvplayer.tv"]["name"] == "TiviMate" # the device wins here
assert by_pkg["org.xbmc.kodi"]["name"] == "" # known to nobody
assert "com.google.android.tvlauncher" in packages # + the launcher
# a system app the device never lists as -3 is NOT invented here
assert "com.android.systemui" not in packages
async def test_the_sweep_issues_the_documented_commands(self):
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
manager = _manager(poller)
await manager.async_refresh_installed_apps()
assert "pm list packages -3" in poller.shell_calls
assert any("resolve-activity" in cmd for cmd in poller.shell_calls)
assert "android.intent.category.HOME" in " ".join(poller.shell_calls)
async def test_labels_are_fetched_in_a_bounded_burst(self):
"""A full sweep must not serialise one shell at a time, and must not flood."""
poller = _DiscoveryPoller()
manager = _manager(poller)
concurrent = {"now": 0, "peak": 0, "total": 0}
real_shell = poller.shell
async def _counted(cmd):
if "pm dump" in cmd:
concurrent["now"] += 1
concurrent["total"] += 1
concurrent["peak"] = max(concurrent["peak"], concurrent["now"])
try:
await asyncio.sleep(0) # yield so overlap is observable
return await real_shell(cmd)
finally:
concurrent["now"] -= 1
return await real_shell(cmd)
poller.shell = _counted
rows = await manager.async_refresh_installed_apps()
assert len(rows) >= 5
assert concurrent["total"] >= 5
# bounded: never more than the configured workers in flight at once
from nsh.const import APP_SWEEP_CONCURRENCY
assert concurrent["peak"] <= APP_SWEEP_CONCURRENCY
assert concurrent["peak"] > 1 # ...and genuinely concurrent, not serial
async def test_a_cached_table_is_reused_and_costs_no_shell(self):
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
manager = _manager(poller)
first = await manager.async_refresh_installed_apps()
calls = len(poller.shell_calls)
second = await manager.async_refresh_installed_apps()
assert second == first
assert len(poller.shell_calls) == calls # cached: zero new shells
assert manager.installed_apps_stale is False
async def test_force_refreshes_even_a_fresh_table(self):
poller = _DiscoveryPoller()
manager = _manager(poller)
await manager.async_refresh_installed_apps()
calls = len(poller.shell_calls)
await manager.async_refresh_installed_apps(force=True)
assert len(poller.shell_calls) > calls
async def test_a_stale_table_is_not_served(self):
now = [1000.0]
poller = _DiscoveryPoller()
manager = ShieldConnectionManager(
hass=None, entry=_entry(), poller=poller, clock=lambda: now[0]
)
await manager.async_refresh_installed_apps()
assert manager.installed_apps()
now[0] += APP_LIST_TTL + 1
assert manager.installed_apps() == []
assert manager.installed_apps_stale is True
async def test_the_watchdog_never_sweeps(self):
"""Acceptance: nothing discovery-related runs on a state poll.
The watchdog cycle is what a poll drives. It may drain ONE label fetch
(the t24/t55 rule for the live entity path), but it must never list
packages or fan out into a discovery burst.
"""
poller = _DiscoveryPoller()
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert not [c for c in poller.shell_calls if "pm list packages" in c]
assert not [c for c in poller.shell_calls if "resolve-activity" in c]
assert manager.installed_apps() == []
async def test_concurrent_callers_share_one_sweep(self):
poller = _DiscoveryPoller()
manager = _manager(poller)
list_calls = {"n": 0}
real_shell = poller.shell
async def _counted(cmd):
if "pm list packages" in cmd:
list_calls["n"] += 1
await asyncio.sleep(0.01)
return await real_shell(cmd)
poller.shell = _counted
results = await asyncio.gather(
manager.async_refresh_installed_apps(force=True),
manager.async_refresh_installed_apps(force=True),
manager.async_refresh_installed_apps(force=True),
)
assert list_calls["n"] == 1 # one sweep, shared
assert results[0] == results[1] == results[2]
class TestDiscoveryDegradesGracefully:
"""ADB off, down or mute: an empty list and a note, never a crash or a loop.
t62 F1: "could not ask" and "asked, nothing there" are different answers.
Only the second one is cached; the first is retried.
"""
async def test_no_poller_is_an_empty_list(self):
manager = ShieldConnectionManager(hass=None, entry=_entry(), poller=None)
rows = await manager.async_refresh_installed_apps()
assert rows == []
assert manager.installed_apps() == []
async def test_a_disconnected_poller_is_an_empty_list(self):
poller = _DiscoveryPoller()
manager = _manager(poller)
manager._poller = poller
poller.connected = False
# the manager's own connected flag is what the sweep reads
assert manager.connected is False
rows = await manager.async_refresh_installed_apps()
assert rows == []
assert poller.shell_calls == [] # nothing was even attempted
async def test_a_mute_transport_is_spaced_but_not_stamped(self):
"""t62 F1: a failed sweep is retried — but not once per state poll."""
now = [1000.0]
poller = _DiscoveryPoller(failure=True)
manager = ShieldConnectionManager(
hass=None, entry=_entry(), poller=poller, clock=lambda: now[0]
)
rows = await manager.async_refresh_installed_apps()
assert rows == []
assert poller.shell_calls # it tried
assert manager.installed_apps_status == "unknown" # NOT an empty answer
# the next poll inside the window does NOT re-shell (no hammering)..
calls = len(poller.shell_calls)
assert await manager.async_refresh_installed_apps() == []
assert len(poller.shell_calls) == calls
# ..but nothing was stamped as an answer, so once the window passes the
# sweep runs again. Pre-fix it waited out APP_LIST_TTL (6 h) instead.
now[0] += manager.APP_SWEEP_RETRY_SECONDS + 1
assert await manager.async_refresh_installed_apps() == []
assert len(poller.shell_calls) > calls
async def test_a_sweep_that_could_not_run_is_not_a_fresh_answer(self):
"""t62 F1 (red proof): startup with ADB down must not BE the answer.
The entity's one startup sweep runs with the poller down. Once ADB comes
back, ``homeassistant.update_entity`` (``sensor.async_update``) must
recover the table. Pre-fix the startup sweep stamped an empty answer, so
this call issued ZERO shells and kept reporting 0/"pending" for
APP_LIST_TTL = 6 h.
"""
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
poller.connected = False
manager = _manager(poller)
assert await manager.async_refresh_installed_apps() == [] # the startup
assert poller.shell_calls == [] # it was not even attempted
assert manager.installed_apps_stale is True # ...so it is not an answer
assert manager.installed_apps_status == "unknown"
poller.connected = True # ADB is back
rows = await manager.async_refresh_installed_apps()
assert rows, "update_entity must recover the table once ADB returns"
assert "pm list packages -3" in poller.shell_calls
assert manager.installed_apps_status == "ok"
async def test_a_genuinely_empty_answer_is_ok(self):
"""t62 F1: 'ok' must be reachable for a really empty device."""
poller = _DiscoveryPoller(packages="", launcher="")
manager = _manager(poller)
assert await manager.async_refresh_installed_apps() == []
assert poller.shell_calls # the commands RAN and answered
assert manager.installed_apps_stale is False # stamped: a real answer
assert manager.installed_apps_status == "ok"
async def test_the_accessor_never_shells(self):
poller = _DiscoveryPoller()
manager = _manager(poller)
assert manager.installed_apps() == []
assert poller.shell_calls == []
class TestDiscoveryUsesTheCachedLabelPath:
"""Acceptance: labels come from the pm-dump path AND its 24 h cache."""
async def test_a_second_sweep_reuses_the_cached_labels(self):
"""The 24 h cache is what makes a re-sweep cheap — negatives included.
A label-less package is a DEFINITIVE answer, so the second sweep must
not re-dump it. The check goes through the cache directly, because the
display accessor (`app_label`) cannot tell "no label" from "not asked".
"""
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
manager = _manager(poller)
await manager.async_refresh_installed_apps()
dumps = [c for c in poller.shell_calls if "pm dump" in c]
assert dumps # the first sweep labelled everything it could
assert len(dumps) >= 5 # ...and it did dump for the unlabelled ones
# t66: and then it STOPPED. On this device 5 probes in a row find no label,
# so the sweep does not spend one shell per package — the packaged-in
# in-flight window is the only slack above the probe limit.
from nsh.const import APP_LABEL_PROBE_LIMIT, APP_SWEEP_CONCURRENCY
assert len(dumps) <= APP_LABEL_PROBE_LIMIT + APP_SWEEP_CONCURRENCY + 1
assert len(dumps) < len(USER_PACKAGES.splitlines())
# the cache now answers for EVERY package, so a forced re-sweep lists
# again but dumps nothing at all (the 24 h label cache, not the table TTL)
manager._installed_apps = None # forget the table, keep the labels
rows = await manager.async_refresh_installed_apps(force=True)
assert [c for c in poller.shell_calls if "pm dump" in c] == dumps
assert manager.app_label("ar.tvplayer.tv") == "TiviMate"
by_pkg = {row["package"]: row for row in rows}
assert by_pkg["ar.tvplayer.tv"]["name"] == "TiviMate"
# nothing names kodi (it is in no curated row, and the memo'd device says
# "no label"): the row shows the id ONCE, in `package`, and name ""
assert by_pkg["org.xbmc.kodi"]["name"] == ""
async def test_a_failed_label_is_retried_by_the_next_sweep(self):
"""UNIT 0 semantics at scale: a transient failure is not permanent."""
# a SMALL app set, with the failing package FIRST: it is the first probe the
# sweep makes, so its failure is recorded before the device-level memo can
# form — which is exactly the state the retry guarantee is about
packages = "package:org.xbmc.kodi\n" + "".join(
f"package:com.example.app{i}\n" for i in range(6)
)
poller = _DiscoveryPoller(
packages=packages, dumps={"org.xbmc.kodi": "application-label: 'Kodi'"}
)
manager = _manager(poller)
real_shell = poller.shell
state = {"fail_kodi": True}
async def _flaky(cmd):
if state["fail_kodi"] and "pm dump" in cmd and "kodi" in cmd:
poller.shell_calls.append(cmd)
return None # the transport delivered nothing
return await real_shell(cmd)
poller.shell = _flaky
rows = await manager.async_refresh_installed_apps()
by_pkg = {row["package"]: row for row in rows}
assert by_pkg["org.xbmc.kodi"]["name"] == "" # fell back (nothing names it)
assert manager.app_label("org.xbmc.kodi") is None # NOT cached
# t66: the device-level memo is set by now (5 label-less answers), so the
# sweep will not fan out again — but kodi's FAILED probe is still owed its
# retry, and the app on screen is what drives it (t57 UNIT 0 survives).
assert manager.app_labels_absent is True
assert manager._probe_still_owed("org.xbmc.kodi") is True
assert await manager._dump_label("org.xbmc.kodi", on_demand=True) is None
state["fail_kodi"] = False
await manager._fetch_app_label("org.xbmc.kodi") # the on-demand retry
assert manager.app_label("org.xbmc.kodi") == "Kodi"
manager._installed_apps = None
rows2 = await manager.async_refresh_installed_apps()
by_pkg2 = {row["package"]: row for row in rows2}
assert by_pkg2["org.xbmc.kodi"]["name"] == "Kodi" # recovered
class TestInstalledAppsSensor:
"""The dashboard surface: state = count, attribute = the table."""
def _sensor(self, manager, options=None):
sensor_module = load_entity_modules()["sensor"]
entry = type("E", (), {})()
entry.entry_id = "e1"
entry.options = dict(options or {})
entry.runtime_data = manager
entry.title = "mdarcy"
sensor = sensor_module.NvidiaShieldAppsSensor.__new__(
sensor_module.NvidiaShieldAppsSensor
)
sensor.hass = entity_stublib.FakeHass()
sensor._entry = entry
return sensor
async def test_state_is_the_count_and_the_attribute_is_the_table(self):
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
manager = _manager(poller)
await manager.async_refresh_installed_apps()
sensor = self._sensor(manager)
assert sensor.native_value == len(manager.installed_apps())
attrs = sensor.extra_state_attributes
assert attrs["discovery"] == "ok"
assert any(
app["package"] == "ar.tvplayer.tv" and app["name"] == "TiviMate"
for app in attrs["installed_apps"]
)
def test_no_discovery_yet_is_an_empty_table_with_a_reason(self):
poller = _DiscoveryPoller()
manager = _manager(poller)
sensor = self._sensor(manager)
assert sensor.native_value == 0
attrs = sensor.extra_state_attributes
assert attrs["installed_apps"] == []
assert attrs["discovery"] in ("pending", "not_connected", "adb_disabled")
async def test_an_empty_discovery_is_ok_not_pending(self):
"""t62 F1: a sweep that RAN and found nothing is the answer 'ok'."""
poller = _DiscoveryPoller(packages="", launcher="")
manager = _manager(poller)
await manager.async_refresh_installed_apps()
sensor = self._sensor(manager, {"adb_enabled": True})
assert sensor.native_value == 0
assert sensor.extra_state_attributes == {
"installed_apps": [],
"discovery": "ok",
}
async def test_a_sweep_that_could_not_run_is_unknown_not_pending(self):
"""t62 F1: 'pending' used to cover both "running" and "could not run"."""
poller = _DiscoveryPoller(failure=True)
manager = _manager(poller)
await manager.async_refresh_installed_apps()
sensor = self._sensor(manager, {"adb_enabled": True})
assert sensor.extra_state_attributes == {
"installed_apps": [],
"discovery": "unknown",
}
def test_adb_disabled_is_reported_as_such(self):
poller = _DiscoveryPoller()
manager = _manager(poller)
poller.connected = False
sensor = self._sensor(manager, {"adb_enabled": False})
attrs = sensor.extra_state_attributes
assert attrs == {"installed_apps": [], "discovery": "adb_disabled"}
async def test_the_sensor_refresh_is_a_no_op_without_a_manager(self):
sensor = self._sensor(None)
sensor._entry.runtime_data = None
assert sensor.native_value == 0
assert sensor.extra_state_attributes["installed_apps"] == []
await sensor.async_update() # must not raise
class TestDiscoveredAppsStillRespectTheUsersMap:
"""The user's own map is an OVERRIDE, and discovery fills the rest (t57)."""
async def test_a_mapped_package_keeps_the_users_name_not_the_device_label(self):
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
manager = _manager(poller)
await manager.async_refresh_installed_apps()
# the shared resolver is what the sensor and the player use; discovery
# only supplies the device label underneath the user's map
from nsh.media_player import resolve_app_name
options = {CONF_APP_NAME_MAP: {"ar.tvplayer.tv": "Telly"}}
assert (
resolve_app_name(
"ar.tvplayer.tv", options[CONF_APP_NAME_MAP], manager.app_label
)
== "Telly"
)
# ...and the device label is what discovery put UNDER it, so dropping
# the map entry still resolves to a real name rather than a package id
assert (
resolve_app_name("ar.tvplayer.tv", {}, manager.app_label) == "TiviMate"
)
def test_the_label_exit_marker_never_becomes_a_name():
"""The marker is a transport detail; it must not leak into a label."""
from nsh.ha_adb_common import parse_application_label
assert parse_application_label("__NSH_EXIT:0\n") is None
assert parse_application_label(DUMP_TIVIMATE + "__NSH_EXIT:0\n") == "TiviMate"
def test_importing_the_discovery_module_needs_no_home_assistant():
"""The parsers live in ha_adb_common and stay importable without HA."""
import nsh.ha_adb_common.installed_apps as module
assert module.__doc__ and "pm list packages -3" in module.__doc__
assert not [n for n in dir(module) if n.startswith("hass")]
class TestTheRealDeviceShape:
"""t66: the user's own `pm dump com.netflix.ninja` shape, and what it implies.
Three properties are pinned against the fixture, because each one was a live
defect: the capture carries no label the parser can mistake for a name; the
fetch must not truncate the dump nor keep shelling every package once the
device has said it has no labels; and the name the user then SEES comes from
the curated table.
"""
def test_no_label_is_extracted_from_the_whole_capture(self):
from nsh.ha_adb_common import parse_app_label, parse_application_label
# the capture's shape, in one place: a whole dumpsys (not a stub), whose
# package section is a resolver table rather than a manifest block
assert DUMP_NETFLIX_REAL.startswith("DUMP OF SERVICE package:\n")
assert "Activity Resolver Table:" in DUMP_NETFLIX_REAL
assert "DUMP OF SERVICE batterystats:" in DUMP_NETFLIX_REAL
assert len(DUMP_NETFLIX_REAL.splitlines()) > 60
# (a) NOTHING in it is a name — this is the "no label" the report saw, and
# it must be a parse result, not a truncation artefact
assert "application-label" not in DUMP_NETFLIX_REAL
assert parse_application_label(DUMP_NETFLIX_REAL) is None
assert parse_app_label(DUMP_NETFLIX_REAL) is None
def test_the_inferred_shapes_are_not_mistaken_for_names(self):
"""The two ApplicationInfo lines the capture's record does not cover."""
from nsh.ha_adb_common import parse_label_res, parse_non_localized_label
assert parse_non_localized_label(DUMP_NETFLIX_REAL) is None # =null
assert parse_label_res(DUMP_NETFLIX_REAL) is None # a resource id
assert parse_non_localized_label("nonLocalizedLabel=Netflix") == "Netflix"
assert parse_label_res('labelRes="Netflix"') == "Netflix"
assert parse_label_res("labelRes=0x7f0f0d31") is None
async def test_the_search_runs_on_the_device_and_the_dump_is_never_truncated(self):
poller = _DiscoveryPoller(
dumps={"com.netflix.ninja": DUMP_NETFLIX_REAL}, on_device_grep=True
)
manager = _manager(poller)
# nothing names this package, and the probe still RAN: a definitive answer
assert await manager._dump_label("com.netflix.ninja") == ""
commands = [c for c in poller.shell_calls if "pm dump" in c]
assert len(commands) == 1
assert "head -300" not in commands[0] # the defect: it cut the dump short
assert "grep -E" in commands[0] # the fix: filtered before the wire
for shape in ("application-label", "nonLocalizedLabel", "labelRes"):
assert shape in commands[0], shape
async def test_one_shell_per_package_then_the_device_is_believed(self):
poller = _DiscoveryPoller(
dumps={"com.netflix.ninja": DUMP_NETFLIX_REAL}, on_device_grep=True
)
manager = _manager(poller)
rows = await manager.async_refresh_installed_apps()
dumps = [c for c in poller.shell_calls if "pm dump" in c]
# (b) the sweep did NOT spend one shell per package: on this device 5
# label-less answers in a row are the device-level conclusion, and only the
# packages already in flight can add to that count
from nsh.const import APP_LABEL_PROBE_LIMIT, APP_SWEEP_CONCURRENCY
assert len(dumps) <= APP_LABEL_PROBE_LIMIT + APP_SWEEP_CONCURRENCY + 1
assert len(dumps) < len(USER_PACKAGES.splitlines())
assert manager.app_labels_absent is True
assert "Netflix" in {row["name"] for row in rows}
# a forced re-sweep costs ZERO further probes: every package is cached
manager._installed_apps = None
await manager.async_refresh_installed_apps(force=True)
assert [c for c in poller.shell_calls if "pm dump" in c] == dumps
async def test_the_curated_name_is_what_surfaces(self):
"""(c) the name the user sees, on every surface that shows one."""
from nsh.const import DEFAULT_APP_DIRECTORY
from nsh.ha_adb_common import resolve_package_name
from nsh.media_player import resolve_app_name
poller = _DiscoveryPoller(
dumps={"com.netflix.ninja": DUMP_NETFLIX_REAL}, on_device_grep=True
)
manager = _manager(poller)
rows = await manager.async_refresh_installed_apps()
by_pkg = {row["package"]: row for row in rows}
# the discovery table
assert by_pkg["com.netflix.ninja"]["name"] == "Netflix"
# the chain the entities call: no user map, and the device named nothing
assert resolve_app_name("com.netflix.ninja", {}, manager.app_label) == "Netflix"
assert (
resolve_package_name(
"com.netflix.ninja", device_label="", curated=DEFAULT_APP_DIRECTORY
)
== "Netflix"
)
# ...and the installed-apps sensor publishes the same table
sensor_module = load_entity_modules()["sensor"]
sensor = sensor_module.NvidiaShieldAppsSensor.__new__(
sensor_module.NvidiaShieldAppsSensor
)
entry = type("E", (), {})()
entry.entry_id = "e1"
entry.options = {}
entry.runtime_data = manager
entry.title = "mdarcy"
sensor.hass = entity_stublib.FakeHass()
sensor._entry = entry
assert any(
app["package"] == "com.netflix.ninja" and app["name"] == "Netflix"
for app in sensor.extra_state_attributes["installed_apps"]
)
def test_the_name_chain_is_one_order_everywhere():
"""user map > device label > curated > raw id, in ONE pure function (t66)."""
from nsh.ha_adb_common import resolve_package_name
curated = {"com.curated": "Curated", "com.both": "Curated Both"}
user = {"com.mapped": "Mapped", "com.both": "Mapped Both"}
assert resolve_package_name("com.mapped", user_map=user, curated=curated) == "Mapped"
assert resolve_package_name("com.curated", device_label="From Device") == "From Device"
assert resolve_package_name("com.curated", curated=curated) == "Curated"
assert resolve_package_name("com.both", user_map=user, curated=curated) == "Mapped Both"
# the device label sits BELOW the user map and ABOVE the curated table
assert (
resolve_package_name("com.both", user_map={}, device_label="D", curated=curated)
== "D"
)
# the id is shown ONCE, and an empty package has no name at all
assert resolve_package_name("com.nobody", curated=curated) == "com.nobody"
assert resolve_package_name("", curated=curated) is None
assert resolve_package_name(None) is None
# a blank device label is not a name (it is what a memo'd device returns)
assert resolve_package_name("com.curated", device_label="", curated=curated) == "Curated"
assert resolve_package_name("com.curated", device_label=" ", curated=curated) == "Curated"
def test_the_device_label_lookup_is_lazy():
"""A mapped app must not cost a device probe (t66).
``media_player.resolve_app_name`` passes its ``app_label`` accessor, which
ENQUEUES an on-demand fetch on a miss: calling it for an app the user already
named would spend a shell to learn something nobody reads.
"""
from nsh.ha_adb_common import resolve_package_name
calls: list[str] = []
def _device_label(package):
calls.append(package)
return None
assert (
resolve_package_name(
"com.mapped", user_map={"com.mapped": "Mapped"}, device_label=_device_label
)
== "Mapped"
)
assert calls == [] # the device was never asked
assert resolve_package_name("com.other", device_label=_device_label) == "com.other"
assert calls == ["com.other"] # ...and it IS asked when the map misses