t66: app names on a device that exposes none

The reported Shield names no app: `pm dump com.netflix.ninja` is ~2241 lines of
dumpsys and grep for `application-label` over the whole capture finds nothing.
The probe shipped `| head -300`, which cut the dump BEFORE the package section,
so "no label" was a truncation artefact; the sweep then named every app after
its own package id, and the apps editor pre-filled the `pkg=pkg` row the user
read as a bug.

  * the probe greps ON THE DEVICE for the three shapes Android prints
    (`application-label`, `nonLocalizedLabel`, `labelRes`); a quoted value is a
    name, `null` and a resource id are not. The dump is never truncated.
  * after APP_LABEL_PROBE_LIMIT consecutive "ran, no label" answers the device
    is taken at its word: the sweep stops spending a shell per package
    (APP_LABEL_TTL expiry re-checks, and a package whose probe FAILED still owes
    its retry — t57 UNIT 0 survives, driven by the app on screen).
  * ONE precedence chain in `ha_adb_common/app_label.resolve_package_name`, used
    by the media player, the Current App sensor, the discovery table, the source
    list and the apps editor: user map > device label > curated map > package id
    (the id shown ONCE, `name` empty when nothing names the app).
  * the curated table covers the user's 21 apps (community-maintained, not
    device truth) and the Configure row says when the device named nothing.
  * the real device shape is a test fixture (trimmed, and marked as such in the
    file: the raw capture is not in the repo) pinning that no label is
    extracted, that the probe count is bounded, and that `Netflix` surfaces.

Also drops the dead `ATTR_MEDIA_DAG_NOW_PLAYING` re-exports left by the t65
flattening and updates the RESEARCH_SHIELD now-playing note to the flat names.

No real Shield was available: the fetch/burst behaviour is exercised against the
documented Android output and the trimmed reproduction of the reported capture.
This commit is contained in:
James Mackay committed 2026-10-12 10:54:38 +01:00
1 parent f29f62ddae
commit 7b32de789a
16 files changed
+973 -176

No files matched your search

