feat(android): on-device OS notification capture (NotificationListenerService)

Add an Android backend to os_notification_listener.py so notifications on the
experimental Android-TV build drive the existing NotificationColorStripSource
LED effects (flash/pulse/sweep, per-app colors + sounds) at app-name parity
with the Windows/Linux backends.

A Kotlin NotificationListenerService forwards the posting app's display label
across the Chaquopy JNI boundary into a new push-based _AndroidBackend +
module-level push_notification() receiver; the existing color-strip pipeline,
per-app colors/filters, and history endpoint are reused unchanged.

- Python: _AndroidBackend (probed first), push_notification() receiver,
  _LinuxBackend.probe() hardened with is_linux() to exclude Android (which
  also reports platform.system() == "Linux").
- Android: LedGrabNotificationListener NLS — serial single-thread executor,
  full crash isolation around Python.getInstance(), label-only forwarding
  (never notification title/body), ongoing/group-summary/self-package noise
  filtering. Manifest service exported + gated by
  BIND_NOTIFICATION_LISTENER_SERVICE (no new uses-permission).
- UX: prompt-once notification-access + manual "Grant notification access"
  button wired into the D-pad focus chain (computed from visible controls);
  en/ru/zh strings.
- Tests: 11 isolated unit tests — module-global + tmp_path history isolation,
  push routing contract, callback-exception swallowing, None app-name, and a
  desktop-regression lock on backend selection order.
- Docs: README OS-support Android column (notification + audio cells),
  ANDROID-REVIEW status flipped to Implemented.

