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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user