+75 -28
View File
@@ -244,7 +244,7 @@ What each retired piece used to provide, and what covers it now:
| Retired piece | Covered by |
|---------------|------------|
| `androidtv` playback state | the ADB media-session state ladder (`playing` / `paused` / `buffering` / `idle` / `off`, plus `on` only when no source reported a power or playback fact) |
| `androidtv` app name / `app_id` lookups | the manager's store + the app-name map + the manifest label of the app that is currently on screen (fetched on demand; a failed fetch is retried on the next poll instead of being cached as a permanent miss) |
| `androidtv` app name / `app_id` lookups | the manager's store + the one app-name chain: your apps map, then the device's own label for the app on screen (fetched on demand; a failed fetch is retried on the next poll instead of being cached as a permanent miss), then the built-in app names, then the package id |
| `androidtv_remote` power, volume and key commands | the protocol transport: the `remote` entity, `media_player` volume, `KEYCODE_*` commands |
| `androidtv_remote` availability | the availability contract (protocol `is_on`, ADB awake, power switch) |
| `universal` media player templates | the media player entity's own attributes plus the `media_dag_*` attributes |
@@ -391,11 +391,16 @@ The **Apps** row opens a small editor rather than one long form. Home Assistant
*Edit* and *Remove* are offered only while there is something to act on: a table with
no remembered rows and no discovery yet shows just the text field and *Add an app*. The
editor's description lists every app it knows — the rows you saved, and the ones
discovery found, marked `[detected]`. **Edit** acts on both (that is the point: the
Shield exposes no app labels of its own, so a detected app reads as `name (package id)`
or as `package id (no name yet)`, and picking it is how you give it a name — saving
stores it as one of your own rows). **Remove** acts only on rows you saved, because a
detected app has no line to delete (it would simply come back with the next sweep).
discovery found, marked `[detected]`. **Edit** acts on both (that is the point: an app
the device does not name reads as `Your Name (package id)` when the map or the built-in
app names know it, and as `package id (no name yet)` when nothing does — picking it is how
you give it a name, and saving stores it as one of your own rows). **Remove** acts only on
rows you saved, because a detected app has no line to delete (it would simply come back
with the next sweep).
A discovered app that nothing can name is **never pre-filled into the text field** as
`package=package`: a row like that would store the package id as if it were a name. It is
offered in the row editor instead, where "no name yet" says what it is.
Removing a row never uninstalls anything from the Shield — the app simply stops being
offered. Rows you do not touch are left exactly as they were.
@@ -508,10 +513,10 @@ outcome that leaves you guessing.
### App discovery (what is actually installed)
The Configure page can ask the Shield which apps it has, so the **Apps** table does not
start from a four-entry guess. Pick **Discover installed apps** on the Configure menu and
the table is rebuilt from two ADB queries — and the row then **says what happened** (see
*What the Discover row tells you* below), because an action that appears to do nothing is
indistinguishable from a broken integration:
start from the built-in app names alone. Pick **Discover installed apps** on the Configure
menu and the table is rebuilt from two ADB queries — and the row then **says what happened**
(see *What the Discover row tells you* below), because an action that appears to do nothing
is indistinguishable from a broken integration:
| Command | What it returns | How it is treated |
| --- | --- | --- |
@@ -529,15 +534,44 @@ capture: no real Shield was available when this was written, so
fixtures — the same "inferred, not observed" mark the state=6 buffering value
carries in the source ladder above.)*
Names come from the same cached path the Current App sensor uses: a `pm dump` per
package, parsed for `application-label`, cached for 24 h. A full sweep runs the dumps
in a small burst (4 at a time, 50 ms apart), which finishes a typical Shield in
seconds instead of the minutes a strictly serialised sweep would take; the commands
share the watchdog's own ADB connection, and `adb-shell` serialises the I/O on it, so
the burst queues politely instead of interleaving on the socket. A label fetch
that **fails is not cached**, so that app shows its package id in this table and the
next sweep tries again — this is the same rule as the entity path, and it is why a
transient ADB hiccup no longer sticks.
Names come from the same cached path the Current App sensor uses, and there is ONE
chain behind every surface — this table, `media_player.app_name`/`source`, the Current
App sensor and the Apps editor. In order, the first answer wins:
1. **your Apps map** — your own edit always wins;
2. **the device's own label** for that app, read from `pm dump <package>`: the probe
greps **on the device** for the label shapes Android prints — `application-label:`,
`nonLocalizedLabel=` and `labelRes=` — so only those lines cross ADB (a real dump on
one reported Shield is ~2241 lines of dumpsys, and the old `head -300` cut it before
the package section even began, which is why it looked like "no labels");
a quoted value counts, `null` and a resource id like `0x7f0f0d31` do not;
3. **the built-in app names** (see *Sources* below) — a curated, community-maintained
`package → name` table;
4. **the package id**, shown **once** (never as `pkg=pkg` in a row).
A label probe is cached for 24 h, and its outcome is remembered either way: a probe that
**ran** and found nothing is a definitive negative (zero further round trips for that
app), while a probe that **could not run** is not cached at all and is retried on the next
poll — this is the same rule as the entity path, and it is why a transient ADB hiccup no
longer sticks.
A full sweep runs a handful of probes at a time (4 at a time, 50 ms apart), which
finishes a typical Shield in seconds instead of the minutes a strictly serialised sweep
would take; the commands share the watchdog's own ADB connection, and `adb-shell`
serialises the I/O on it, so the burst queues politely instead of interleaving on the
socket.
**Some devices expose no labels at all.** The reported Shield is one: nothing in that
2241-line dump names any app. So after 5 probes in a row come back "ran, no label" the
integration stops spending a shell per package for the rest of the 24 h TTL: the sweep
answers the remaining packages from the map instead of probing them, names come from
tiers 1, 3 and 4, and a later device change or update is re-checked when the TTL
expires. The Configure menu says so in its verdict when it happens, and the table's
`name` is then your map's name, the built-in name, or **empty** — an app nothing can name
keeps the id in `package` alone rather than inventing a name out of it.
An app that nothing names is still offered by the **Apps** row editor (it reads
"`com.example.app` (no name yet)"), which is where you give it one.
Icons are **best effort and explicitly heuristic**: `pm dump` does not expose an app's
icon resource (an extracted APK's icon is a bitmap only its own code can resolve), so
@@ -563,6 +597,7 @@ one-line verdict, which replaces the usual "not discovered yet" note:
| --- | --- |
| `N apps found on the Shield — the table is updated.` | the sweep ran, and the editor's text field now carries the discovered rows |
| `the Shield reported no installed apps — the table keeps your saved rows.` | the sweep ran and found nothing to add; your rows are untouched |
| `N apps found — this Shield exposes no app labels over ADB, so names come from the built-in app names and your own rows.` | the sweep ran, and the device itself named nothing: the per-package probes have stopped (see *App discovery* above) |
| `the discovery sweep could not reach the Shield — check the ADB connection; your saved rows are unchanged.` | the sweep failed; nothing was cached as a result and nothing you wrote was lost |
| `ADB is not set up for this entry — nothing could be discovered.` | this entry has no ADB manager (a remote-protocol-only setup), so there is nothing to sweep |
@@ -588,11 +623,20 @@ sweep could not run), while the Configure row forces one.
### Sources (the app list in a dropdown)
The media player's source list is your **Apps** map — the same map app-name resolution
uses. With no map configured it falls back to a small built-in list
(TiviMate, Wholphin, Netflix, YouTube), so a universal-media-player card always has
something to show. Discovery fills the **Configure** table, not this fallback: the
dropdown stays exactly the map you saved, so what a card offers is always what you
chose. An app that discovery found and you want in the dropdown is one row to add.
uses. With no map configured it falls back to the built-in app names, which on a device
that exposes no labels *is* the naming: the table covers the apps the reported Shield has
(Projectivy Launcher, Samba (SMB), X-plore, Plex Media Server, TiviMate, Hyperion Grabber,
Prime Video, MediaSession2MQTT, Speech Services by Google, Button Mapper, Wholphin,
Findroid, Pluto TV, YouTube, Downloader, TV Quick Actions, Argosy, Netflix, RetroArch and
the Google TV launcher, plus Google's speech services), so a card shows
"Netflix" instead of `com.netflix.ninja` with nothing configured at all.
That table is a **community-maintained convenience, not device truth**: package ids are
stable, but display names change with vendors and app updates, and an app it has never
heard of falls through to its package id. Your own map overrides any row in it — one line
in **Apps** — and discovery fills the **Configure** table, not this fallback, so the
dropdown stays exactly what you saved. An app that discovery found and you want in the
dropdown is one row to add.
**Apps are sources, not browser media.** This integration no longer publishes the app
directory through HA's media **Browser**: the Browse dialog shows an **empty tree** for
@@ -610,9 +654,11 @@ entity: media_player.living_room_shield
```
- **`source_list`** — the app names. They come from the map (in the order it lists
them), so editing the map renames the dropdown entries.
- **`source`** — the app currently on screen, resolved exactly like the Current App
sensor: your map first, then the cached device label, then the raw package.
them), so editing the map renames the dropdown entries; with no map, the built-in
app names above.
- **`source`** — the app currently on screen, resolved by the same chain the Current App
sensor uses: your map first, then the cached device label, then the built-in app name,
then the raw package id.
- **`media_player.select_source`** — picks one **by name** and launches it over the
protocol, the same path `play_media` uses for apps. A raw package id works too (that
holds the package id), names match case-insensitively, and a name that
@@ -640,7 +686,8 @@ The map is your own override, so two shapes are accepted rather than papered ove
- A **padded value** typed by hand (`com.x= Netflix `) is shown cleaned in the
dropdown, but `app_name`, `source` and the Current App sensor
display the map value **verbatim** — so `source` can be a string that is not in
`source_list`. A blank value falls through to the raw package instead.
`source_list`. A blank value falls through the rest of the chain instead: the device
label, then the built-in app name, then the raw package id.
### Remote command script
+28 -8
View File
@@ -154,11 +154,15 @@ def _apps_table_text(
"""The Editable apps table: stored rows first, then discovered apps (t57).
Every app the Shield reports gets a row, so the page opens on the REAL app
list instead of the four curated entries ("it doesn't find all the apps").
list instead of only the built-in names ("it doesn't find all the apps").
The stored rows keep their position, name and icon — the user's edit is
never overwritten by a discovery — and a discovered app the map does not
mention is appended with the device label (or its package id when the label
fetch did not answer) and its heuristic icon.
mention is appended with the name the SAME chain resolves (user map > device
label > curated map) and its heuristic icon. An app whose name is unknown
anywhere is NOT written as ``pkg=pkg``: a row that saves the package id as if
it were a name is the defect the user reported from this very field, so such
an app is offered in the row editor (which says "no name yet" out loud)
instead of being pre-filled here.
The icon column is emitted only for a stored icon, because the discovered
icon is an MDI NAME (``mdi:netflix``) and the icon column's documented
@@ -178,7 +182,11 @@ def _apps_table_text(
if not package or package in seen:
continue
seen.add(package)
name = app.get("name") or package
name = str(app.get("name") or "").strip()
if not name or name == package:
# nothing names this app: the id would be doubled into a "name" that
# is not one (t66). The row editor still offers it for naming.
continue
lines.append(f"{package}={name}")
return "\n".join(lines)
@@ -1287,6 +1295,13 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
"the Shield reported no installed apps — the table keeps your "
"saved rows."
)
if getattr(manager, "app_labels_absent", False):
# Measured, not guessed: the sweep probed and this firmware named
# nothing, so say which names the user is looking at (t66).
return (
f"{len(apps)} apps found — this Shield exposes no app labels over "
"ADB, so names come from the built-in app names and your own rows."
)
return f"{len(apps)} apps found on the Shield — the table is updated."
async def async_step_menu_again(self, user_input=None):
@@ -1530,19 +1545,24 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
"""The apps the device reported, ``{package: {name, icon}}`` (t64).
Straight from the t57 cache — no sweep, no shell. The name may be empty:
the user's Shield exposes no device label at all, and a package whose
``pm dump`` failed falls back to its id, which is NOT a name (see
``_editable_apps``).
the user's Shield exposes no device label at all (t66: the discovery table
no longer repeats the package id as a name), and an app nothing names is
left empty so ``_editable_apps`` can mark it as needing a name.
"""
detected: dict = {}
for app in self._discovered_apps():
package = str(app.get("package") or "").strip()
if not package:
continue
name = str(app.get("name") or "").strip()
if name == package:
# a name that IS the id is not a name (t66): the row editor says
# "(no name yet)" instead of offering the id twice.
name = ""
detected.setdefault(
package,
{
"name": str(app.get("name") or "").strip(),
"name": name,
"icon": str(app.get("icon") or "").strip(),
},
)
+166 -17
View File
@@ -39,11 +39,15 @@ from .const import (
APP_ICON_FALLBACKS,
APP_ICON_GENERIC,
APP_LABEL_EXIT_MARKER,
APP_LABEL_GREP_SHAPES,
APP_LABEL_PROBE_LIMIT,
APP_LABEL_TTL,
APP_LIST_TTL,
APP_SWEEP_CONCURRENCY,
APP_SWEEP_SPACING,
CONF_APP_NAME_MAP,
CONF_LOGCAT_EVENTS,
DEFAULT_APP_DIRECTORY,
LOGCAT_SUPPRESS_WINDOW,
STATE_STORE_TTL,
SYSTEM_MEDIA_PACKAGES,
@@ -54,8 +58,9 @@ from .ha_adb_common import (
icon_for_package,
installed_packages,
is_launchable,
parse_application_label,
parse_app_label,
parse_audio_playback,
resolve_package_name,
)
_LOGGER = logging.getLogger(__name__)
@@ -224,6 +229,16 @@ class ShieldConnectionManager:
# t55: packages whose label fetch is queued or in flight — a render
# during the fetch must not enqueue the same package twice.
self._app_label_inflight: set[str] = set()
# t66: the DEVICE-level "this firmware exposes no labels" memo. Set after
# APP_LABEL_PROBE_LIMIT consecutive probes that RAN and found nothing (one
# label-less package proves nothing — see _dump_label), and it expires
# with APP_LABEL_TTL so a changed device is re-checked within a day.
self._labels_absent_at: float | None = None
self._label_negative_streak: int = 0
# t66: packages whose probe FAILED (transport delivered nothing) and which
# therefore still owe a retry even while the memo above is set — t57's
# "a failed fetch is never cached as an answer" survives the memo.
self._app_label_failed: set[str] = set()
# t57: the discovered installed-apps table — (rows, stamp), or None when
# no sweep has produced a fresh answer. Only a sweep that RAN writes
# here (t62 F1): a failed or unavailable sweep records itself as UNKNOWN
@@ -624,6 +639,68 @@ class ShieldConnectionManager:
self._app_label_requests.add(package)
return None
def _probe_still_owed(self, package: str) -> bool:
"""True when a package's only remaining question is a RETRY (t66).
A probe that FAILED (no poller, shell error, no output at all) records
the package here and caches nothing, so the t57 UNIT 0 guarantee — a
failed fetch is retried, never stamped as an answer — survives the
device-level memo below. A probe that RAN and found no label is not owed
anything: it answered.
"""
return package in self._app_label_failed
def _labels_absent(self) -> bool:
"""The device-level "no labels here" memo, expiring with APP_LABEL_TTL.
A conclusion about a piece of firmware cannot be permanent — the user may
swap the device, update it, or add an app that does carry a label — so
after APP_LABEL_TTL the memo clears itself and one probe re-checks the
device (t66).
"""
if self._labels_absent_at is None:
return False
if self._clock() - self._labels_absent_at > APP_LABEL_TTL:
self._labels_absent_at = None # re-probe once per TTL
self._label_negative_streak = 0
return False
return True
@property
def app_labels_absent(self) -> bool:
"""Public read of the memo (tests, and the options page's own note)."""
return self._labels_absent_at is not None and (
self._clock() - self._labels_absent_at <= APP_LABEL_TTL
)
def _entry_app_name_map(self) -> dict:
"""The user's own apps map from the entry options ({} when unset)."""
options = getattr(self.entry, "options", None) or {}
return dict(options.get(CONF_APP_NAME_MAP) or {})
def _resolved_app_name(self, package: str, device_label=None, user_map=None) -> str:
"""The display name for a discovered app, or "" when nothing names it (t66).
The ONE place the discovery table's name comes from, so it obeys the same
chain as the media player and the Current App sensor
(:func:`~.ha_adb_common.app_label.resolve_package_name`).
"" is deliberate: the row's ``package`` field already carries the id, and
a name that repeats it is exactly the ``pkg=pkg`` row the user reported
from the apps editor. Every consumer already reads an empty name as "this
app needs a name" (the editor says so out loud; the installed-apps sensor
publishes it as-is).
"""
if user_map is None:
user_map = self._entry_app_name_map()
name = resolve_package_name(
package,
user_map=user_map,
device_label=device_label,
curated=DEFAULT_APP_DIRECTORY,
)
return "" if not name or name == package else name
def _known_label(self, package: str) -> tuple[bool, str]:
"""``(known, label)`` from the label cache — "known" = a fetch RAN (t57).
@@ -682,28 +759,66 @@ class ShieldConnectionManager:
except Exception as err:
_LOGGER.debug("protocol device info unavailable: %s", err)
async def _dump_label(self, package: str) -> str | None:
"""One `pm dump` label read: the label, or None for a FAILED fetch.
async def _dump_label(self, package: str, *, on_demand: bool = False) -> str | None:
"""One label read: the label, or None for a FAILED fetch (t66).
The ONE place the dump is issued, shared by the on-demand path
The ONE place the probe is issued, shared by the on-demand path
(:meth:`_fetch_app_label`) and the discovery sweep, so the t57 UNIT 0
distinction — "the command ran and found no label" versus "the
transport delivered nothing" — cannot drift between them. A successful
fetch is also cached here, so both paths benefit from the 24 h cache and
neither double-stamps.
Returns the label (possibly "") when the dump RAN, and None when it did
WHAT CHANGED IN t66, and why the old shape could not work on the user's
Shield: the probe used to ship ``pm dump <pkg> | head -300`` and parse the
answer with ``parse_application_label`` alone. A real dump on that device
is ~2241 lines of dumpsys, so ``head -300`` cut before the package
sections even appeared — the "no label" answer was a truncation
artefact, not a fact about the app. The probe now searches ON THE DEVICE
for every label shape Android prints (``APP_LABEL_GREP_SHAPES``) and only
those lines cross the wire, which is both smaller and honest.
A device that has already answered "no labels" for a run of packages is
NOT probed again (``APP_LABEL_PROBE_LIMIT``, expiring with
``APP_LABEL_TTL``): the sweep's per-package fan-out stops there and a
package the device has never been asked about is answered "" from the
memo, with no shell at all. The two exceptions are deliberate: a package
whose probe FAILED still owes its retry (``_probe_still_owed``), and that
retry is only ever driven by the app ON SCREEN (``on_demand``) — one
shell, once, user-driven, then cached.
The two meanings of "" must stay distinct in the reader's head: a probe
that RAN and found nothing is a cached answer (zero further shells), and a
probe that COULD NOT run is not cached at all — it is recorded as owed and
retried for the app on screen.
Returns the label (possibly "") when the probe RAN, and None when it did
not run or delivered no output at all.
The exit marker is a liveness tell, not a status code (t62 nit b): it sits
after a ``head`` pipeline, and the Android shell that runs this has no
PIPESTATUS, so ``$?`` is ``head``'s status rather than the dump's. Nothing
reads its VALUE — ``parse_application_label`` strips the marker line — and
the only property tested here is that a command which RAN leaves non-blank
output even when the device prints no label. That non-blankness is what
after a pipeline, and the Android shell that runs this has no PIPESTATUS,
so ``$?`` is the last command's status rather than the dump's. Nothing
reads its VALUE — the parser ignores the marker line — and the only
property tested here is that a command which RAN leaves non-blank output
even when the device prints no label. That non-blankness is what
separates "ran, no label" from "the transport delivered nothing", which
this method turns into a cached negative versus a retry.
"""
if self._labels_absent():
if not self._probe_still_owed(package):
# The firmware already answered; a shell here would spend a round
# trip on a settled question (t66). The negative is cached so the
# render path stops asking cycle after cycle.
self.cache_app_label(package, "")
return ""
if not on_demand:
# a retry is not the sweep's job: the fan-out stops here
return ""
_LOGGER.debug(
"retrying the failed label probe for %s on the app now on "
"screen (the device reports no labels, but this one never "
"answered)",
package,
)
poller = self._poller
if poller is None or not self.connected:
_LOGGER.debug(
@@ -711,12 +826,14 @@ class ShieldConnectionManager:
"retry on the next render",
package,
)
self._app_label_failed.add(package)
return None
try:
import shlex
dump = await poller.shell(
f"pm dump {shlex.quote(package)} 2>/dev/null | head -300; "
f"pm dump {shlex.quote(package)} 2>/dev/null | "
f"grep -E {shlex.quote(APP_LABEL_GREP_SHAPES)} | head -40; "
f"echo {APP_LABEL_EXIT_MARKER}$?"
)
except Exception as err:
@@ -726,6 +843,7 @@ class ShieldConnectionManager:
package,
err,
)
self._app_label_failed.add(package)
return None
if dump is None or not dump.strip():
# the transport never delivered output: a FAILED fetch, not an
@@ -735,12 +853,37 @@ class ShieldConnectionManager:
"the next render",
package,
)
self._app_label_failed.add(package)
return None
label = parse_application_label(dump)
# F1: label-less pm dumps negatively cache too (empty label) so a
# later cycle performs zero additional pm dumps for the package.
self._app_label_failed.discard(package) # it answered — nothing owed
label = parse_app_label(dump)
if label:
# This firmware DOES name apps: a run of misses before it proved nothing.
self._label_negative_streak = 0
else:
self._label_negative_streak += 1
if (
self._label_negative_streak >= APP_LABEL_PROBE_LIMIT
and self._labels_absent_at is None
):
# A RUN of label-less answers, each of which really ran: this
# device keeps its labels somewhere we cannot read, so every
# further per-package probe is a shell spent on a settled
# question (t66). The conclusion expires with APP_LABEL_TTL.
self._labels_absent_at = self._clock()
_LOGGER.info(
"%d label probes in a row found no app label — this device "
"exposes none; names now come from the curated map and the "
"user's own apps map for the next %s seconds",
self._label_negative_streak,
APP_LABEL_TTL,
)
# F1: label-less probes negatively cache too (empty label) so a later
# cycle performs zero additional probes for the package.
self.cache_app_label(package, label)
return label
# "" (not None) is the "the probe RAN" answer, even when the device named
# nothing: None is reserved for a fetch that could not run (t57 UNIT 0).
return label or ""
async def _fetch_app_label(self, package: str) -> None:
"""One on-demand `pm dump` label fetch; stamps ONLY a fetch that RAN.
@@ -752,7 +895,7 @@ class ShieldConnectionManager:
"""
self._app_label_inflight.add(package)
try:
await self._dump_label(package)
await self._dump_label(package, on_demand=True)
finally:
self._app_label_inflight.discard(package)
@@ -1049,10 +1192,16 @@ class ShieldConnectionManager:
packages = [p for p in installed_packages(user_output, launcher_output)
if is_launchable(p)]
labels = await self._sweep_labels(packages)
# t66: ONE precedence chain for the table too — the user's own map, then
# the device's label (when it exposes one), then the curated map, then the
# package id. An app nothing can name gets name "" rather than its own id:
# the row already carries the id in ``package``, and writing it twice is
# the ``pkg=pkg`` the user read as a bug in the apps editor.
user_map = self._entry_app_name_map()
rows = [
{
"package": package,
"name": labels.get(package) or package,
"name": self._resolved_app_name(package, labels.get(package), user_map),
"icon": icon_for_package(package, APP_ICON_FALLBACKS, APP_ICON_GENERIC),
}
for package in packages
+48 -4
View File
@@ -3,7 +3,6 @@
# Re-export shared Media DAG constants
from .ha_adb_common.const import ( # noqa: F401
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
ATTR_MEDIA_DAG_NOW_PLAYING,
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
ATTR_MEDIA_DAG_RECEIVER_INPUT,
ATTR_MEDIA_DAG_ROLE,
@@ -128,6 +127,26 @@ APP_LABEL_TTL = 24 * 3600
# unlikely to collide with a manifest line.
APP_LABEL_EXIT_MARKER = "__NSH_EXIT:"
# t66: the label candidates searched FOR ON THE DEVICE. A real `pm dump` on the
# user's Shield is ~2241 lines of dumpsys (package + activity + meminfo +
# procstats + usagestats + batterystats), and the old fetch shipped
# ``| head -300`` — which cut before the package sections even appeared, so the
# negative was a truncation artefact rather than an answer. Searching on-device
# keeps the wire small AND the answer honest: these are the three shapes Android
# prints for an app's label (see ha_adb_common/app_label.py for what counts as a
# name in each).
APP_LABEL_GREP_SHAPES = "application-label|nonLocalizedLabel|labelRes"
# t66: how many label probes may come back "ran, no label" IN A ROW before the
# device is treated as one that exposes no labels at all (this user's Shield).
# One label-less package is ordinary (system/OEM packages often have none), so a
# single miss proves nothing — but a run of them means the whole firmware keeps
# its labels in resources we cannot read, and every further ``pm dump`` per
# package is a shell spent on a question already answered. The conclusion expires
# with APP_LABEL_TTL so a device change is noticed within a day, and any label
# found anywhere resets the run.
APP_LABEL_PROBE_LIMIT = 5
# P4-2a adb_command service: rawADB shell on this entry — OFF by default
# (same security posture as the core androidtv integration).
CONF_ADB_COMMAND_ENABLED = "adb_command_enabled"
@@ -142,13 +161,38 @@ CONF_LOGCAT_EVENTS = "logcat_events"
# re-stamped (repeat suppression).
LOGCAT_SUPPRESS_WINDOW = 15.0
# P4-2c BrowseMedia app directory fallback when the options map is empty
# (curated; the packages are the plan's reference apps).
# P4-2c/t66: the curated package -> name table. It is BOTH the BrowseMedia/source
# directory fallback and the third tier of the app-name chain (after the user's
# own map and a device label, before the raw package id) — on a Shield whose
# `pm dump` carries no label at all it is the answer that makes names work.
#
# It is a community-maintained convenience, NOT device truth: package ids are
# stable, display names change with vendors and app updates, and an app this
# table has never heard of falls back to its package id (shown once). Users
# override any entry in Configure > Apps, and that edit always wins. Adding a row
# is deliberately trivial — keep the ids sorted so the diff stays reviewable.
DEFAULT_APP_DIRECTORY = {
"ar.tvplayer.tv": "TiviMate",
"be.digitalia.mediasession2mqtt": "MediaSession2MQTT",
"com.amazon.amazonvideo.livingroom": "Prime Video",
"com.esaba.downloader": "Downloader",
"com.github.damontecres.wholphin": "Wholphin",
"com.netflix.ninja": "Netflix",
"com.google.android.sambadocumentsprovider": "Samba (SMB)",
"com.google.android.speech.pumpkin": "Google Speech Services",
"com.google.android.tts": "Speech Services by Google",
"com.google.android.tvlauncher": "Google TV launcher",
"com.google.android.youtube.tv": "YouTube",
"com.hyperion.grabber": "Hyperion Grabber",
"com.lonelycatgames.Xplore": "X-plore File Manager",
"com.nendo.argosy": "Argosy",
"com.netflix.ninja": "Netflix",
"com.plexapp.mediaserver.smb": "Plex Media Server",
"com.retroarch": "RetroArch",
"com.spocky.projengmenu": "Projectivy Launcher",
"dev.jdtech.jellyfin": "Findroid",
"dev.trooped.tvquickbars": "TV Quick Actions",
"flar2.homebutton": "Button Mapper",
"tv.pluto.android": "Pluto TV",
}
# t57: best-effort icon per discovered app. `pm dump` does NOT expose the
@@ -1,10 +1,15 @@
"""Shared ADB utilities for Home Assistant custom integrations."""
from .adb_client import AdbClientBase, parse_wakefulness
from .app_label import parse_application_label
from .app_label import (
parse_app_label,
parse_application_label,
parse_label_res,
parse_non_localized_label,
resolve_package_name,
)
from .const import (
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
ATTR_MEDIA_DAG_NOW_PLAYING,
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
ATTR_MEDIA_DAG_RECEIVER_INPUT,
ATTR_MEDIA_DAG_ROLE,
@@ -31,7 +36,6 @@ from .running_apps import running_apps_from_sessions, running_apps_from_tasks
__all__ = [
"ATTR_MEDIA_DAG_JELLYFIN_ENTITY",
"ATTR_MEDIA_DAG_NOW_PLAYING",
"ATTR_MEDIA_DAG_RECEIVER_ENTITY",
"ATTR_MEDIA_DAG_RECEIVER_INPUT",
"ATTR_MEDIA_DAG_ROLE",
@@ -41,7 +45,11 @@ __all__ = [
"PlaybackIntent",
"ensure_adb_key",
"parse_all_media_sessions",
"parse_app_label",
"parse_application_label",
"parse_label_res",
"parse_non_localized_label",
"resolve_package_name",
"parse_audio_playback",
"parse_foreground",
"foreground_from_logcat",
@@ -1,19 +1,55 @@
"""App friendly-name resolution from the package manifest (P5-1).
"""App friendly-name resolution from the package dump (P5-1, t66).
Parses `pm dump <package>` output for the ``application-label`` line — the
default-locale variant; localized suffixed variants
(``application-label-<lang>:``) are skipped, and multi-label blocks across
locales are tolerated (first default-locale label wins), mirroring the
tolerant pattern of the androidtv-adb core helper.
Parses the label candidate LINES of a `pm dump <package>` / `dumpsys package`
answer. Three shapes are searched, in priority order, because Android firmware
prints different ones (and one of them on this user's Shield prints nothing at
all):
1. ``application-label: 'Netflix'`` — the AAPT-style manifest label, with its
localized suffixed variants (``application-label-<lang>:``) as a fallback.
2. ``nonLocalizedLabel=Netflix`` — the ApplicationInfo field. It is usually
``null`` on a TV device (the label comes from a resource), so a null/empty
value is not a name.
3. ``labelRes=0x7f0f0001`` — a RESOURCE ID, not text. A dump can only name the
app here if it prints a quoted string, so a numeric/hex id is deliberately
NOT used as a name: we would be showing ``0x7f0f0001`` as if it were a name.
Nothing here is a guess about which line exists: every branch is anchored on the
literal prefix Android prints, and a miss returns None so the caller falls
through to the curated map (the answer on a device with no labels at all).
Also exports the ONE package -> name precedence chain, so the media player, the
Current App sensor, the discovery table and the options editor cannot drift:
user map > device label > curated map > raw package id.
"""
import re
#: ``application-label: 'X'`` / ``application-label-de: "X"`` — the AAPT form.
_APPLICATION_LABEL_RE = re.compile(
r"application-label(?:-[A-Za-z_-]+)?:\s*'([^']*)'"
r'|application-label(?:-[A-Za-z_-]+)?:\s*"([^"]*)"'
)
#: ``nonLocalizedLabel=X`` — bare word, quoted string, or ``null``.
_NON_LOCALIZED_LABEL_RE = re.compile(
r"nonLocalizedLabel=(?:'([^']*)'|\"([^\"]*)\"|([^\s,}]+))"
)
#: ``labelRes=0x7f0f0001`` — only a QUOTED value is text; an id is not a name.
_LABEL_RES_RE = re.compile(r"labelRes=(?:'([^']*)'|\"([^\"]*)\")")
#: Values a dump prints that are not names.
_NOT_A_NAME = ("", "null", "0x0", "0")
def _clean(value: str | None) -> str | None:
"""A candidate label, or None when it is empty/``null``."""
text = (value or "").strip()
if text.lower() in _NOT_A_NAME:
return None
return text
def parse_application_label(dump: str) -> str | None:
"""The package's default-locale application label, or None.
@@ -24,7 +60,92 @@ def parse_application_label(dump: str) -> str | None:
if not dump:
return None
for match in _APPLICATION_LABEL_RE.finditer(dump):
label = (match.group(1) or match.group(2) or "").strip()
if label and label not in ("null", ""):
label = _clean(match.group(1) or match.group(2))
if label:
return label
return None
def parse_non_localized_label(dump: str) -> str | None:
"""The ``nonLocalizedLabel=`` value, or None (t66).
``null`` is the common case on a TV and is NOT a name.
"""
if not dump:
return None
for match in _NON_LOCALIZED_LABEL_RE.finditer(dump):
label = _clean(match.group(1) or match.group(2) or match.group(3))
if label:
return label
return None
def parse_label_res(dump: str) -> str | None:
"""A QUOTED ``labelRes=`` value, or None (t66).
``labelRes=0x7f0f0001`` is a resource id — showing it as an app name would be
worse than showing the package id, so only a quoted string counts.
"""
if not dump:
return None
for match in _LABEL_RES_RE.finditer(dump):
label = _clean(match.group(1) or match.group(2))
if label:
return label
return None
def parse_app_label(dump: str) -> str | None:
"""The best device label in ``dump``, trying every shape Android prints.
Priority follows how specific the shape is: the AAPT manifest label first,
then the ApplicationInfo field, then a quoted resource label. None means the
device did not name this app — which, on a device whose dump carries no
labels at all, is the definitive answer that sends the caller to the curated
map (t66).
"""
return (
parse_application_label(dump)
or parse_non_localized_label(dump)
or parse_label_res(dump)
)
def resolve_package_name(
package: str | None,
*,
user_map=None,
device_label=None,
curated=None,
) -> str | None:
"""THE package -> display-name chain (t66): one order, every surface.
1. the user's explicit apps map (their own edit always wins);
2. the device's own label, when the device exposes one;
3. the curated community map (the answer on a device with no labels: see
``DEFAULT_APP_DIRECTORY``);
4. the raw package id, shown ONCE.
``device_label`` may be a VALUE or a CALLABLE. A callable is looked up only
after the user map has missed, which is what keeps tier 2 LAZY: the manager's
``app_label()`` accessor enqueues an on-demand device fetch on a miss, so
calling it for an app the user has already named would spend a shell to learn
something nobody reads.
Returns None only for an empty package, so a caller can always display
something. The id is never doubled into a ``pkg=pkg`` row: tier 4 IS the id,
and there is no tier that formats it as a name as well.
"""
if not package:
return None
mapped = (user_map or {}).get(package)
if mapped:
return str(mapped)
raw_label = device_label(package) if callable(device_label) else device_label
label = _clean(raw_label)
if label:
return label
curated_name = (curated or {}).get(package)
if curated_name:
return str(curated_name)
return package
@@ -17,9 +17,11 @@ ATTR_MEDIA_DAG_RECEIVER_INPUT = "media_dag_receiver_input"
# Jellyfin media_player entity for rich metadata
ATTR_MEDIA_DAG_JELLYFIN_ENTITY = "media_dag_jellyfin_entity"
# Now-playing dict: {state, title, media_type, series?, season?, episode?,
# duration?, position?, thumbnail?, artist?, album?, media_info?, source?}
ATTR_MEDIA_DAG_NOW_PLAYING = "media_dag_now_playing"
# t65 flattened the now-playing reading into separate top-level ``now_playing_*``
# attributes on the media player (``now_playing_via`` names the tier), so the single
# nested ``media_dag_now_playing`` key this file used to define is GONE — the last
# reference to it was removed in t66's cleanup. The flattened names live in
# ``custom_components/nvidia_shield/media_player.py`` (``now_playing_attribute_names``).
# List of running app dicts: [{package, state, title?, artist?}, ...]
ATTR_MEDIA_DAG_RUNNING_APPS = "media_dag_running_apps"
+22 -15
View File
@@ -116,7 +116,11 @@ from .const import (
TIVIMATE_PACKAGE,
command_scripts,
)
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
from .ha_adb_common import (
PlaybackIntent,
resolve_package_name,
running_apps_from_tasks,
)
from .jellyfin import fetch_session_media_info
from .power import discover_power_siblings
@@ -377,26 +381,29 @@ def resolve_app_name(
package: str | None,
app_name_map,
app_label,
curated=None,
) -> str | None:
"""Resolve a package id to a friendly name (t40; entity tier removed t41).
"""Resolve a package id to a friendly name (t40; curated tier t66).
The single chain behind ``media_player.app_name`` and the Current App
sensor:
sensor, delegating to ``ha_adb_common.app_label.resolve_package_name`` so the
discovery table and the options editor cannot drift from it:
1. the user's explicit apps map (``CONF_APP_NAME_MAP``);
2. the cached device-manifest label (``manager.app_label`` — a cache miss
enqueues the on-demand fetch and falls through);
3. the raw package text — a bare or unknown package falls back to itself,
never to None.
2. the cached device label (``manager.app_label`` — a cache miss enqueues the
on-demand fetch and falls through);
3. the curated table (``DEFAULT_APP_DIRECTORY``) — the tier that names apps on
a Shield whose ``pm dump`` exposes no label at all;
4. the raw package text — a bare or unknown package falls back to itself,
never to None, and it is shown ONCE (no ``pkg=pkg`` anywhere).
"""
if package:
mapped = (app_name_map or {}).get(package)
if mapped:
return mapped
label = app_label(package) if app_label is not None else None
if label:
return label
return package or None
return resolve_package_name(
package,
user_map=app_name_map,
# the callable keeps the device tier LAZY: the user's map answers first
device_label=app_label,
curated=DEFAULT_APP_DIRECTORY if curated is None else curated,
)
class NvidiaShieldMediaPlayer(MediaPlayerEntity):
+4 -3
View File
@@ -124,9 +124,10 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
"""The resolved friendly name (t40), not the raw package id.
Exactly the ``media_player.app_name`` chain via the shared
``resolve_app_name``: apps map → cached manifest label → the package
text itself. A bare or unknown package shows its package text rather
than None, like the media player's app_name.
``resolve_app_name``: apps map → cached device label → the built-in app
names (t66) → the package text itself. A bare or unknown package shows its
package text rather than None, like the media player's app_name, and that
text is the id ONCE — never a ``pkg=pkg`` pair.
This is why ``_package`` must be the CURRENT app and the label fetch
must be driven off it (t55): the device-manifest label is resolved on
+1 -1
View File
@@ -404,7 +404,7 @@ covers what the external pieces provided:
| External piece | What it provided | Now native (file) |
|---|---|---|
| `mediasession2mqtt` bridge sensors | per-app now-playing pushed over MQTT from the Shield's notification listener | ADB media-session polling + the foreground tier (`connection.py`, `media_player.py`); the `media_dag_now_playing` attribute carries `source: jellyfin \| media_session \| app` |
| `mediasession2mqtt` bridge sensors | per-app now-playing pushed over MQTT from the Shield's notification listener | ADB media-session polling + the foreground tier (`connection.py`, `media_player.py`); the reading is published as separate top-level `now_playing_*` attributes, and `now_playing_via` names the tier (`jellyfin` \| `media_session` \| `app`) |
| Universal media player `media_dag_running_apps` template | app switcher list with playback states | `running_apps_from_tasks(..., last_known=...)` (`ha_adb_common/running_apps.py`) — enriched with `foreground`/`state`/`last_known_state`/`last_known_title` |
| Universal player "currently in app X" template | fallback state for no-session apps | the foreground ladder tier (`media_player.py` state property, tier 3.5) |
| External androidtv_remote entity for commands | remote keys | the protocol-backed `remote` entity (Phase 2) — the external entity remains only as the transitional last command tier, removed in Phase 4 |
+44 -34
View File
@@ -875,7 +875,9 @@ class TestPrivacyOrdering:
player._last_channel = "101"
np = player._get_app_now_playing()
assert np["title"] == TIVIMATE_PACKAGE # app identity, not the channel
# t66: the identity tier now resolves through the shared chain, so the app
# reads by its name — still the APP, never the channel or the media title.
assert np["title"] == "TiviMate"
assert np["title"] != "101"
async def test_channel_number_never_leaks_in_privacy_mode_with_last_known(self, modules):
@@ -893,7 +895,8 @@ class TestPrivacyOrdering:
np = player._get_app_now_playing()
assert np["state"] == "playing" # playback truth survives privacy
assert np["title"] == TIVIMATE_PACKAGE # but identity stays the app
assert np["title"] == "TiviMate" # but identity stays the app
assert "Sky News" not in np.values() # the media title never leaks
class TestVolumeAndPowerLadder:
@@ -1725,23 +1728,26 @@ class TestCurrentAppNameEndToEndT55:
return player, manager, sensor, poller
async def test_known_app_resolves_by_fetching_its_label(self, modules):
# t66: a package NOT in the curated table, so the fall-through under test
# is really the raw id and not a curated name.
player, manager, sensor, poller = self._wire(
modules, "com.netflix.ninja", {"com.netflix.ninja": "application-label: 'Netflix'"}
modules,
"com.example.labelled",
{"com.example.labelled": "application-label: 'Labelled App'"},
)
# first render: cache miss → the sensor falls through to the package
assert sensor.native_value == "com.netflix.ninja"
assert manager.app_label_requests == {"com.netflix.ninja"}
assert sensor.native_value == "com.example.labelled"
assert manager.app_label_requests == {"com.example.labelled"}
# the watchdog drains the fetch for the CURRENT package
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert poller.dumps and "com.netflix.ninja" in poller.dumps[0]
assert poller.dumps and "com.example.labelled" in poller.dumps[0]
# next render: the resolved device name, in the sensor AND the player
assert sensor.native_value == "Netflix"
assert sensor.extra_state_attributes["app_name"] == "Netflix"
assert player.app_name == "Netflix"
assert sensor.native_value == "Labelled App"
assert player.app_name == "Labelled App"
async def test_mapped_app_never_needs_the_device_label(self, modules):
player, manager, sensor, poller = self._wire(
@@ -1818,7 +1824,9 @@ class TestCurrentAppNameEndToEndT55:
here — the pre-fix tree fails this at the second cycle.
"""
player, manager, sensor, poller = self._wire(
modules, "ar.tvplayer.tv", {"ar.tvplayer.tv": "application-label: 'TiviMate'"}
modules,
"com.example.labelled",
{"com.example.labelled": "application-label: 'Labelled App'"},
)
async def _blank(_cmd):
@@ -1827,16 +1835,16 @@ class TestCurrentAppNameEndToEndT55:
# cycle 1: the transport delivered nothing → a FAILED fetch
poller.shell = _blank
assert sensor.native_value == "ar.tvplayer.tv"
assert sensor.native_value == "com.example.labelled"
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert "ar.tvplayer.tv" not in manager._app_labels # nothing stamped
assert sensor.native_value == "ar.tvplayer.tv"
assert "com.example.labelled" not in manager._app_labels # nothing stamped
assert sensor.native_value == "com.example.labelled"
# the next render re-enqueues, and the retry is what recovers the label
assert manager.app_label("ar.tvplayer.tv") is None
assert manager.app_label_requests == {"ar.tvplayer.tv"}
assert manager.app_label("com.example.labelled") is None
assert manager.app_label_requests == {"com.example.labelled"}
dump_count = len([d for d in poller.dumps if "pm dump" in d])
real_shell = _LabelPoller.shell.__get__(poller, _LabelPoller)
@@ -1846,9 +1854,9 @@ class TestCurrentAppNameEndToEndT55:
# a later GOOD dump recovers it — the pre-fix cache made this impossible
assert len([d for d in poller.dumps if "pm dump" in d]) == dump_count + 1
assert sensor.native_value == "TiviMate"
assert sensor.extra_state_attributes["app_name"] == "TiviMate"
assert player.app_name == "TiviMate"
assert sensor.native_value == "Labelled App"
assert sensor.extra_state_attributes["app_name"] == "Labelled App"
assert player.app_name == "Labelled App"
@pytest.mark.parametrize("blank", [None, ""], ids=["none", "empty"])
async def test_the_player_recovers_the_label_after_a_failed_fetch(
@@ -1861,7 +1869,9 @@ class TestCurrentAppNameEndToEndT55:
stuck on the package id — so both are pinned.
"""
player, manager, sensor, poller = self._wire(
modules, "com.netflix.ninja", {"com.netflix.ninja": "application-label: 'Netflix'"}
modules,
"com.example.labelled",
{"com.example.labelled": "application-label: 'Labelled App'"},
)
async def _blank(_cmd):
@@ -1869,19 +1879,19 @@ class TestCurrentAppNameEndToEndT55:
return blank
poller.shell = _blank
assert player.app_name == "com.netflix.ninja"
assert player.app_name == "com.example.labelled"
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert "com.netflix.ninja" not in manager._app_labels
assert player.app_name == "com.netflix.ninja"
assert "com.example.labelled" not in manager._app_labels
assert player.app_name == "com.example.labelled"
assert manager.app_label("com.netflix.ninja") is None # re-enqueued
assert manager.app_label("com.example.labelled") is None # re-enqueued
poller.shell = _LabelPoller.shell.__get__(poller, _LabelPoller)
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert player.app_name == "Netflix"
assert sensor.native_value == "Netflix"
assert player.app_name == "Labelled App"
assert sensor.native_value == "Labelled App"
async def test_a_genuinely_label_less_package_is_still_negatively_cached(
self, modules
@@ -1910,16 +1920,16 @@ class TestCurrentAppNameEndToEndT55:
async def test_the_fetch_targets_the_app_on_screen_not_the_previous_one(self, modules):
player, manager, sensor, poller = self._wire(
modules,
"ar.tvplayer.tv",
"com.example.one",
{
"ar.tvplayer.tv": "application-label: 'TiviMate'",
"com.google.android.youtube.tv": "application-label: 'YouTube'",
"com.example.one": "application-label: 'One TV'",
"com.example.two": "application-label: 'Two TV'",
},
)
assert sensor.native_value == "ar.tvplayer.tv" # miss for the 1st app
manager._on_current_app("com.google.android.youtube.tv")
assert sensor.native_value == "com.google.android.youtube.tv"
assert sensor.native_value == "com.example.one" # miss for the 1st app
manager._on_current_app("com.example.two")
assert sensor.native_value == "com.example.two"
# both packages are queued; the watchdog drains ONE per cycle
await manager._watchdog_cycle(5.0)
@@ -1929,9 +1939,9 @@ class TestCurrentAppNameEndToEndT55:
# every drained fetch names a package, and the app that is CURRENT is
# the one the sensor now shows the label of
assert "ar.tvplayer.tv" in poller.dumps[0]
assert "com.google.android.youtube.tv" in poller.dumps[1]
assert sensor.native_value == "YouTube"
assert "com.example.one" in poller.dumps[0]
assert "com.example.two" in poller.dumps[1]
assert sensor.native_value == "Two TV"
class TestAvailabilityLadder:
+6 -3
View File
@@ -2250,8 +2250,9 @@ class TestDiscoveryFeedsTheAppsTable:
[
{"package": "ar.tvplayer.tv", "name": "TiviMate", "icon": "mdi:television-classic"},
{"package": "org.xbmc.kodi", "name": "Kodi", "icon": "mdi:kodi"},
{"package": "com.example.handmade", "name": "com.example.handmade",
"icon": "mdi:television"},
# t66: a discovered app nothing can name comes through as name ""
# (the sweep no longer repeats the package id as a name)
{"package": "com.example.handmade", "name": "", "icon": "mdi:television"},
],
)
@@ -2263,7 +2264,9 @@ class TestDiscoveryFeedsTheAppsTable:
assert lines[0] == "ar.tvplayer.tv=Telly"
# every discovered app is a row too — this is the live bug
assert "org.xbmc.kodi=Kodi" in lines
assert "com.example.handmade=com.example.handmade" in lines
# ...but an app nothing names is NOT pre-filled as `pkg=pkg` (t66): the id
# would be stored as if it were a name. The row editor still offers it.
assert not [line for line in lines if line.startswith("com.example.handmade=")]
# the user's name wins over the device label for the same package
assert not [line for line in lines if line == "ar.tvplayer.tv=TiviMate"]
# ...and the discovered icon is NEVER written into the icon column
+8 -4
View File
@@ -18,7 +18,7 @@ from nsh.connection import (
ShieldConnectionManager,
StateStore,
)
from nsh.const import ADB_POLL_INTERVAL, APP_LABEL_EXIT_MARKER
from nsh.const import ADB_POLL_INTERVAL, APP_LABEL_EXIT_MARKER, APP_LABEL_GREP_SHAPES
def test_connection_module_depends_on_no_homeassistant():
@@ -310,12 +310,16 @@ class TestAppLabelCache:
await asyncio.sleep(0) # drain the fetch task
# t57: the dump carries the exit marker too, so a blank answer can be
# told from a FAILED fetch; the package is still quoted
# t57: the probe carries the exit marker too, so a blank answer can be told
# from a FAILED fetch; the package is still quoted. t66: the label candidates
# are filtered ON THE DEVICE, and the 2241-line dump is never truncated —
# `head -300` cut it before the package section on the user's Shield.
assert manager._poller.shell_calls == [
f"pm dump {shlex.quote(wicked)} 2>/dev/null | head -300; "
f"pm dump {shlex.quote(wicked)} 2>/dev/null | "
f"grep -E {shlex.quote(APP_LABEL_GREP_SHAPES)} | head -40; "
f"echo {APP_LABEL_EXIT_MARKER}$?"
]
assert "head -300" not in manager._poller.shell_calls[0]
class TestLogcatStream:
+378 -39
View File
@@ -37,6 +37,8 @@ def scoped_entity_stubs():
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_APP_NAME_MAP,
)
@@ -50,16 +52,35 @@ from nsh.ha_adb_common import ( # noqa: E402
# ── realistic device output ────────────────────────────────────────────────
#: `pm list packages -3` on a Shield with a normal app set. Wrapped the way a
#: real adb shell answers (a bare list, no header).
#: `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.netflix.ninja\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.plexapp.android\n"
"package:com.spotify.tv.android\n"
"package:com.example.handmade\n"
)
@@ -88,18 +109,133 @@ DUMP_LABEL_LESS = (
" 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):
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
@@ -119,7 +255,7 @@ class _DiscoveryPoller:
for package, dump in self.dumps.items():
if shlex.quote(package) in cmd or package in cmd:
return dump
return _on_device_grep(dump) if self.on_device_grep else dump
return "__NSH_EXIT:0\n" # ran, no label
return None
@@ -132,15 +268,9 @@ 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) == [
"ar.tvplayer.tv",
"com.github.damontecres.wholphin",
"com.netflix.ninja",
"com.google.android.youtube.tv",
"org.xbmc.kodi",
"com.plexapp.android",
"com.spotify.tv.android",
"com.example.handmade",
line.removeprefix("package:") for line in USER_PACKAGES.splitlines()
]
def test_noise_and_duplicates_are_tolerated(self):
@@ -192,10 +322,19 @@ class TestInstalledPackagesMerge:
"""User apps plus the active launcher, de-duplicated."""
def test_launcher_is_added_last(self):
packages = installed_packages(USER_PACKAGES, LAUNCHER_RESOLVED)
# 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"
@@ -239,17 +378,22 @@ class TestDiscoverySweep:
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"
# a label-less app keeps its discovered name (the package id) and is
# still a row — it must not vanish from the table
assert by_pkg["com.example.handmade"]["name"] == "com.example.handmade"
# and every row's name/icon is a string
# 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) and row["name"]
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, plus the
curated four, is offered — not just the curated four."""
"""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})
@@ -257,21 +401,19 @@ class TestDiscoverySweep:
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 list the integration used to fall back to
# the curated table is exactly the app set the report listed
assert set(DEFAULT_APP_DIRECTORY) <= packages
# ...and the apps it missed, which is what the user reported
for package in (
"ar.tvplayer.tv", # TiviMate
"com.github.damontecres.wholphin", # Wholphin
"com.netflix.ninja", # Netflix
"com.google.android.youtube.tv", # YouTube
"org.xbmc.kodi",
"com.plexapp.android",
"com.spotify.tv.android",
"com.example.handmade",
):
assert package in packages, package
# ...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
@@ -504,6 +646,13 @@ class TestDiscoveryUsesTheCachedLabelPath:
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)
@@ -514,11 +663,21 @@ class TestDiscoveryUsesTheCachedLabelPath:
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"
assert by_pkg["org.xbmc.kodi"]["name"] == "org.xbmc.kodi"
# 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."""
poller = _DiscoveryPoller(dumps={"org.xbmc.kodi": "application-label: 'Kodi'"})
# 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}
@@ -532,10 +691,19 @@ class TestDiscoveryUsesTheCachedLabelPath:
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"] == "org.xbmc.kodi" # fell back
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}
@@ -667,3 +835,174 @@ def test_importing_the_discovery_module_needs_no_home_assistant():
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
+27 -4
View File
@@ -57,7 +57,13 @@ class TestSourceList:
def test_it_falls_back_to_the_curated_directory(self, modules):
player, _served = _player(modules) # no map configured
assert player.source_list == ["TiviMate", "Wholphin", "Netflix", "YouTube"]
# t66: the curated table grew to cover the apps the report listed, and the
# dropdown is the same table (so it is asserted against the table itself,
# not a copy that would drift)
from nsh.const import DEFAULT_APP_DIRECTORY
assert player.source_list == list(DEFAULT_APP_DIRECTORY.values())
assert "Netflix" in player.source_list # a name the user recognises
def test_a_blank_name_never_reaches_the_dropdown(self, modules):
"""t49: the map is hand-editable, so 'com.x=' must not offer an empty entry.
@@ -464,8 +470,25 @@ class TestCaseInsensitiveNames:
assert player.source == " TiviMate " # this surface shows it as typed
assert player.source not in player.source_list
def test_a_blank_map_value_falls_through_to_the_raw_package(self, modules):
"""Blanks are not displayed as nothing — the chain continues past them."""
def test_a_blank_map_value_falls_through_the_chain(self, modules):
"""Blanks are not displayed as nothing — the chain continues past them (t66).
Past a blank map value comes the device label, then the curated table, then
the raw id: `ar.tvplayer.tv` is curated, so the dropdown row is its NAME.
The raw-id tail of the same chain is pinned in the test below.
"""
player, _served = _player(modules, app_map={TIVIMATE: ""}, foreground=TIVIMATE)
assert player.source == TIVIMATE
assert player.source == "TiviMate"
def test_a_package_nothing_names_stays_raw(self, modules):
"""The last tier: no map value, no device label, no curated row → the id.
It is shown ONCE (the id itself), never as a `pkg=pkg` pair.
"""
player, _served = _player(
modules, app_map={"com.example.unknown": ""}, foreground="com.example.unknown"
)
assert player.source == "com.example.unknown"
assert player.source_list == [] # a name is the ticker's raw id
+21 -2
View File
@@ -1360,7 +1360,12 @@ class TestAppNameMapResolution:
assert player.app_name == "TiviMate"
assert player.app_id == "ar.tvplayer.tv" # app_id stays raw
async def test_unmapped_package_stays_raw(self):
async def test_a_curated_package_reads_by_its_name(self):
"""t66: an app the user did NOT map still gets a name — from the curated table.
This is the device the report describes: `pm dump` names nothing, and a
package absent from the user's own map used to read as its raw id.
"""
import entity_stublib
modules = entity_stublib.load_entity_modules()
@@ -1371,7 +1376,21 @@ class TestAppNameMapResolution:
manager._on_current_app("com.google.android.youtube.tv")
await asyncio.sleep(0.05)
assert player.app_name == "com.google.android.youtube.tv"
assert player.app_name == "YouTube"
async def test_an_unmapped_uncurated_package_stays_raw(self):
"""The chain's last tier, on the same surface: nothing names it → the id."""
import entity_stublib
modules = entity_stublib.load_entity_modules()
player, _s, _i = _cmd_player(modules)
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
manager = player._manager
manager._on_current_app("com.example.nobody")
await asyncio.sleep(0.05)
assert player.app_name == "com.example.nobody"
async def test_cached_manifest_label_is_the_second_tier(self):
"""P5-1: below the explicit map sits the device manifest label."""