Apply per-event notification logging rules

This commit is contained in:
ajp_anton
2026-07-23 09:13:57 +00:00
parent 6d77a43c55
commit d5aee8495f
6 changed files with 135 additions and 13 deletions
@@ -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
}
}
}
@@ -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"))
}
}