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:
2026-06-02 14:57:29 +03:00
parent 68040173c6
commit 1c1bbe2551
24 changed files with 1044 additions and 63 deletions
+12
View File
@@ -65,6 +65,18 @@
service start. -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- PACKAGE_USAGE_STATS — read the foreground app for the "Application"
automation rule (foreground app -> activate scene) via UsageStatsManager.
A special-access permission: it can't be granted at runtime; the user
toggles it under Settings > Usage access (opened from MainActivity).
tools:ignore="ProtectedPermissions" silences the build warning that this
is a system/signature-level permission — it is honoured as a user-grantable
special access. NO QUERY_ALL_PACKAGES is needed: matching only compares the
foreground package NAME, and the app picker uses LauncherApps. -->
<uses-permission
android:name="android.permission.PACKAGE_USAGE_STATS"
tools:ignore="ProtectedPermissions" />
<!-- Autostart on boot — BootReceiver spawns CaptureService in root
mode so capture resumes without the user touching the remote. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -0,0 +1,154 @@
package com.ledgrab.android
import android.app.AppOpsManager
import android.app.usage.UsageEvents
import android.app.usage.UsageStatsManager
import android.content.Context
import android.content.pm.LauncherApps
import android.os.Build
import android.os.Process
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
/**
* Foreground-app + installed-app bridge exposed to the Python server via Chaquopy.
*
* Backs the Android implementation of the "Application" automation rule
* (foreground app -> activate scene). Desktop detects the foreground process via
* Win32 ctypes in ``platform_detector.py``; Android has no such API, so this
* bridge wraps two in-platform services into synchronous calls a Python thread
* can invoke (Chaquopy proxy threads are real OS threads):
*
* - [getForegroundPackage] via [UsageStatsManager] (needs PACKAGE_USAGE_STATS,
* a special-access permission granted from Settings — see MainActivity).
* - [listLaunchableApps] via [LauncherApps] for the automation editor's app
* picker (no QUERY_ALL_PACKAGES needed — getActivityList is the sanctioned
* launchable-app enumeration API).
* - [hasUsageAccess] so the server / UI can detect the missing grant.
*
* Detection only ever string-compares the foreground *package name*, so no label
* resolution / package visibility is required at match time.
*
* Python callers access the singleton via
* `jclass("com.ledgrab.android.ForegroundAppBridge").INSTANCE` — see
* `server/src/ledgrab/core/automations/platform_detector.py`.
*/
object ForegroundAppBridge {
private const val TAG = "ForegroundAppBridge"
// Trailing window for queryEvents. queryEvents reports discrete foreground
// transitions (not "current app"), and events can lag a few seconds, so we
// look back far enough to reliably catch the latest MOVE_TO_FOREGROUND while
// staying recent enough not to report a stale app on the ~1s automation tick.
private const val WINDOW_MS = 10_000L
@Volatile private var appContext: Context? = null
/** Called once from [LedGrabApp.onCreate] to bind the application context. */
@JvmStatic
fun init(context: Context) {
appContext = context.applicationContext
}
/**
* Package name of the most recently foregrounded app, or null when none is
* found in the trailing window, Usage Access is not granted, or on any error.
* Never throws across the JNI boundary.
*/
@JvmStatic
fun getForegroundPackage(): String? {
val ctx = appContext ?: run {
Log.w(TAG, "getForegroundPackage: context not bound (init not called)")
return null
}
return try {
val usm = ctx.getSystemService(Context.USAGE_STATS_SERVICE) as? UsageStatsManager
?: return null
val end = System.currentTimeMillis()
val events = usm.queryEvents(end - WINDOW_MS, end)
val event = UsageEvents.Event()
var latestPkg: String? = null
var latestTs = Long.MIN_VALUE
while (events.hasNextEvent()) {
events.getNextEvent(event)
// ACTIVITY_RESUMED (API 29+) shares the value of the legacy
// MOVE_TO_FOREGROUND constant, so the single check covers both.
// >= (not >) so that on an exact-timestamp tie the later-iterated
// event wins — events arrive chronologically, so that is the most
// recent foreground transition.
if (event.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND &&
event.timeStamp >= latestTs
) {
latestTs = event.timeStamp
latestPkg = event.packageName
}
}
latestPkg
} catch (e: Exception) {
// SecurityException when access is missing, plus any service error.
Log.w(TAG, "getForegroundPackage failed: ${e.message}")
null
}
}
/** Whether the user has granted Usage Access (PACKAGE_USAGE_STATS) to this app. */
@JvmStatic
fun hasUsageAccess(): Boolean {
val ctx = appContext ?: return false
return try {
val appOps = ctx.getSystemService(Context.APP_OPS_SERVICE) as? AppOpsManager
?: return false
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
appOps.unsafeCheckOpNoThrow(
AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), ctx.packageName,
)
} else {
@Suppress("DEPRECATION")
appOps.checkOpNoThrow(
AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), ctx.packageName,
)
}
mode == AppOpsManager.MODE_ALLOWED
} catch (e: Exception) {
Log.w(TAG, "hasUsageAccess failed: ${e.message}")
false
}
}
/**
* Launchable apps as a JSON array string the Python server parses:
* `[{"package":"com.netflix.mediaclient","label":"Netflix"}, ...]`
*
* Uses [LauncherApps.getActivityList] (launcher + leanback launchables) —
* no QUERY_ALL_PACKAGES. De-duplicated by package, sorted by label.
* Returns `[]` on any error.
*/
@JvmStatic
fun listLaunchableApps(): String {
val arr = JSONArray()
val ctx = appContext ?: run {
Log.w(TAG, "listLaunchableApps: context not bound (init not called)")
return arr.toString()
}
try {
val launcher = ctx.getSystemService(Context.LAUNCHER_APPS_SERVICE) as? LauncherApps
?: return arr.toString()
val seen = HashSet<String>()
val items = ArrayList<Pair<String, String>>()
for (info in launcher.getActivityList(null, Process.myUserHandle())) {
val pkg = info.applicationInfo?.packageName ?: continue
if (!seen.add(pkg)) continue
val label = info.label?.toString().takeUnless { it.isNullOrBlank() } ?: pkg
items.add(pkg to label)
}
items.sortBy { it.second.lowercase() }
for ((pkg, label) in items) {
arr.put(JSONObject().put("package", pkg).put("label", label))
}
} catch (e: Exception) {
Log.w(TAG, "listLaunchableApps failed: ${e.message}")
}
return arr.toString()
}
}
@@ -54,6 +54,10 @@ class LedGrabApp : Application() {
// Bind application context for the camera bridge so Python can
// enumerate cameras and open them on demand (webcam capture).
CameraBridge.init(this)
// Bind application context for the foreground-app bridge so Python can
// detect the foreground app (Application automation rule) and list
// launchable apps for the editor's picker.
ForegroundAppBridge.init(this)
// Pre-warm the API key on a background thread. First-launch
// generation does a SharedPreferences.commit() (synchronous
@@ -69,6 +69,7 @@ class MainActivity : Activity() {
private lateinit var autostartCheck: CheckBox
private lateinit var autostartPrefs: AutostartPrefs
private lateinit var grantNotificationButton: Button
private lateinit var grantUsageAccessButton: Button
// Running-state views (lazy-inflated via ViewStub).
private lateinit var runningPanelStub: ViewStub
@@ -113,6 +114,7 @@ class MainActivity : Activity() {
versionText = findViewById(R.id.version_text)
autostartCheck = findViewById(R.id.autostart_check)
grantNotificationButton = findViewById(R.id.grant_notification_button)
grantUsageAccessButton = findViewById(R.id.grant_usage_access_button)
val versionName = packageManager.getPackageInfo(packageName, 0).versionName
versionText.text = getString(R.string.version_prefix, versionName ?: "?")
@@ -134,9 +136,10 @@ class MainActivity : Activity() {
}
grantNotificationButton.setOnClickListener { openNotificationListenerSettings() }
grantUsageAccessButton.setOnClickListener { openUsageAccessSettings() }
toggleButton.setOnClickListener { startCapture() }
updateNotificationAccessUi()
updateStoppedPermissionButtons()
updateUI()
}
@@ -166,7 +169,7 @@ class MainActivity : Activity() {
if (CaptureService.isRunning) {
updateUI()
} else {
updateNotificationAccessUi()
updateStoppedPermissionButtons()
}
}
@@ -544,6 +547,26 @@ class MainActivity : Activity() {
}.onFailure { Log.w(TAG, "Notification-access settings unavailable: ${it.message}") }
}
/**
* Whether Usage Access (PACKAGE_USAGE_STATS) is granted — needed by the
* foreground-app automation rule. Delegates to the bridge's AppOps check.
*/
private fun isUsageAccessGranted(): Boolean = ForegroundAppBridge.hasUsageAccess()
/**
* Open the system Usage-Access screen so the user can grant LedGrab access
* for the foreground-app automation rule. Falls back to the generic Settings
* screen on TV-box OEM builds that strip the dedicated intent.
*/
private fun openUsageAccessSettings() {
runCatching {
startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS))
}.onFailure {
Log.w(TAG, "Usage-access settings unavailable: ${it.message}")
runCatching { startActivity(Intent(Settings.ACTION_SETTINGS)) }
}
}
/**
* Prompt-once-then-remember: the first time capture starts without
* notification-listener access, open the settings screen so the user can
@@ -559,20 +582,24 @@ class MainActivity : Activity() {
}
/**
* Show the "Grant notification access" button only while access is missing,
* then re-wire the D-pad focus chain. Called on create and on resume
* (access can change in Settings while we're backgrounded).
* Show each "Grant <permission> access" button only while that access is
* missing, then re-wire the D-pad focus chain. Called on create and on resume
* (access can change in Settings while we're backgrounded). The usage-access
* button is a passive affordance (no auto-prompt at capture start) — the
* primary guidance is the web-UI banner when an Android app rule needs it.
*/
private fun updateNotificationAccessUi() {
private fun updateStoppedPermissionButtons() {
if (!::grantNotificationButton.isInitialized) return
grantNotificationButton.visibility =
if (isNotificationAccessGranted()) View.GONE else View.VISIBLE
grantUsageAccessButton.visibility =
if (isUsageAccessGranted()) View.GONE else View.VISIBLE
wireStoppedFocusChain()
}
/**
* Link the visible stopped-panel controls into a single up/down D-pad chain.
* Both optional controls (the grant-access button and the root-only autostart
* The optional controls (the grant-access buttons and the root-only autostart
* checkbox) may be GONE, so the chain is computed from whatever is visible —
* a static nextFocus pointing at a GONE view would strand the focus on a TV
* remote.
@@ -581,6 +608,7 @@ class MainActivity : Activity() {
val chain = listOfNotNull(
toggleButton,
grantNotificationButton.takeIf { it.visibility == View.VISIBLE },
grantUsageAccessButton.takeIf { it.visibility == View.VISIBLE },
autostartCheck.takeIf { it.visibility == View.VISIBLE },
)
chain.forEachIndexed { i, view ->
@@ -81,6 +81,21 @@
android:focusableInTouchMode="true"
android:visibility="gone" />
<!-- Shown only while Usage Access is missing (needed by the foreground-app
automation rule). Like the grant-notification button, its D-pad focus
chain is wired at runtime (wireStoppedFocusChain). -->
<Button
android:id="@+id/grant_usage_access_button"
style="@style/Widget.LedGrab.Button.Secondary"
android:layout_width="320dp"
android:layout_height="56dp"
android:layout_marginTop="20dp"
android:text="@string/btn_grant_usage_access"
android:textSize="18sp"
android:focusable="true"
android:focusableInTouchMode="true"
android:visibility="gone" />
<CheckBox
android:id="@+id/autostart_check"
android:layout_width="wrap_content"
@@ -27,4 +27,5 @@
<string name="notification_text">Веб-интерфейс: %1$s</string>
<string name="notification_listener_label">Захват уведомлений LedGrab</string>
<string name="btn_grant_notification_access">Разрешить доступ к уведомлениям</string>
<string name="btn_grant_usage_access">Разрешить доступ к статистике использования</string>
</resources>
@@ -27,4 +27,5 @@
<string name="notification_text">Web界面:%1$s</string>
<string name="notification_listener_label">LedGrab 通知捕获</string>
<string name="btn_grant_notification_access">授予通知访问权限</string>
<string name="btn_grant_usage_access">授予使用情况访问权限</string>
</resources>
@@ -27,4 +27,5 @@
<string name="notification_text">Web UI: %1$s</string>
<string name="notification_listener_label">LedGrab notification capture</string>
<string name="btn_grant_notification_access">Grant notification access</string>
<string name="btn_grant_usage_access">Grant usage access</string>
</resources>