Route notification alerts through playback
This commit is contained in:
+50
@@ -0,0 +1,50 @@
|
||||
package se.ajpanton.notificationsmaster.alerts
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
|
||||
import se.ajpanton.notificationsmaster.settings.LoggingRule
|
||||
import se.ajpanton.notificationsmaster.settings.LoggingRuleStore
|
||||
import se.ajpanton.notificationsmaster.settings.LoggingType
|
||||
|
||||
class AlertListenerPolicyDeviceTest {
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val component = ComponentName(context, NotificationCaptureService::class.java)
|
||||
|
||||
@After
|
||||
fun restoreDefaults() {
|
||||
LoggingType.entries.forEach { LoggingRuleStore(context).save(it, LoggingRule()) }
|
||||
AlertConfigurationStore(context).save(AlertConfiguration())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun alertRuleKeepsListenerEnabledWithoutLogging() {
|
||||
LoggingType.entries.forEach { LoggingRuleStore(context).save(it, LoggingRule(enabled = false)) }
|
||||
val profile = AlertProfile("profile", "Profile", vibrationPattern = listOf(0, 10))
|
||||
val configuration = AlertConfiguration(
|
||||
profiles = listOf(profile),
|
||||
rules = listOf(
|
||||
AlertRule(
|
||||
"rule", "example.app", 0, setOf(AlertSource.NOTIFICATION_POST),
|
||||
outcome = AlertOutcome.PLAY_PROFILE, profileId = profile.id,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
AlertConfigurationStore(context).save(configuration)
|
||||
assertEquals(
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
|
||||
context.packageManager.getComponentEnabledSetting(component),
|
||||
)
|
||||
|
||||
AlertConfigurationStore(context).save(AlertConfiguration())
|
||||
assertEquals(
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
|
||||
context.packageManager.getComponentEnabledSetting(component),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import android.app.Application
|
||||
import android.content.pm.PackageManager
|
||||
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
|
||||
import se.ajpanton.notificationsmaster.settings.AppFilterSettingsStore
|
||||
import se.ajpanton.notificationsmaster.settings.LoggingRuleStore
|
||||
import se.ajpanton.notificationsmaster.settings.LoggingType
|
||||
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
|
||||
import se.ajpanton.notificationsmaster.settings.PerAppEventSettingsStore
|
||||
|
||||
@@ -22,12 +20,7 @@ class NotificationLogApplication : Application() {
|
||||
* Reconcile it before the UI starts so fresh default rules can bind again.
|
||||
*/
|
||||
private fun synchronizeListenerComponent() {
|
||||
val rules = LoggingRuleStore(this)
|
||||
NotificationListenerComponentController.update(
|
||||
this,
|
||||
LoggingType.entries.associateWith(rules::ruleFor),
|
||||
PerAppEventSettingsStore(this).hasEnabledEventOverride(),
|
||||
)
|
||||
NotificationListenerComponentController.synchronize(this)
|
||||
}
|
||||
|
||||
private fun removeStaleAppStorage() {
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.json.JSONObject
|
||||
import se.ajpanton.notificationsmaster.data.AesGcmCipher
|
||||
import se.ajpanton.notificationsmaster.data.EncryptedPayload
|
||||
import se.ajpanton.notificationsmaster.data.LogEncryptionKeyProvider
|
||||
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.DataInputStream
|
||||
@@ -16,7 +17,8 @@ import java.io.FileNotFoundException
|
||||
|
||||
/** Atomic, encrypted storage for alert settings. An unsupported format fails closed. */
|
||||
class AlertConfigurationStore(context: Context) {
|
||||
private val file = AtomicFile(File(context.applicationContext.filesDir, FILE_NAME))
|
||||
private val context = context.applicationContext
|
||||
private val file = AtomicFile(File(this.context.filesDir, FILE_NAME))
|
||||
private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate())
|
||||
|
||||
@Synchronized
|
||||
@@ -44,6 +46,7 @@ class AlertConfigurationStore(context: Context) {
|
||||
stream.write(payload.cipherText)
|
||||
stream.flush()
|
||||
file.finishWrite(output)
|
||||
NotificationListenerComponentController.synchronize(context)
|
||||
} catch (error: Exception) {
|
||||
file.failWrite(output)
|
||||
throw error
|
||||
@@ -59,6 +62,8 @@ class AlertConfigurationStore(context: Context) {
|
||||
internal object AlertConfigurationJson {
|
||||
fun encode(configuration: AlertConfiguration): ByteArray = JSONObject()
|
||||
.put("version", 1)
|
||||
.put("enabled", configuration.enabled)
|
||||
.put("ignoreRoutineUpdates", configuration.ignoreRoutineUpdates)
|
||||
.put("profiles", JSONArray().apply { configuration.profiles.forEach { put(it.toJson()) } })
|
||||
.put("apps", JSONArray().apply { configuration.appSettings.forEach { put(it.toJson()) } })
|
||||
.put("rules", JSONArray().apply { configuration.rules.forEach { put(it.toJson()) } })
|
||||
@@ -70,6 +75,8 @@ internal object AlertConfigurationJson {
|
||||
val root = JSONObject(bytes.decodeToString())
|
||||
require(root.getInt("version") == 1) { "Unsupported alert-configuration format." }
|
||||
return AlertConfiguration(
|
||||
enabled = root.getBoolean("enabled"),
|
||||
ignoreRoutineUpdates = root.getBoolean("ignoreRoutineUpdates"),
|
||||
profiles = root.getJSONArray("profiles").map { (it as JSONObject).toProfile() },
|
||||
appSettings = root.getJSONArray("apps").map { (it as JSONObject).toAppSettings() },
|
||||
rules = root.getJSONArray("rules").map { (it as JSONObject).toRule() },
|
||||
|
||||
@@ -119,6 +119,8 @@ data class AlertQueueSettings(
|
||||
}
|
||||
|
||||
data class AlertConfiguration(
|
||||
val enabled: Boolean = true,
|
||||
val ignoreRoutineUpdates: Boolean = true,
|
||||
val profiles: List<AlertProfile> = emptyList(),
|
||||
val appSettings: List<AlertAppSettings> = emptyList(),
|
||||
val rules: List<AlertRule> = emptyList(),
|
||||
@@ -132,6 +134,8 @@ data class AlertConfiguration(
|
||||
rule.profileId in profiles.map { it.id }
|
||||
})
|
||||
}
|
||||
|
||||
val hasEnabledRules get() = enabled && rules.any { it.enabled }
|
||||
}
|
||||
|
||||
data class AlertEvent(
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package se.ajpanton.notificationsmaster.alerts
|
||||
|
||||
/**
|
||||
* Converts a captured notification event into immutable playback work.
|
||||
* Rootless operation can play matched profiles but intentionally cannot
|
||||
* suppress the originating app's native effect.
|
||||
*/
|
||||
object AlertNotificationDispatcher {
|
||||
fun dispatch(
|
||||
configuration: AlertConfiguration,
|
||||
event: AlertEvent,
|
||||
isRoutineUpdate: Boolean,
|
||||
): List<QueuedAlert> {
|
||||
if (!configuration.enabled ||
|
||||
event.source == AlertSource.NOTIFICATION_UPDATE &&
|
||||
isRoutineUpdate &&
|
||||
configuration.ignoreRoutineUpdates
|
||||
) {
|
||||
return emptyList()
|
||||
}
|
||||
val app = configuration.appSettings.firstOrNull { it.packageName == event.packageName }
|
||||
?: AlertAppSettings(event.packageName)
|
||||
return AlertRuleEvaluator.evaluate(
|
||||
app,
|
||||
configuration.rules,
|
||||
configuration.profiles.associateBy { it.id },
|
||||
event,
|
||||
).decisions.mapNotNull { decision ->
|
||||
decision.snapshot()?.let { QueuedAlert(decision.ruleId, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
-12
@@ -5,6 +5,13 @@ import android.content.pm.PackageManager
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import android.util.Log
|
||||
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
|
||||
import se.ajpanton.notificationsmaster.alerts.AlertEvent
|
||||
import se.ajpanton.notificationsmaster.alerts.AlertNotificationDispatcher
|
||||
import se.ajpanton.notificationsmaster.alerts.AlertPlaybackController
|
||||
import se.ajpanton.notificationsmaster.alerts.AlertQueueSettings
|
||||
import se.ajpanton.notificationsmaster.alerts.AlertSource
|
||||
import se.ajpanton.notificationsmaster.alerts.AndroidAlertEffectPlayer
|
||||
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
|
||||
import se.ajpanton.notificationsmaster.data.EncryptedImageStore
|
||||
import se.ajpanton.notificationsmaster.model.NotificationAction
|
||||
@@ -27,6 +34,9 @@ class NotificationCaptureService : NotificationListenerService() {
|
||||
private lateinit var appFilterStore: AppFilterSettingsStore
|
||||
private lateinit var perAppEventSettings: PerAppEventSettingsStore
|
||||
private lateinit var imageStore: EncryptedImageStore
|
||||
private lateinit var alertConfigurationStore: AlertConfigurationStore
|
||||
private lateinit var alertPlayback: AlertPlaybackController
|
||||
private var alertQueueSettings = AlertQueueSettings()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@@ -36,6 +46,8 @@ class NotificationCaptureService : NotificationListenerService() {
|
||||
appFilterStore = AppFilterSettingsStore(this)
|
||||
perAppEventSettings = PerAppEventSettingsStore(this)
|
||||
imageStore = EncryptedImageStore(this)
|
||||
alertConfigurationStore = AlertConfigurationStore(this)
|
||||
alertPlayback = AlertPlaybackController(AndroidAlertEffectPlayer(this)) { alertQueueSettings }
|
||||
writeExecutor = Executors.newSingleThreadExecutor { runnable ->
|
||||
Thread(runnable, "notification-log-writer")
|
||||
}
|
||||
@@ -62,23 +74,29 @@ class NotificationCaptureService : NotificationListenerService() {
|
||||
SeenApps.markSeen(snapshot.packageName)
|
||||
val previous = activeNotifications.put(snapshot.key, snapshot)
|
||||
when {
|
||||
previous == null -> record(
|
||||
snapshot,
|
||||
NotificationAction.APPEARED,
|
||||
LoggingType.APPEARING,
|
||||
includeContents = true,
|
||||
imageBytes = { NotificationContents.extractImageBytes(sbn.notification, this) },
|
||||
)
|
||||
NotificationChangeClassifier.isMeaningfulEdit(previous, snapshot) &&
|
||||
!NotificationChangeClassifier.shouldIgnoreEdit(previous, snapshot, ruleStore.ruleFor(LoggingType.EDITS).ignoreRoutineUpdates) ->
|
||||
previous == null -> {
|
||||
record(
|
||||
snapshot,
|
||||
NotificationAction.EDITED,
|
||||
LoggingType.EDITS,
|
||||
NotificationAction.APPEARED,
|
||||
LoggingType.APPEARING,
|
||||
includeContents = true,
|
||||
previousSnapshot = previous,
|
||||
imageBytes = { NotificationContents.extractImageBytes(sbn.notification, this) },
|
||||
)
|
||||
dispatchAlert(snapshot, AlertSource.NOTIFICATION_POST)
|
||||
}
|
||||
NotificationChangeClassifier.isMeaningfulEdit(previous, snapshot) -> {
|
||||
if (!NotificationChangeClassifier.shouldIgnoreEdit(previous, snapshot, ruleStore.ruleFor(LoggingType.EDITS).ignoreRoutineUpdates)) {
|
||||
record(
|
||||
snapshot,
|
||||
NotificationAction.EDITED,
|
||||
LoggingType.EDITS,
|
||||
includeContents = true,
|
||||
previousSnapshot = previous,
|
||||
imageBytes = { NotificationContents.extractImageBytes(sbn.notification, this) },
|
||||
)
|
||||
}
|
||||
dispatchAlert(snapshot, AlertSource.NOTIFICATION_UPDATE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +111,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
alertPlayback.stop()
|
||||
writeExecutor.shutdown()
|
||||
super.onDestroy()
|
||||
}
|
||||
@@ -160,6 +179,16 @@ class NotificationCaptureService : NotificationListenerService() {
|
||||
return eventEnabled && NotificationRuleEvaluator.allows(appFilterStore.load(), packageName)
|
||||
}
|
||||
|
||||
private fun dispatchAlert(snapshot: NotificationSnapshot, source: AlertSource) {
|
||||
val configuration = alertConfigurationStore.load()
|
||||
alertQueueSettings = configuration.queueSettings
|
||||
AlertNotificationDispatcher.dispatch(
|
||||
configuration,
|
||||
AlertEvent(snapshot.packageName, source, snapshot.alertTitle, snapshot.alertBody, snapshot.alertSender),
|
||||
isRoutineUpdate = source == AlertSource.NOTIFICATION_UPDATE && snapshot.isRoutine,
|
||||
).forEach(alertPlayback::enqueue)
|
||||
}
|
||||
|
||||
private fun appName(packageName: String): String = try {
|
||||
val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
|
||||
packageManager.getApplicationLabel(applicationInfo).toString()
|
||||
|
||||
@@ -19,9 +19,18 @@ data class NotificationSnapshot(
|
||||
val hasImage: Boolean,
|
||||
val isGroupSummary: Boolean,
|
||||
val isRoutine: Boolean,
|
||||
val alertTitle: String? = null,
|
||||
val alertBody: String? = null,
|
||||
val alertSender: String? = null,
|
||||
)
|
||||
|
||||
object NotificationContents {
|
||||
data class AlertTextContents(
|
||||
val title: String?,
|
||||
val body: String?,
|
||||
val sender: String?,
|
||||
)
|
||||
|
||||
fun extract(notification: Notification): String? {
|
||||
val extras = notification.extras ?: Bundle.EMPTY
|
||||
messagingContents(extras)?.let { return it }
|
||||
@@ -69,6 +78,7 @@ object NotificationContents {
|
||||
fun snapshot(sbn: StatusBarNotification): NotificationSnapshot {
|
||||
val notification = sbn.notification
|
||||
val extras = notification.extras
|
||||
val alertContents = alertContents(notification)
|
||||
return NotificationSnapshot(
|
||||
key = sbn.key,
|
||||
packageName = sbn.packageName,
|
||||
@@ -83,9 +93,24 @@ object NotificationContents {
|
||||
it.getInt(Notification.EXTRA_PROGRESS_MAX, 0) > 0 &&
|
||||
it.getInt(Notification.EXTRA_PROGRESS, -1) >= 0
|
||||
} == true,
|
||||
alertTitle = alertContents.title,
|
||||
alertBody = alertContents.body,
|
||||
alertSender = alertContents.sender,
|
||||
)
|
||||
}
|
||||
|
||||
fun alertContents(notification: Notification): AlertTextContents {
|
||||
val extras = notification.extras ?: Bundle.EMPTY
|
||||
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()
|
||||
val body = extract(notification)?.let { contents ->
|
||||
title?.let { contents.removePrefix(it + "\n") }?.ifBlank { null } ?: contents
|
||||
}
|
||||
val sender = Notification.MessagingStyle.Message.getMessagesFromBundleArray(
|
||||
extras.getParcelableArray(Notification.EXTRA_MESSAGES, Bundle::class.java),
|
||||
).lastOrNull()?.senderPerson?.name?.toString()
|
||||
return AlertTextContents(title, body, sender)
|
||||
}
|
||||
|
||||
/** Called only after the event has passed all capture filters. */
|
||||
fun extractImageBytes(notification: Notification, context: Context): ByteArray? {
|
||||
val extras = notification.extras ?: return null
|
||||
|
||||
@@ -28,11 +28,7 @@ class LoggingRuleStore(context: Context) {
|
||||
private fun key(type: LoggingType, suffix: String) = "${type.name.lowercase()}.$suffix"
|
||||
|
||||
private fun updateListenerComponent() {
|
||||
NotificationListenerComponentController.update(
|
||||
appContext,
|
||||
LoggingType.entries.associateWith(::ruleFor),
|
||||
PerAppEventSettingsStore(appContext).hasEnabledEventOverride(),
|
||||
)
|
||||
NotificationListenerComponentController.synchronize(appContext)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
+19
-2
@@ -5,6 +5,7 @@ import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.util.Log
|
||||
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
|
||||
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
|
||||
|
||||
/**
|
||||
@@ -13,11 +14,27 @@ import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
|
||||
* is therefore both the no-work battery mode and the no-start-on-boot mode.
|
||||
*/
|
||||
internal object NotificationListenerComponentController {
|
||||
fun update(context: Context, rules: Map<LoggingType, LoggingRule>, hasEnabledEventOverride: Boolean = false) {
|
||||
fun synchronize(context: Context) {
|
||||
val applicationContext = context.applicationContext
|
||||
val rules = LoggingRuleStore(applicationContext)
|
||||
update(
|
||||
applicationContext,
|
||||
LoggingType.entries.associateWith(rules::ruleFor),
|
||||
PerAppEventSettingsStore(applicationContext).hasEnabledEventOverride(),
|
||||
AlertConfigurationStore(applicationContext).load().hasEnabledRules,
|
||||
)
|
||||
}
|
||||
|
||||
fun update(
|
||||
context: Context,
|
||||
rules: Map<LoggingType, LoggingRule>,
|
||||
hasEnabledEventOverride: Boolean = false,
|
||||
hasEnabledAlertRule: Boolean = false,
|
||||
) {
|
||||
val applicationContext = context.applicationContext
|
||||
val component = ComponentName(applicationContext, NotificationCaptureService::class.java)
|
||||
val packageManager = applicationContext.packageManager
|
||||
val desired = if (NotificationListenerPolicy.shouldRun(rules, hasEnabledEventOverride)) {
|
||||
val desired = if (NotificationListenerPolicy.shouldRun(rules, hasEnabledEventOverride, hasEnabledAlertRule)) {
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
} else {
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||
|
||||
+4
-1
@@ -5,5 +5,8 @@ object NotificationListenerPolicy {
|
||||
fun shouldRun(
|
||||
rules: Map<LoggingType, LoggingRule>,
|
||||
hasEnabledEventOverride: Boolean = false,
|
||||
): Boolean = LoggingType.eventTypes.any { rules.getValue(it).enabled } || hasEnabledEventOverride
|
||||
hasEnabledAlertRule: Boolean = false,
|
||||
): Boolean = LoggingType.eventTypes.any { rules.getValue(it).enabled } ||
|
||||
hasEnabledEventOverride ||
|
||||
hasEnabledAlertRule
|
||||
}
|
||||
|
||||
+1
-2
@@ -52,8 +52,7 @@ class PerAppEventSettingsStore(context: Context) {
|
||||
}
|
||||
|
||||
private fun updateListenerComponent() {
|
||||
val rules = LoggingType.entries.associateWith { LoggingRuleStore(appContext).ruleFor(it) }
|
||||
NotificationListenerComponentController.update(appContext, rules, hasEnabledEventOverride())
|
||||
NotificationListenerComponentController.synchronize(appContext)
|
||||
}
|
||||
|
||||
private fun key(packageName: String) = "$KEY_PREFIX$packageName"
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package se.ajpanton.notificationsmaster.alerts
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AlertNotificationDispatcherTest {
|
||||
private val profile = AlertProfile("profile", "Profile", "content://sound")
|
||||
private val event = AlertEvent("chat.app", AlertSource.NOTIFICATION_POST, body = "hello")
|
||||
private val rule = AlertRule(
|
||||
"rule", "chat.app", 0, setOf(AlertSource.NOTIFICATION_POST),
|
||||
outcome = AlertOutcome.PLAY_PROFILE, profileId = profile.id,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun disabledAlertingProducesNoPlayback() {
|
||||
assertTrue(AlertNotificationDispatcher.dispatch(configuration(enabled = false), event, false).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchedPostProducesSnapshotPlayback() {
|
||||
val queued = AlertNotificationDispatcher.dispatch(configuration(), event, false)
|
||||
|
||||
assertEquals(listOf("rule"), queued.map { it.ruleId })
|
||||
assertEquals(profile.soundUri, queued.single().snapshot.soundUri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun routineUpdateIsIgnoredByDefault() {
|
||||
val updateRule = rule.copy(sources = setOf(AlertSource.NOTIFICATION_UPDATE))
|
||||
val configuration = AlertConfiguration(profiles = listOf(profile), rules = listOf(updateRule))
|
||||
|
||||
assertTrue(
|
||||
AlertNotificationDispatcher.dispatch(
|
||||
configuration,
|
||||
event.copy(source = AlertSource.NOTIFICATION_UPDATE),
|
||||
isRoutineUpdate = true,
|
||||
).isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appCanQueueEveryMatchingRule() {
|
||||
val second = rule.copy(id = "second", order = 1)
|
||||
val configuration = AlertConfiguration(
|
||||
profiles = listOf(profile),
|
||||
appSettings = listOf(AlertAppSettings("chat.app", allowMultipleMatches = true)),
|
||||
rules = listOf(rule, second),
|
||||
)
|
||||
|
||||
assertEquals(listOf("rule", "second"), AlertNotificationDispatcher.dispatch(configuration, event, false).map { it.ruleId })
|
||||
}
|
||||
|
||||
private fun configuration(enabled: Boolean = true) = AlertConfiguration(
|
||||
enabled = enabled,
|
||||
profiles = listOf(profile),
|
||||
rules = listOf(rule),
|
||||
)
|
||||
}
|
||||
@@ -59,7 +59,7 @@ class AlertRuleEditorTest {
|
||||
id, packageName, order, definition.sources, definition.matcher, definition.outcome, definition.profileId,
|
||||
)
|
||||
|
||||
private fun configuration(vararg rules: AlertRule) = AlertConfiguration(listOf(profile), rules = rules.toList())
|
||||
private fun configuration(vararg rules: AlertRule) = AlertConfiguration(profiles = listOf(profile), rules = rules.toList())
|
||||
|
||||
private fun iterOf(vararg values: String) = values.iterator()
|
||||
}
|
||||
|
||||
+5
@@ -26,6 +26,11 @@ class NotificationListenerPolicyTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listenerRunsForEnabledAlertRuleEvenWhenLoggingIsOff() {
|
||||
assertTrue(NotificationListenerPolicy.shouldRun(rules(), hasEnabledAlertRule = true))
|
||||
}
|
||||
|
||||
private fun rules(vararg enabled: Pair<LoggingType, Boolean>) = LoggingType.entries.associateWith { type ->
|
||||
LoggingRule(enabled = enabled.firstOrNull { it.first == type }?.second ?: false)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user