"""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. MARK — documented, not observed: the command SHAPES below are Android's own documentation, not a captured run. No real Shield was available when this was written (t57), so every fixture in ``tests/test_installed_apps.py`` is built from those documented shapes, and the parsers are deliberately tolerant of whitespace and ordering. This is the same "inferred, not observed" mark the ``state=6`` buffering value carries in ``media_player.py``. 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:`` 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[\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[\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:`` 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(".") )