diff --git a/ANDROID-REVIEW/android-foreground-app-automation-plan.md b/ANDROID-REVIEW/android-foreground-app-automation-plan.md new file mode 100644 index 0000000..595e0aa --- /dev/null +++ b/ANDROID-REVIEW/android-foreground-app-automation-plan.md @@ -0,0 +1,94 @@ +# Android foreground-app automation condition — implementation notes + +> Status: implemented on `feature/android-foreground-app-automation`. Last updated 2026-06-02. + +## What & why + +The desktop build has an **Application** automation rule (`ApplicationRule`): activate a scene +when given apps are running / foreground / fullscreen. It was already wired end-to-end on +Android (engine, storage, API, editor) but **silently never fired**, because the two +Windows-only ctypes paths return empty off-Windows: + +1. **Detection** — `PlatformDetector._get_topmost_process_sync()` (and the running/fullscreen + variants) returned `(None, False)` / `set()` on Android. +2. **The app picker** — populated from `GET /api/v1/system/processes` → + `get_running_processes()`, also empty on Android, so users couldn't even choose an app. + +This feature fills both holes using in-platform Android APIs and the established Kotlin↔Python +bridge pattern. **Zero new Python or Gradle dependencies.** + +## Design decision: one implicit "foreground" mode on Android + +Android exposes exactly one obtainable signal — the **current foreground app package**. The +desktop rule's four match types (`running` / `topmost` / `fullscreen` / `topmost_fullscreen`) +are either unobtainable (`running` — `getRunningTasks` is restricted) or identical (a foreground +TV app effectively *is* fullscreen). So on Android: + +- The editor **hides the match-type selector** and the collector forces `match_type="topmost"`. +- `_get_topmost_process_sync()` returns `(package, True)`; the running/fullscreen detectors + return the foreground app as a best-effort single-element set so legacy rules still behave. + +This avoided touching the existing plain ` + ${t('automations.rule.application.apps.hint_android')} + + + `; + const textarea = container.querySelector('.rule-apps') as HTMLTextAreaElement; + attachAppPicker(container, textarea); + return; + } + const matchType = data.match_type || 'running'; container.innerHTML = `
@@ -1299,7 +1357,10 @@ const RULE_COLLECTORS: Record = { return r; }, application: (row) => { - const matchType = (row.querySelector('.rule-match-type') as HTMLSelectElement).value; + // On Android the match-type selector is hidden (only the foreground app is + // detectable), so default to "topmost" when the select isn't present. + const matchSel = row.querySelector('.rule-match-type') as HTMLSelectElement | null; + const matchType = matchSel ? matchSel.value : 'topmost'; const appsText = (row.querySelector('.rule-apps') as HTMLTextAreaElement).value.trim(); const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : []; return { rule_type: 'application', apps, match_type: matchType }; diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 140ee24..99c5a98 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -1226,6 +1226,10 @@ "automations.rule.application.match_type.topmost_fullscreen.desc": "Foreground + fullscreen", "automations.rule.application.match_type.fullscreen": "Fullscreen", "automations.rule.application.match_type.fullscreen.desc": "Any fullscreen app", + "automations.rule.application.apps.hint_android": "Package names, one per line (e.g. com.netflix.mediaclient)", + "automations.rule.application.search_apps": "Filter apps...", + "automations.rule.application.no_apps": "No apps found", + "automations.rule.application.usage_access_required": "Needs Usage Access. On your LedGrab TV, open the app and tap 'Grant usage access'.", "automations.rule.time_of_day": "Time of Day", "automations.rule.time_of_day.desc": "Time range", "automations.rule.time_of_day.start_time": "Start Time:", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index ea68ab0..744e2af 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -1260,6 +1260,10 @@ "automations.rule.application.match_type.topmost_fullscreen.desc": "В фокусе + полный экран", "automations.rule.application.match_type.fullscreen": "Полный экран", "automations.rule.application.match_type.fullscreen.desc": "Любое полноэкранное", + "automations.rule.application.apps.hint_android": "Имена пакетов, по одному в строке (напр. com.netflix.mediaclient)", + "automations.rule.application.search_apps": "Поиск приложений...", + "automations.rule.application.no_apps": "Приложения не найдены", + "automations.rule.application.usage_access_required": "Требуется доступ к статистике использования. Откройте LedGrab на телевизоре и нажмите «Разрешить доступ к статистике использования».", "automations.rule.time_of_day": "Время суток", "automations.rule.time_of_day.desc": "Диапазон времени", "automations.rule.time_of_day.start_time": "Время начала:", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index 9d1fdf2..4246415 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -1256,6 +1256,10 @@ "automations.rule.application.match_type.topmost_fullscreen.desc": "前台 + 全屏", "automations.rule.application.match_type.fullscreen": "全屏", "automations.rule.application.match_type.fullscreen.desc": "任意全屏应用", + "automations.rule.application.apps.hint_android": "包名,每行一个(例如 com.netflix.mediaclient)", + "automations.rule.application.search_apps": "筛选应用…", + "automations.rule.application.no_apps": "未找到应用", + "automations.rule.application.usage_access_required": "需要使用情况访问权限。在您的 LedGrab 电视上打开应用并点按「授予使用情况访问权限」。", "automations.rule.time_of_day": "时段", "automations.rule.time_of_day.desc": "时间范围", "automations.rule.time_of_day.start_time": "开始时间:", diff --git a/server/src/ledgrab/storage/automation.py b/server/src/ledgrab/storage/automation.py index 6518ac7..95043a1 100644 --- a/server/src/ledgrab/storage/automation.py +++ b/server/src/ledgrab/storage/automation.py @@ -30,11 +30,24 @@ class Rule: @dataclass class ApplicationRule(Rule): - """Activate when specified applications are running or topmost.""" + """Activate when specified applications are running or topmost. + + ``apps`` values are platform-specific and NOT portable across OSes: + on Windows they are **process names** (e.g. ``chrome.exe``); on Android + they are **package names** (e.g. ``com.android.chrome``). Matching is + exact and case-insensitive. The automation editor sources values from the + right place per platform (running processes on desktop, launchable apps on + Android), so a rule authored on one OS will simply not match on another. + + ``match_type`` is honoured on Windows for all four values below. On Android + only the foreground app is obtainable, so every match type collapses to + "this app is in the foreground" and the editor hides the selector. + """ rule_type: str = "application" apps: List[str] = field(default_factory=list) - match_type: str = "running" # "running" | "topmost" + # "running" | "topmost" | "fullscreen" | "topmost_fullscreen" + match_type: str = "running" def to_dict(self) -> dict: d = super().to_dict() diff --git a/server/tests/api/routes/test_system_routes.py b/server/tests/api/routes/test_system_routes.py index 56b8041..ff09374 100644 --- a/server/tests/api/routes/test_system_routes.py +++ b/server/tests/api/routes/test_system_routes.py @@ -92,3 +92,57 @@ class TestRootEndpoint: resp = client.get("/") assert resp.status_code == 200 assert "text/html" in resp.headers["content-type"] + + +class TestInstalledAppsEndpoint: + def test_requires_auth(self, client): + resp = client.get("/api/v1/system/installed-apps") + assert resp.status_code == 401 + + def test_empty_off_android(self, client): + """Desktop test host: is_android() is False, so the bridge wrapper + short-circuits to an empty list.""" + resp = client.get("/api/v1/system/installed-apps", headers=_auth_headers()) + assert resp.status_code == 200 + assert resp.json() == {"apps": [], "count": 0} + + def test_returns_apps_when_available(self, client, monkeypatch): + from ledgrab.core.automations import platform_detector as pd + + monkeypatch.setattr( + pd, + "list_installed_apps", + lambda: [{"package": "com.netflix.mediaclient", "label": "Netflix"}], + ) + resp = client.get("/api/v1/system/installed-apps", headers=_auth_headers()) + assert resp.status_code == 200 + data = resp.json() + assert data["count"] == 1 + assert data["apps"][0] == {"package": "com.netflix.mediaclient", "label": "Netflix"} + + +class TestSystemInfoEndpoint: + def test_requires_auth(self, client): + resp = client.get("/api/v1/system/info") + assert resp.status_code == 401 + + def test_desktop_signal(self, client): + resp = client.get("/api/v1/system/info", headers=_auth_headers()) + assert resp.status_code == 200 + data = resp.json() + assert data["is_android"] is False + assert data["app_match_kind"] == "process" + assert data["usage_access_granted"] is True + + def test_android_signal(self, client, monkeypatch): + import ledgrab.utils.platform as plat + from ledgrab.core.automations import platform_detector as pd + + monkeypatch.setattr(plat, "is_android", lambda: True) + monkeypatch.setattr(pd, "has_usage_access", lambda: False) + resp = client.get("/api/v1/system/info", headers=_auth_headers()) + assert resp.status_code == 200 + data = resp.json() + assert data["is_android"] is True + assert data["app_match_kind"] == "package" + assert data["usage_access_granted"] is False diff --git a/server/tests/core/test_android_foreground.py b/server/tests/core/test_android_foreground.py new file mode 100644 index 0000000..577a465 --- /dev/null +++ b/server/tests/core/test_android_foreground.py @@ -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() == []