From a17d5651c807645787867432f5b358019233f10d Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Tue, 18 Aug 2026 16:32:31 +0000 Subject: [PATCH] Control direct notification vibrations --- .../module/AlertPolicySyncDeviceTest.kt | 11 ++++ .../notificationsmaster/AlertRuleDialog.kt | 7 ++- .../notificationsmaster/AppRulesFragment.kt | 16 ++++- .../notificationsmaster/AppsFragment.kt | 8 ++- .../notificationsmaster/RulesFragment.kt | 4 +- .../notificationsmaster/alerts/AlertModels.kt | 12 ++++ .../capture/NotificationCaptureService.kt | 24 +++++++- .../module/AlertPolicySync.kt | 6 +- .../module/DirectAlertSync.kt | 44 ++++++++++++++ .../module/DirectVibrationBridge.kt | 58 +++++++++++++++++++ .../module/NotificationsMasterModule.kt | 3 + .../module/SystemAlertPolicyCache.kt | 17 +++++- ...NotificationListenerComponentController.kt | 2 +- .../main/res/layout/fragment_app_rules.xml | 12 ++++ .../alerts/AlertRuleEvaluatorTest.kt | 2 + test-helper/src/main/AndroidManifest.xml | 1 + .../helper/MainActivity.kt | 12 ++++ 17 files changed, 224 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/DirectAlertSync.kt create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/DirectVibrationBridge.kt diff --git a/app/src/androidTest/java/se/ajpanton/notificationsmaster/module/AlertPolicySyncDeviceTest.kt b/app/src/androidTest/java/se/ajpanton/notificationsmaster/module/AlertPolicySyncDeviceTest.kt index 80155f4..4da5e1d 100644 --- a/app/src/androidTest/java/se/ajpanton/notificationsmaster/module/AlertPolicySyncDeviceTest.kt +++ b/app/src/androidTest/java/se/ajpanton/notificationsmaster/module/AlertPolicySyncDeviceTest.kt @@ -6,6 +6,7 @@ import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith import se.ajpanton.notificationsmaster.alerts.AlertConfiguration +import se.ajpanton.notificationsmaster.alerts.AlertAppSettings import se.ajpanton.notificationsmaster.alerts.AlertOutcome import se.ajpanton.notificationsmaster.alerts.AlertProfile import se.ajpanton.notificationsmaster.alerts.AlertRule @@ -21,6 +22,7 @@ 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", @@ -31,6 +33,15 @@ 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) diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/AlertRuleDialog.kt b/app/src/main/java/se/ajpanton/notificationsmaster/AlertRuleDialog.kt index 3c7846e..07b1e0b 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/AlertRuleDialog.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/AlertRuleDialog.kt @@ -62,6 +62,10 @@ 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 } @@ -69,7 +73,7 @@ object AlertRuleDialog { text = "Allow during DND (requires system support)" 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 { 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 @@ -92,6 +96,7 @@ 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 } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/AppRulesFragment.kt b/app/src/main/java/se/ajpanton/notificationsmaster/AppRulesFragment.kt index ef078a4..f52b267 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/AppRulesFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/AppRulesFragment.kt @@ -31,6 +31,8 @@ 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) @@ -70,8 +72,18 @@ class AppRulesFragment : Fragment(R.layout.fragment_app_rules) { val configuration = store.load() val old = configuration.appSettings.firstOrNull { it.packageName == packageName } 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 } + - 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() { @@ -85,7 +97,7 @@ class AppRulesFragment : Fragment(R.layout.fragment_app_rules) { }) override fun onBindViewHolder(holder: Holder, position: Int) { 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) } } override fun getItemCount() = items.size diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/AppsFragment.kt b/app/src/main/java/se/ajpanton/notificationsmaster/AppsFragment.kt index f5b70d1..14bcb9f 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/AppsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/AppsFragment.kt @@ -27,11 +27,13 @@ class AppsFragment : Fragment(R.layout.fragment_apps) { private fun refresh() { val container = binding?.apps ?: return 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() - 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 { - 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" isAllCaps = false setOnClickListener { open(packageName) } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/RulesFragment.kt b/app/src/main/java/se/ajpanton/notificationsmaster/RulesFragment.kt index 7636d4d..44124bd 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/RulesFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/RulesFragment.kt @@ -30,7 +30,7 @@ class RulesFragment : Fragment(R.layout.fragment_rules) { AlertRuleEditor.consolidate(configuration.rules).forEach { rule -> binding!!.rules.addView(Button(requireContext()).apply { 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 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) { AlertOutcome.PLAY_PROFILE -> configuration.profiles.firstOrNull { it.id == profileId }?.name ?: "Missing profile" AlertOutcome.SILENCE_ORIGINAL -> "Silence original alert" diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertModels.kt b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertModels.kt index e0d3e3c..20d3dc0 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertModels.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertModels.kt @@ -10,6 +10,13 @@ enum class AlertSource { ; 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 } @@ -136,6 +143,8 @@ data class AlertConfiguration( } val hasEnabledRules get() = enabled && rules.any { it.enabled } + val hasDirectAlertControl get() = enabled && appSettings.any { it.directAlertControl } + val needsListener get() = hasEnabledRules || hasDirectAlertControl } data class AlertEvent( @@ -174,3 +183,6 @@ data class AlertEvaluation( val decisions: List, val silenceUnmatchedDirectAlert: Boolean, ) + +fun AlertEvaluation.suppressesOriginal() = + silenceUnmatchedDirectAlert || decisions.any { it.outcome != AlertOutcome.PASS_THROUGH } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/capture/NotificationCaptureService.kt b/app/src/main/java/se/ajpanton/notificationsmaster/capture/NotificationCaptureService.kt index 885d22a..2cace8c 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/capture/NotificationCaptureService.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/capture/NotificationCaptureService.kt @@ -1,6 +1,9 @@ package se.ajpanton.notificationsmaster.capture import android.app.Notification +import android.content.BroadcastReceiver +import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager import android.service.notification.NotificationListenerService import android.service.notification.StatusBarNotification @@ -13,6 +16,7 @@ import se.ajpanton.notificationsmaster.alerts.AlertQueueSettings import se.ajpanton.notificationsmaster.alerts.AlertSource import se.ajpanton.notificationsmaster.alerts.AndroidAlertEffectPlayer import se.ajpanton.notificationsmaster.module.AlertPolicySync +import se.ajpanton.notificationsmaster.module.DirectAlertSync import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore import se.ajpanton.notificationsmaster.data.EncryptedImageStore import se.ajpanton.notificationsmaster.model.NotificationAction @@ -25,6 +29,7 @@ import se.ajpanton.notificationsmaster.settings.AppFilterSettingsStore import se.ajpanton.notificationsmaster.settings.PerAppEventSettingsStore import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import java.util.UUID class NotificationCaptureService : NotificationListenerService() { private val activeNotifications = mutableMapOf() @@ -38,6 +43,21 @@ class NotificationCaptureService : NotificationListenerService() { private lateinit var alertConfigurationStore: AlertConfigurationStore private lateinit var alertPlayback: AlertPlaybackController private var alertQueueSettings = AlertQueueSettings() + private val directAlertToken = UUID.randomUUID().toString() + private val directAlertReceiver = object : BroadcastReceiver() { + override fun onReceive(context: android.content.Context, intent: Intent) { + if (intent.action != DirectAlertSync.ACTION) return + val request = DirectAlertSync.snapshot(intent, directAlertToken) + if (request == null) { + Log.w(TAG, "Rejected malformed direct alert request") + return + } + request.let { (ruleId, snapshot) -> + Log.i(TAG, "Playing matched direct notification vibration") + alertPlayback.enqueue(se.ajpanton.notificationsmaster.alerts.QueuedAlert(ruleId, snapshot)) + } + } + } override fun onCreate() { super.onCreate() @@ -49,6 +69,7 @@ class NotificationCaptureService : NotificationListenerService() { imageStore = EncryptedImageStore(this) alertConfigurationStore = AlertConfigurationStore(this) alertPlayback = AlertPlaybackController(AndroidAlertEffectPlayer(this)) { alertQueueSettings } + registerReceiver(directAlertReceiver, IntentFilter(DirectAlertSync.ACTION), null, null, RECEIVER_EXPORTED) writeExecutor = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "notification-log-writer") } @@ -56,7 +77,7 @@ class NotificationCaptureService : NotificationListenerService() { override fun onListenerConnected() { super.onListenerConnected() - AlertPolicySync.publishPlaybackState(this, true) + AlertPolicySync.publishPlaybackState(this, true, directAlertToken) getActiveNotifications()?.forEach { sbn -> val snapshot = NotificationContents.snapshot(sbn) SeenApps.markSeen(snapshot.packageName) @@ -115,6 +136,7 @@ class NotificationCaptureService : NotificationListenerService() { override fun onDestroy() { AlertPolicySync.publishPlaybackState(this, false) alertPlayback.stop() + unregisterReceiver(directAlertReceiver) writeExecutor.shutdown() super.onDestroy() } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicySync.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicySync.kt index 214c6d8..6e02d92 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicySync.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicySync.kt @@ -17,6 +17,7 @@ internal object AlertPolicySync { const val PLAYBACK_STATE_ACTION = "se.ajpanton.notificationsmaster.UPDATE_ALERT_PLAYBACK_STATE" const val EXTRA_CONFIGURATION = "configuration" const val EXTRA_PLAYBACK_READY = "playback_ready" + const val EXTRA_DIRECT_ALERT_TOKEN = "direct_alert_token" const val PERMISSION = "se.ajpanton.notificationsmaster.permission.UPDATE_ALERT_POLICY" private const val SYSTEM_PACKAGE = "android" private const val MAX_CONFIGURATION_BYTES = 64 * 1024 @@ -35,11 +36,12 @@ internal object AlertPolicySync { Log.i(TAG, "Published alert policy snapshot") } - fun publishPlaybackState(context: Context, ready: Boolean) { + fun publishPlaybackState(context: Context, ready: Boolean, directAlertToken: String? = null) { context.sendBroadcast( Intent(PLAYBACK_STATE_ACTION) .setPackage(SYSTEM_PACKAGE) - .putExtra(EXTRA_PLAYBACK_READY, ready), + .putExtra(EXTRA_PLAYBACK_READY, ready) + .putExtra(EXTRA_DIRECT_ALERT_TOKEN, directAlertToken), ) } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/DirectAlertSync.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/DirectAlertSync.kt new file mode 100644 index 0000000..21aceca --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/DirectAlertSync.kt @@ -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? = 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() +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/DirectVibrationBridge.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/DirectVibrationBridge.kt new file mode 100644 index 0000000..dfaf0de --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/DirectVibrationBridge.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt index ea27761..81449e8 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt @@ -17,6 +17,9 @@ 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) } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/SystemAlertPolicyCache.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/SystemAlertPolicyCache.kt index 8954b89..43d4ebb 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/SystemAlertPolicyCache.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/SystemAlertPolicyCache.kt @@ -12,7 +12,7 @@ import se.ajpanton.notificationsmaster.alerts.AlertConfigurationJson import se.ajpanton.notificationsmaster.alerts.AlertEvaluation import se.ajpanton.notificationsmaster.alerts.AlertPolicyEvaluator 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. @@ -26,6 +26,8 @@ internal object SystemAlertPolicyCache { private var configuration = AlertConfiguration(enabled = false) @Volatile private var playbackReady = false + @Volatile + private var directAlertToken: String? = null private var installed = false private var attempts = 0 @@ -63,7 +65,16 @@ internal object SystemAlertPolicyCache { AlertPolicyEvaluator.evaluate(configuration, event, isRoutineUpdate = false) 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 { val activityThread = Class.forName("android.app.ActivityThread") @@ -84,6 +95,8 @@ internal object SystemAlertPolicyCache { override fun onReceive(context: Context, intent: Intent) { if (intent.action == AlertPolicySync.PLAYBACK_STATE_ACTION) { playbackReady = intent.getBooleanExtra(AlertPolicySync.EXTRA_PLAYBACK_READY, false) + directAlertToken = intent.getStringExtra(AlertPolicySync.EXTRA_DIRECT_ALERT_TOKEN) + ?.takeIf { playbackReady } Log.i(TAG, "Alert playback listener ready=$playbackReady") return } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/settings/NotificationListenerComponentController.kt b/app/src/main/java/se/ajpanton/notificationsmaster/settings/NotificationListenerComponentController.kt index dff6e14..ad63acb 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/settings/NotificationListenerComponentController.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/settings/NotificationListenerComponentController.kt @@ -22,7 +22,7 @@ internal object NotificationListenerComponentController { applicationContext, LoggingType.entries.associateWith(rules::ruleFor), PerAppEventSettingsStore(applicationContext).hasEnabledEventOverride(), - AlertConfigurationStore(applicationContext).load().hasEnabledRules, + AlertConfigurationStore(applicationContext).load().needsListener, ) } diff --git a/app/src/main/res/layout/fragment_app_rules.xml b/app/src/main/res/layout/fragment_app_rules.xml index 9ffb2eb..03002fa 100644 --- a/app/src/main/res/layout/fragment_app_rules.xml +++ b/app/src/main/res/layout/fragment_app_rules.xml @@ -25,6 +25,18 @@ android:layout_marginTop="8dp" android:text="Allow multiple matching rules" /> + + + + + diff --git a/test-helper/src/main/java/se/ajpanton/notificationsmaster/helper/MainActivity.kt b/test-helper/src/main/java/se/ajpanton/notificationsmaster/helper/MainActivity.kt index 43185bd..e293468 100644 --- a/test-helper/src/main/java/se/ajpanton/notificationsmaster/helper/MainActivity.kt +++ b/test-helper/src/main/java/se/ajpanton/notificationsmaster/helper/MainActivity.kt @@ -9,6 +9,9 @@ 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 @@ -69,6 +72,7 @@ class MainActivity : AppCompatActivity() { "chronometer" -> postChronometer() "timeout" -> postTimeout() "group" -> postGroup() + "direct_vibration" -> directNotificationVibration() "cancel_text" -> manager.cancel(TEXT_ID) "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()) } + 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" @@ -145,6 +156,7 @@ 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", ) }