From d5aee8495f7211e653b81bfe47dd03dcce4adbb3 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Thu, 23 Jul 2026 09:13:57 +0000 Subject: [PATCH] Apply per-event notification logging rules --- .../capture/NotificationCaptureService.kt | 34 +++++++++++++--- .../capture/NotificationSnapshot.kt | 13 +++---- .../settings/LoggingRuleStore.kt | 39 +++++++++++++++++++ .../notificationlog/settings/LoggingType.kt | 23 +++++++++++ .../settings/NotificationRuleEvaluator.kt | 12 ++++++ .../settings/NotificationRuleEvaluatorTest.kt | 27 +++++++++++++ 6 files changed, 135 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/se/ajpanton/notificationlog/settings/LoggingRuleStore.kt create mode 100644 app/src/main/java/se/ajpanton/notificationlog/settings/LoggingType.kt create mode 100644 app/src/main/java/se/ajpanton/notificationlog/settings/NotificationRuleEvaluator.kt create mode 100644 app/src/test/java/se/ajpanton/notificationlog/settings/NotificationRuleEvaluatorTest.kt diff --git a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt index 3767dbb..8cf17e5 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt @@ -8,6 +8,9 @@ import android.util.Log import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore import se.ajpanton.notificationlog.model.NotificationAction 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.Executors @@ -15,10 +18,12 @@ class NotificationCaptureService : NotificationListenerService() { private val activeNotifications = mutableMapOf() private lateinit var logStore: EncryptedNotificationLogStore private lateinit var writeExecutor: ExecutorService + private lateinit var ruleStore: LoggingRuleStore override fun onCreate() { super.onCreate() logStore = EncryptedNotificationLogStore(this) + ruleStore = LoggingRuleStore(this) writeExecutor = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "notification-log-writer") } @@ -29,7 +34,7 @@ class NotificationCaptureService : NotificationListenerService() { getActiveNotifications()?.forEach { sbn -> val snapshot = NotificationContents.snapshot(sbn) 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 previous = activeNotifications.put(snapshot.key, snapshot) when { - previous == null -> record(snapshot, NotificationAction.APPEARED, includeContents = true) - previous.contents != snapshot.contents -> record(snapshot, NotificationAction.EDITED, includeContents = true) + previous == null -> record(snapshot, NotificationAction.APPEARED, LoggingType.APPEARING, 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, ) { 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() { @@ -56,14 +62,20 @@ class NotificationCaptureService : NotificationListenerService() { 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 entry = NotificationLogEntry( recordedAtEpochMillis = System.currentTimeMillis(), packageName = snapshot.packageName, appName = appName, action = action, - contents = if (includeContents) snapshot.contents else null, + contents = if (includeContents) visibleContents(snapshot) else null, ) writeExecutor.execute { 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 { val applicationInfo = packageManager.getApplicationInfo(packageName, 0) packageManager.getApplicationLabel(applicationInfo).toString() diff --git a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationSnapshot.kt b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationSnapshot.kt index b95cdd8..54165fb 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationSnapshot.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationSnapshot.kt @@ -7,7 +7,8 @@ import android.service.notification.StatusBarNotification data class NotificationSnapshot( val key: String, val packageName: String, - val contents: String?, + val textContents: String?, + val hasImage: Boolean, ) object NotificationContents { @@ -25,18 +26,16 @@ object NotificationContents { ).forEach { message -> 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(" — ") } fun snapshot(sbn: StatusBarNotification) = NotificationSnapshot( key = sbn.key, 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) { diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingRuleStore.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingRuleStore.kt new file mode 100644 index 0000000..fb72eeb --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingRuleStore.kt @@ -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" + } +} diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingType.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingType.kt new file mode 100644 index 0000000..8b14c6d --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingType.kt @@ -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 = emptySet(), + val onlySeenApps: Boolean = false, + val seenAppsFirst: Boolean = true, + val ignoreRoutineUpdates: Boolean = true, +) diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationRuleEvaluator.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationRuleEvaluator.kt new file mode 100644 index 0000000..da620e6 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationRuleEvaluator.kt @@ -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 + } + } +} diff --git a/app/src/test/java/se/ajpanton/notificationlog/settings/NotificationRuleEvaluatorTest.kt b/app/src/test/java/se/ajpanton/notificationlog/settings/NotificationRuleEvaluatorTest.kt new file mode 100644 index 0000000..96075d4 --- /dev/null +++ b/app/src/test/java/se/ajpanton/notificationlog/settings/NotificationRuleEvaluatorTest.kt @@ -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")) + } +}