Files
ha-nvidia-shield/custom_components/nvidia_shield/ha_adb_common/installed_apps.py
T
engineer c884c7e1ab docs(discovery): the not-observed mark lands in the in-scope module (t62 nit c)
The "no real Shield was available, so this output shape is Android's
documentation rather than a capture" mark lived only in the test module, where a
reader of the discovery code never looks. It now sits in connection.py's
discovery section — the module that issues the two listing commands — and in the
README next to the command table, matching the state=6 buffering inference mark.

This replaces the copy that had been added to
ha_adb_common/installed_apps.py: t62's in-scope set is connection.py, sensor.py,
README.md and tests/, and the completion contract rejects a changed path outside
it, so the parser-module copy is reverted rather than left undeclared. The
discovery section above names that parser module, so the mark is still found
from the code it qualifies.
2026-10-12 08:25:41 +01:00

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(".")
)