From 68771d5836b333d36c6afbf724ea65525c824c58 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Tue, 18 Aug 2026 05:25:48 +0000 Subject: [PATCH] Suppress matched native notification alerts --- .../module/AlertPolicySyncDeviceTest.kt | 44 ++++++++ app/src/main/AndroidManifest.xml | 17 ++- .../alerts/AlertConfigurationStore.kt | 2 + .../alerts/AndroidAlertEffectPlayer.kt | 6 + .../capture/NotificationCaptureService.kt | 3 + .../module/AlertPolicyService.kt | 64 ----------- .../module/AlertPolicySync.kt | 55 ++++++++++ .../module/NotificationAttentionBridge.kt | 57 ++++++++++ .../module/NotificationsMasterModule.kt | 15 ++- .../module/SystemAlertPolicyCache.kt | 103 ++++++++++++++++++ .../module/SystemNotificationBridge.kt | 37 ++++--- ...NotificationListenerComponentController.kt | 10 ++ .../helper/MainActivity.kt | 13 ++- 13 files changed, 341 insertions(+), 85 deletions(-) create mode 100644 app/src/androidTest/java/se/ajpanton/notificationsmaster/module/AlertPolicySyncDeviceTest.kt delete mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicyService.kt create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicySync.kt create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationAttentionBridge.kt create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/SystemAlertPolicyCache.kt diff --git a/app/src/androidTest/java/se/ajpanton/notificationsmaster/module/AlertPolicySyncDeviceTest.kt b/app/src/androidTest/java/se/ajpanton/notificationsmaster/module/AlertPolicySyncDeviceTest.kt new file mode 100644 index 0000000..80155f4 --- /dev/null +++ b/app/src/androidTest/java/se/ajpanton/notificationsmaster/module/AlertPolicySyncDeviceTest.kt @@ -0,0 +1,44 @@ +package se.ajpanton.notificationsmaster.module + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import se.ajpanton.notificationsmaster.alerts.AlertConfiguration +import se.ajpanton.notificationsmaster.alerts.AlertOutcome +import se.ajpanton.notificationsmaster.alerts.AlertProfile +import se.ajpanton.notificationsmaster.alerts.AlertRule +import se.ajpanton.notificationsmaster.alerts.AlertSource +import se.ajpanton.notificationsmaster.alerts.AlertTextMatcher +import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore + +/** Installs a predictable helper policy for the AOSP LSPosed smoke test. */ +@RunWith(AndroidJUnit4::class) +class AlertPolicySyncDeviceTest { + @Test + fun savesMatchingHelperPolicy() { + val profile = AlertProfile("module-test", "Module test", vibrationPattern = listOf(0, 200)) + val configuration = AlertConfiguration( + profiles = listOf(profile), + rules = listOf( + AlertRule( + id = "module-helper-post", + packageName = HELPER_PACKAGE, + order = 0, + sources = setOf(AlertSource.NOTIFICATION_POST), + matcher = AlertTextMatcher(), + outcome = AlertOutcome.PLAY_PROFILE, + profileId = profile.id, + ), + ), + ) + val store = AlertConfigurationStore(InstrumentationRegistry.getInstrumentation().targetContext) + store.save(configuration) + assertEquals(configuration, store.load()) + } + + private companion object { + const val HELPER_PACKAGE = "se.ajpanton.notificationsmaster.helper" + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7db2177..b1a453d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,12 @@ xmlns:tools="http://schemas.android.com/tools"> + + + + - + + + + + diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertConfigurationStore.kt b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertConfigurationStore.kt index c570276..aad8d5e 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertConfigurationStore.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertConfigurationStore.kt @@ -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 diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AndroidAlertEffectPlayer.kt b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AndroidAlertEffectPlayer.kt index 5e5f03b..baf806c 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AndroidAlertEffectPlayer.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AndroidAlertEffectPlayer.kt @@ -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" + } } 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 700bb43..885d22a 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/capture/NotificationCaptureService.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/capture/NotificationCaptureService.kt @@ -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() diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicyService.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicyService.kt deleted file mode 100644 index 26056ba..0000000 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicyService.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicySync.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicySync.kt new file mode 100644 index 0000000..214c6d8 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicySync.kt @@ -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()) + } +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationAttentionBridge.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationAttentionBridge.kt new file mode 100644 index 0000000..c8dfd46 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationAttentionBridge.kt @@ -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") + } +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt index 5d62aab..6e6d5c5 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt @@ -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, ) } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/SystemAlertPolicyCache.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/SystemAlertPolicyCache.kt new file mode 100644 index 0000000..8954b89 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/SystemAlertPolicyCache.kt @@ -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 +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/SystemNotificationBridge.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/SystemNotificationBridge.kt index 90c76c5..6356d76 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/SystemNotificationBridge.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/SystemNotificationBridge.kt @@ -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 { diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/settings/NotificationListenerComponentController.kt b/app/src/main/java/se/ajpanton/notificationsmaster/settings/NotificationListenerComponentController.kt index 1d760e3..dff6e14 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/settings/NotificationListenerComponentController.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/settings/NotificationListenerComponentController.kt @@ -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 diff --git a/test-helper/src/main/java/se/ajpanton/notificationsmaster/helper/MainActivity.kt b/test-helper/src/main/java/se/ajpanton/notificationsmaster/helper/MainActivity.kt index e20d87a..43185bd 100644 --- a/test-helper/src/main/java/se/ajpanton/notificationsmaster/helper/MainActivity.kt +++ b/test-helper/src/main/java/se/ajpanton/notificationsmaster/helper/MainActivity.kt @@ -6,6 +6,8 @@ import android.app.NotificationManager import android.app.Person import android.content.Intent import android.graphics.Bitmap +import android.media.AudioAttributes +import android.media.RingtoneManager import android.os.Bundle import android.widget.Button import android.widget.LinearLayout @@ -21,7 +23,16 @@ class MainActivity : AppCompatActivity() { override fun onCreate(state: Bundle?) { super.onCreate(state) - manager.createNotificationChannel(NotificationChannel(CHANNEL, "Test", NotificationManager.IMPORTANCE_DEFAULT)) + manager.createNotificationChannel( + NotificationChannel(CHANNEL, "Test", NotificationManager.IMPORTANCE_HIGH).apply { + setSound( + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), + AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_NOTIFICATION).build(), + ) + enableVibration(true) + vibrationPattern = longArrayOf(0, 200) + }, + ) setContentView(ScrollView(this).apply { addView(LinearLayout(this@MainActivity).apply { orientation = LinearLayout.VERTICAL