feat(discovery): enumerate the apps installed on the Shield (t57)

The live complaint ("it doesn't find all the apps") came from the curated
list: only four packages were ever offered, so anything the user had
installed elsewhere was invisible. The device knows its own apps.

ha_adb_common/installed_apps.py parses the two queries the sweep makes:

* `pm list packages -3` — every third-party (user-installed) package;
* `cmd package resolve-activity --brief -c android.intent.category.HOME`
  — the ACTIVE launcher, a system app that is nonetheless what the user
  opens, so it is appended rather than filtered away.

System apps are otherwise not enumerated (a Shield ships a few hundred and
they are not launchable apps); the parsers are tolerant of the noise a real
adb shell can prepend and of duplicate lines.

ShieldConnectionManager gains the sweep:
`async_refresh_installed_apps(force=False)` returns a cached
`[{package, name, icon}]` table, and `installed_apps()` is a pure read that
never issues a shell — so a state poll costs nothing.

Naming stays on the one path: the sweep calls `_dump_label`, the same
`pm dump` + parse + 24 h cache the entity path uses, now extracted so the
two cannot drift, including the t57 UNIT 0 rule that a fetch that returned
nothing is NOT cached. Labels are fetched APP_SWEEP_CONCURRENCY (4) at a
time with APP_SWEEP_SPACING (50 ms), so a typical Shield finishes in
seconds rather than the minutes a one-per-watchdog-cycle drain would take
while the socket still sees a trickle.

Failure is a normal outcome: no poller, a disconnected transport or a mute
shell stores an EMPTY table with a stamp and returns [], so nothing retries
in a loop and every surface degrades to "no discovered apps".

Icons: `pm dump` does NOT expose an app's icon resource — an extracted
APK's icon is a bitmap only its own code resolves — so this is honestly a
heuristic: APP_ICON_FALLBACKS (a curated package -> mdi: map) with
APP_ICON_GENERIC (mdi:television) for anything unknown, and always a string.
This commit is contained in:
James Mackay committed 2026-10-12 07:28:50 +01:00
1 parent 9461d69469
commit ee3f2845af
4 files changed
+407 -57

No files matched your search

@@ -0,0 +1,125 @@
"""Discover the apps actually installed on the Shield (t57).
The live complaint ("it doesn't find all the apps") came from the curated
fallback list: only four packages were ever offered, so a user with Wholphin,
SmartTube, Kodi, Plex or anything else saw nothing. The device knows its own
apps, so this module asks it.
The commands (both plain `pm`, available on Shield Experience 9 / Android 11
with no extra permission beyond the ADB pairing this integration already has):
* ``pm list packages -3`` — third-party (user-installed) packages, one
``package:<id>`` line each. This is what the live complaint is about: an app
the user installed is a ``-3`` package.
* ``cmd package resolve-activity --brief -c android.intent.category.HOME``
— the ACTIVE launcher (``com.google.android.tvlauncher/....MainActivity``).
A launcher is a system app, so it is not in ``-3``, yet it is exactly the app
a user sees and may want to name or launch — so it is added explicitly.
System apps are otherwise NOT enumerated: the Shield ships a few hundred of
them and they are not launchable apps a media-player source list wants. The
one system app that matters (the launcher) is resolved, not filtered.
Labels come from the existing ``pm dump`` + ``parse_application_label`` path and
its 24 h cache (``ShieldConnectionManager.app_label``), so discovery adds no
second naming mechanism.
Icons are best effort and explicitly heuristic — see ``icon_for_package``.
"""
from __future__ import annotations
import re
#: One `pm list packages` line: ``package:com.example.app``.
_PACKAGE_LINE_RE = re.compile(r"^package:(?P<package>[\w.]+)\s*$", re.MULTILINE)
#: `resolve-activity --brief` prints ``package/activity`` on the last line,
#: possibly preceded by the (verbose) intent header.
_COMPONENT_RE = re.compile(r"^(?P<package>[\w.]+)/[\w.$]+$", re.MULTILINE)
#: Activity components that belong to a launcher but are not what the user
#: opens: settings/hidden entry points a HOME resolution can land on.
_LAUNCHER_NOISE = ("com.android.tv.settings",)
def parse_package_list(output: str | None) -> list[str]:
"""The package ids in a ``pm list packages`` dump, in device order.
Tolerant on purpose: the real output has one ``package:<id>`` per line, and
a shell that prepends noise (adb banners, warnings) must not lose the list.
Duplicates are collapsed, order preserved.
"""
if not output:
return []
seen: set[str] = set()
packages: list[str] = []
for match in _PACKAGE_LINE_RE.finditer(output):
package = match.group("package")
if package not in seen:
seen.add(package)
packages.append(package)
return packages
def parse_active_launcher(output: str | None) -> str | None:
"""The active HOME package from ``cmd package resolve-activity --brief``.
Android prints a verbose intent header and then a single
``package/activity`` line; only the last component-looking line is the
answer. Returns None when nothing resolved (a device with no launcher, or a
command that produced no output) — the caller treats that as "no launcher
to add" rather than as a failure.
"""
if not output:
return None
packages = _COMPONENT_RE.findall(output)
for package in reversed(packages):
if package not in _LAUNCHER_NOISE:
return package
return None
def installed_packages(user_output: str | None, launcher_output: str | None) -> list[str]:
"""The discovered package ids: user apps first, then the active launcher.
The launcher goes last so a source list built from this keeps the user's own
apps at the top, and it is de-duplicated because a launcher CAN be a
third-party package (a replacement TV launcher installed from the store).
"""
packages = parse_package_list(user_output)
launcher = parse_active_launcher(launcher_output)
if launcher and launcher not in packages:
packages.append(launcher)
return packages
def icon_for_package(package: str, curated: dict[str, str] | None = None,
generic: str = "mdi:television") -> str:
"""A best-effort MDI icon for a package, never a non-string (t57).
HEURISTIC, and labelled as such: the icon is looked up in a curated
package -> MDI map, and anything unknown gets the generic fallback. There is
no device query behind this because there cannot be one — ``pm dump`` does
not print the app's icon resource, and resolving an extracted APK's bitmap
would need code the app itself runs. The user's own icon column (a picture
URL or a ``camera.*`` entity) is what ``entity_picture`` consumes, and it
outranks this.
Returns a string in every case: the UI is never handed None or a dict.
"""
if not package:
return generic
table = curated if curated is not None else {}
return table.get(package) or generic
def is_launchable(package: str) -> bool:
"""Cheap sanity filter for a package id.
Not a real Android query: a package id is dotted and non-empty. The filter
exists so a malformed line cannot become a table row or a source entry.
"""
return bool(package) and "." in package and all(
part.isalnum() or part in ("_", "-") for part in package.split(".")
)