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
}