From b06434b7a0c26b2ab69a9001f9b96fd7ca3bff6d Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Wed, 19 Aug 2026 19:38:36 +0000 Subject: [PATCH] Fix direct alert delivery across users --- app/src/main/AndroidManifest.xml | 9 +++++ .../NotificationLogApplication.kt | 3 ++ .../alerts/AlertPlaybackRuntime.kt | 18 ++++++++++ .../capture/NotificationCaptureService.kt | 32 +++-------------- .../module/AlertPolicySync.kt | 6 ++-- .../module/DirectAlertReceiver.kt | 31 ++++++++++++++++ .../module/DirectAlertSync.kt | 36 ++++++++++++------- .../module/DirectVibrationBridge.kt | 5 ++- .../module/SystemAlertPolicyCache.kt | 14 +++----- 9 files changed, 100 insertions(+), 54 deletions(-) create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertPlaybackRuntime.kt create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/DirectAlertReceiver.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b1a453d..46f6c21 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -52,6 +52,15 @@ + + + + + + diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/NotificationLogApplication.kt b/app/src/main/java/se/ajpanton/notificationsmaster/NotificationLogApplication.kt index dd342a2..70dcce7 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/NotificationLogApplication.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/NotificationLogApplication.kt @@ -2,12 +2,15 @@ package se.ajpanton.notificationsmaster import android.app.Application import android.content.pm.PackageManager +import se.ajpanton.notificationsmaster.alerts.AlertPlaybackRuntime import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore import se.ajpanton.notificationsmaster.settings.AppFilterSettingsStore import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController import se.ajpanton.notificationsmaster.settings.PerAppEventSettingsStore class NotificationLogApplication : Application() { + val alertPlayback by lazy { AlertPlaybackRuntime(this) } + override fun onCreate() { super.onCreate() synchronizeListenerComponent() diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertPlaybackRuntime.kt b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertPlaybackRuntime.kt new file mode 100644 index 0000000..d464999 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertPlaybackRuntime.kt @@ -0,0 +1,18 @@ +package se.ajpanton.notificationsmaster.alerts + +import android.content.Context + +/** Process-wide playback queue shared by the notification listener and direct-alert handoff. */ +class AlertPlaybackRuntime(context: Context) { + private var queueSettings = AlertQueueSettings() + private val controller = AlertPlaybackController(AndroidAlertEffectPlayer(context)) { queueSettings } + + @Synchronized + fun enqueue(alert: QueuedAlert, settings: AlertQueueSettings) { + queueSettings = settings + controller.enqueue(alert) + } + + @Synchronized + fun stop() = controller.stop() +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/capture/NotificationCaptureService.kt b/app/src/main/java/se/ajpanton/notificationsmaster/capture/NotificationCaptureService.kt index 2cace8c..77db4b7 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/capture/NotificationCaptureService.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/capture/NotificationCaptureService.kt @@ -1,9 +1,7 @@ package se.ajpanton.notificationsmaster.capture import android.app.Notification -import android.content.BroadcastReceiver import android.content.Intent -import android.content.IntentFilter import android.content.pm.PackageManager import android.service.notification.NotificationListenerService import android.service.notification.StatusBarNotification @@ -11,12 +9,10 @@ import android.util.Log import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore import se.ajpanton.notificationsmaster.alerts.AlertEvent import se.ajpanton.notificationsmaster.alerts.AlertNotificationDispatcher -import se.ajpanton.notificationsmaster.alerts.AlertPlaybackController import se.ajpanton.notificationsmaster.alerts.AlertQueueSettings import se.ajpanton.notificationsmaster.alerts.AlertSource -import se.ajpanton.notificationsmaster.alerts.AndroidAlertEffectPlayer +import se.ajpanton.notificationsmaster.NotificationLogApplication import se.ajpanton.notificationsmaster.module.AlertPolicySync -import se.ajpanton.notificationsmaster.module.DirectAlertSync import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore import se.ajpanton.notificationsmaster.data.EncryptedImageStore import se.ajpanton.notificationsmaster.model.NotificationAction @@ -29,7 +25,6 @@ import se.ajpanton.notificationsmaster.settings.AppFilterSettingsStore import se.ajpanton.notificationsmaster.settings.PerAppEventSettingsStore import java.util.concurrent.ExecutorService import java.util.concurrent.Executors -import java.util.UUID class NotificationCaptureService : NotificationListenerService() { private val activeNotifications = mutableMapOf() @@ -41,23 +36,8 @@ class NotificationCaptureService : NotificationListenerService() { private lateinit var perAppEventSettings: PerAppEventSettingsStore private lateinit var imageStore: EncryptedImageStore private lateinit var alertConfigurationStore: AlertConfigurationStore - private lateinit var alertPlayback: AlertPlaybackController + private lateinit var alertPlayback: se.ajpanton.notificationsmaster.alerts.AlertPlaybackRuntime private var alertQueueSettings = AlertQueueSettings() - private val directAlertToken = UUID.randomUUID().toString() - private val directAlertReceiver = object : BroadcastReceiver() { - override fun onReceive(context: android.content.Context, intent: Intent) { - if (intent.action != DirectAlertSync.ACTION) return - val request = DirectAlertSync.snapshot(intent, directAlertToken) - if (request == null) { - Log.w(TAG, "Rejected malformed direct alert request") - return - } - request.let { (ruleId, snapshot) -> - Log.i(TAG, "Playing matched direct notification vibration") - alertPlayback.enqueue(se.ajpanton.notificationsmaster.alerts.QueuedAlert(ruleId, snapshot)) - } - } - } override fun onCreate() { super.onCreate() @@ -68,8 +48,7 @@ class NotificationCaptureService : NotificationListenerService() { perAppEventSettings = PerAppEventSettingsStore(this) imageStore = EncryptedImageStore(this) alertConfigurationStore = AlertConfigurationStore(this) - alertPlayback = AlertPlaybackController(AndroidAlertEffectPlayer(this)) { alertQueueSettings } - registerReceiver(directAlertReceiver, IntentFilter(DirectAlertSync.ACTION), null, null, RECEIVER_EXPORTED) + alertPlayback = (application as NotificationLogApplication).alertPlayback writeExecutor = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "notification-log-writer") } @@ -77,7 +56,7 @@ class NotificationCaptureService : NotificationListenerService() { override fun onListenerConnected() { super.onListenerConnected() - AlertPolicySync.publishPlaybackState(this, true, directAlertToken) + AlertPolicySync.publishPlaybackState(this, true) getActiveNotifications()?.forEach { sbn -> val snapshot = NotificationContents.snapshot(sbn) SeenApps.markSeen(snapshot.packageName) @@ -136,7 +115,6 @@ class NotificationCaptureService : NotificationListenerService() { override fun onDestroy() { AlertPolicySync.publishPlaybackState(this, false) alertPlayback.stop() - unregisterReceiver(directAlertReceiver) writeExecutor.shutdown() super.onDestroy() } @@ -211,7 +189,7 @@ class NotificationCaptureService : NotificationListenerService() { configuration, AlertEvent(snapshot.packageName, source, snapshot.alertTitle, snapshot.alertBody, snapshot.alertSender), isRoutineUpdate = source == AlertSource.NOTIFICATION_UPDATE && snapshot.isRoutine, - ).forEach(alertPlayback::enqueue) + ).forEach { alertPlayback.enqueue(it, alertQueueSettings) } } private fun appName(packageName: String): String = try { diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicySync.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicySync.kt index 6e02d92..214c6d8 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicySync.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicySync.kt @@ -17,7 +17,6 @@ internal object AlertPolicySync { const val PLAYBACK_STATE_ACTION = "se.ajpanton.notificationsmaster.UPDATE_ALERT_PLAYBACK_STATE" const val EXTRA_CONFIGURATION = "configuration" const val EXTRA_PLAYBACK_READY = "playback_ready" - const val EXTRA_DIRECT_ALERT_TOKEN = "direct_alert_token" const val PERMISSION = "se.ajpanton.notificationsmaster.permission.UPDATE_ALERT_POLICY" private const val SYSTEM_PACKAGE = "android" private const val MAX_CONFIGURATION_BYTES = 64 * 1024 @@ -36,12 +35,11 @@ internal object AlertPolicySync { Log.i(TAG, "Published alert policy snapshot") } - fun publishPlaybackState(context: Context, ready: Boolean, directAlertToken: String? = null) { + fun publishPlaybackState(context: Context, ready: Boolean) { context.sendBroadcast( Intent(PLAYBACK_STATE_ACTION) .setPackage(SYSTEM_PACKAGE) - .putExtra(EXTRA_PLAYBACK_READY, ready) - .putExtra(EXTRA_DIRECT_ALERT_TOKEN, directAlertToken), + .putExtra(EXTRA_PLAYBACK_READY, ready), ) } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/DirectAlertReceiver.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/DirectAlertReceiver.kt new file mode 100644 index 0000000..0ef6a8f --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/DirectAlertReceiver.kt @@ -0,0 +1,31 @@ +package se.ajpanton.notificationsmaster.module + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import se.ajpanton.notificationsmaster.NotificationLogApplication +import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore +import se.ajpanton.notificationsmaster.alerts.QueuedAlert + +/** Signature-protected handoff that can start the app when its listener process was evicted. */ +class DirectAlertReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != DirectAlertSync.ACTION) return + val request = DirectAlertSync.snapshot(intent) ?: run { + Log.w(TAG, "Rejected malformed direct alert request") + return + } + val (ruleId, snapshot) = request + val settings = AlertConfigurationStore(context).load().queueSettings + (context.applicationContext as NotificationLogApplication).alertPlayback.enqueue( + QueuedAlert(ruleId, snapshot), + settings, + ) + Log.i(TAG, "Playing matched direct notification alert") + } + + private companion object { + const val TAG = "NotificationCapture" + } +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/DirectAlertSync.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/DirectAlertSync.kt index 21aceca..f2402ad 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/DirectAlertSync.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/DirectAlertSync.kt @@ -1,36 +1,44 @@ package se.ajpanton.notificationsmaster.module +import android.annotation.SuppressLint +import android.content.ComponentName import android.content.Context import android.content.Intent +import android.os.UserHandle +import android.os.Binder import se.ajpanton.notificationsmaster.alerts.AlertDecision import se.ajpanton.notificationsmaster.alerts.AlertSnapshot import se.ajpanton.notificationsmaster.alerts.snapshot -/** One-way requests from system_server to the live listener for direct vibration matches. */ +/** One-way requests from system_server to the app's private direct-alert receiver. */ internal object DirectAlertSync { const val ACTION = "se.ajpanton.notificationsmaster.PLAY_DIRECT_ALERT" private const val PACKAGE = "se.ajpanton.notificationsmaster" + private const val RECEIVER = "$PACKAGE.module.DirectAlertReceiver" private const val EXTRA_RULE_ID = "ruleId" private const val EXTRA_SOUND_URI = "soundUri" private const val EXTRA_VIBRATION = "vibration" private const val EXTRA_PROTECTED = "protected" private const val EXTRA_ALLOW_DND = "allowDnd" - private const val EXTRA_TOKEN = "token" - fun send(context: Context, decision: AlertDecision, token: String): Boolean { + @SuppressLint("MissingPermission") // This method runs only in system_server via the LSPosed module. + fun send(context: Context, decision: AlertDecision, userId: Int): Boolean { val snapshot = decision.snapshot() ?: return false - context.sendBroadcast(Intent(ACTION).setPackage(PACKAGE) - .putExtra(EXTRA_RULE_ID, decision.ruleId) - .putExtra(EXTRA_SOUND_URI, snapshot.soundUri) - .putExtra(EXTRA_VIBRATION, snapshot.vibrationPattern.toLongArray()) - .putExtra(EXTRA_PROTECTED, snapshot.playToCompletion) - .putExtra(EXTRA_ALLOW_DND, snapshot.allowDuringDnd) - .putExtra(EXTRA_TOKEN, token)) + val identity = Binder.clearCallingIdentity() + try { + context.sendBroadcastAsUser(Intent(ACTION).setComponent(ComponentName(PACKAGE, RECEIVER)) + .putExtra(EXTRA_RULE_ID, decision.ruleId) + .putExtra(EXTRA_SOUND_URI, snapshot.soundUri) + .putExtra(EXTRA_VIBRATION, snapshot.vibrationPattern.toLongArray()) + .putExtra(EXTRA_PROTECTED, snapshot.playToCompletion) + .putExtra(EXTRA_ALLOW_DND, snapshot.allowDuringDnd), userHandle(userId)) + } finally { + Binder.restoreCallingIdentity(identity) + } return true } - fun snapshot(intent: Intent, token: String): Pair? = runCatching { - require(intent.getStringExtra(EXTRA_TOKEN) == token) + fun snapshot(intent: Intent): Pair? = runCatching { val soundUri = intent.getStringExtra(EXTRA_SOUND_URI) val vibration = intent.getLongArrayExtra(EXTRA_VIBRATION)?.toList().orEmpty() val snapshot = AlertSnapshot( @@ -41,4 +49,8 @@ internal object DirectAlertSync { ) intent.getStringExtra(EXTRA_RULE_ID)?.takeIf(String::isNotBlank)?.let { it to snapshot } }.getOrNull() + + private fun userHandle(userId: Int): UserHandle = UserHandle::class.java + .getDeclaredConstructor(Int::class.javaPrimitiveType) + .newInstance(userId) } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/DirectVibrationBridge.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/DirectVibrationBridge.kt index dfaf0de..d40511b 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/DirectVibrationBridge.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/DirectVibrationBridge.kt @@ -18,12 +18,13 @@ internal class DirectVibrationBridge(private val framework: XposedInterface) { } method.isAccessible = true framework.hook(method).intercept { chain -> + val uid = chain.args.getOrNull(UID_INDEX) as? Int val packageName = chain.args.getOrNull(PACKAGE_INDEX) as? String val attributes = chain.args.getOrNull(ATTRIBUTES_INDEX) as? VibrationAttributes val event = packageName?.takeIf { attributes?.usage == VibrationAttributes.USAGE_NOTIFICATION } ?.let { se.ajpanton.notificationsmaster.alerts.AlertEvent(it, AlertSource.DIRECT_NOTIFICATION_VIBRATION) } if (event != null && SystemAlertPolicyCache.shouldSuppress(event)) { - SystemAlertPolicyCache.sendDirectAlerts(event) + SystemAlertPolicyCache.sendDirectAlerts(event, (uid ?: 0).coerceAtLeast(0) / USER_UID_RANGE) Log.i(TAG, "Suppressed matched direct notification vibration from $packageName") null } else { @@ -44,7 +45,9 @@ internal class DirectVibrationBridge(private val framework: XposedInterface) { const val TAG = "NotificationsMaster" const val VIBRATOR_MANAGER_SERVICE = "com.android.server.vibrator.VibratorManagerService" const val PACKAGE_INDEX = 2 + const val UID_INDEX = 0 const val ATTRIBUTES_INDEX = 4 + const val USER_UID_RANGE = 100_000 val VIBRATE_PARAMETERS = arrayOf( Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/SystemAlertPolicyCache.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/SystemAlertPolicyCache.kt index 43d4ebb..00c9a67 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/SystemAlertPolicyCache.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/SystemAlertPolicyCache.kt @@ -26,8 +26,6 @@ internal object SystemAlertPolicyCache { private var configuration = AlertConfiguration(enabled = false) @Volatile private var playbackReady = false - @Volatile - private var directAlertToken: String? = null private var installed = false private var attempts = 0 @@ -64,15 +62,13 @@ internal object SystemAlertPolicyCache { fun evaluate(event: AlertEvent): AlertEvaluation = AlertPolicyEvaluator.evaluate(configuration, event, isRoutineUpdate = false) - fun shouldSuppress(event: AlertEvent): Boolean = playbackReady && - (!event.source.isDirect || directAlertToken != null) && - evaluate(event).suppressesOriginal() + fun shouldSuppress(event: AlertEvent): Boolean = + (event.source.isDirect || playbackReady) && evaluate(event).suppressesOriginal() - fun sendDirectAlerts(event: AlertEvent) { + fun sendDirectAlerts(event: AlertEvent, userId: Int) { val context = systemContext() ?: return - val token = directAlertToken ?: return val decisions = evaluate(event).decisions - val sent = decisions.count { DirectAlertSync.send(context, it, token) } + val sent = decisions.count { DirectAlertSync.send(context, it, userId) } Log.i(TAG, "Sent $sent direct alert request(s) for ${event.packageName}") } @@ -95,8 +91,6 @@ internal object SystemAlertPolicyCache { override fun onReceive(context: Context, intent: Intent) { if (intent.action == AlertPolicySync.PLAYBACK_STATE_ACTION) { playbackReady = intent.getBooleanExtra(AlertPolicySync.EXTRA_PLAYBACK_READY, false) - directAlertToken = intent.getStringExtra(AlertPolicySync.EXTRA_DIRECT_ALERT_TOKEN) - ?.takeIf { playbackReady } Log.i(TAG, "Alert playback listener ready=$playbackReady") return }