Zero new Python deps; no build.gradle.kts / Chaquopy pip changes.
This commit is contained in:
2026-06-02 11:47:13 +03:00
parent 4b2e8fc5ec
commit 0be3f833df
11 changed files with 532 additions and 23 deletions
+17
View File
@@ -110,6 +110,23 @@
android:value="Root-mode screen capture for ambient LED sync. Uses /system/bin/screenrecord on rooted devices to avoid MediaProjection's persistent capture indicator overlay, which is required for the always-on ambient-lighting use case." />
</service>
<!-- Notification capture — a NotificationListenerService bound by
system_server. exported="true" is REQUIRED here (the system binds
it cross-process) and intentionally diverges from CaptureService
(exported="false"); access is gated by the system-held
BIND_NOTIFICATION_LISTENER_SERVICE permission, so no new
<uses-permission> is needed. The user grants access via
Settings > Notification access (opened from MainActivity). -->
<service
android:name=".LedGrabNotificationListener"
android:label="@string/notification_listener_label"
android:exported="true"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<!-- Autostart — fires on device boot (and package replace).
On rooted devices, launches CaptureService directly so capture
resumes without the user tapping Start. Unrooted devices are
@@ -0,0 +1,97 @@
package com.ledgrab.android
import android.app.Notification
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import android.util.Log
import com.chaquo.python.Python
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
/**
* Captures posted OS notifications and forwards the posting app's display
* label to the Python notification pipeline, where the existing
* `NotificationColorStripSource` fires its one-shot LED effect.
*
* Direction is Kotlin -> Python via the process-global Chaquopy instance
* (NOT a per-[CaptureService] [PythonBridge]): `system_server` binds this
* service independently of [CaptureService], so it resolves Python itself.
* The Python receiver (`os_notification_listener.push_notification`) is a
* no-op whenever the server/listener isn't running, so a notification
* arriving before — or after — a capture session is safely ignored.
*/
class LedGrabNotificationListener : NotificationListenerService() {
// Serial executor: the Python receiver does a (non-concurrency-safe) history
// disk write and may play a sound, so pushes must not overlap. Off the main
// looper to keep the system service responsive.
private val pushExecutor = Executors.newSingleThreadExecutor()
// packageName -> resolved human-readable label. Matches the app_name the
// Windows/Linux backends pass, so per-app colors/filters keep working.
// Naturally bounded by the number of notification-posting apps (tens) and
// cleared with the process — no eviction needed.
private val labelCache = ConcurrentHashMap<String, String>()
override fun onNotificationPosted(sbn: StatusBarNotification?) {
val notification = sbn ?: return
// The Python server (and thus the listener) only exists during a capture
// session. isRunning is a coarse early-out — the authoritative gate is the
// Python receiver's None-check — but it avoids needless JNI churn here.
if (!CaptureService.isRunning) return
// Filter notifications that should never drive an effect:
// - ongoing (media transport, downloads): not user-facing "alerts"
// - group summaries: duplicate their child notifications
// - our own foreground-service notification: would self-trigger
if (notification.isOngoing) return
if ((notification.notification.flags and Notification.FLAG_GROUP_SUMMARY) != 0) return
if (notification.packageName == packageName) return
val label = resolveAppLabel(notification.packageName)
pushExecutor.execute {
try {
Python.getInstance()
.getModule(PY_MODULE)
.callAttr("push_notification", label)
} catch (t: Throwable) {
// Never crash a system-bound service. Python.getInstance() throws
// IllegalStateException if Python.start() hasn't run (e.g. the
// service was bound at boot before the app process initialized).
// Log at debug — the label is potentially sensitive on a shared TV.
Log.d(TAG, "push_notification failed: ${t.message}")
}
}
}
/** Resolve (and cache) a package's human-readable label; fall back to the package name. */
private fun resolveAppLabel(pkg: String): String {
labelCache[pkg]?.let { return it }
val resolved = runCatching {
val info = packageManager.getApplicationInfo(pkg, 0)
packageManager.getApplicationLabel(info).toString()
}.getOrDefault(pkg)
labelCache[pkg] = resolved
return resolved
}
override fun onListenerConnected() {
Log.i(TAG, "Notification listener connected")
}
override fun onListenerDisconnected() {
Log.i(TAG, "Notification listener disconnected")
}
override fun onDestroy() {
pushExecutor.shutdown()
super.onDestroy()
}
companion object {
private const val TAG = "LedGrabNotifListener"
private const val PY_MODULE = "ledgrab.core.processing.os_notification_listener"
}
}
@@ -24,6 +24,7 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.google.zxing.BarcodeFormat
@@ -55,6 +56,8 @@ class MainActivity : Activity() {
private const val REQUEST_POST_NOTIFICATIONS = 1002
private const val REQUEST_RECORD_AUDIO = 1003
private const val QR_SIZE_PX = 560
private const val NOTIF_PREFS = "ledgrab_notif"
private const val KEY_NOTIF_ACCESS_PROMPTED = "notif_access_prompted"
}
// Stopped-state views (always inflated).
@@ -64,6 +67,7 @@ class MainActivity : Activity() {
private lateinit var versionText: TextView
private lateinit var autostartCheck: CheckBox
private lateinit var autostartPrefs: AutostartPrefs
private lateinit var grantNotificationButton: Button
// Running-state views (lazy-inflated via ViewStub).
private lateinit var runningPanelStub: ViewStub
@@ -107,6 +111,7 @@ class MainActivity : Activity() {
toggleButton = findViewById(R.id.toggle_button)
versionText = findViewById(R.id.version_text)
autostartCheck = findViewById(R.id.autostart_check)
grantNotificationButton = findViewById(R.id.grant_notification_button)
val versionName = packageManager.getPackageInfo(packageName, 0).versionName
versionText.text = getString(R.string.version_prefix, versionName ?: "?")
@@ -127,8 +132,10 @@ class MainActivity : Activity() {
autostartCheck.visibility = View.GONE
}
grantNotificationButton.setOnClickListener { openNotificationListenerSettings() }
toggleButton.setOnClickListener { startCapture() }
updateNotificationAccessUi()
updateUI()
}
@@ -149,12 +156,16 @@ class MainActivity : Activity() {
override fun onResume() {
super.onResume()
if (!::stoppedPanel.isInitialized) return
// Restart the pulse if we returned to the foreground while the
// service is still running. The running panel's view may have
// been recreated; ensureRunningPanelInflated already keys off
// the field reference.
if (CaptureService.isRunning && ::stoppedPanel.isInitialized) {
// service is still running. The running panel's view may have been
// recreated; ensureRunningPanelInflated already keys off the field
// reference. When stopped, refresh the notification-access button —
// the user may have just granted/revoked access in Settings.
if (CaptureService.isRunning) {
updateUI()
} else {
updateNotificationAccessUi()
}
}
@@ -197,6 +208,7 @@ class MainActivity : Activity() {
private fun startRootCaptureService() {
ensureNotificationPermission()
ensureNotificationListenerAccess()
ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
updateUI()
}
@@ -216,6 +228,7 @@ class MainActivity : Activity() {
private fun startCaptureService(resultCode: Int, resultData: Intent) {
ensureNotificationPermission()
ensureNotificationListenerAccess()
ensureAudioPermission()
val intent = CaptureService.createIntent(this, resultCode, resultData)
ContextCompat.startForegroundService(this, intent)
@@ -493,4 +506,60 @@ class MainActivity : Activity() {
)
}
}
/** Whether the user has granted notification-listener access to this app. */
private fun isNotificationAccessGranted(): Boolean =
NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName)
/** Open the system Notification-access screen (manual affordance / re-grant). */
private fun openNotificationListenerSettings() {
runCatching {
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
}.onFailure { Log.w(TAG, "Notification-access settings unavailable: ${it.message}") }
}
/**
* Prompt-once-then-remember: the first time capture starts without
* notification-listener access, open the settings screen so the user can
* grant it — then never nag again (the manual "Grant notification access"
* button stays available). Fire-and-forget like [ensureNotificationPermission].
*/
private fun ensureNotificationListenerAccess() {
if (isNotificationAccessGranted()) return
val prefs = getSharedPreferences(NOTIF_PREFS, MODE_PRIVATE)
if (prefs.getBoolean(KEY_NOTIF_ACCESS_PROMPTED, false)) return
prefs.edit().putBoolean(KEY_NOTIF_ACCESS_PROMPTED, true).apply()
openNotificationListenerSettings()
}
/**
* 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).
*/
private fun updateNotificationAccessUi() {
if (!::grantNotificationButton.isInitialized) return
grantNotificationButton.visibility =
if (isNotificationAccessGranted()) 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
* 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.
*/
private fun wireStoppedFocusChain() {
val chain = listOfNotNull(
toggleButton,
grantNotificationButton.takeIf { it.visibility == View.VISIBLE },
autostartCheck.takeIf { it.visibility == View.VISIBLE },
)
chain.forEachIndexed { i, view ->
view.nextFocusUpId = (chain.getOrNull(i - 1) ?: view).id
view.nextFocusDownId = (chain.getOrNull(i + 1) ?: view).id
}
}
}
@@ -66,6 +66,21 @@
android:focusableInTouchMode="true"
android:nextFocusDown="@+id/autostart_check" />
<!-- Shown only while notification-listener access is missing. The D-pad
focus chain is wired at runtime (wireStoppedFocusChain) because this
button and the autostart checkbox are both conditionally visible. -->
<Button
android:id="@+id/grant_notification_button"
style="@style/Widget.LedGrab.Button.Secondary"
android:layout_width="320dp"
android:layout_height="56dp"
android:layout_marginTop="20dp"
android:text="@string/btn_grant_notification_access"
android:textSize="18sp"
android:focusable="true"
android:focusableInTouchMode="true"
android:visibility="gone" />
<CheckBox
android:id="@+id/autostart_check"
android:layout_width="wrap_content"
@@ -25,4 +25,6 @@
<string name="notification_channel_description">Отображается, пока LedGrab захватывает экран.</string>
<string name="notification_title">LedGrab работает</string>
<string name="notification_text">Веб-интерфейс: %1$s</string>
<string name="notification_listener_label">Захват уведомлений LedGrab</string>
<string name="btn_grant_notification_access">Разрешить доступ к уведомлениям</string>
</resources>
@@ -25,4 +25,6 @@
<string name="notification_channel_description">LedGrab 捕获屏幕时显示。</string>
<string name="notification_title">LedGrab 运行中</string>
<string name="notification_text">Web界面:%1$s</string>
<string name="notification_listener_label">LedGrab 通知捕获</string>
<string name="btn_grant_notification_access">授予通知访问权限</string>
</resources>
@@ -25,4 +25,6 @@
<string name="notification_channel_description">Shows while LedGrab is capturing the screen.</string>
<string name="notification_title">LedGrab Running</string>
<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>
</resources>