t66: app names on a device that exposes none

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

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

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

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

No files matched your search

+166 -17
View File
@@ -39,11 +39,15 @@ from .const import (
APP_ICON_FALLBACKS,
APP_ICON_GENERIC,
APP_LABEL_EXIT_MARKER,
APP_LABEL_GREP_SHAPES,
APP_LABEL_PROBE_LIMIT,
APP_LABEL_TTL,
APP_LIST_TTL,
APP_SWEEP_CONCURRENCY,
APP_SWEEP_SPACING,
CONF_APP_NAME_MAP,
CONF_LOGCAT_EVENTS,
DEFAULT_APP_DIRECTORY,
LOGCAT_SUPPRESS_WINDOW,
STATE_STORE_TTL,
SYSTEM_MEDIA_PACKAGES,
@@ -54,8 +58,9 @@ from .ha_adb_common import (
icon_for_package,
installed_packages,
is_launchable,
parse_application_label,
parse_app_label,
parse_audio_playback,
resolve_package_name,
)
_LOGGER = logging.getLogger(__name__)
@@ -224,6 +229,16 @@ class ShieldConnectionManager:
# t55: packages whose label fetch is queued or in flight — a render
# during the fetch must not enqueue the same package twice.
self._app_label_inflight: set[str] = set()
# t66: the DEVICE-level "this firmware exposes no labels" memo. Set after
# APP_LABEL_PROBE_LIMIT consecutive probes that RAN and found nothing (one
# label-less package proves nothing — see _dump_label), and it expires
# with APP_LABEL_TTL so a changed device is re-checked within a day.
self._labels_absent_at: float | None = None
self._label_negative_streak: int = 0
# t66: packages whose probe FAILED (transport delivered nothing) and which
# therefore still owe a retry even while the memo above is set — t57's
# "a failed fetch is never cached as an answer" survives the memo.
self._app_label_failed: set[str] = set()
# t57: the discovered installed-apps table — (rows, stamp), or None when
# no sweep has produced a fresh answer. Only a sweep that RAN writes
# here (t62 F1): a failed or unavailable sweep records itself as UNKNOWN
@@ -624,6 +639,68 @@ class ShieldConnectionManager:
self._app_label_requests.add(package)
return None
def _probe_still_owed(self, package: str) -> bool:
"""True when a package's only remaining question is a RETRY (t66).
A probe that FAILED (no poller, shell error, no output at all) records
the package here and caches nothing, so the t57 UNIT 0 guarantee — a
failed fetch is retried, never stamped as an answer — survives the
device-level memo below. A probe that RAN and found no label is not owed
anything: it answered.
"""
return package in self._app_label_failed
def _labels_absent(self) -> bool:
"""The device-level "no labels here" memo, expiring with APP_LABEL_TTL.
A conclusion about a piece of firmware cannot be permanent — the user may
swap the device, update it, or add an app that does carry a label — so
after APP_LABEL_TTL the memo clears itself and one probe re-checks the
device (t66).
"""
if self._labels_absent_at is None:
return False
if self._clock() - self._labels_absent_at > APP_LABEL_TTL:
self._labels_absent_at = None # re-probe once per TTL
self._label_negative_streak = 0
return False
return True
@property
def app_labels_absent(self) -> bool:
"""Public read of the memo (tests, and the options page's own note)."""
return self._labels_absent_at is not None and (
self._clock() - self._labels_absent_at <= APP_LABEL_TTL
)
def _entry_app_name_map(self) -> dict:
"""The user's own apps map from the entry options ({} when unset)."""
options = getattr(self.entry, "options", None) or {}
return dict(options.get(CONF_APP_NAME_MAP) or {})
def _resolved_app_name(self, package: str, device_label=None, user_map=None) -> str:
"""The display name for a discovered app, or "" when nothing names it (t66).
The ONE place the discovery table's name comes from, so it obeys the same
chain as the media player and the Current App sensor
(:func:`~.ha_adb_common.app_label.resolve_package_name`).
"" is deliberate: the row's ``package`` field already carries the id, and
a name that repeats it is exactly the ``pkg=pkg`` row the user reported
from the apps editor. Every consumer already reads an empty name as "this
app needs a name" (the editor says so out loud; the installed-apps sensor
publishes it as-is).
"""
if user_map is None:
user_map = self._entry_app_name_map()
name = resolve_package_name(
package,
user_map=user_map,
device_label=device_label,
curated=DEFAULT_APP_DIRECTORY,
)
return "" if not name or name == package else name
def _known_label(self, package: str) -> tuple[bool, str]:
"""``(known, label)`` from the label cache — "known" = a fetch RAN (t57).
@@ -682,28 +759,66 @@ class ShieldConnectionManager:
except Exception as err:
_LOGGER.debug("protocol device info unavailable: %s", err)
async def _dump_label(self, package: str) -> str | None:
"""One `pm dump` label read: the label, or None for a FAILED fetch.
async def _dump_label(self, package: str, *, on_demand: bool = False) -> str | None:
"""One label read: the label, or None for a FAILED fetch (t66).
The ONE place the dump is issued, shared by the on-demand path
The ONE place the probe is issued, shared by the on-demand path
(:meth:`_fetch_app_label`) and the discovery sweep, so the t57 UNIT 0
distinction — "the command ran and found no label" versus "the
transport delivered nothing" — cannot drift between them. A successful
fetch is also cached here, so both paths benefit from the 24 h cache and
neither double-stamps.
Returns the label (possibly "") when the dump RAN, and None when it did
WHAT CHANGED IN t66, and why the old shape could not work on the user's
Shield: the probe used to ship ``pm dump <pkg> | head -300`` and parse the
answer with ``parse_application_label`` alone. A real dump on that device
is ~2241 lines of dumpsys, so ``head -300`` cut before the package
sections even appeared — the "no label" answer was a truncation
artefact, not a fact about the app. The probe now searches ON THE DEVICE
for every label shape Android prints (``APP_LABEL_GREP_SHAPES``) and only
those lines cross the wire, which is both smaller and honest.
A device that has already answered "no labels" for a run of packages is
NOT probed again (``APP_LABEL_PROBE_LIMIT``, expiring with
``APP_LABEL_TTL``): the sweep's per-package fan-out stops there and a
package the device has never been asked about is answered "" from the
memo, with no shell at all. The two exceptions are deliberate: a package
whose probe FAILED still owes its retry (``_probe_still_owed``), and that
retry is only ever driven by the app ON SCREEN (``on_demand``) — one
shell, once, user-driven, then cached.
The two meanings of "" must stay distinct in the reader's head: a probe
that RAN and found nothing is a cached answer (zero further shells), and a
probe that COULD NOT run is not cached at all — it is recorded as owed and
retried for the app on screen.
Returns the label (possibly "") when the probe RAN, and None when it did
not run or delivered no output at all.
The exit marker is a liveness tell, not a status code (t62 nit b): it sits
after a ``head`` pipeline, and the Android shell that runs this has no
PIPESTATUS, so ``$?`` is ``head``'s status rather than the dump's. Nothing
reads its VALUE — ``parse_application_label`` strips the marker line — and
the only property tested here is that a command which RAN leaves non-blank
output even when the device prints no label. That non-blankness is what
after a pipeline, and the Android shell that runs this has no PIPESTATUS,
so ``$?`` is the last command's status rather than the dump's. Nothing
reads its VALUE — the parser ignores the marker line — and the only
property tested here is that a command which RAN leaves non-blank output
even when the device prints no label. That non-blankness is what
separates "ran, no label" from "the transport delivered nothing", which
this method turns into a cached negative versus a retry.
"""
if self._labels_absent():
if not self._probe_still_owed(package):
# The firmware already answered; a shell here would spend a round
# trip on a settled question (t66). The negative is cached so the
# render path stops asking cycle after cycle.
self.cache_app_label(package, "")
return ""
if not on_demand:
# a retry is not the sweep's job: the fan-out stops here
return ""
_LOGGER.debug(
"retrying the failed label probe for %s on the app now on "
"screen (the device reports no labels, but this one never "
"answered)",
package,
)
poller = self._poller
if poller is None or not self.connected:
_LOGGER.debug(
@@ -711,12 +826,14 @@ class ShieldConnectionManager:
"retry on the next render",
package,
)
self._app_label_failed.add(package)
return None
try:
import shlex
dump = await poller.shell(
f"pm dump {shlex.quote(package)} 2>/dev/null | head -300; "
f"pm dump {shlex.quote(package)} 2>/dev/null | "
f"grep -E {shlex.quote(APP_LABEL_GREP_SHAPES)} | head -40; "
f"echo {APP_LABEL_EXIT_MARKER}$?"
)
except Exception as err:
@@ -726,6 +843,7 @@ class ShieldConnectionManager:
package,
err,
)
self._app_label_failed.add(package)
return None
if dump is None or not dump.strip():
# the transport never delivered output: a FAILED fetch, not an
@@ -735,12 +853,37 @@ class ShieldConnectionManager:
"the next render",
package,
)
self._app_label_failed.add(package)
return None
label = parse_application_label(dump)
# F1: label-less pm dumps negatively cache too (empty label) so a
# later cycle performs zero additional pm dumps for the package.
self._app_label_failed.discard(package) # it answered — nothing owed
label = parse_app_label(dump)
if label:
# This firmware DOES name apps: a run of misses before it proved nothing.
self._label_negative_streak = 0
else:
self._label_negative_streak += 1
if (
self._label_negative_streak >= APP_LABEL_PROBE_LIMIT
and self._labels_absent_at is None
):
# A RUN of label-less answers, each of which really ran: this
# device keeps its labels somewhere we cannot read, so every
# further per-package probe is a shell spent on a settled
# question (t66). The conclusion expires with APP_LABEL_TTL.
self._labels_absent_at = self._clock()
_LOGGER.info(
"%d label probes in a row found no app label — this device "
"exposes none; names now come from the curated map and the "
"user's own apps map for the next %s seconds",
self._label_negative_streak,
APP_LABEL_TTL,
)
# F1: label-less probes negatively cache too (empty label) so a later
# cycle performs zero additional probes for the package.
self.cache_app_label(package, label)
return label
# "" (not None) is the "the probe RAN" answer, even when the device named
# nothing: None is reserved for a fetch that could not run (t57 UNIT 0).
return label or ""
async def _fetch_app_label(self, package: str) -> None:
"""One on-demand `pm dump` label fetch; stamps ONLY a fetch that RAN.
@@ -752,7 +895,7 @@ class ShieldConnectionManager:
"""
self._app_label_inflight.add(package)
try:
await self._dump_label(package)
await self._dump_label(package, on_demand=True)
finally:
self._app_label_inflight.discard(package)
@@ -1049,10 +1192,16 @@ class ShieldConnectionManager:
packages = [p for p in installed_packages(user_output, launcher_output)
if is_launchable(p)]
labels = await self._sweep_labels(packages)
# t66: ONE precedence chain for the table too — the user's own map, then
# the device's label (when it exposes one), then the curated map, then the
# package id. An app nothing can name gets name "" rather than its own id:
# the row already carries the id in ``package``, and writing it twice is
# the ``pkg=pkg`` the user read as a bug in the apps editor.
user_map = self._entry_app_name_map()
rows = [
{
"package": package,
"name": labels.get(package) or package,
"name": self._resolved_app_name(package, labels.get(package), user_map),
"icon": icon_for_package(package, APP_ICON_FALLBACKS, APP_ICON_GENERIC),
}
for package in packages