Limit custom alerts to notification events

This commit is contained in:
ajp_anton
2026-08-19 20:57:17 +00:00
parent e80486df65
commit de984fb36c
24 changed files with 19 additions and 325 deletions
-8
View File
@@ -53,14 +53,6 @@
</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>
@@ -62,10 +62,6 @@ object AlertRuleDialog {
}
val post = CheckBox(context).apply { text = "Appearing"; isChecked = existing == null || AlertSource.NOTIFICATION_POST in existing.sources }
val update = CheckBox(context).apply { text = "Edits"; isChecked = existing == null || AlertSource.NOTIFICATION_UPDATE in existing.sources }
val directVibration = CheckBox(context).apply {
text = "Direct notification vibration (experimental)"
isChecked = AlertSource.DIRECT_NOTIFICATION_VIBRATION in (existing?.sources ?: emptySet())
}
val pattern = EditText(context).apply { hint = "Text to match (optional)"; setText(existing?.matcher?.value) }
val regex = SwitchMaterial(context).apply { text = "Use regular expression"; isChecked = existing?.matcher?.mode == AlertTextMode.REGEX }
val protected = SwitchMaterial(context).apply { text = "Play to completion"; isChecked = existing?.playToCompletion == true }
@@ -73,7 +69,7 @@ object AlertRuleDialog {
text = "Allow during DND (requires system support)"
isChecked = existing?.allowDuringDnd == true
}
listOf(outcome, profile, post, update, directVibration, pattern, regex, protected, dnd).forEach(form::addView)
listOf(outcome, profile, post, update, pattern, regex, protected, dnd).forEach(form::addView)
form.addView(android.widget.TextView(context).apply {
text = "Android blocks direct custom alerts during Do Not Disturb. This setting is retained for optional system support, but does not bypass Do Not Disturb in the normal app."
textSize = 14f
@@ -96,7 +92,6 @@ object AlertRuleDialog {
val sources = buildSet {
if (post.isChecked) add(AlertSource.NOTIFICATION_POST)
if (update.isChecked) add(AlertSource.NOTIFICATION_UPDATE)
if (directVibration.isChecked) add(AlertSource.DIRECT_NOTIFICATION_VIBRATION)
}
if (sources.isEmpty()) { post.error = "Choose an event"; return@setOnClickListener }
if (apps != null && selectedApps.isEmpty()) { app?.error = "Choose at least one app"; return@setOnClickListener }
@@ -31,8 +31,6 @@ class AppRulesFragment : Fragment(R.layout.fragment_app_rules) {
binding!!.addRule.setOnClickListener { edit(null) }
binding!!.allowMultipleMatches.isChecked = store.load().appSettings.firstOrNull { it.packageName == packageName }?.allowMultipleMatches == true
binding!!.allowMultipleMatches.setOnCheckedChangeListener { _, enabled -> setAllowMultipleMatches(enabled) }
binding!!.directAlertControl.isChecked = store.load().appSettings.firstOrNull { it.packageName == packageName }?.directAlertControl == true
binding!!.directAlertControl.setOnCheckedChangeListener { _, enabled -> setDirectAlertControl(enabled) }
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) {
override fun onMove(recyclerView: RecyclerView, holder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
adapter.move(holder.bindingAdapterPosition, target.bindingAdapterPosition)
@@ -75,15 +73,9 @@ class AppRulesFragment : Fragment(R.layout.fragment_app_rules) {
saveAppSettings(configuration, setting)
}
private fun setDirectAlertControl(enabled: Boolean) {
val configuration = store.load()
val old = configuration.appSettings.firstOrNull { it.packageName == packageName }
saveAppSettings(configuration, (old ?: AlertAppSettings(packageName)).copy(directAlertControl = enabled))
}
private fun saveAppSettings(configuration: se.ajpanton.notificationsmaster.alerts.AlertConfiguration, setting: AlertAppSettings) {
store.save(configuration.copy(appSettings = configuration.appSettings.filterNot { it.packageName == packageName } +
if (setting.allowMultipleMatches || setting.directAlertControl) listOf(setting) else emptyList()))
if (setting.allowMultipleMatches) listOf(setting) else emptyList()))
}
private class RuleAdapter(private val onClick: (AlertRule) -> Unit) : RecyclerView.Adapter<RuleAdapter.Holder>() {
@@ -2,15 +2,12 @@ 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()
@@ -94,7 +94,6 @@ internal object AlertConfigurationJson {
private fun AlertAppSettings.toJson() = JSONObject()
.put("packageName", packageName).put("allowMultipleMatches", allowMultipleMatches)
.put("directAlertControl", directAlertControl)
private fun AlertRule.toJson() = JSONObject()
.put("id", id).put("packageName", packageName).put("order", order)
@@ -113,7 +112,7 @@ internal object AlertConfigurationJson {
)
private fun JSONObject.toAppSettings() = AlertAppSettings(
getString("packageName"), getBoolean("allowMultipleMatches"), getBoolean("directAlertControl"),
getString("packageName"), getBoolean("allowMultipleMatches"),
)
private fun JSONObject.toRule() = AlertRule(
@@ -5,17 +5,11 @@ import com.google.re2j.Pattern
enum class AlertSource {
NOTIFICATION_POST,
NOTIFICATION_UPDATE,
DIRECT_NOTIFICATION_SOUND,
DIRECT_NOTIFICATION_VIBRATION,
;
val isDirect get() = this == DIRECT_NOTIFICATION_SOUND || this == DIRECT_NOTIFICATION_VIBRATION
fun label() = when (this) {
NOTIFICATION_POST -> "Appearing"
NOTIFICATION_UPDATE -> "Edits"
DIRECT_NOTIFICATION_SOUND -> "Direct notification sound"
DIRECT_NOTIFICATION_VIBRATION -> "Direct notification vibration"
}
}
@@ -113,7 +107,6 @@ data class AlertRuleGroupingKey(
data class AlertAppSettings(
val packageName: String,
val allowMultipleMatches: Boolean = false,
val directAlertControl: Boolean = false,
)
data class AlertQueueSettings(
@@ -143,8 +136,7 @@ data class AlertConfiguration(
}
val hasEnabledRules get() = enabled && rules.any { it.enabled }
val hasDirectAlertControl get() = enabled && appSettings.any { it.directAlertControl }
val needsListener get() = hasEnabledRules || hasDirectAlertControl
val needsListener get() = hasEnabledRules
}
data class AlertEvent(
@@ -181,8 +173,7 @@ fun AlertDecision.snapshot(): AlertSnapshot? = profile?.let {
data class AlertEvaluation(
val decisions: List<AlertDecision>,
val silenceUnmatchedDirectAlert: Boolean,
)
fun AlertEvaluation.suppressesOriginal() =
silenceUnmatchedDirectAlert || decisions.any { it.outcome != AlertOutcome.PASS_THROUGH }
decisions.any { it.outcome != AlertOutcome.PASS_THROUGH }
@@ -1,18 +0,0 @@
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()
}
@@ -12,7 +12,7 @@ object AlertPolicyEvaluator {
isRoutineUpdate &&
configuration.ignoreRoutineUpdates
) {
return AlertEvaluation(emptyList(), false)
return AlertEvaluation(emptyList())
}
val app = configuration.appSettings.firstOrNull { it.packageName == event.packageName }
?: AlertAppSettings(event.packageName)
@@ -7,9 +7,7 @@ object AlertRuleEvaluator {
profiles: Map<String, AlertProfile>,
event: AlertEvent,
): AlertEvaluation {
if (app.packageName != event.packageName || event.source.isDirect && !app.directAlertControl) {
return AlertEvaluation(emptyList(), false)
}
if (app.packageName != event.packageName) return AlertEvaluation(emptyList())
val matches = rules.asSequence()
.filter { it.enabled && it.packageName == event.packageName && event.source in it.sources }
.sortedWith(compareBy<AlertRule> { it.order }.thenBy { it.id })
@@ -17,7 +15,7 @@ object AlertRuleEvaluator {
.mapNotNull { rule -> rule.decision(profiles[rule.profileId]) }
.let { if (app.allowMultipleMatches) it else it.take(1) }
.toList()
return AlertEvaluation(matches, event.source.isDirect && matches.isEmpty())
return AlertEvaluation(matches)
}
private fun AlertRule.decision(profile: AlertProfile?): AlertDecision? = when (outcome) {
@@ -9,9 +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.NotificationLogApplication
import se.ajpanton.notificationsmaster.alerts.AndroidAlertEffectPlayer
import se.ajpanton.notificationsmaster.module.AlertPolicySync
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
import se.ajpanton.notificationsmaster.data.EncryptedImageStore
@@ -36,7 +37,7 @@ class NotificationCaptureService : NotificationListenerService() {
private lateinit var perAppEventSettings: PerAppEventSettingsStore
private lateinit var imageStore: EncryptedImageStore
private lateinit var alertConfigurationStore: AlertConfigurationStore
private lateinit var alertPlayback: se.ajpanton.notificationsmaster.alerts.AlertPlaybackRuntime
private lateinit var alertPlayback: AlertPlaybackController
private var alertQueueSettings = AlertQueueSettings()
override fun onCreate() {
@@ -48,7 +49,7 @@ class NotificationCaptureService : NotificationListenerService() {
perAppEventSettings = PerAppEventSettingsStore(this)
imageStore = EncryptedImageStore(this)
alertConfigurationStore = AlertConfigurationStore(this)
alertPlayback = (application as NotificationLogApplication).alertPlayback
alertPlayback = AlertPlaybackController(AndroidAlertEffectPlayer(this)) { alertQueueSettings }
writeExecutor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "notification-log-writer")
}
@@ -189,7 +190,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(it, alertQueueSettings) }
).forEach(alertPlayback::enqueue)
}
private fun appName(packageName: String): String = try {
@@ -1,31 +0,0 @@
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,56 +0,0 @@
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 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"
@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
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): Pair<String, AlertSnapshot>? = runCatching {
val soundUri = intent.getStringExtra(EXTRA_SOUND_URI)
val vibration = intent.getLongArrayExtra(EXTRA_VIBRATION)?.toList().orEmpty()
val snapshot = AlertSnapshot(
soundUri,
vibration,
intent.getBooleanExtra(EXTRA_PROTECTED, false),
intent.getBooleanExtra(EXTRA_ALLOW_DND, false),
)
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)
}
@@ -1,61 +0,0 @@
package se.ajpanton.notificationsmaster.module
import android.os.Build
import android.os.CombinedVibration
import android.os.IBinder
import android.os.VibrationAttributes
import android.util.Log
import io.github.libxposed.api.XposedInterface
import java.lang.reflect.Method
import se.ajpanton.notificationsmaster.alerts.AlertSource
/** Intercepts only direct vibrations explicitly labelled as notification effects. */
internal class DirectVibrationBridge(private val framework: XposedInterface) {
fun install(classLoader: ClassLoader) {
val method = findVibrateMethod(classLoader) ?: run {
Log.w(TAG, "Direct vibration method shape was not found; leaving Android unchanged")
return
}
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, (uid ?: 0).coerceAtLeast(0) / USER_UID_RANGE)
Log.i(TAG, "Suppressed matched direct notification vibration from $packageName")
null
} else {
chain.proceed()
}
}
Log.i(TAG, "Installed API ${Build.VERSION.SDK_INT} direct notification vibration bridge")
}
private fun findVibrateMethod(classLoader: ClassLoader): Method? = runCatching {
Class.forName(VIBRATOR_MANAGER_SERVICE, false, classLoader).declaredMethods.singleOrNull { method ->
method.name == "vibrate" && method.returnType == Void.TYPE &&
method.parameterTypes.contentEquals(VIBRATE_PARAMETERS)
}
}.getOrNull()
private companion object {
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,
String::class.java,
CombinedVibration::class.java,
VibrationAttributes::class.java,
String::class.java,
IBinder::class.java,
)
}
}
@@ -17,9 +17,6 @@ class NotificationsMasterModule : XposedModule() {
installBridge("notification attention") {
NotificationAttentionBridge(this, ::diagnoseAttentionHelperEvent).install(param.classLoader)
}
installBridge("direct notification vibration") {
DirectVibrationBridge(this).install(param.classLoader)
}
installBridge("notification enqueue") {
SystemNotificationBridge(this, ::diagnoseHelperEvent).install(param.classLoader)
}
@@ -63,14 +63,7 @@ internal object SystemAlertPolicyCache {
AlertPolicyEvaluator.evaluate(configuration, event, isRoutineUpdate = false)
fun shouldSuppress(event: AlertEvent): Boolean =
(event.source.isDirect || playbackReady) && evaluate(event).suppressesOriginal()
fun sendDirectAlerts(event: AlertEvent, userId: Int) {
val context = systemContext() ?: return
val decisions = evaluate(event).decisions
val sent = decisions.count { DirectAlertSync.send(context, it, userId) }
Log.i(TAG, "Sent $sent direct alert request(s) for ${event.packageName}")
}
playbackReady && evaluate(event).suppressesOriginal()
private fun systemContext(): Context? = runCatching {
val activityThread = Class.forName("android.app.ActivityThread")
@@ -25,18 +25,6 @@
android:layout_marginTop="8dp"
android:text="Allow multiple matching rules" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/direct_alert_control"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Control direct notification-style vibration (experimental)" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="With optional system integration, direct notification-style vibration is silenced by default. Direct sound is not controlled yet."
android:textSize="12sp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rules"
android:layout_width="match_parent"