Fix direct alert delivery across users
This commit is contained in:
@@ -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()
|
||||
}
|
||||
+5
-27
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user