diff --git a/README.md b/README.md index 24825a9..3dd0e55 100644 --- a/README.md +++ b/README.md @@ -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 `: 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 diff --git a/custom_components/nvidia_shield/config_flow.py b/custom_components/nvidia_shield/config_flow.py index d6d5771..e3d879f 100644 --- a/custom_components/nvidia_shield/config_flow.py +++ b/custom_components/nvidia_shield/config_flow.py @@ -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(), }, ) diff --git a/custom_components/nvidia_shield/connection.py b/custom_components/nvidia_shield/connection.py index c39c872..cc95097 100644 --- a/custom_components/nvidia_shield/connection.py +++ b/custom_components/nvidia_shield/connection.py @@ -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 | 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 diff --git a/custom_components/nvidia_shield/const.py b/custom_components/nvidia_shield/const.py index efa2873..73ec0bd 100644 --- a/custom_components/nvidia_shield/const.py +++ b/custom_components/nvidia_shield/const.py @@ -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 diff --git a/custom_components/nvidia_shield/ha_adb_common/__init__.py b/custom_components/nvidia_shield/ha_adb_common/__init__.py index 409f371..724e0b6 100644 --- a/custom_components/nvidia_shield/ha_adb_common/__init__.py +++ b/custom_components/nvidia_shield/ha_adb_common/__init__.py @@ -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", diff --git a/custom_components/nvidia_shield/ha_adb_common/app_label.py b/custom_components/nvidia_shield/ha_adb_common/app_label.py index d61fe56..d7f064c 100644 --- a/custom_components/nvidia_shield/ha_adb_common/app_label.py +++ b/custom_components/nvidia_shield/ha_adb_common/app_label.py @@ -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 ` output for the ``application-label`` line — the -default-locale variant; localized suffixed variants -(``application-label-:``) 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 ` / `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-:``) 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 diff --git a/custom_components/nvidia_shield/ha_adb_common/const.py b/custom_components/nvidia_shield/ha_adb_common/const.py index 79df930..938e659 100644 --- a/custom_components/nvidia_shield/ha_adb_common/const.py +++ b/custom_components/nvidia_shield/ha_adb_common/const.py @@ -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" diff --git a/custom_components/nvidia_shield/media_player.py b/custom_components/nvidia_shield/media_player.py index dd57442..9539f72 100644 --- a/custom_components/nvidia_shield/media_player.py +++ b/custom_components/nvidia_shield/media_player.py @@ -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): diff --git a/custom_components/nvidia_shield/sensor.py b/custom_components/nvidia_shield/sensor.py index bbb734b..727a889 100644 --- a/custom_components/nvidia_shield/sensor.py +++ b/custom_components/nvidia_shield/sensor.py @@ -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 diff --git a/docs/RESEARCH_SHIELD.md b/docs/RESEARCH_SHIELD.md index a9ba903..f34157f 100644 --- a/docs/RESEARCH_SHIELD.md +++ b/docs/RESEARCH_SHIELD.md @@ -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 | diff --git a/tests/test_command_routing.py b/tests/test_command_routing.py index 7abdfb4..7afeb6a 100644 --- a/tests/test_command_routing.py +++ b/tests/test_command_routing.py @@ -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: diff --git a/tests/test_config_flow_ux.py b/tests/test_config_flow_ux.py index 96579a5..d8c7fba 100644 --- a/tests/test_config_flow_ux.py +++ b/tests/test_config_flow_ux.py @@ -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 diff --git a/tests/test_connection.py b/tests/test_connection.py index 8856873..4fdaa8f 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -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: diff --git a/tests/test_installed_apps.py b/tests/test_installed_apps.py index efbfc3f..b910336 100644 --- a/tests/test_installed_apps.py +++ b/tests/test_installed_apps.py @@ -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 | grep -E | 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 diff --git a/tests/test_media_sources.py b/tests/test_media_sources.py index 329d438..adfbce0 100644 --- a/tests/test_media_sources.py +++ b/tests/test_media_sources.py @@ -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 diff --git a/tests/test_standalone_setup.py b/tests/test_standalone_setup.py index 3e58bf4..1fc8389 100644 --- a/tests/test_standalone_setup.py +++ b/tests/test_standalone_setup.py @@ -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."""