diff --git a/custom_components/nvidia_shield/ha_adb_common/__init__.py b/custom_components/nvidia_shield/ha_adb_common/__init__.py index 83161b9..764829c 100644 --- a/custom_components/nvidia_shield/ha_adb_common/__init__.py +++ b/custom_components/nvidia_shield/ha_adb_common/__init__.py @@ -9,6 +9,7 @@ from .const import ( ATTR_MEDIA_DAG_ROLE, ATTR_MEDIA_DAG_RUNNING_APPS, ) +from .foreground import parse_foreground from .intent import PlaybackIntent from .keys import ensure_adb_key from .media_session import ( @@ -32,6 +33,7 @@ __all__ = [ "ensure_adb_key", "parse_all_media_sessions", "parse_audio_playback", + "parse_foreground", "parse_wakefulness", "parse_media_session", "running_apps_from_sessions", diff --git a/custom_components/nvidia_shield/ha_adb_common/foreground.py b/custom_components/nvidia_shield/ha_adb_common/foreground.py new file mode 100644 index 0000000..23cefd6 --- /dev/null +++ b/custom_components/nvidia_shield/ha_adb_common/foreground.py @@ -0,0 +1,91 @@ +"""Foreground-app parsing from `dumpsys activity activities` (RESEARCH_SHIELD R4). + +Primary pattern: ``mResumedActivity: ActivityRecord{... u0 / ...}`` +(Shield Android 11). ``topResumedActivity`` (newer firmware) is the fallback, +checked only after the primary — per R4 finding 1. ``dumpsys window`` focus is +NOT parsed (R4 finding 2: the first ``mCurrentFocus`` can be literally null). + +Multi-line ActivityRecord wraps are handled by joining the block until the +record closes or a state line arrives (R4 finding 3: match on the joined +block, not per-line). ``ResumedActivity``/``Resumed`` duplicates are tolerated. +""" + +import re + +_RESUMED_MARKERS = ( + ("mResumedActivity", "mResumedActivity:"), + ("topResumedActivity", "topResumedActivity:"), + ("ResumedActivity", "ResumedActivity:"), + ("Resumed", "Resumed:"), +) + +# `u0 /` — the userId prefix tolerates u0..uN; the package is +# anything up to the first `/` (or closing brace when the activity is bare). +_PACKAGE_RE = re.compile(r"u\d+\s+(\S+?)\s*/|u\d+\s+(\S+?)\s*}") + + +def _marker_kind(stripped: str) -> str | None: + for kind, marker in _RESUMED_MARKERS: + if stripped.startswith(marker): + return kind + return None + + +def _resumed_blocks(dump: str) -> list[tuple[str, str]]: + """(kind, joined-text) blocks for each resumed-activity line, in order.""" + blocks: list[tuple[str, str]] = [] + kind: str | None = None + buf = "" + for raw in dump.split("\n"): + stripped = raw.strip() + marker = _marker_kind(stripped) + if marker is not None: + if buf: + blocks.append((kind, buf)) + kind, buf = marker, stripped + continue + if kind is None: + continue + # Continuation of a wrapped ActivityRecord: join until the record + # closes (brace) or the state line completes the block (R4 finding 3). + buf += " " + stripped + if "}" in stripped or "state=" in stripped: + blocks.append((kind, buf)) + kind, buf = None, "" + if buf: + blocks.append((kind, buf)) + return blocks + + +def _extract_package(text: str) -> str | None: + match = _PACKAGE_RE.search(text) + if match is None: + return None + package = match.group(1) or match.group(2) + if not package or package in ("null", "Window{"): + return None + return package + + +def parse_foreground(dump: str) -> str | None: + """Foreground package: mResumedActivity primary, topResumed fallback. + + Returns None when neither pattern matches (e.g. pre-parse wrappers) — + callers must not fall back to window-focus parsing (R4 finding 2). + """ + if not dump: + return None + blocks = _resumed_blocks(dump) + for kind, text in blocks: + if kind == "topResumedActivity": + continue + package = _extract_package(text) + if package: + return package + for kind, text in blocks: + if kind != "topResumedActivity": + continue + package = _extract_package(text) + if package: + return package + return None