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
+3 -8
View File
@@ -46,17 +46,12 @@ play to completion.
Without optional system integration, a matching profile plays alongside the Without optional system integration, a matching profile plays alongside the
source app's normal alert. With a compatible LSPosed setup enabled, Notifications source app's normal alert. With a compatible LSPosed setup enabled, Notifications
Master can suppress a matched standard notification alert while leaving the Master can suppress a matched standard notification alert while leaving the
notification itself visible. Experimental per-app control is also available for notification itself visible. It only evaluates actual posted or updated
direct vibrations that are explicitly marked by Android as notification usage. notifications; standalone sounds and vibrations remain under the source app's
It deliberately does not touch media, alarms, ringtones, touch feedback, or control.
unknown vibration usage. Direct app-originated sounds are not intercepted: they
do not have a safe, complete Android system boundary, so their original sound
continues to play.
To use the optional integration, enable Notifications Master as an LSPosed To use the optional integration, enable Notifications Master as an LSPosed
module and give it the **Android/system server** scope, then restart Android. module and give it the **Android/system server** scope, then restart Android.
Enable direct-vibration control only for apps where you want that experimental
behaviour; normal notification rules work independently of this setting.
Custom alerts respect Do Not Disturb. Android does not allow a normal app to Custom alerts respect Do Not Disturb. Android does not allow a normal app to
force direct custom sound or vibration through Do Not Disturb. force direct custom sound or vibration through Do Not Disturb.
@@ -26,7 +26,7 @@ class AlertConfigurationStoreTest {
val profile = AlertProfile("profile", "Sensitive profile", vibrationPattern = listOf(0, 120)) val profile = AlertProfile("profile", "Sensitive profile", vibrationPattern = listOf(0, 120))
val configuration = AlertConfiguration( val configuration = AlertConfiguration(
profiles = listOf(profile), profiles = listOf(profile),
appSettings = listOf(AlertAppSettings("chat.app", directAlertControl = true)), appSettings = listOf(AlertAppSettings("chat.app")),
rules = listOf( rules = listOf(
AlertRule( AlertRule(
"rule", "chat.app", 0, setOf(AlertSource.NOTIFICATION_POST), "rule", "chat.app", 0, setOf(AlertSource.NOTIFICATION_POST),
@@ -48,23 +48,4 @@ class AlertListenerPolicyDeviceTest {
) )
} }
@Test
fun directAlertControlKeepsListenerEnabledWithoutRules() {
LoggingType.entries.forEach { LoggingRuleStore(context).save(it, LoggingRule(enabled = false)) }
AlertConfigurationStore(context).save(
AlertConfiguration(appSettings = listOf(AlertAppSettings("example.app", directAlertControl = true))),
)
assertEquals(
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
context.packageManager.getComponentEnabledSetting(component),
)
AlertConfigurationStore(context).save(AlertConfiguration())
assertEquals(
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
context.packageManager.getComponentEnabledSetting(component),
)
}
} }
@@ -22,7 +22,6 @@ class AlertPolicySyncDeviceTest {
val profile = AlertProfile("module-test", "Module test", vibrationPattern = listOf(0, 200)) val profile = AlertProfile("module-test", "Module test", vibrationPattern = listOf(0, 200))
val configuration = AlertConfiguration( val configuration = AlertConfiguration(
profiles = listOf(profile), profiles = listOf(profile),
appSettings = listOf(AlertAppSettings(HELPER_PACKAGE, directAlertControl = true)),
rules = listOf( rules = listOf(
AlertRule( AlertRule(
id = "module-helper-post", id = "module-helper-post",
@@ -33,15 +32,6 @@ class AlertPolicySyncDeviceTest {
outcome = AlertOutcome.PLAY_PROFILE, outcome = AlertOutcome.PLAY_PROFILE,
profileId = profile.id, profileId = profile.id,
), ),
AlertRule(
id = "module-helper-direct-vibration",
packageName = HELPER_PACKAGE,
order = 1,
sources = setOf(AlertSource.DIRECT_NOTIFICATION_VIBRATION),
matcher = AlertTextMatcher(),
outcome = AlertOutcome.PLAY_PROFILE,
profileId = profile.id,
),
), ),
) )
val store = AlertConfigurationStore(InstrumentationRegistry.getInstrumentation().targetContext) val store = AlertConfigurationStore(InstrumentationRegistry.getInstrumentation().targetContext)
-8
View File
@@ -53,14 +53,6 @@
</intent-filter> </intent-filter>
</receiver> </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> </application>
</manifest> </manifest>
@@ -62,10 +62,6 @@ object AlertRuleDialog {
} }
val post = CheckBox(context).apply { text = "Appearing"; isChecked = existing == null || AlertSource.NOTIFICATION_POST in existing.sources } 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 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 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 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 } 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)" text = "Allow during DND (requires system support)"
isChecked = existing?.allowDuringDnd == true 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 { 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." 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 textSize = 14f
@@ -96,7 +92,6 @@ object AlertRuleDialog {
val sources = buildSet { val sources = buildSet {
if (post.isChecked) add(AlertSource.NOTIFICATION_POST) if (post.isChecked) add(AlertSource.NOTIFICATION_POST)
if (update.isChecked) add(AlertSource.NOTIFICATION_UPDATE) 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 (sources.isEmpty()) { post.error = "Choose an event"; return@setOnClickListener }
if (apps != null && selectedApps.isEmpty()) { app?.error = "Choose at least one app"; 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!!.addRule.setOnClickListener { edit(null) }
binding!!.allowMultipleMatches.isChecked = store.load().appSettings.firstOrNull { it.packageName == packageName }?.allowMultipleMatches == true binding!!.allowMultipleMatches.isChecked = store.load().appSettings.firstOrNull { it.packageName == packageName }?.allowMultipleMatches == true
binding!!.allowMultipleMatches.setOnCheckedChangeListener { _, enabled -> setAllowMultipleMatches(enabled) } 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) { ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) {
override fun onMove(recyclerView: RecyclerView, holder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { override fun onMove(recyclerView: RecyclerView, holder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
adapter.move(holder.bindingAdapterPosition, target.bindingAdapterPosition) adapter.move(holder.bindingAdapterPosition, target.bindingAdapterPosition)
@@ -75,15 +73,9 @@ class AppRulesFragment : Fragment(R.layout.fragment_app_rules) {
saveAppSettings(configuration, setting) 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) { private fun saveAppSettings(configuration: se.ajpanton.notificationsmaster.alerts.AlertConfiguration, setting: AlertAppSettings) {
store.save(configuration.copy(appSettings = configuration.appSettings.filterNot { it.packageName == packageName } + 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>() { 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.app.Application
import android.content.pm.PackageManager import android.content.pm.PackageManager
import se.ajpanton.notificationsmaster.alerts.AlertPlaybackRuntime
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
import se.ajpanton.notificationsmaster.settings.AppFilterSettingsStore import se.ajpanton.notificationsmaster.settings.AppFilterSettingsStore
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
import se.ajpanton.notificationsmaster.settings.PerAppEventSettingsStore import se.ajpanton.notificationsmaster.settings.PerAppEventSettingsStore
class NotificationLogApplication : Application() { class NotificationLogApplication : Application() {
val alertPlayback by lazy { AlertPlaybackRuntime(this) }
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
synchronizeListenerComponent() synchronizeListenerComponent()
@@ -94,7 +94,6 @@ internal object AlertConfigurationJson {
private fun AlertAppSettings.toJson() = JSONObject() private fun AlertAppSettings.toJson() = JSONObject()
.put("packageName", packageName).put("allowMultipleMatches", allowMultipleMatches) .put("packageName", packageName).put("allowMultipleMatches", allowMultipleMatches)
.put("directAlertControl", directAlertControl)
private fun AlertRule.toJson() = JSONObject() private fun AlertRule.toJson() = JSONObject()
.put("id", id).put("packageName", packageName).put("order", order) .put("id", id).put("packageName", packageName).put("order", order)
@@ -113,7 +112,7 @@ internal object AlertConfigurationJson {
) )
private fun JSONObject.toAppSettings() = AlertAppSettings( private fun JSONObject.toAppSettings() = AlertAppSettings(
getString("packageName"), getBoolean("allowMultipleMatches"), getBoolean("directAlertControl"), getString("packageName"), getBoolean("allowMultipleMatches"),
) )
private fun JSONObject.toRule() = AlertRule( private fun JSONObject.toRule() = AlertRule(
@@ -5,17 +5,11 @@ import com.google.re2j.Pattern
enum class AlertSource { enum class AlertSource {
NOTIFICATION_POST, NOTIFICATION_POST,
NOTIFICATION_UPDATE, NOTIFICATION_UPDATE,
DIRECT_NOTIFICATION_SOUND,
DIRECT_NOTIFICATION_VIBRATION,
; ;
val isDirect get() = this == DIRECT_NOTIFICATION_SOUND || this == DIRECT_NOTIFICATION_VIBRATION
fun label() = when (this) { fun label() = when (this) {
NOTIFICATION_POST -> "Appearing" NOTIFICATION_POST -> "Appearing"
NOTIFICATION_UPDATE -> "Edits" 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( data class AlertAppSettings(
val packageName: String, val packageName: String,
val allowMultipleMatches: Boolean = false, val allowMultipleMatches: Boolean = false,
val directAlertControl: Boolean = false,
) )
data class AlertQueueSettings( data class AlertQueueSettings(
@@ -143,8 +136,7 @@ data class AlertConfiguration(
} }
val hasEnabledRules get() = enabled && rules.any { it.enabled } val hasEnabledRules get() = enabled && rules.any { it.enabled }
val hasDirectAlertControl get() = enabled && appSettings.any { it.directAlertControl } val needsListener get() = hasEnabledRules
val needsListener get() = hasEnabledRules || hasDirectAlertControl
} }
data class AlertEvent( data class AlertEvent(
@@ -181,8 +173,7 @@ fun AlertDecision.snapshot(): AlertSnapshot? = profile?.let {
data class AlertEvaluation( data class AlertEvaluation(
val decisions: List<AlertDecision>, val decisions: List<AlertDecision>,
val silenceUnmatchedDirectAlert: Boolean,
) )
fun AlertEvaluation.suppressesOriginal() = 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 && isRoutineUpdate &&
configuration.ignoreRoutineUpdates configuration.ignoreRoutineUpdates
) { ) {
return AlertEvaluation(emptyList(), false) return AlertEvaluation(emptyList())
} }
val app = configuration.appSettings.firstOrNull { it.packageName == event.packageName } val app = configuration.appSettings.firstOrNull { it.packageName == event.packageName }
?: AlertAppSettings(event.packageName) ?: AlertAppSettings(event.packageName)
@@ -7,9 +7,7 @@ object AlertRuleEvaluator {
profiles: Map<String, AlertProfile>, profiles: Map<String, AlertProfile>,
event: AlertEvent, event: AlertEvent,
): AlertEvaluation { ): AlertEvaluation {
if (app.packageName != event.packageName || event.source.isDirect && !app.directAlertControl) { if (app.packageName != event.packageName) return AlertEvaluation(emptyList())
return AlertEvaluation(emptyList(), false)
}
val matches = rules.asSequence() val matches = rules.asSequence()
.filter { it.enabled && it.packageName == event.packageName && event.source in it.sources } .filter { it.enabled && it.packageName == event.packageName && event.source in it.sources }
.sortedWith(compareBy<AlertRule> { it.order }.thenBy { it.id }) .sortedWith(compareBy<AlertRule> { it.order }.thenBy { it.id })
@@ -17,7 +15,7 @@ object AlertRuleEvaluator {
.mapNotNull { rule -> rule.decision(profiles[rule.profileId]) } .mapNotNull { rule -> rule.decision(profiles[rule.profileId]) }
.let { if (app.allowMultipleMatches) it else it.take(1) } .let { if (app.allowMultipleMatches) it else it.take(1) }
.toList() .toList()
return AlertEvaluation(matches, event.source.isDirect && matches.isEmpty()) return AlertEvaluation(matches)
} }
private fun AlertRule.decision(profile: AlertProfile?): AlertDecision? = when (outcome) { 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.AlertConfigurationStore
import se.ajpanton.notificationsmaster.alerts.AlertEvent import se.ajpanton.notificationsmaster.alerts.AlertEvent
import se.ajpanton.notificationsmaster.alerts.AlertNotificationDispatcher import se.ajpanton.notificationsmaster.alerts.AlertNotificationDispatcher
import se.ajpanton.notificationsmaster.alerts.AlertPlaybackController
import se.ajpanton.notificationsmaster.alerts.AlertQueueSettings import se.ajpanton.notificationsmaster.alerts.AlertQueueSettings
import se.ajpanton.notificationsmaster.alerts.AlertSource 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.module.AlertPolicySync
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
import se.ajpanton.notificationsmaster.data.EncryptedImageStore import se.ajpanton.notificationsmaster.data.EncryptedImageStore
@@ -36,7 +37,7 @@ class NotificationCaptureService : NotificationListenerService() {
private lateinit var perAppEventSettings: PerAppEventSettingsStore private lateinit var perAppEventSettings: PerAppEventSettingsStore
private lateinit var imageStore: EncryptedImageStore private lateinit var imageStore: EncryptedImageStore
private lateinit var alertConfigurationStore: AlertConfigurationStore private lateinit var alertConfigurationStore: AlertConfigurationStore
private lateinit var alertPlayback: se.ajpanton.notificationsmaster.alerts.AlertPlaybackRuntime private lateinit var alertPlayback: AlertPlaybackController
private var alertQueueSettings = AlertQueueSettings() private var alertQueueSettings = AlertQueueSettings()
override fun onCreate() { override fun onCreate() {
@@ -48,7 +49,7 @@ class NotificationCaptureService : NotificationListenerService() {
perAppEventSettings = PerAppEventSettingsStore(this) perAppEventSettings = PerAppEventSettingsStore(this)
imageStore = EncryptedImageStore(this) imageStore = EncryptedImageStore(this)
alertConfigurationStore = AlertConfigurationStore(this) alertConfigurationStore = AlertConfigurationStore(this)
alertPlayback = (application as NotificationLogApplication).alertPlayback alertPlayback = AlertPlaybackController(AndroidAlertEffectPlayer(this)) { alertQueueSettings }
writeExecutor = Executors.newSingleThreadExecutor { runnable -> writeExecutor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "notification-log-writer") Thread(runnable, "notification-log-writer")
} }
@@ -189,7 +190,7 @@ class NotificationCaptureService : NotificationListenerService() {
configuration, configuration,
AlertEvent(snapshot.packageName, source, snapshot.alertTitle, snapshot.alertBody, snapshot.alertSender), AlertEvent(snapshot.packageName, source, snapshot.alertTitle, snapshot.alertBody, snapshot.alertSender),
isRoutineUpdate = source == AlertSource.NOTIFICATION_UPDATE && snapshot.isRoutine, isRoutineUpdate = source == AlertSource.NOTIFICATION_UPDATE && snapshot.isRoutine,
).forEach { alertPlayback.enqueue(it, alertQueueSettings) } ).forEach(alertPlayback::enqueue)
} }
private fun appName(packageName: String): String = try { 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") { installBridge("notification attention") {
NotificationAttentionBridge(this, ::diagnoseAttentionHelperEvent).install(param.classLoader) NotificationAttentionBridge(this, ::diagnoseAttentionHelperEvent).install(param.classLoader)
} }
installBridge("direct notification vibration") {
DirectVibrationBridge(this).install(param.classLoader)
}
installBridge("notification enqueue") { installBridge("notification enqueue") {
SystemNotificationBridge(this, ::diagnoseHelperEvent).install(param.classLoader) SystemNotificationBridge(this, ::diagnoseHelperEvent).install(param.classLoader)
} }
@@ -63,14 +63,7 @@ internal object SystemAlertPolicyCache {
AlertPolicyEvaluator.evaluate(configuration, event, isRoutineUpdate = false) AlertPolicyEvaluator.evaluate(configuration, event, isRoutineUpdate = false)
fun shouldSuppress(event: AlertEvent): Boolean = fun shouldSuppress(event: AlertEvent): Boolean =
(event.source.isDirect || playbackReady) && evaluate(event).suppressesOriginal() 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}")
}
private fun systemContext(): Context? = runCatching { private fun systemContext(): Context? = runCatching {
val activityThread = Class.forName("android.app.ActivityThread") val activityThread = Class.forName("android.app.ActivityThread")
@@ -25,18 +25,6 @@
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:text="Allow multiple matching rules" /> 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 <androidx.recyclerview.widget.RecyclerView
android:id="@+id/rules" android:id="@+id/rules"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -21,7 +21,7 @@ class AlertConfigurationJsonTest {
) )
val original = AlertConfiguration( val original = AlertConfiguration(
profiles = listOf(profile), profiles = listOf(profile),
appSettings = listOf(AlertAppSettings("chat.app", true, true)), appSettings = listOf(AlertAppSettings("chat.app", true)),
rules = listOf(rule), rules = listOf(rule),
queueSettings = AlertQueueSettings(7, AlertQueueOverflow.DROP_NEWEST), queueSettings = AlertQueueSettings(7, AlertQueueOverflow.DROP_NEWEST),
) )
@@ -1,7 +1,6 @@
package se.ajpanton.notificationsmaster.alerts package se.ajpanton.notificationsmaster.alerts
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@@ -28,46 +27,11 @@ class AlertRuleEvaluatorTest {
assertEquals(listOf("first", "second"), result.decisions.map { it.ruleId }) assertEquals(listOf("first", "second"), result.decisions.map { it.ruleId })
} }
@Test fun `direct notification alert is silent by default for opted in app`() {
val app = app.copy(directAlertControl = true)
val result = AlertRuleEvaluator.evaluate(
app,
emptyList(),
emptyMap(),
AlertEvent("chat.app", AlertSource.DIRECT_NOTIFICATION_SOUND),
)
assertTrue(result.decisions.isEmpty())
assertTrue(result.silenceUnmatchedDirectAlert)
assertTrue(result.suppressesOriginal())
}
@Test fun `direct notification alert is untouched without opt in`() {
val result = AlertRuleEvaluator.evaluate(
app,
emptyList(),
emptyMap(),
AlertEvent("chat.app", AlertSource.DIRECT_NOTIFICATION_SOUND),
)
assertFalse(result.silenceUnmatchedDirectAlert)
assertFalse(result.suppressesOriginal())
}
@Test fun `text and regex match only notification contents`() { @Test fun `text and regex match only notification contents`() {
val rule = rule("match", 0, AlertTextMatcher(AlertTextField.BODY, AlertTextMode.REGEX, "invoice\\s+#\\d+")) val rule = rule("match", 0, AlertTextMatcher(AlertTextField.BODY, AlertTextMode.REGEX, "invoice\\s+#\\d+"))
val match = evaluate(listOf(rule), AlertEvent("chat.app", AlertSource.NOTIFICATION_POST, body = "Invoice #42")) val match = evaluate(listOf(rule), AlertEvent("chat.app", AlertSource.NOTIFICATION_POST, body = "Invoice #42"))
val direct = AlertRuleEvaluator.evaluate(
app.copy(directAlertControl = true),
listOf(rule.copy(sources = setOf(AlertSource.DIRECT_NOTIFICATION_SOUND))),
mapOf(profile.id to profile),
AlertEvent("chat.app", AlertSource.DIRECT_NOTIFICATION_SOUND),
)
assertEquals(listOf("match"), match.decisions.map { it.ruleId }) assertEquals(listOf("match"), match.decisions.map { it.ruleId })
assertTrue(direct.decisions.isEmpty())
assertTrue(direct.silenceUnmatchedDirectAlert)
} }
@Test fun `grouping ignores app assignment and local order`() { @Test fun `grouping ignores app assignment and local order`() {
-1
View File
@@ -1,6 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<application android:theme="@style/Theme.AppCompat.DayNight"> <application android:theme="@style/Theme.AppCompat.DayNight">
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop"> <activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop">
@@ -9,9 +9,6 @@ import android.graphics.Bitmap
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.RingtoneManager import android.media.RingtoneManager
import android.os.Bundle import android.os.Bundle
import android.os.VibrationAttributes
import android.os.VibrationEffect
import android.os.VibratorManager
import android.widget.Button import android.widget.Button
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.ScrollView import android.widget.ScrollView
@@ -72,7 +69,6 @@ class MainActivity : AppCompatActivity() {
"chronometer" -> postChronometer() "chronometer" -> postChronometer()
"timeout" -> postTimeout() "timeout" -> postTimeout()
"group" -> postGroup() "group" -> postGroup()
"direct_vibration" -> directNotificationVibration()
"cancel_text" -> manager.cancel(TEXT_ID) "cancel_text" -> manager.cancel(TEXT_ID)
"cancel_all" -> manager.cancelAll() "cancel_all" -> manager.cancelAll()
} }
@@ -125,13 +121,6 @@ class MainActivity : AppCompatActivity() {
manager.notify(GROUP_SUMMARY, base().setContentTitle("Grouped summary").setGroup(GROUP_KEY).setGroupSummary(true).build()) manager.notify(GROUP_SUMMARY, base().setContentTitle("Grouped summary").setGroup(GROUP_KEY).setGroupSummary(true).build())
} }
private fun directNotificationVibration() {
getSystemService(VibratorManager::class.java).defaultVibrator.vibrate(
VibrationEffect.createWaveform(longArrayOf(0, 200), -1),
VibrationAttributes.Builder().setUsage(VibrationAttributes.USAGE_NOTIFICATION).build(),
)
}
private companion object { private companion object {
const val CHANNEL = "test" const val CHANNEL = "test"
const val EXTRA_ACTION = "helper_action" const val EXTRA_ACTION = "helper_action"
@@ -156,7 +145,6 @@ class MainActivity : AppCompatActivity() {
"Post very long text" to "very_long_text", "Post very long text" to "very_long_text",
"Post inbox" to "inbox", "Post progress" to "progress", "Update progress" to "progress_update", "Post inbox" to "inbox", "Post progress" to "progress", "Update progress" to "progress_update",
"Post chronometer" to "chronometer", "Post timeout" to "timeout", "Post group" to "group", "Post chronometer" to "chronometer", "Post timeout" to "timeout", "Post group" to "group",
"Direct notification vibration" to "direct_vibration",
"Cancel text" to "cancel_text", "Cancel all" to "cancel_all", "Cancel text" to "cancel_text", "Cancel all" to "cancel_all",
) )
} }