Control direct notification vibrations

This commit is contained in:
ajp_anton
2026-08-18 16:32:31 +00:00
parent 8b66456d8c
commit a17d5651c8
17 changed files with 224 additions and 15 deletions
@@ -6,6 +6,7 @@ import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import se.ajpanton.notificationsmaster.alerts.AlertConfiguration import se.ajpanton.notificationsmaster.alerts.AlertConfiguration
import se.ajpanton.notificationsmaster.alerts.AlertAppSettings
import se.ajpanton.notificationsmaster.alerts.AlertOutcome import se.ajpanton.notificationsmaster.alerts.AlertOutcome
import se.ajpanton.notificationsmaster.alerts.AlertProfile import se.ajpanton.notificationsmaster.alerts.AlertProfile
import se.ajpanton.notificationsmaster.alerts.AlertRule import se.ajpanton.notificationsmaster.alerts.AlertRule
@@ -21,6 +22,7 @@ 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",
@@ -31,6 +33,15 @@ 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)
@@ -62,6 +62,10 @@ 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 }
@@ -69,7 +73,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, pattern, regex, protected, dnd).forEach(form::addView) listOf(outcome, profile, post, update, directVibration, 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
@@ -92,6 +96,7 @@ 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,6 +31,8 @@ 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)
@@ -70,8 +72,18 @@ class AppRulesFragment : Fragment(R.layout.fragment_app_rules) {
val configuration = store.load() val configuration = store.load()
val old = configuration.appSettings.firstOrNull { it.packageName == packageName } val old = configuration.appSettings.firstOrNull { it.packageName == packageName }
val setting = (old ?: AlertAppSettings(packageName)).copy(allowMultipleMatches = enabled) val setting = (old ?: AlertAppSettings(packageName)).copy(allowMultipleMatches = enabled)
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 } + store.save(configuration.copy(appSettings = configuration.appSettings.filterNot { it.packageName == packageName } +
if (enabled || setting.directAlertControl) listOf(setting) else emptyList())) if (setting.allowMultipleMatches || setting.directAlertControl) 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>() {
@@ -85,7 +97,7 @@ class AppRulesFragment : Fragment(R.layout.fragment_app_rules) {
}) })
override fun onBindViewHolder(holder: Holder, position: Int) { override fun onBindViewHolder(holder: Holder, position: Int) {
val rule = items[position] val rule = items[position]
holder.text.text = rule.name ?: "Rule ${position + 1}" + "\n" + rule.sources.joinToString { if (it.name == "NOTIFICATION_POST") "Appearing" else "Edits" } + rule.matcher.value.takeIf { it.isNotBlank() }?.let { " • contains $it" }.orEmpty() holder.text.text = rule.name ?: "Rule ${position + 1}" + "\n" + rule.sources.joinToString { it.label() } + rule.matcher.value.takeIf { it.isNotBlank() }?.let { " • contains $it" }.orEmpty()
holder.text.setOnClickListener { onClick(rule) } holder.text.setOnClickListener { onClick(rule) }
} }
override fun getItemCount() = items.size override fun getItemCount() = items.size
@@ -27,11 +27,13 @@ class AppsFragment : Fragment(R.layout.fragment_apps) {
private fun refresh() { private fun refresh() {
val container = binding?.apps ?: return val container = binding?.apps ?: return
val context = requireContext() val context = requireContext()
val grouped = AlertConfigurationStore(context).load().rules.groupBy { it.packageName } val configuration = AlertConfigurationStore(context).load()
val grouped = configuration.rules.groupBy { it.packageName }
val packages = grouped.keys + configuration.appSettings.map { it.packageName }
container.removeAllViews() container.removeAllViews()
grouped.keys.sortedBy { InstalledApps.name(context, it).lowercase() }.forEach { packageName -> packages.distinct().sortedBy { InstalledApps.name(context, it).lowercase() }.forEach { packageName ->
container.addView(Button(context).apply { container.addView(Button(context).apply {
val count = grouped.getValue(packageName).size val count = grouped[packageName].orEmpty().size
text = InstalledApps.name(context, packageName) + "\n" + packageName + "" + count + if (count == 1) " rule" else " rules" text = InstalledApps.name(context, packageName) + "\n" + packageName + "" + count + if (count == 1) " rule" else " rules"
isAllCaps = false isAllCaps = false
setOnClickListener { open(packageName) } setOnClickListener { open(packageName) }
@@ -30,7 +30,7 @@ class RulesFragment : Fragment(R.layout.fragment_rules) {
AlertRuleEditor.consolidate(configuration.rules).forEach { rule -> AlertRuleEditor.consolidate(configuration.rules).forEach { rule ->
binding!!.rules.addView(Button(requireContext()).apply { binding!!.rules.addView(Button(requireContext()).apply {
text = rule.definition.outcomeLabel(configuration) + "\n" + text = rule.definition.outcomeLabel(configuration) + "\n" +
rule.packageNames.joinToString() + "" + rule.definition.sources.joinToString { it.shortName() } rule.packageNames.joinToString() + "" + rule.definition.sources.joinToString { it.label() }
isAllCaps = false isAllCaps = false
setOnClickListener { showEditor(rule.ruleIds) } setOnClickListener { showEditor(rule.ruleIds) }
}) })
@@ -55,8 +55,6 @@ class RulesFragment : Fragment(R.layout.fragment_rules) {
) )
} }
private fun AlertSource.shortName() = if (this == AlertSource.NOTIFICATION_POST) "Appearing" else "Edits"
private fun AlertRuleDefinition.outcomeLabel(configuration: se.ajpanton.notificationsmaster.alerts.AlertConfiguration) = when (outcome) { private fun AlertRuleDefinition.outcomeLabel(configuration: se.ajpanton.notificationsmaster.alerts.AlertConfiguration) = when (outcome) {
AlertOutcome.PLAY_PROFILE -> configuration.profiles.firstOrNull { it.id == profileId }?.name ?: "Missing profile" AlertOutcome.PLAY_PROFILE -> configuration.profiles.firstOrNull { it.id == profileId }?.name ?: "Missing profile"
AlertOutcome.SILENCE_ORIGINAL -> "Silence original alert" AlertOutcome.SILENCE_ORIGINAL -> "Silence original alert"
@@ -10,6 +10,13 @@ enum class AlertSource {
; ;
val isDirect get() = this == DIRECT_NOTIFICATION_SOUND || this == 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"
}
} }
enum class AlertOutcome { PLAY_PROFILE, SILENCE_ORIGINAL, PASS_THROUGH } enum class AlertOutcome { PLAY_PROFILE, SILENCE_ORIGINAL, PASS_THROUGH }
@@ -136,6 +143,8 @@ 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 || hasDirectAlertControl
} }
data class AlertEvent( data class AlertEvent(
@@ -174,3 +183,6 @@ data class AlertEvaluation(
val decisions: List<AlertDecision>, val decisions: List<AlertDecision>,
val silenceUnmatchedDirectAlert: Boolean, val silenceUnmatchedDirectAlert: Boolean,
) )
fun AlertEvaluation.suppressesOriginal() =
silenceUnmatchedDirectAlert || decisions.any { it.outcome != AlertOutcome.PASS_THROUGH }
@@ -1,6 +1,9 @@
package se.ajpanton.notificationsmaster.capture package se.ajpanton.notificationsmaster.capture
import android.app.Notification import android.app.Notification
import android.content.BroadcastReceiver
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.service.notification.NotificationListenerService import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification import android.service.notification.StatusBarNotification
@@ -13,6 +16,7 @@ import se.ajpanton.notificationsmaster.alerts.AlertQueueSettings
import se.ajpanton.notificationsmaster.alerts.AlertSource import se.ajpanton.notificationsmaster.alerts.AlertSource
import se.ajpanton.notificationsmaster.alerts.AndroidAlertEffectPlayer import se.ajpanton.notificationsmaster.alerts.AndroidAlertEffectPlayer
import se.ajpanton.notificationsmaster.module.AlertPolicySync import se.ajpanton.notificationsmaster.module.AlertPolicySync
import se.ajpanton.notificationsmaster.module.DirectAlertSync
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
import se.ajpanton.notificationsmaster.data.EncryptedImageStore import se.ajpanton.notificationsmaster.data.EncryptedImageStore
import se.ajpanton.notificationsmaster.model.NotificationAction import se.ajpanton.notificationsmaster.model.NotificationAction
@@ -25,6 +29,7 @@ import se.ajpanton.notificationsmaster.settings.AppFilterSettingsStore
import se.ajpanton.notificationsmaster.settings.PerAppEventSettingsStore import se.ajpanton.notificationsmaster.settings.PerAppEventSettingsStore
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.UUID
class NotificationCaptureService : NotificationListenerService() { class NotificationCaptureService : NotificationListenerService() {
private val activeNotifications = mutableMapOf<String, NotificationSnapshot>() private val activeNotifications = mutableMapOf<String, NotificationSnapshot>()
@@ -38,6 +43,21 @@ class NotificationCaptureService : NotificationListenerService() {
private lateinit var alertConfigurationStore: AlertConfigurationStore private lateinit var alertConfigurationStore: AlertConfigurationStore
private lateinit var alertPlayback: AlertPlaybackController private lateinit var alertPlayback: AlertPlaybackController
private var alertQueueSettings = AlertQueueSettings() 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() { override fun onCreate() {
super.onCreate() super.onCreate()
@@ -49,6 +69,7 @@ class NotificationCaptureService : NotificationListenerService() {
imageStore = EncryptedImageStore(this) imageStore = EncryptedImageStore(this)
alertConfigurationStore = AlertConfigurationStore(this) alertConfigurationStore = AlertConfigurationStore(this)
alertPlayback = AlertPlaybackController(AndroidAlertEffectPlayer(this)) { alertQueueSettings } alertPlayback = AlertPlaybackController(AndroidAlertEffectPlayer(this)) { alertQueueSettings }
registerReceiver(directAlertReceiver, IntentFilter(DirectAlertSync.ACTION), null, null, RECEIVER_EXPORTED)
writeExecutor = Executors.newSingleThreadExecutor { runnable -> writeExecutor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "notification-log-writer") Thread(runnable, "notification-log-writer")
} }
@@ -56,7 +77,7 @@ class NotificationCaptureService : NotificationListenerService() {
override fun onListenerConnected() { override fun onListenerConnected() {
super.onListenerConnected() super.onListenerConnected()
AlertPolicySync.publishPlaybackState(this, true) AlertPolicySync.publishPlaybackState(this, true, directAlertToken)
getActiveNotifications()?.forEach { sbn -> getActiveNotifications()?.forEach { sbn ->
val snapshot = NotificationContents.snapshot(sbn) val snapshot = NotificationContents.snapshot(sbn)
SeenApps.markSeen(snapshot.packageName) SeenApps.markSeen(snapshot.packageName)
@@ -115,6 +136,7 @@ class NotificationCaptureService : NotificationListenerService() {
override fun onDestroy() { override fun onDestroy() {
AlertPolicySync.publishPlaybackState(this, false) AlertPolicySync.publishPlaybackState(this, false)
alertPlayback.stop() alertPlayback.stop()
unregisterReceiver(directAlertReceiver)
writeExecutor.shutdown() writeExecutor.shutdown()
super.onDestroy() super.onDestroy()
} }
@@ -17,6 +17,7 @@ internal object AlertPolicySync {
const val PLAYBACK_STATE_ACTION = "se.ajpanton.notificationsmaster.UPDATE_ALERT_PLAYBACK_STATE" const val PLAYBACK_STATE_ACTION = "se.ajpanton.notificationsmaster.UPDATE_ALERT_PLAYBACK_STATE"
const val EXTRA_CONFIGURATION = "configuration" const val EXTRA_CONFIGURATION = "configuration"
const val EXTRA_PLAYBACK_READY = "playback_ready" 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" const val PERMISSION = "se.ajpanton.notificationsmaster.permission.UPDATE_ALERT_POLICY"
private const val SYSTEM_PACKAGE = "android" private const val SYSTEM_PACKAGE = "android"
private const val MAX_CONFIGURATION_BYTES = 64 * 1024 private const val MAX_CONFIGURATION_BYTES = 64 * 1024
@@ -35,11 +36,12 @@ internal object AlertPolicySync {
Log.i(TAG, "Published alert policy snapshot") Log.i(TAG, "Published alert policy snapshot")
} }
fun publishPlaybackState(context: Context, ready: Boolean) { fun publishPlaybackState(context: Context, ready: Boolean, directAlertToken: String? = null) {
context.sendBroadcast( context.sendBroadcast(
Intent(PLAYBACK_STATE_ACTION) Intent(PLAYBACK_STATE_ACTION)
.setPackage(SYSTEM_PACKAGE) .setPackage(SYSTEM_PACKAGE)
.putExtra(EXTRA_PLAYBACK_READY, ready), .putExtra(EXTRA_PLAYBACK_READY, ready)
.putExtra(EXTRA_DIRECT_ALERT_TOKEN, directAlertToken),
) )
} }
@@ -0,0 +1,44 @@
package se.ajpanton.notificationsmaster.module
import android.content.Context
import android.content.Intent
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. */
internal object DirectAlertSync {
const val ACTION = "se.ajpanton.notificationsmaster.PLAY_DIRECT_ALERT"
private const val PACKAGE = "se.ajpanton.notificationsmaster"
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 {
val snapshot = decision.snapshot() ?: return false
context.sendBroadcast(Intent(ACTION).setPackage(PACKAGE)
.putExtra(EXTRA_RULE_ID, decision.ruleId)
.putExtra(EXTRA_SOUND_URI, snapshot.soundUri)
.putExtra(EXTRA_VIBRATION, snapshot.vibrationPattern.toLongArray())
.putExtra(EXTRA_PROTECTED, snapshot.playToCompletion)
.putExtra(EXTRA_ALLOW_DND, snapshot.allowDuringDnd)
.putExtra(EXTRA_TOKEN, token))
return true
}
fun snapshot(intent: Intent, token: String): Pair<String, AlertSnapshot>? = runCatching {
require(intent.getStringExtra(EXTRA_TOKEN) == token)
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()
}
@@ -0,0 +1,58 @@
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 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)
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 ATTRIBUTES_INDEX = 4
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,6 +17,9 @@ 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)
} }
@@ -12,7 +12,7 @@ import se.ajpanton.notificationsmaster.alerts.AlertConfigurationJson
import se.ajpanton.notificationsmaster.alerts.AlertEvaluation import se.ajpanton.notificationsmaster.alerts.AlertEvaluation
import se.ajpanton.notificationsmaster.alerts.AlertPolicyEvaluator import se.ajpanton.notificationsmaster.alerts.AlertPolicyEvaluator
import se.ajpanton.notificationsmaster.alerts.AlertEvent import se.ajpanton.notificationsmaster.alerts.AlertEvent
import se.ajpanton.notificationsmaster.alerts.AlertOutcome import se.ajpanton.notificationsmaster.alerts.suppressesOriginal
/** /**
* A system-server-local, read-only copy of the app's alert rules. * A system-server-local, read-only copy of the app's alert rules.
@@ -26,6 +26,8 @@ internal object SystemAlertPolicyCache {
private var configuration = AlertConfiguration(enabled = false) private var configuration = AlertConfiguration(enabled = false)
@Volatile @Volatile
private var playbackReady = false private var playbackReady = false
@Volatile
private var directAlertToken: String? = null
private var installed = false private var installed = false
private var attempts = 0 private var attempts = 0
@@ -63,7 +65,16 @@ internal object SystemAlertPolicyCache {
AlertPolicyEvaluator.evaluate(configuration, event, isRoutineUpdate = false) AlertPolicyEvaluator.evaluate(configuration, event, isRoutineUpdate = false)
fun shouldSuppress(event: AlertEvent): Boolean = playbackReady && fun shouldSuppress(event: AlertEvent): Boolean = playbackReady &&
evaluate(event).decisions.any { it.outcome != AlertOutcome.PASS_THROUGH } (!event.source.isDirect || directAlertToken != null) &&
evaluate(event).suppressesOriginal()
fun sendDirectAlerts(event: AlertEvent) {
val context = systemContext() ?: return
val token = directAlertToken ?: return
val decisions = evaluate(event).decisions
val sent = decisions.count { DirectAlertSync.send(context, it, token) }
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")
@@ -84,6 +95,8 @@ internal object SystemAlertPolicyCache {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AlertPolicySync.PLAYBACK_STATE_ACTION) { if (intent.action == AlertPolicySync.PLAYBACK_STATE_ACTION) {
playbackReady = intent.getBooleanExtra(AlertPolicySync.EXTRA_PLAYBACK_READY, false) 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") Log.i(TAG, "Alert playback listener ready=$playbackReady")
return return
} }
@@ -22,7 +22,7 @@ internal object NotificationListenerComponentController {
applicationContext, applicationContext,
LoggingType.entries.associateWith(rules::ruleFor), LoggingType.entries.associateWith(rules::ruleFor),
PerAppEventSettingsStore(applicationContext).hasEnabledEventOverride(), PerAppEventSettingsStore(applicationContext).hasEnabledEventOverride(),
AlertConfigurationStore(applicationContext).load().hasEnabledRules, AlertConfigurationStore(applicationContext).load().needsListener,
) )
} }
@@ -25,6 +25,18 @@
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"
@@ -39,6 +39,7 @@ class AlertRuleEvaluatorTest {
assertTrue(result.decisions.isEmpty()) assertTrue(result.decisions.isEmpty())
assertTrue(result.silenceUnmatchedDirectAlert) assertTrue(result.silenceUnmatchedDirectAlert)
assertTrue(result.suppressesOriginal())
} }
@Test fun `direct notification alert is untouched without opt in`() { @Test fun `direct notification alert is untouched without opt in`() {
@@ -50,6 +51,7 @@ class AlertRuleEvaluatorTest {
) )
assertFalse(result.silenceUnmatchedDirectAlert) assertFalse(result.silenceUnmatchedDirectAlert)
assertFalse(result.suppressesOriginal())
} }
@Test fun `text and regex match only notification contents`() { @Test fun `text and regex match only notification contents`() {
+1
View File
@@ -1,5 +1,6 @@
<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,6 +9,9 @@ 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
@@ -69,6 +72,7 @@ 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()
} }
@@ -121,6 +125,13 @@ 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"
@@ -145,6 +156,7 @@ 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",
) )
} }