Control direct notification vibrations
This commit is contained in:
+11
@@ -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 }
|
||||||
|
|||||||
+23
-1
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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,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",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user