Apply per-event notification logging rules
This commit is contained in:
+28
-6
@@ -8,6 +8,9 @@ import android.util.Log
|
|||||||
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
|
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
|
||||||
import se.ajpanton.notificationlog.model.NotificationAction
|
import se.ajpanton.notificationlog.model.NotificationAction
|
||||||
import se.ajpanton.notificationlog.model.NotificationLogEntry
|
import se.ajpanton.notificationlog.model.NotificationLogEntry
|
||||||
|
import se.ajpanton.notificationlog.settings.LoggingRuleStore
|
||||||
|
import se.ajpanton.notificationlog.settings.LoggingType
|
||||||
|
import se.ajpanton.notificationlog.settings.NotificationRuleEvaluator
|
||||||
import java.util.concurrent.ExecutorService
|
import java.util.concurrent.ExecutorService
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
@@ -15,10 +18,12 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
private val activeNotifications = mutableMapOf<String, NotificationSnapshot>()
|
private val activeNotifications = mutableMapOf<String, NotificationSnapshot>()
|
||||||
private lateinit var logStore: EncryptedNotificationLogStore
|
private lateinit var logStore: EncryptedNotificationLogStore
|
||||||
private lateinit var writeExecutor: ExecutorService
|
private lateinit var writeExecutor: ExecutorService
|
||||||
|
private lateinit var ruleStore: LoggingRuleStore
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
logStore = EncryptedNotificationLogStore(this)
|
logStore = EncryptedNotificationLogStore(this)
|
||||||
|
ruleStore = LoggingRuleStore(this)
|
||||||
writeExecutor = Executors.newSingleThreadExecutor { runnable ->
|
writeExecutor = Executors.newSingleThreadExecutor { runnable ->
|
||||||
Thread(runnable, "notification-log-writer")
|
Thread(runnable, "notification-log-writer")
|
||||||
}
|
}
|
||||||
@@ -29,7 +34,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
getActiveNotifications()?.forEach { sbn ->
|
getActiveNotifications()?.forEach { sbn ->
|
||||||
val snapshot = NotificationContents.snapshot(sbn)
|
val snapshot = NotificationContents.snapshot(sbn)
|
||||||
activeNotifications[snapshot.key] = snapshot
|
activeNotifications[snapshot.key] = snapshot
|
||||||
record(snapshot, NotificationAction.ALREADY_ACTIVE, includeContents = true)
|
record(snapshot, NotificationAction.ALREADY_ACTIVE, LoggingType.APPEARING, includeContents = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,8 +42,9 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
val snapshot = NotificationContents.snapshot(sbn)
|
val snapshot = NotificationContents.snapshot(sbn)
|
||||||
val previous = activeNotifications.put(snapshot.key, snapshot)
|
val previous = activeNotifications.put(snapshot.key, snapshot)
|
||||||
when {
|
when {
|
||||||
previous == null -> record(snapshot, NotificationAction.APPEARED, includeContents = true)
|
previous == null -> record(snapshot, NotificationAction.APPEARED, LoggingType.APPEARING, includeContents = true)
|
||||||
previous.contents != snapshot.contents -> record(snapshot, NotificationAction.EDITED, includeContents = true)
|
previous.textContents != snapshot.textContents || previous.hasImage != snapshot.hasImage ->
|
||||||
|
record(snapshot, NotificationAction.EDITED, LoggingType.EDITS, includeContents = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +54,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
reason: Int,
|
reason: Int,
|
||||||
) {
|
) {
|
||||||
val snapshot = activeNotifications.remove(sbn.key) ?: NotificationContents.snapshot(sbn)
|
val snapshot = activeNotifications.remove(sbn.key) ?: NotificationContents.snapshot(sbn)
|
||||||
record(snapshot, actionForRemoval(reason), includeContents = false)
|
record(snapshot, actionForRemoval(reason), LoggingType.DISAPPEARING, includeContents = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
@@ -56,14 +62,20 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun record(snapshot: NotificationSnapshot, action: NotificationAction, includeContents: Boolean) {
|
private fun record(
|
||||||
|
snapshot: NotificationSnapshot,
|
||||||
|
action: NotificationAction,
|
||||||
|
loggingType: LoggingType,
|
||||||
|
includeContents: Boolean,
|
||||||
|
) {
|
||||||
|
if (!NotificationRuleEvaluator.allows(ruleStore.ruleFor(loggingType), snapshot.packageName)) return
|
||||||
val appName = appName(snapshot.packageName)
|
val appName = appName(snapshot.packageName)
|
||||||
val entry = NotificationLogEntry(
|
val entry = NotificationLogEntry(
|
||||||
recordedAtEpochMillis = System.currentTimeMillis(),
|
recordedAtEpochMillis = System.currentTimeMillis(),
|
||||||
packageName = snapshot.packageName,
|
packageName = snapshot.packageName,
|
||||||
appName = appName,
|
appName = appName,
|
||||||
action = action,
|
action = action,
|
||||||
contents = if (includeContents) snapshot.contents else null,
|
contents = if (includeContents) visibleContents(snapshot) else null,
|
||||||
)
|
)
|
||||||
writeExecutor.execute {
|
writeExecutor.execute {
|
||||||
try {
|
try {
|
||||||
@@ -74,6 +86,16 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun visibleContents(snapshot: NotificationSnapshot): String? {
|
||||||
|
val text = snapshot.textContents?.takeIf {
|
||||||
|
NotificationRuleEvaluator.allows(ruleStore.ruleFor(LoggingType.TEXT_CONTENT), snapshot.packageName)
|
||||||
|
}
|
||||||
|
val image = snapshot.hasImage && NotificationRuleEvaluator.allows(
|
||||||
|
ruleStore.ruleFor(LoggingType.IMAGE_CONTENT), snapshot.packageName,
|
||||||
|
)
|
||||||
|
return listOfNotNull(text, if (image) "[image]" else null).joinToString(" — ").ifEmpty { null }
|
||||||
|
}
|
||||||
|
|
||||||
private fun appName(packageName: String): String = try {
|
private fun appName(packageName: String): String = try {
|
||||||
val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
|
val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
|
||||||
packageManager.getApplicationLabel(applicationInfo).toString()
|
packageManager.getApplicationLabel(applicationInfo).toString()
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import android.service.notification.StatusBarNotification
|
|||||||
data class NotificationSnapshot(
|
data class NotificationSnapshot(
|
||||||
val key: String,
|
val key: String,
|
||||||
val packageName: String,
|
val packageName: String,
|
||||||
val contents: String?,
|
val textContents: String?,
|
||||||
|
val hasImage: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
object NotificationContents {
|
object NotificationContents {
|
||||||
@@ -25,18 +26,16 @@ object NotificationContents {
|
|||||||
).forEach { message ->
|
).forEach { message ->
|
||||||
listOfNotNull(message.senderPerson?.name, message.text).joinToString(": ").addTo(parts)
|
listOfNotNull(message.senderPerson?.name, message.text).joinToString(": ").addTo(parts)
|
||||||
}
|
}
|
||||||
if (extras.containsKey(Notification.EXTRA_PICTURE) ||
|
|
||||||
extras.containsKey(Notification.EXTRA_PICTURE_ICON)
|
|
||||||
) {
|
|
||||||
"[image]".addTo(parts)
|
|
||||||
}
|
|
||||||
return parts.takeIf { it.isNotEmpty() }?.joinToString(" — ")
|
return parts.takeIf { it.isNotEmpty() }?.joinToString(" — ")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun snapshot(sbn: StatusBarNotification) = NotificationSnapshot(
|
fun snapshot(sbn: StatusBarNotification) = NotificationSnapshot(
|
||||||
key = sbn.key,
|
key = sbn.key,
|
||||||
packageName = sbn.packageName,
|
packageName = sbn.packageName,
|
||||||
contents = extract(sbn.notification),
|
textContents = extract(sbn.notification),
|
||||||
|
hasImage = sbn.notification.extras?.let {
|
||||||
|
it.containsKey(Notification.EXTRA_PICTURE) || it.containsKey(Notification.EXTRA_PICTURE_ICON)
|
||||||
|
} == true,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun CharSequence.addTo(parts: MutableSet<String>) {
|
private fun CharSequence.addTo(parts: MutableSet<String>) {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package se.ajpanton.notificationlog.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.edit
|
||||||
|
|
||||||
|
/** Persists rule choices only; notification contents never enter SharedPreferences. */
|
||||||
|
class LoggingRuleStore(context: Context) {
|
||||||
|
private val preferences = context.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
fun ruleFor(type: LoggingType): LoggingRule = LoggingRule(
|
||||||
|
enabled = preferences.getBoolean(key(type, "enabled"), true),
|
||||||
|
appRuleMode = AppRuleMode.valueOf(
|
||||||
|
preferences.getString(key(type, "app_rule_mode"), AppRuleMode.BLACKLIST.name)
|
||||||
|
?: AppRuleMode.BLACKLIST.name,
|
||||||
|
),
|
||||||
|
selectedPackages = preferences.getStringSet(key(type, "selected_packages"), emptySet())?.toSet()
|
||||||
|
?: emptySet(),
|
||||||
|
onlySeenApps = preferences.getBoolean(key(type, "only_seen_apps"), false),
|
||||||
|
seenAppsFirst = preferences.getBoolean(key(type, "seen_apps_first"), true),
|
||||||
|
ignoreRoutineUpdates = preferences.getBoolean(key(type, "ignore_routine_updates"), true),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun save(type: LoggingType, rule: LoggingRule) {
|
||||||
|
preferences.edit {
|
||||||
|
putBoolean(key(type, "enabled"), rule.enabled)
|
||||||
|
putString(key(type, "app_rule_mode"), rule.appRuleMode.name)
|
||||||
|
putStringSet(key(type, "selected_packages"), rule.selectedPackages)
|
||||||
|
putBoolean(key(type, "only_seen_apps"), rule.onlySeenApps)
|
||||||
|
putBoolean(key(type, "seen_apps_first"), rule.seenAppsFirst)
|
||||||
|
putBoolean(key(type, "ignore_routine_updates"), rule.ignoreRoutineUpdates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun key(type: LoggingType, suffix: String) = "${type.name.lowercase()}.$suffix"
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val FILE_NAME = "logging-rules"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package se.ajpanton.notificationlog.settings
|
||||||
|
|
||||||
|
enum class LoggingType {
|
||||||
|
APPEARING,
|
||||||
|
DISAPPEARING,
|
||||||
|
TEXT_CONTENT,
|
||||||
|
IMAGE_CONTENT,
|
||||||
|
EDITS,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AppRuleMode {
|
||||||
|
WHITELIST,
|
||||||
|
BLACKLIST,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class LoggingRule(
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val appRuleMode: AppRuleMode = AppRuleMode.BLACKLIST,
|
||||||
|
val selectedPackages: Set<String> = emptySet(),
|
||||||
|
val onlySeenApps: Boolean = false,
|
||||||
|
val seenAppsFirst: Boolean = true,
|
||||||
|
val ignoreRoutineUpdates: Boolean = true,
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package se.ajpanton.notificationlog.settings
|
||||||
|
|
||||||
|
object NotificationRuleEvaluator {
|
||||||
|
fun allows(rule: LoggingRule, packageName: String): Boolean {
|
||||||
|
if (!rule.enabled) return false
|
||||||
|
val selected = packageName in rule.selectedPackages
|
||||||
|
return when (rule.appRuleMode) {
|
||||||
|
AppRuleMode.WHITELIST -> selected
|
||||||
|
AppRuleMode.BLACKLIST -> !selected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package se.ajpanton.notificationlog.settings
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class NotificationRuleEvaluatorTest {
|
||||||
|
@Test fun `default blacklist allows every app`() {
|
||||||
|
assertTrue(NotificationRuleEvaluator.allows(LoggingRule(), "example.app"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `blacklist excludes selected app`() {
|
||||||
|
val rule = LoggingRule(selectedPackages = setOf("example.app"))
|
||||||
|
assertFalse(NotificationRuleEvaluator.allows(rule, "example.app"))
|
||||||
|
assertTrue(NotificationRuleEvaluator.allows(rule, "other.app"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `whitelist accepts only selected app`() {
|
||||||
|
val rule = LoggingRule(appRuleMode = AppRuleMode.WHITELIST, selectedPackages = setOf("example.app"))
|
||||||
|
assertTrue(NotificationRuleEvaluator.allows(rule, "example.app"))
|
||||||
|
assertFalse(NotificationRuleEvaluator.allows(rule, "other.app"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `disabled rule rejects selected app`() {
|
||||||
|
assertFalse(NotificationRuleEvaluator.allows(LoggingRule(enabled = false), "example.app"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user