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