Fix direct alert delivery across users

This commit is contained in:
ajp_anton
2026-08-19 19:38:36 +00:00
parent e2fb9e0d1c
commit b06434b7a0
9 changed files with 100 additions and 54 deletions
+9
View File
@@ -52,6 +52,15 @@
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver
android:name=".module.DirectAlertReceiver"
android:exported="true"
android:permission="se.ajpanton.notificationsmaster.permission.UPDATE_ALERT_POLICY">
<intent-filter>
<action android:name="se.ajpanton.notificationsmaster.PLAY_DIRECT_ALERT" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -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()
@@ -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()
}
@@ -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<String, NotificationSnapshot>()
@@ -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 {
@@ -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),
)
}
@@ -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"
}
}
@@ -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)
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)
.putExtra(EXTRA_TOKEN, token))
.putExtra(EXTRA_ALLOW_DND, snapshot.allowDuringDnd), userHandle(userId))
} finally {
Binder.restoreCallingIdentity(identity)
}
return true
}
fun snapshot(intent: Intent, token: String): Pair<String, AlertSnapshot>? = runCatching {
require(intent.getStringExtra(EXTRA_TOKEN) == token)
fun snapshot(intent: Intent): Pair<String, AlertSnapshot>? = 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)
}
@@ -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,
@@ -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
}