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
source app's normal alert. With a compatible LSPosed setup enabled, Notifications
Master can suppress a matched standard notification alert while leaving the
notification itself visible. Experimental per-app control is also available for
direct vibrations that are explicitly marked by Android as notification usage.
It deliberately does not touch media, alarms, ringtones, touch feedback, or
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.
notification itself visible. It only evaluates actual posted or updated
notifications; standalone sounds and vibrations remain under the source app's
control.
To use the optional integration, enable Notifications Master as an LSPosed
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
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 configuration = AlertConfiguration(
profiles = listOf(profile),
appSettings = listOf(AlertAppSettings("chat.app", directAlertControl = true)),
appSettings = listOf(AlertAppSettings("chat.app")),
rules = listOf(
AlertRule(
"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 configuration = AlertConfiguration(
profiles = listOf(profile),
appSettings = listOf(AlertAppSettings(HELPER_PACKAGE, directAlertControl = true)),
rules = listOf(
AlertRule(
id = "module-helper-post",
@@ -33,15 +32,6 @@ class AlertPolicySyncDeviceTest {
outcome = AlertOutcome.PLAY_PROFILE,
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)
-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"
@@ -21,7 +21,7 @@ class AlertConfigurationJsonTest {
)
val original = AlertConfiguration(
profiles = listOf(profile),
appSettings = listOf(AlertAppSettings("chat.app", true, true)),
appSettings = listOf(AlertAppSettings("chat.app", true)),
rules = listOf(rule),
queueSettings = AlertQueueSettings(7, AlertQueueOverflow.DROP_NEWEST),
)
@@ -1,7 +1,6 @@
package se.ajpanton.notificationsmaster.alerts
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -28,46 +27,11 @@ class AlertRuleEvaluatorTest {
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`() {
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 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 })
assertTrue(direct.decisions.isEmpty())
assertTrue(direct.silenceUnmatchedDirectAlert)
}
@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">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<application android:theme="@style/Theme.AppCompat.DayNight">
<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.RingtoneManager
import android.os.Bundle
import android.os.VibrationAttributes
import android.os.VibrationEffect
import android.os.VibratorManager
import android.widget.Button
import android.widget.LinearLayout
import android.widget.ScrollView
@@ -72,7 +69,6 @@ class MainActivity : AppCompatActivity() {
"chronometer" -> postChronometer()
"timeout" -> postTimeout()
"group" -> postGroup()
"direct_vibration" -> directNotificationVibration()
"cancel_text" -> manager.cancel(TEXT_ID)
"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())
}
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 {
const val CHANNEL = "test"
const val EXTRA_ACTION = "helper_action"
@@ -156,7 +145,6 @@ class MainActivity : AppCompatActivity() {
"Post very long text" to "very_long_text",
"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",
"Direct notification vibration" to "direct_vibration",
"Cancel text" to "cancel_text", "Cancel all" to "cancel_all",
)
}