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.
126 lines
5.2 KiB
Python
126 lines
5.2 KiB
Python
"""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(".")
|
|
)
|