feat(android): foreground-app automation condition
Make the existing Application automation rule (foreground app -> activate scene) work on the Android-TV build. A Kotlin ForegroundAppBridge reads the foreground app via UsageStatsManager and lists launchable apps via LauncherApps; PlatformDetector bridges it in (ahead of the Windows-only ctypes guard) so the existing AutomationEngine / ApplicationRule / storage / deactivation modes are unchanged. New /system/installed-apps + /system/info endpoints feed an app picker that stores package names (vs process names on desktop); on Android the editor hides the match-type selector since the foreground app is the only obtainable signal. PACKAGE_USAGE_STATS is granted via an on-device button + a web-UI banner (no blanket prompt at capture start); detection degrades gracefully until granted. Zero new Python/Gradle deps (UsageStatsManager + LauncherApps are in-platform; matching only string-compares the package name, so no QUERY_ALL_PACKAGES). assembleDebug + 1897 pytest + ruff + tsc + npm build all green; independent final review (0 blockers) + security review (no critical issues).
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
"""Tests for Android foreground-app detection in PlatformDetector.
|
||||
|
||||
These run on desktop CI (no Android device needed): ``is_android`` and the
|
||||
Kotlin-bridge wrappers (``has_usage_access`` / ``get_foreground_package``) are
|
||||
monkeypatched, exactly as the Kotlin ``ForegroundAppBridge`` would drive them on
|
||||
device. The critical invariant under test is that the Android branch runs *ahead
|
||||
of* the import-time ``_IS_WINDOWS`` guard, and that the Windows/desktop paths are
|
||||
left untouched.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from ledgrab.core.automations import platform_detector as pd
|
||||
from ledgrab.core.automations.platform_detector import PlatformDetector
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def detector(monkeypatch):
|
||||
"""A PlatformDetector with the Windows display-power listener stubbed out.
|
||||
|
||||
``__init__`` otherwise spawns a thread that registers a global window class +
|
||||
runs a ctypes message pump — irrelevant here and noisy when many detectors are
|
||||
constructed in one process.
|
||||
"""
|
||||
monkeypatch.setattr(PlatformDetector, "_display_power_listener", lambda self: None)
|
||||
return PlatformDetector()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_warn():
|
||||
"""Reset the process-global warn-once flag around every test."""
|
||||
pd._warned_no_usage_access = False
|
||||
yield
|
||||
pd._warned_no_usage_access = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# topmost (foreground) detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_topmost_android_returns_lowercased_foreground_package(detector, monkeypatch):
|
||||
monkeypatch.setattr(pd, "is_android", lambda: True)
|
||||
monkeypatch.setattr(pd, "has_usage_access", lambda: True)
|
||||
monkeypatch.setattr(pd, "get_foreground_package", lambda: "com.Netflix.MediaClient")
|
||||
|
||||
assert detector._get_topmost_process_sync() == ("com.netflix.mediaclient", True)
|
||||
|
||||
|
||||
def test_topmost_android_no_access_returns_none_and_warns_once(detector, monkeypatch):
|
||||
monkeypatch.setattr(pd, "is_android", lambda: True)
|
||||
monkeypatch.setattr(pd, "has_usage_access", lambda: False)
|
||||
fg_calls = []
|
||||
monkeypatch.setattr(pd, "get_foreground_package", lambda: fg_calls.append(1) or "x")
|
||||
warns = []
|
||||
monkeypatch.setattr(pd.logger, "warning", lambda *a, **k: warns.append(a))
|
||||
|
||||
assert detector._get_topmost_process_sync() == (None, False)
|
||||
assert detector._get_topmost_process_sync() == (None, False)
|
||||
|
||||
# Foreground is never queried when access is missing; warned exactly once.
|
||||
assert fg_calls == []
|
||||
assert len(warns) == 1
|
||||
assert pd._warned_no_usage_access is True
|
||||
|
||||
|
||||
def test_topmost_android_no_foreground_event_returns_none(detector, monkeypatch):
|
||||
monkeypatch.setattr(pd, "is_android", lambda: True)
|
||||
monkeypatch.setattr(pd, "has_usage_access", lambda: True)
|
||||
monkeypatch.setattr(pd, "get_foreground_package", lambda: None)
|
||||
|
||||
assert detector._get_topmost_process_sync() == (None, False)
|
||||
|
||||
|
||||
def test_android_branch_precedes_windows_guard(detector, monkeypatch):
|
||||
"""Even with _IS_WINDOWS True, is_android() must win.
|
||||
|
||||
Proves the Android branch sits ahead of the ``if not _IS_WINDOWS`` early
|
||||
return and never falls through to the Win32 ctypes path (the plan-review
|
||||
critical gap: a naive wiring would no-op behind the Windows guard).
|
||||
"""
|
||||
monkeypatch.setattr(pd, "_IS_WINDOWS", True)
|
||||
monkeypatch.setattr(pd, "is_android", lambda: True)
|
||||
monkeypatch.setattr(pd, "has_usage_access", lambda: True)
|
||||
monkeypatch.setattr(pd, "get_foreground_package", lambda: "com.App.X")
|
||||
|
||||
assert detector._get_topmost_process_sync() == ("com.app.x", True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# running / fullscreen best-effort (foreground app as the sole entry)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_running_and_fullscreen_android_return_foreground_set(detector, monkeypatch):
|
||||
monkeypatch.setattr(pd, "is_android", lambda: True)
|
||||
monkeypatch.setattr(pd, "has_usage_access", lambda: True)
|
||||
monkeypatch.setattr(pd, "get_foreground_package", lambda: "com.App.Y")
|
||||
|
||||
assert detector._get_running_processes_sync() == {"com.app.y"}
|
||||
assert detector._get_fullscreen_processes_sync() == {"com.app.y"}
|
||||
|
||||
|
||||
def test_running_and_fullscreen_android_empty_without_access(detector, monkeypatch):
|
||||
monkeypatch.setattr(pd, "is_android", lambda: True)
|
||||
monkeypatch.setattr(pd, "has_usage_access", lambda: False)
|
||||
monkeypatch.setattr(pd, "get_foreground_package", lambda: "x")
|
||||
|
||||
assert detector._get_running_processes_sync() == set()
|
||||
assert detector._get_fullscreen_processes_sync() == set()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# desktop paths untouched
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_non_android_non_windows_skips_bridge(detector, monkeypatch):
|
||||
"""Desktop Linux/mac: no Android branch, no Win32 path, empty results, and
|
||||
the bridge wrappers are never consulted."""
|
||||
monkeypatch.setattr(pd, "_IS_WINDOWS", False)
|
||||
monkeypatch.setattr(pd, "is_android", lambda: False)
|
||||
calls = []
|
||||
monkeypatch.setattr(pd, "get_foreground_package", lambda: calls.append("fg"))
|
||||
monkeypatch.setattr(pd, "has_usage_access", lambda: calls.append("acc") or True)
|
||||
|
||||
assert detector._get_topmost_process_sync() == (None, False)
|
||||
assert detector._get_running_processes_sync() == set()
|
||||
assert detector._get_fullscreen_processes_sync() == set()
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_wrappers_return_safe_defaults_off_android(monkeypatch):
|
||||
"""is_android() False short-circuits the bridge accessor to None, so the
|
||||
public wrappers return safe defaults without any java interop."""
|
||||
monkeypatch.setattr(pd, "is_android", lambda: False)
|
||||
|
||||
assert pd._foreground_bridge() is None
|
||||
assert pd.has_usage_access() is False
|
||||
assert pd.get_foreground_package() is None
|
||||
assert pd.list_installed_apps() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bridge-response parsing wrappers (fed via a fake bridge object)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeBridge:
|
||||
"""Stand-in for the Kotlin ForegroundAppBridge singleton."""
|
||||
|
||||
def __init__(self, fg=None, apps_json=None):
|
||||
self._fg = fg
|
||||
self._apps_json = apps_json
|
||||
|
||||
def getForegroundPackage(self):
|
||||
return self._fg
|
||||
|
||||
def listLaunchableApps(self):
|
||||
return self._apps_json
|
||||
|
||||
|
||||
def test_get_foreground_package_strips_whitespace(monkeypatch):
|
||||
# Stripped but NOT lowercased — the caller (_get_android_foreground) lowercases.
|
||||
monkeypatch.setattr(pd, "_foreground_bridge", lambda: _FakeBridge(fg=" com.App.X "))
|
||||
assert pd.get_foreground_package() == "com.App.X"
|
||||
|
||||
|
||||
def test_get_foreground_package_blank_returns_none(monkeypatch):
|
||||
monkeypatch.setattr(pd, "_foreground_bridge", lambda: _FakeBridge(fg=" "))
|
||||
assert pd.get_foreground_package() is None
|
||||
|
||||
|
||||
def test_list_installed_apps_parses_and_filters(monkeypatch):
|
||||
import json
|
||||
|
||||
payload = json.dumps(
|
||||
[
|
||||
{"package": "com.a", "label": "A"},
|
||||
{"package": "com.b", "label": ""}, # blank label -> falls back to package
|
||||
{"label": "no package"}, # skipped: no package
|
||||
"not a dict", # skipped: not an object
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(pd, "_foreground_bridge", lambda: _FakeBridge(apps_json=payload))
|
||||
assert pd.list_installed_apps() == [
|
||||
{"package": "com.a", "label": "A"},
|
||||
{"package": "com.b", "label": "com.b"},
|
||||
]
|
||||
|
||||
|
||||
def test_list_installed_apps_invalid_json_returns_empty(monkeypatch):
|
||||
monkeypatch.setattr(pd, "_foreground_bridge", lambda: _FakeBridge(apps_json="not json{"))
|
||||
assert pd.list_installed_apps() == []
|
||||
Reference in New Issue
Block a user