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:
@@ -39,8 +39,11 @@ from ledgrab.api.schemas.system import (
|
||||
DisplayListResponse,
|
||||
GpuInfo,
|
||||
HealthResponse,
|
||||
InstalledAppItem,
|
||||
InstalledAppsResponse,
|
||||
PerformanceResponse,
|
||||
ProcessListResponse,
|
||||
SystemInfoResponse,
|
||||
VersionResponse,
|
||||
)
|
||||
from ledgrab.config import get_config, is_demo_mode
|
||||
@@ -278,6 +281,52 @@ async def get_running_processes(_: AuthRequired):
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/system/installed-apps",
|
||||
response_model=InstalledAppsResponse,
|
||||
tags=["Config"],
|
||||
)
|
||||
def get_installed_apps(_: AuthRequired):
|
||||
"""List launchable apps for the application-rule app picker (Android only).
|
||||
|
||||
Returns launchable apps (package + human label) on Android, where the
|
||||
foreground-app automation rule matches package names. Returns an empty list
|
||||
on desktop, where the process picker (``/system/processes``) is used instead.
|
||||
Sync ``def`` so FastAPI runs the (potentially blocking) bridge call in a
|
||||
thread pool.
|
||||
"""
|
||||
from ledgrab.core.automations import platform_detector as pd
|
||||
|
||||
try:
|
||||
apps = pd.list_installed_apps()
|
||||
items = [InstalledAppItem(package=a["package"], label=a["label"]) for a in apps]
|
||||
return InstalledAppsResponse(apps=items, count=len(items))
|
||||
except Exception as e:
|
||||
logger.error("Failed to list installed apps: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/api/v1/system/info", response_model=SystemInfoResponse, tags=["Info"])
|
||||
def get_system_info(_: AuthRequired):
|
||||
"""Platform capability signal for the automation editor.
|
||||
|
||||
Tells the frontend whether the server is on Android (so the application-rule
|
||||
editor uses the launchable-app picker + package matching and surfaces the
|
||||
Usage-Access banner) vs desktop (process picker + process names), and whether
|
||||
Usage Access is currently granted. Sync ``def`` so the bridge call runs in a
|
||||
thread pool.
|
||||
"""
|
||||
from ledgrab.core.automations import platform_detector as pd
|
||||
from ledgrab.utils.platform import is_android
|
||||
|
||||
android = is_android()
|
||||
return SystemInfoResponse(
|
||||
is_android=android,
|
||||
app_match_kind="package" if android else "process",
|
||||
usage_access_granted=(pd.has_usage_access() if android else True),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/system/performance",
|
||||
response_model=PerformanceResponse,
|
||||
|
||||
@@ -11,9 +11,21 @@ class RuleSchema(BaseModel):
|
||||
|
||||
rule_type: str = Field(description="Rule type discriminator (e.g. 'application')")
|
||||
# Application rule fields
|
||||
apps: List[str] | None = Field(None, description="Process names (for application rule)")
|
||||
apps: List[str] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"App identifiers for the application rule. Platform-specific and not "
|
||||
"portable: process names on Windows (e.g. 'chrome.exe'), package names "
|
||||
"on Android (e.g. 'com.android.chrome'). Matched case-insensitively."
|
||||
),
|
||||
)
|
||||
match_type: str | None = Field(
|
||||
None, description="'running' or 'topmost' (for application rule)"
|
||||
None,
|
||||
description=(
|
||||
"'running', 'topmost', 'fullscreen', or 'topmost_fullscreen' (application "
|
||||
"rule). On Android only the foreground app is detectable, so all values "
|
||||
"behave as 'foreground'."
|
||||
),
|
||||
)
|
||||
# Time-of-day rule fields
|
||||
start_time: str | None = Field(None, description="Start time HH:MM (for time_of_day rule)")
|
||||
|
||||
@@ -68,6 +68,42 @@ class ProcessListResponse(BaseModel):
|
||||
count: int = Field(description="Number of unique processes")
|
||||
|
||||
|
||||
class InstalledAppItem(BaseModel):
|
||||
"""A launchable Android app, for the automation app picker."""
|
||||
|
||||
package: str = Field(description="Android package name, e.g. 'com.netflix.mediaclient'")
|
||||
label: str = Field(description="Human-readable app label, e.g. 'Netflix'")
|
||||
|
||||
|
||||
class InstalledAppsResponse(BaseModel):
|
||||
"""Launchable apps for the application-rule picker (Android only; empty elsewhere)."""
|
||||
|
||||
apps: List[InstalledAppItem] = Field(description="Launchable apps, sorted by label")
|
||||
count: int = Field(description="Number of apps")
|
||||
|
||||
|
||||
class SystemInfoResponse(BaseModel):
|
||||
"""Platform capability signal for the frontend (automation editor).
|
||||
|
||||
Lets the application-rule editor choose the right app source and matching
|
||||
semantics per platform, and surface the Usage-Access permission state.
|
||||
"""
|
||||
|
||||
is_android: bool = Field(description="True when the server runs on Android (Chaquopy)")
|
||||
app_match_kind: Literal["process", "package"] = Field(
|
||||
description=(
|
||||
"What ApplicationRule.apps values represent: 'process' names on desktop, "
|
||||
"'package' names on Android."
|
||||
)
|
||||
)
|
||||
usage_access_granted: bool = Field(
|
||||
description=(
|
||||
"Android: whether PACKAGE_USAGE_STATS (Usage Access) is granted, gating "
|
||||
"foreground-app detection. Always True (not applicable) off-Android."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class GpuInfo(BaseModel):
|
||||
"""GPU performance information."""
|
||||
|
||||
|
||||
@@ -6,12 +6,14 @@ Non-Windows: graceful degradation (returns empty results).
|
||||
|
||||
import asyncio
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from typing import Set
|
||||
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.platform import is_android
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -21,6 +23,105 @@ if _IS_WINDOWS:
|
||||
import ctypes.wintypes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Android ForegroundAppBridge interop — lazy + guarded (never at import time)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Android reports ``sys.platform == "linux"`` so ``_IS_WINDOWS`` is False there;
|
||||
# the foreground app is read via the Kotlin ``ForegroundAppBridge`` (UsageStats)
|
||||
# instead of Win32 ctypes. These module-level wrappers are the monkeypatch
|
||||
# surface used by tests (mirrors ``android_camera_engine``) — patch the module
|
||||
# function, not the live ``jclass`` object.
|
||||
|
||||
# Emit the "Usage Access not granted" warning only once per process so the ~1s
|
||||
# automation poll loop doesn't spam the log while access is missing.
|
||||
_warned_no_usage_access = False
|
||||
|
||||
|
||||
def _foreground_bridge():
|
||||
"""Return the Kotlin ``ForegroundAppBridge`` singleton, or None off-Android.
|
||||
|
||||
The ``from java import jclass`` import only resolves inside the Chaquopy
|
||||
runtime, so it must never run at module import time (this module is imported
|
||||
on desktop CI too). Mirrors ``android_camera_engine._camera_bridge()``.
|
||||
"""
|
||||
if not is_android():
|
||||
return None
|
||||
try:
|
||||
from java import jclass # type: ignore[import-not-found]
|
||||
except ImportError as exc:
|
||||
logger.debug("Chaquopy java interop not available: %s", exc)
|
||||
return None
|
||||
try:
|
||||
return jclass("com.ledgrab.android.ForegroundAppBridge").INSTANCE
|
||||
except Exception as exc: # pragma: no cover - Android-only path
|
||||
logger.debug("ForegroundAppBridge singleton unavailable: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def has_usage_access() -> bool:
|
||||
"""Whether Usage Access (PACKAGE_USAGE_STATS) is granted. False off-Android."""
|
||||
bridge = _foreground_bridge()
|
||||
if bridge is None:
|
||||
return False
|
||||
try:
|
||||
return bool(bridge.hasUsageAccess())
|
||||
except Exception as exc: # pragma: no cover - Android-only path
|
||||
logger.debug("ForegroundAppBridge.hasUsageAccess failed: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def get_foreground_package() -> str | None:
|
||||
"""Current foreground app package via the Kotlin bridge, or None.
|
||||
|
||||
None off-Android, when the bridge is unavailable, when Usage Access is
|
||||
missing, or when no foreground event is found in the trailing window.
|
||||
Monkeypatched in tests.
|
||||
"""
|
||||
bridge = _foreground_bridge()
|
||||
if bridge is None:
|
||||
return None
|
||||
try:
|
||||
pkg = bridge.getForegroundPackage()
|
||||
except Exception as exc: # pragma: no cover - Android-only path
|
||||
logger.warning("ForegroundAppBridge.getForegroundPackage failed: %s", exc)
|
||||
return None
|
||||
if pkg is None:
|
||||
return None
|
||||
s = str(pkg).strip()
|
||||
return s or None
|
||||
|
||||
|
||||
def list_installed_apps() -> list[dict]:
|
||||
"""Launchable apps via the Kotlin bridge: ``[{"package": .., "label": ..}]``.
|
||||
|
||||
Returns ``[]`` off-Android, when the bridge is unavailable, on error, or on
|
||||
invalid JSON. Sorted by label (the bridge sorts; order is preserved here).
|
||||
Monkeypatched in tests.
|
||||
"""
|
||||
bridge = _foreground_bridge()
|
||||
if bridge is None:
|
||||
return []
|
||||
try:
|
||||
raw = bridge.listLaunchableApps() # JSON array string
|
||||
except Exception as exc: # pragma: no cover - Android-only path
|
||||
logger.warning("ForegroundAppBridge.listLaunchableApps failed: %s", exc)
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(str(raw))
|
||||
except (ValueError, TypeError) as exc: # pragma: no cover - Android-only path
|
||||
logger.warning("ForegroundAppBridge.listLaunchableApps returned invalid JSON: %s", exc)
|
||||
return []
|
||||
apps: list[dict] = []
|
||||
for entry in parsed if isinstance(parsed, list) else []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
pkg = entry.get("package")
|
||||
if not pkg:
|
||||
continue
|
||||
apps.append({"package": str(pkg), "label": str(entry.get("label") or pkg)})
|
||||
return apps
|
||||
|
||||
|
||||
class PlatformDetector:
|
||||
"""Detect running processes and the foreground window's process."""
|
||||
|
||||
@@ -215,6 +316,31 @@ class PlatformDetector:
|
||||
|
||||
# ---- Process detection ----
|
||||
|
||||
def _get_android_foreground(self) -> tuple:
|
||||
"""(package_lowercased, True) for the foreground app on Android.
|
||||
|
||||
Returns ``(None, False)`` when Usage Access is not granted (warned once)
|
||||
or no foreground app is found. ``is_fullscreen`` is reported True because
|
||||
a foreground TV app effectively covers the screen — so an Android rule's
|
||||
``topmost``/``topmost_fullscreen``/``fullscreen`` match types all behave
|
||||
as "this app is in front". Delegates to the module-level bridge wrappers
|
||||
(the monkeypatch surface used by tests).
|
||||
"""
|
||||
global _warned_no_usage_access
|
||||
if not has_usage_access():
|
||||
if not _warned_no_usage_access:
|
||||
logger.warning(
|
||||
"Android 'Application' automation rules need Usage Access "
|
||||
"(Settings > Usage access). Foreground-app rules will not match "
|
||||
"until it is granted."
|
||||
)
|
||||
_warned_no_usage_access = True
|
||||
return (None, False)
|
||||
pkg = get_foreground_package()
|
||||
if not pkg:
|
||||
return (None, False)
|
||||
return (pkg.lower(), True)
|
||||
|
||||
def _get_running_processes_sync(self) -> Set[str]:
|
||||
"""Get set of lowercase process names via Win32 EnumProcesses.
|
||||
|
||||
@@ -222,7 +348,14 @@ class PlatformDetector:
|
||||
which is ~300x faster than WMI (~8ms vs ~3s). System services
|
||||
running under protected accounts are not visible, but all
|
||||
user-facing applications are covered.
|
||||
|
||||
On Android there is no process enumeration API (getRunningTasks is
|
||||
restricted); the foreground app is reported as the sole "running" entry
|
||||
as a best-effort so ``match_type="running"`` rules still work.
|
||||
"""
|
||||
if is_android():
|
||||
pkg, _ = self._get_android_foreground()
|
||||
return {pkg} if pkg else set()
|
||||
if not _IS_WINDOWS:
|
||||
return set()
|
||||
|
||||
@@ -276,9 +409,13 @@ class PlatformDetector:
|
||||
def _get_topmost_process_sync(self) -> tuple:
|
||||
"""Get (process_name, is_fullscreen) of the foreground window.
|
||||
|
||||
Returns (None, False) when detection fails.
|
||||
On Android the "foreground window" is the foreground app package (read
|
||||
via the Kotlin ForegroundAppBridge); see ``_get_android_foreground``.
|
||||
Returns (None, False) when detection fails / Usage Access is missing.
|
||||
Blocking — call via executor.
|
||||
"""
|
||||
if is_android():
|
||||
return self._get_android_foreground()
|
||||
if not _IS_WINDOWS:
|
||||
return (None, False)
|
||||
|
||||
@@ -369,7 +506,13 @@ class PlatformDetector:
|
||||
|
||||
Enumerates all top-level windows and checks each for fullscreen.
|
||||
Returns process names (lowercase) whose window covers an entire monitor.
|
||||
|
||||
On Android the foreground app is treated as fullscreen, so it is the
|
||||
sole entry (best-effort, mirrors ``_get_running_processes_sync``).
|
||||
"""
|
||||
if is_android():
|
||||
pkg, _ = self._get_android_foreground()
|
||||
return {pkg} if pkg else set()
|
||||
if not _IS_WINDOWS:
|
||||
return set()
|
||||
|
||||
|
||||
@@ -74,6 +74,19 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Android-only: shown in the application rule when Usage Access is missing,
|
||||
so the foreground-app rule can't fire until the user grants it on the TV. */
|
||||
.rule-usage-warning {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.35;
|
||||
color: var(--warning-color, #ff9800);
|
||||
background: color-mix(in srgb, var(--warning-color, #ff9800) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--warning-color, #ff9800) 35%, transparent);
|
||||
}
|
||||
|
||||
.btn-remove-rule {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
* Command-palette style name picker — reusable UI for browsing a list of
|
||||
* names fetched from any API endpoint. Mirrors the EntityPalette pattern.
|
||||
*
|
||||
* Two concrete pickers are exported:
|
||||
* Three concrete pickers are exported:
|
||||
*
|
||||
* - **ProcessPalette** — picks from running OS processes (`/system/processes`)
|
||||
* - **NotificationAppPalette** — picks from OS notification history apps
|
||||
* - **AppPalette** — picks from Android launchable apps (`/system/installed-apps`),
|
||||
* displaying the human label but inserting the package name
|
||||
*
|
||||
* Both support single-select (returns one value) and multi-select (appends to
|
||||
* a textarea).
|
||||
* Items may be plain strings (display == stored value) or `{ value, label }`
|
||||
* pairs (display the label, store the value — used by AppPalette so the rule
|
||||
* stores the package name while the user sees "Netflix").
|
||||
*
|
||||
* All support single-select (returns one value) and multi-select (appends the
|
||||
* value to a textarea).
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
@@ -29,8 +35,16 @@ import { ICON_SEARCH } from './icons.ts';
|
||||
|
||||
/* ─── types ────────────────────────────────────────────────── */
|
||||
|
||||
interface PaletteItem {
|
||||
name: string;
|
||||
/** An item with a display label distinct from its stored value. */
|
||||
interface AppItem {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Raw items a fetcher may return: bare strings or labelled pairs. */
|
||||
type RawItem = string | AppItem;
|
||||
|
||||
interface PaletteEntry extends AppItem {
|
||||
added: boolean;
|
||||
}
|
||||
|
||||
@@ -44,7 +58,9 @@ interface PickMultiOpts {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
type FetchItemsFn = () => Promise<string[]>;
|
||||
type FetchItemsFn = () => Promise<RawItem[]>;
|
||||
|
||||
const DEFAULT_EMPTY_KEY = 'automations.condition.application.no_processes';
|
||||
|
||||
/* ─── generic NamePalette (shared logic) ───────────────────── */
|
||||
|
||||
@@ -53,19 +69,21 @@ class NamePalette {
|
||||
private _input: HTMLInputElement;
|
||||
private _list: HTMLDivElement;
|
||||
private _fetchItems: FetchItemsFn;
|
||||
private _emptyKey: string;
|
||||
|
||||
private _resolveSingle: ((v: string | undefined) => void) | null = null;
|
||||
private _multiTextarea: HTMLTextAreaElement | null = null;
|
||||
|
||||
private _items: string[] = [];
|
||||
private _items: AppItem[] = [];
|
||||
private _existing: Set<string> = new Set();
|
||||
private _filtered: PaletteItem[] = [];
|
||||
private _filtered: PaletteEntry[] = [];
|
||||
private _highlightIdx = 0;
|
||||
private _currentValue: string | undefined;
|
||||
private _isMulti = false;
|
||||
|
||||
constructor(fetchItems: FetchItemsFn) {
|
||||
constructor(fetchItems: FetchItemsFn, emptyKey: string = DEFAULT_EMPTY_KEY) {
|
||||
this._fetchItems = fetchItems;
|
||||
this._emptyKey = emptyKey;
|
||||
|
||||
this._overlay = document.createElement('div');
|
||||
this._overlay.className = 'entity-palette-overlay process-palette-overlay';
|
||||
@@ -107,14 +125,20 @@ class NamePalette {
|
||||
this._isMulti = true;
|
||||
this._multiTextarea = opts.textarea;
|
||||
this._resolveSingle = resolve as any;
|
||||
this._existing = new Set(
|
||||
opts.textarea.value.split('\n').map(s => s.trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
this._existing = this._textareaValues(opts.textarea);
|
||||
this._currentValue = undefined;
|
||||
this._open(opts.placeholder);
|
||||
});
|
||||
}
|
||||
|
||||
private _textareaValues(ta: HTMLTextAreaElement): Set<string> {
|
||||
return new Set(ta.value.split('\n').map(s => s.trim().toLowerCase()).filter(Boolean));
|
||||
}
|
||||
|
||||
private _normalize(raw: RawItem[]): AppItem[] {
|
||||
return raw.map(r => (typeof r === 'string' ? { value: r, label: r } : r));
|
||||
}
|
||||
|
||||
private async _open(placeholder?: string) {
|
||||
this._input.placeholder = placeholder || '';
|
||||
this._input.value = '';
|
||||
@@ -123,15 +147,13 @@ class NamePalette {
|
||||
requestAnimationFrame(() => this._input.focus());
|
||||
|
||||
try {
|
||||
this._items = await this._fetchItems();
|
||||
this._items = this._normalize(await this._fetchItems());
|
||||
} catch {
|
||||
this._items = [];
|
||||
}
|
||||
|
||||
if (this._isMulti) {
|
||||
this._existing = new Set(
|
||||
this._multiTextarea!.value.split('\n').map(s => s.trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
this._existing = this._textareaValues(this._multiTextarea!);
|
||||
}
|
||||
|
||||
this._filter();
|
||||
@@ -142,14 +164,11 @@ class NamePalette {
|
||||
private _filter() {
|
||||
const q = this._input.value.toLowerCase().trim();
|
||||
this._filtered = this._items
|
||||
.filter(p => !q || p.toLowerCase().includes(q))
|
||||
.map(p => ({
|
||||
name: p,
|
||||
added: this._existing.has(p.toLowerCase()),
|
||||
}));
|
||||
.filter(p => !q || p.label.toLowerCase().includes(q) || p.value.toLowerCase().includes(q))
|
||||
.map(p => ({ ...p, added: this._existing.has(p.value.toLowerCase()) }));
|
||||
|
||||
this._highlightIdx = this._filtered.findIndex(
|
||||
i => i.name.toLowerCase() === (this._currentValue || '').toLowerCase(),
|
||||
i => i.value.toLowerCase() === (this._currentValue || '').toLowerCase(),
|
||||
);
|
||||
if (this._highlightIdx === -1) this._highlightIdx = 0;
|
||||
this._render();
|
||||
@@ -158,9 +177,7 @@ class NamePalette {
|
||||
private _render() {
|
||||
if (this._filtered.length === 0) {
|
||||
this._list.innerHTML = `<div class="entity-palette-empty">${
|
||||
this._items.length === 0
|
||||
? t('automations.condition.application.no_processes')
|
||||
: '—'
|
||||
this._items.length === 0 ? t(this._emptyKey) : '—'
|
||||
}</div>`;
|
||||
return;
|
||||
}
|
||||
@@ -170,12 +187,21 @@ class NamePalette {
|
||||
'entity-palette-item',
|
||||
i === this._highlightIdx ? 'ep-highlight' : '',
|
||||
item.added ? 'ep-current' : '',
|
||||
item.name.toLowerCase() === (this._currentValue || '').toLowerCase() ? 'ep-current' : '',
|
||||
item.value.toLowerCase() === (this._currentValue || '').toLowerCase() ? 'ep-current' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
// When the label differs from the stored value (e.g. "Netflix" vs
|
||||
// "com.netflix.mediaclient"), show the value as a secondary line so
|
||||
// users can see exactly what gets matched. Otherwise fall back to the
|
||||
// ✓ added-marker.
|
||||
const showValue = item.label !== item.value;
|
||||
const trailing = showValue
|
||||
? `<span class="ep-item-desc">${escapeHtml(item.value)}</span>`
|
||||
: (item.added ? '<span class="ep-item-desc">✓</span>' : '');
|
||||
|
||||
return `<div class="${cls}" data-idx="${i}">
|
||||
<span class="ep-item-label">${escapeHtml(item.name)}</span>
|
||||
${item.added ? '<span class="ep-item-desc">\u2713</span>' : ''}
|
||||
<span class="ep-item-label">${escapeHtml(item.label)}</span>
|
||||
${trailing}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
@@ -192,19 +218,19 @@ class NamePalette {
|
||||
|
||||
/* ── selection ──────────────────────────────────────────── */
|
||||
|
||||
private _selectItem(item: PaletteItem) {
|
||||
private _selectItem(item: PaletteEntry) {
|
||||
if (this._isMulti) {
|
||||
if (!item.added) {
|
||||
const ta = this._multiTextarea!;
|
||||
const cur = ta.value.trim();
|
||||
ta.value = cur ? cur + '\n' + item.name : item.name;
|
||||
this._existing.add(item.name.toLowerCase());
|
||||
ta.value = cur ? cur + '\n' + item.value : item.value;
|
||||
this._existing.add(item.value.toLowerCase());
|
||||
item.added = true;
|
||||
this._render();
|
||||
}
|
||||
} else {
|
||||
this._overlay.classList.remove('open');
|
||||
if (this._resolveSingle) this._resolveSingle(item.name);
|
||||
if (this._resolveSingle) this._resolveSingle(item.value);
|
||||
this._resolveSingle = null;
|
||||
}
|
||||
}
|
||||
@@ -269,6 +295,17 @@ async function _fetchNotificationApps(): Promise<string[]> {
|
||||
return Array.from(seen.values()).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
async function _fetchInstalledApps(): Promise<AppItem[]> {
|
||||
try {
|
||||
const data = await apiGet<{ apps?: Array<{ package: string; label: string }> }>(
|
||||
'/system/installed-apps',
|
||||
);
|
||||
return (data.apps || []).map(a => ({ value: a.package, label: a.label || a.package }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── ProcessPalette (running processes) ───────────────────── */
|
||||
|
||||
let _processInst: NamePalette | null = null;
|
||||
@@ -301,6 +338,22 @@ export class NotificationAppPalette {
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── AppPalette (Android launchable apps) ─────────────────── */
|
||||
|
||||
let _appInst: NamePalette | null = null;
|
||||
|
||||
export class AppPalette {
|
||||
static pick(opts: PickOpts): Promise<string | undefined> {
|
||||
if (!_appInst) _appInst = new NamePalette(_fetchInstalledApps, 'automations.rule.application.no_apps');
|
||||
return _appInst.pickSingle(opts);
|
||||
}
|
||||
|
||||
static pickMulti(opts: PickMultiOpts): Promise<void> {
|
||||
if (!_appInst) _appInst = new NamePalette(_fetchInstalledApps, 'automations.rule.application.no_apps');
|
||||
return _appInst.pickMulti(opts);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── drop-in replacement for the old attachProcessPicker ─── */
|
||||
|
||||
/**
|
||||
@@ -334,3 +387,19 @@ export function attachNotificationAppPicker(containerEl: HTMLElement, textareaEl
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire up a `.btn-browse-apps` button to open the Android launchable-app palette
|
||||
* (multi-select, feeding package names into a textarea while showing labels).
|
||||
*/
|
||||
export function attachAppPicker(containerEl: HTMLElement, textareaEl: HTMLTextAreaElement): void {
|
||||
const browseBtn = containerEl.querySelector('.btn-browse-apps');
|
||||
if (!browseBtn) return;
|
||||
|
||||
browseBtn.addEventListener('click', () => {
|
||||
AppPalette.pickMulti({
|
||||
textarea: textareaEl,
|
||||
placeholder: t('automations.rule.application.search_apps') || 'Filter apps…',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import { getBaseOrigin } from './settings.ts';
|
||||
import { IconSelect } from '../core/icon-select.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { enhanceMiniSelects } from '../core/mini-select.ts';
|
||||
import { attachProcessPicker } from '../core/process-picker.ts';
|
||||
import { attachProcessPicker, attachAppPicker } from '../core/process-picker.ts';
|
||||
import { TreeNav } from '../core/tree-nav.ts';
|
||||
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
|
||||
import type { Automation, RuleType } from '../types.ts';
|
||||
@@ -215,6 +215,28 @@ document.addEventListener('server:automation_state_changed', () => {
|
||||
if (apiKey && isActiveTab('automations')) loadAutomations();
|
||||
});
|
||||
|
||||
/** Platform capability signal from `/system/info` — drives the application-rule
|
||||
* editor (process picker + match types on desktop vs. app picker + foreground-only
|
||||
* on Android) and the Usage-Access banner. Fetched once and cached. */
|
||||
interface PlatformInfo {
|
||||
is_android: boolean;
|
||||
app_match_kind: 'process' | 'package';
|
||||
usage_access_granted: boolean;
|
||||
}
|
||||
|
||||
let _platformInfo: PlatformInfo | null = null;
|
||||
|
||||
async function ensurePlatformInfo(): Promise<PlatformInfo> {
|
||||
if (_platformInfo) return _platformInfo;
|
||||
try {
|
||||
_platformInfo = await apiGet<PlatformInfo>('/system/info');
|
||||
} catch {
|
||||
// Default to desktop semantics if the signal can't be fetched.
|
||||
_platformInfo = { is_android: false, app_match_kind: 'process', usage_access_granted: true };
|
||||
}
|
||||
return _platformInfo;
|
||||
}
|
||||
|
||||
export async function loadAutomations() {
|
||||
if (_automationsLoading) return;
|
||||
set_automationsLoading(true);
|
||||
@@ -222,6 +244,10 @@ export async function loadAutomations() {
|
||||
if (!container) { set_automationsLoading(false); return; }
|
||||
if (!csAutomations.isMounted()) setTabRefreshing('automations-content', true);
|
||||
|
||||
// Prime the platform signal so the editor renders the right app source +
|
||||
// match semantics without an async hop when a rule row is expanded.
|
||||
void ensurePlatformInfo();
|
||||
|
||||
try {
|
||||
const [automations, scenes] = await Promise.all([
|
||||
automationsCacheObj.fetch(),
|
||||
@@ -559,6 +585,11 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
|
||||
errorEl.style.display = 'none';
|
||||
ruleList!.innerHTML = '';
|
||||
|
||||
// Ensure the platform signal is loaded before rendering rule rows so the
|
||||
// application rule picks the right app source + match semantics. The
|
||||
// automations tab primes this, but the graph editor opens this directly.
|
||||
await ensurePlatformInfo();
|
||||
|
||||
_ensureRuleLogicIconSelect();
|
||||
_ensureDeactivationModeIconSelect();
|
||||
|
||||
@@ -1129,6 +1160,33 @@ function _renderWebhookFields(container: HTMLElement, data: any): void {
|
||||
|
||||
function _renderApplicationFields(container: HTMLElement, data: any): void {
|
||||
const appsValue = (data.apps || []).join('\n');
|
||||
|
||||
// On Android there is exactly one obtainable signal — the foreground app —
|
||||
// so the match-type selector is hidden (match_type is forced to "topmost" by
|
||||
// the collector) and the app list comes from launchable apps (package names)
|
||||
// rather than running processes (process names).
|
||||
if (_platformInfo?.is_android) {
|
||||
const banner = _platformInfo.usage_access_granted
|
||||
? ''
|
||||
: `<div class="rule-usage-warning">${t('automations.rule.application.usage_access_required')}</div>`;
|
||||
container.innerHTML = `
|
||||
<div class="rule-fields">
|
||||
${banner}
|
||||
<div class="rule-field">
|
||||
<div class="rule-apps-header">
|
||||
<label>${t('automations.rule.application.apps')}</label>
|
||||
<button type="button" class="btn btn-icon btn-secondary btn-browse-apps" title="${t('automations.rule.application.browse')}">${ICON_SEARCH}</button>
|
||||
</div>
|
||||
<textarea class="rule-apps" rows="3" placeholder="com.netflix.mediaclient com.android.chrome">${escapeHtml(appsValue)}</textarea>
|
||||
<small class="rule-hint-desc">${t('automations.rule.application.apps.hint_android')}</small>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const textarea = container.querySelector('.rule-apps') as HTMLTextAreaElement;
|
||||
attachAppPicker(container, textarea);
|
||||
return;
|
||||
}
|
||||
|
||||
const matchType = data.match_type || 'running';
|
||||
container.innerHTML = `
|
||||
<div class="rule-fields">
|
||||
@@ -1299,7 +1357,10 @@ const RULE_COLLECTORS: Record<RuleType, RuleCollector> = {
|
||||
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 };
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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": "Время начала:",
|
||||
|
||||
@@ -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": "开始时间:",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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