Suppress matched native notification alerts

This commit is contained in:
ajp_anton
2026-08-18 05:25:48 +00:00
parent 7166cfe18b
commit 68771d5836
13 changed files with 341 additions and 85 deletions
@@ -8,6 +8,7 @@ import se.ajpanton.notificationsmaster.data.AesGcmCipher
import se.ajpanton.notificationsmaster.data.EncryptedPayload
import se.ajpanton.notificationsmaster.data.LogEncryptionKeyProvider
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
import se.ajpanton.notificationsmaster.module.AlertPolicySync
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.DataInputStream
@@ -47,6 +48,7 @@ class AlertConfigurationStore(context: Context) {
stream.flush()
file.finishWrite(output)
NotificationListenerComponentController.synchronize(context)
AlertPolicySync.publish(context, configuration)
} catch (error: Exception) {
file.failWrite(output)
throw error
@@ -9,6 +9,7 @@ import android.os.Looper
import android.os.VibrationAttributes
import android.os.VibrationEffect
import android.os.VibratorManager
import android.util.Log
/** Plays resolved notification-class effects and releases all platform resources on stop. */
class AndroidAlertEffectPlayer(context: Context) : AlertEffectPlayer {
@@ -21,6 +22,7 @@ class AndroidAlertEffectPlayer(context: Context) : AlertEffectPlayer {
private var completion: (() -> Unit)? = null
override fun play(alert: QueuedAlert, onFinished: () -> Unit) = synchronized(this) {
Log.i(TAG, "Playing custom notification alert")
stopLocked()
val token = generation
completion = onFinished
@@ -83,4 +85,8 @@ class AndroidAlertEffectPlayer(context: Context) : AlertEffectPlayer {
player = null
vibrator.cancel()
}
private companion object {
const val TAG = "NotificationsMaster"
}
}
@@ -12,6 +12,7 @@ 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.module.AlertPolicySync
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
import se.ajpanton.notificationsmaster.data.EncryptedImageStore
import se.ajpanton.notificationsmaster.model.NotificationAction
@@ -55,6 +56,7 @@ class NotificationCaptureService : NotificationListenerService() {
override fun onListenerConnected() {
super.onListenerConnected()
AlertPolicySync.publishPlaybackState(this, true)
getActiveNotifications()?.forEach { sbn ->
val snapshot = NotificationContents.snapshot(sbn)
SeenApps.markSeen(snapshot.packageName)
@@ -111,6 +113,7 @@ class NotificationCaptureService : NotificationListenerService() {
}
override fun onDestroy() {
AlertPolicySync.publishPlaybackState(this, false)
alertPlayback.stop()
writeExecutor.shutdown()
super.onDestroy()
@@ -1,64 +0,0 @@
package se.ajpanton.notificationsmaster.module
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import android.os.Parcel
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
import se.ajpanton.notificationsmaster.alerts.AlertEvent
import se.ajpanton.notificationsmaster.alerts.AlertPolicyEvaluator
import se.ajpanton.notificationsmaster.alerts.AlertSource
import se.ajpanton.notificationsmaster.alerts.snapshot
/**
* A deliberately narrow endpoint for the optional system-server module.
* It is exported only so system_server can bind. A bind itself is not a
* sensitive operation; every transaction is authenticated before encrypted
* app data is read.
*/
class AlertPolicyService : Service() {
override fun onBind(intent: Intent): IBinder = PolicyBinder(applicationContext)
private class PolicyBinder(context: android.content.Context) : Binder() {
private val configurationStore = AlertConfigurationStore(context)
init { attachInterface(null, DESCRIPTOR) }
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
if (Binder.getCallingUid() != android.os.Process.SYSTEM_UID) return false
if (code == INTERFACE_TRANSACTION) {
reply?.writeString(DESCRIPTOR)
return true
}
if (code != TRANSACTION_EVALUATE || reply == null) return false
data.enforceInterface(DESCRIPTOR)
val packageName = data.readString() ?: return false
val source = runCatching { AlertSource.valueOf(data.readString() ?: return false) }.getOrNull() ?: return false
val event = AlertEvent(packageName, source, data.readString(), data.readString(), data.readString())
val routineUpdate = data.readBoolean()
val result = AlertPolicyEvaluator.evaluate(configurationStore.load(), event, routineUpdate)
reply.writeNoException()
reply.writeBoolean(result.silenceUnmatchedDirectAlert)
reply.writeInt(result.decisions.size)
result.decisions.forEach { decision ->
reply.writeString(decision.ruleId)
reply.writeString(decision.outcome.name)
val snapshot = decision.snapshot()
reply.writeBoolean(snapshot != null)
snapshot?.let {
reply.writeString(it.soundUri)
reply.writeLongArray(it.vibrationPattern.toLongArray())
reply.writeBoolean(it.playToCompletion)
reply.writeBoolean(it.allowDuringDnd)
}
}
return true
}
}
companion object {
const val DESCRIPTOR = "se.ajpanton.notificationsmaster.AlertPolicy"
const val TRANSACTION_EVALUATE = IBinder.FIRST_CALL_TRANSACTION
}
}
@@ -0,0 +1,55 @@
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.alerts.AlertConfiguration
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationJson
/**
* Sends a bounded, one-way policy snapshot to the optional system-server
* module. Android enforces this APK's signature permission, and the broadcast
* is targeted at Android's system process rather than being visible to apps.
*/
internal object AlertPolicySync {
const val ACTION = "se.ajpanton.notificationsmaster.UPDATE_ALERT_POLICY"
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 PERMISSION = "se.ajpanton.notificationsmaster.permission.UPDATE_ALERT_POLICY"
private const val SYSTEM_PACKAGE = "android"
private const val MAX_CONFIGURATION_BYTES = 64 * 1024
fun publish(context: Context, configuration: AlertConfiguration) {
val payload = AlertConfigurationJson.encode(configuration)
if (payload.size > MAX_CONFIGURATION_BYTES) {
Log.w(TAG, "Alert policy is too large for the system cache; leaving native alerts unchanged")
return
}
context.sendBroadcast(
Intent(ACTION)
.setPackage(SYSTEM_PACKAGE)
.putExtra(EXTRA_CONFIGURATION, payload),
)
Log.i(TAG, "Published alert policy snapshot")
}
fun publishPlaybackState(context: Context, ready: Boolean) {
context.sendBroadcast(
Intent(PLAYBACK_STATE_ACTION)
.setPackage(SYSTEM_PACKAGE)
.putExtra(EXTRA_PLAYBACK_READY, ready),
)
}
private const val TAG = "NotificationsMaster"
}
/** Re-publishes active alert policy after boot, without starting a service. */
class AlertPolicyBootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (Intent.ACTION_BOOT_COMPLETED != intent.action) return
AlertPolicySync.publish(context, se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore(context).load())
}
}
@@ -0,0 +1,57 @@
package se.ajpanton.notificationsmaster.module
import android.os.Build
import android.service.notification.StatusBarNotification
import android.util.Log
import io.github.libxposed.api.XposedInterface
import java.lang.reflect.Method
/**
* API 36's standard channel-attention boundary. It deliberately leaves the
* notification itself untouched.
*/
internal class NotificationAttentionBridge(
private val framework: XposedInterface,
private val observer: (SystemNotificationEvent) -> Unit,
) {
fun install(classLoader: ClassLoader) {
if (Build.VERSION.SDK_INT != 36) return
val helperClass = runCatching { Class.forName(ATTENTION_HELPER, false, classLoader) }.getOrNull() ?: run {
Log.w(TAG, "Notification attention helper was not found; leaving Android unchanged")
return
}
val method = helperClass.declaredMethods.singleOrNull(::isSupportedAttention) ?: run {
Log.w(TAG, "Notification attention method shape was not found; leaving Android unchanged")
return
}
method.isAccessible = true
framework.hook(method).intercept { chain ->
val event = eventFrom(chain.args.firstOrNull())
if (event != null && SystemAlertPolicyCache.shouldSuppress(event.asAlertEvent())) {
observer(event)
Log.i(TAG, "Suppressed native notification effects for matched alert from " + event.packageName)
0
} else {
chain.proceed()
}
}
Log.i(TAG, "Installed API 36 notification attention bridge")
}
private fun eventFrom(record: Any?): SystemNotificationEvent? = runCatching {
val sbn = record?.javaClass?.getMethod("getSbn")?.invoke(record) as? StatusBarNotification ?: return null
SystemNotificationEvent.fromStatusBarNotification(sbn)
}.getOrNull()
private companion object {
const val TAG = "NotificationsMaster"
const val ATTENTION_HELPER = "com.android.server.notification.NotificationAttentionHelper"
fun isSupportedAttention(method: Method): Boolean =
method.name == "buzzBeepBlinkLocked" &&
method.returnType == Int::class.javaPrimitiveType &&
method.parameterTypes.size == 2 &&
method.parameterTypes[0].name == "com.android.server.notification.NotificationRecord" &&
method.parameterTypes[1].name.endsWith("NotificationAttentionHelper\$Signals")
}
}
@@ -11,6 +11,8 @@ import io.github.libxposed.api.XposedModuleInterface
class NotificationsMasterModule : XposedModule() {
override fun onSystemServerStarting(param: XposedModuleInterface.SystemServerStartingParam) {
runCatching {
SystemAlertPolicyCache.installWhenReady()
NotificationAttentionBridge(this, ::diagnoseAttentionHelperEvent).install(param.classLoader)
SystemNotificationBridge(this, ::diagnoseHelperEvent).install(param.classLoader)
}.onFailure { error ->
Log.e(TAG, "Notification bridge was not installed; leaving Android unchanged", error)
@@ -18,11 +20,20 @@ class NotificationsMasterModule : XposedModule() {
}
private fun diagnoseHelperEvent(event: SystemNotificationEvent) {
diagnoseHelperEvent("enqueue", event)
}
private fun diagnoseAttentionHelperEvent(event: SystemNotificationEvent) {
diagnoseHelperEvent("attention", event)
}
private fun diagnoseHelperEvent(phase: String, event: SystemNotificationEvent) {
if (event.packageName != TEST_HELPER_PACKAGE) return
val matches = SystemAlertPolicyCache.evaluate(event.asAlertEvent()).decisions.size
Log.i(
TAG,
"Observed helper notification: title=" + event.title + " body=" + event.body?.replace("\n", " | ") +
" channel=" + event.channelId + " summary=" + event.isGroupSummary,
"Observed helper $phase: title=" + event.title + " body=" + event.body?.replace("\n", " | ") +
" channel=" + event.channelId + " summary=" + event.isGroupSummary + " matches=" + matches,
)
}
@@ -0,0 +1,103 @@
package se.ajpanton.notificationsmaster.module
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Handler
import android.os.Looper
import android.util.Log
import se.ajpanton.notificationsmaster.alerts.AlertConfiguration
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationJson
import se.ajpanton.notificationsmaster.alerts.AlertEvaluation
import se.ajpanton.notificationsmaster.alerts.AlertPolicyEvaluator
import se.ajpanton.notificationsmaster.alerts.AlertEvent
import se.ajpanton.notificationsmaster.alerts.AlertOutcome
/**
* A system-server-local, read-only copy of the app's alert rules.
*
* Notification hooks only read this volatile snapshot; they never synchronously
* enter the app process. Missing, rejected, or malformed policy always means
* no suppression.
*/
internal object SystemAlertPolicyCache {
@Volatile
private var configuration = AlertConfiguration(enabled = false)
@Volatile
private var playbackReady = false
private var installed = false
private var attempts = 0
fun installWhenReady() {
Handler(Looper.getMainLooper()).post(::tryInstall)
}
private fun tryInstall() {
if (installed) return
val context = systemContext()
if (context == null) {
retryOrGiveUp()
return
}
runCatching {
context.registerReceiver(
PolicyReceiver(),
IntentFilter().apply {
addAction(AlertPolicySync.ACTION)
addAction(AlertPolicySync.PLAYBACK_STATE_ACTION)
},
AlertPolicySync.PERMISSION,
null,
Context.RECEIVER_EXPORTED,
)
}.onSuccess {
installed = true
Log.i(TAG, "Installed fail-closed alert policy cache for " + context.packageName)
}.onFailure {
retryOrGiveUp()
}
}
fun evaluate(event: AlertEvent): AlertEvaluation =
AlertPolicyEvaluator.evaluate(configuration, event, isRoutineUpdate = false)
fun shouldSuppress(event: AlertEvent): Boolean = playbackReady &&
evaluate(event).decisions.any { it.outcome != AlertOutcome.PASS_THROUGH }
private fun systemContext(): Context? = runCatching {
val activityThread = Class.forName("android.app.ActivityThread")
val current = activityThread.getMethod("currentActivityThread").invoke(null) ?: return null
activityThread.getMethod("getSystemContext").invoke(current) as? Context
}.getOrNull()
private fun retryOrGiveUp() {
attempts += 1
if (attempts < MAX_ATTEMPTS) {
Handler(Looper.getMainLooper()).postDelayed(::tryInstall, RETRY_DELAY_MILLIS)
} else {
Log.w(TAG, "System policy cache is unavailable; leaving native alerts unchanged")
}
}
private class PolicyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AlertPolicySync.PLAYBACK_STATE_ACTION) {
playbackReady = intent.getBooleanExtra(AlertPolicySync.EXTRA_PLAYBACK_READY, false)
Log.i(TAG, "Alert playback listener ready=$playbackReady")
return
}
val payload = intent.getByteArrayExtra(AlertPolicySync.EXTRA_CONFIGURATION) ?: return
if (payload.size > MAX_CONFIGURATION_BYTES) return
configuration = runCatching { AlertConfigurationJson.decode(payload) }
.onFailure { Log.w(TAG, "Rejected malformed alert policy snapshot", it) }
.getOrNull() ?: return
Log.i(TAG, "Updated fail-closed alert policy cache")
}
}
private const val TAG = "NotificationsMaster"
private const val MAX_CONFIGURATION_BYTES = 64 * 1024
private const val MAX_ATTEMPTS = 10
private const val RETRY_DELAY_MILLIS = 1_000L
}
@@ -2,6 +2,7 @@ package se.ajpanton.notificationsmaster.module
import android.app.Notification
import android.os.Build
import android.service.notification.StatusBarNotification
import android.util.Log
import io.github.libxposed.api.XposedInterface
import java.lang.reflect.Method
@@ -14,12 +15,31 @@ internal data class SystemNotificationEvent(
val packageName: String,
val title: String?,
val body: String?,
val sender: String?,
val channelId: String?,
val isGroupSummary: Boolean,
val isOngoing: Boolean,
) {
fun asAlertEvent(source: AlertSource = AlertSource.NOTIFICATION_POST) =
AlertEvent(packageName, source, title, body)
AlertEvent(packageName, source, title, body, sender)
companion object {
fun fromNotification(packageName: String, notification: Notification): SystemNotificationEvent {
val contents = NotificationContents.alertContents(notification)
return SystemNotificationEvent(
packageName = packageName,
title = contents.title,
body = contents.body,
sender = contents.sender,
channelId = notification.channelId,
isGroupSummary = notification.flags and Notification.FLAG_GROUP_SUMMARY != 0,
isOngoing = notification.flags and Notification.FLAG_ONGOING_EVENT != 0,
)
}
fun fromStatusBarNotification(sbn: StatusBarNotification): SystemNotificationEvent =
fromNotification(sbn.packageName, sbn.notification)
}
}
/**
@@ -64,20 +84,7 @@ internal class SystemNotificationBridge(
): SystemNotificationEvent? {
val packageName = args.getOrNull(packageIndex) as? String ?: return null
val notification = args.getOrNull(notificationIndex) as? Notification ?: return null
val title = notification.extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString()
return SystemNotificationEvent(
packageName = packageName,
title = title,
body = notificationBody(notification, title),
channelId = notification.channelId,
isGroupSummary = notification.flags and Notification.FLAG_GROUP_SUMMARY != 0,
isOngoing = notification.flags and Notification.FLAG_ONGOING_EVENT != 0,
)
}
private fun notificationBody(notification: Notification, title: String?): String? {
val contents = NotificationContents.extract(notification) ?: return null
return title?.let { contents.removePrefix(it + "\n") }?.ifBlank { null } ?: contents
return SystemNotificationEvent.fromNotification(packageName, notification)
}
private companion object {
@@ -7,6 +7,7 @@ import android.service.notification.NotificationListenerService
import android.util.Log
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
import se.ajpanton.notificationsmaster.module.AlertPolicyBootReceiver
/**
* Notification listeners are re-bound by Android after boot when their component
@@ -42,6 +43,15 @@ internal object NotificationListenerComponentController {
if (packageManager.getComponentEnabledSetting(component) != desired) {
packageManager.setComponentEnabledSetting(component, desired, PackageManager.DONT_KILL_APP)
}
val bootReceiver = ComponentName(applicationContext, AlertPolicyBootReceiver::class.java)
val bootReceiverState = if (hasEnabledAlertRule) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
}
if (packageManager.getComponentEnabledSetting(bootReceiver) != bootReceiverState) {
packageManager.setComponentEnabledSetting(bootReceiver, bootReceiverState, PackageManager.DONT_KILL_APP)
}
if (desired == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
try {
// Data clearing and OEM listener management can leave an enabled