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