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:
1 parent
f29f62ddae
commit
7b32de789a
16 files changed
+973
-176
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
Reference in new issue
Block a user