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 the module docstring next to the commands it qualifies, and in the README's app-discovery section next to the command table, matching the state=6 buffering inference mark in the source ladder.
133 lines
5.6 KiB
Python
133 lines
5.6 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.
|
|
|
|
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:<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(".")
|
|
)
|