diff --git a/app/src/main/java/se/ajpanton/notificationlog/NotificationLogApplication.kt b/app/src/main/java/se/ajpanton/notificationlog/NotificationLogApplication.kt index 6d7fde5..4906146 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/NotificationLogApplication.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/NotificationLogApplication.kt @@ -25,7 +25,7 @@ class NotificationLogApplication : Application() { val rules = LoggingRuleStore(this) NotificationListenerComponentController.update( this, - LoggingType.entries.map(rules::ruleFor), + LoggingType.entries.associateWith(rules::ruleFor), PerAppEventSettingsStore(this).hasEnabledEventOverride(), ) } 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 c2e9649..53a9ab3 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt @@ -44,22 +44,41 @@ class NotificationCaptureService : NotificationListenerService() { override fun onListenerConnected() { super.onListenerConnected() getActiveNotifications()?.forEach { sbn -> - val snapshot = NotificationContents.snapshot(sbn, this) + val snapshot = NotificationContents.snapshot(sbn) SeenApps.markSeen(snapshot.packageName) activeNotifications[snapshot.key] = snapshot - record(snapshot, NotificationAction.ALREADY_ACTIVE, LoggingType.APPEARING, includeContents = true) + record( + snapshot, + NotificationAction.ALREADY_ACTIVE, + LoggingType.APPEARING, + includeContents = true, + imageBytes = { NotificationContents.extractImageBytes(sbn.notification, this) }, + ) } } override fun onNotificationPosted(sbn: StatusBarNotification) { - val snapshot = NotificationContents.snapshot(sbn, this) + val snapshot = NotificationContents.snapshot(sbn) SeenApps.markSeen(snapshot.packageName) val previous = activeNotifications.put(snapshot.key, snapshot) when { - previous == null -> record(snapshot, NotificationAction.APPEARED, LoggingType.APPEARING, includeContents = true) + 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) -> - record(snapshot, NotificationAction.EDITED, LoggingType.EDITS, includeContents = true, previousSnapshot = previous) + record( + snapshot, + NotificationAction.EDITED, + LoggingType.EDITS, + includeContents = true, + previousSnapshot = previous, + imageBytes = { NotificationContents.extractImageBytes(sbn.notification, this) }, + ) } } @@ -68,7 +87,7 @@ class NotificationCaptureService : NotificationListenerService() { rankingMap: RankingMap, reason: Int, ) { - val snapshot = activeNotifications.remove(sbn.key) ?: NotificationContents.snapshot(sbn, this) + val snapshot = activeNotifications.remove(sbn.key) ?: NotificationContents.snapshot(sbn) SeenApps.markSeen(snapshot.packageName) record(snapshot, actionForRemoval(reason), LoggingType.DISAPPEARING, includeContents = false) } @@ -84,11 +103,13 @@ class NotificationCaptureService : NotificationListenerService() { loggingType: LoggingType, includeContents: Boolean, previousSnapshot: NotificationSnapshot? = null, + imageBytes: (() -> ByteArray?)? = null, ) { if (!GroupSummaryPolicy.shouldLog(snapshot, action, captureSettings.logGroupSummaries)) return if (!allows(loggingType, snapshot.packageName)) return val appName = appName(snapshot.packageName) - val retainImage = includeContents && snapshot.imageBytes != null && allows(LoggingType.IMAGE_CONTENT, snapshot.packageName) + val retainImage = includeContents && snapshot.hasImage && allows(LoggingType.IMAGE_CONTENT, snapshot.packageName) + val retainedImageBytes = if (retainImage) imageBytes?.invoke() else null val entry = NotificationLogEntry( recordedAtEpochMillis = System.currentTimeMillis(), eventTimeZoneId = java.util.TimeZone.getDefault().id, @@ -97,13 +118,13 @@ class NotificationCaptureService : NotificationListenerService() { action = action, contents = if (includeContents) visibleContents(snapshot) else null, previousContents = previousSnapshot?.let(::visibleContents), - imageId = if (retainImage) java.util.UUID.randomUUID().toString() else null, + imageId = if (retainedImageBytes != null) java.util.UUID.randomUUID().toString() else null, ) writeExecutor.execute { try { - if (retainImage) { + if (retainedImageBytes != null) { try { - imageStore.save(entry.imageId!!, snapshot.imageBytes!!) + imageStore.save(entry.imageId!!, retainedImageBytes) } catch (error: Exception) { Log.w(TAG, "Could not retain notification image; keeping the text event", error) logStore.append(entry.copy(imageId = null)) @@ -131,7 +152,7 @@ class NotificationCaptureService : NotificationListenerService() { private fun allows(type: LoggingType, packageName: String): Boolean { val globalEnabled = ruleStore.ruleFor(type).enabled - val eventEnabled = if (type in EVENT_TYPES) { + val eventEnabled = if (type in LoggingType.eventTypes) { perAppEventSettings.isEnabled(packageName, type, globalEnabled) } else { globalEnabled @@ -177,6 +198,5 @@ class NotificationCaptureService : NotificationListenerService() { private companion object { const val TAG = "NotificationCapture" const val MAX_CONTENT_CHARACTERS = 16_000 - val EVENT_TYPES = setOf(LoggingType.APPEARING, LoggingType.DISAPPEARING, LoggingType.EDITS) } } 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 5b44848..51a17c0 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationSnapshot.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationSnapshot.kt @@ -17,7 +17,6 @@ data class NotificationSnapshot( val hasImage: Boolean, val isGroupSummary: Boolean, val isRoutine: Boolean, - val imageBytes: ByteArray?, ) object NotificationContents { @@ -63,23 +62,28 @@ object NotificationContents { return parts.takeIf { it.isNotEmpty() }?.joinToString("\n") } - fun snapshot(sbn: StatusBarNotification, context: Context): NotificationSnapshot { + fun snapshot(sbn: StatusBarNotification): NotificationSnapshot { val notification = sbn.notification val extras = notification.extras - val picture = extras?.getParcelable(Notification.EXTRA_PICTURE, Bitmap::class.java) - val pictureIcon = extras?.getParcelable(Notification.EXTRA_PICTURE_ICON, Icon::class.java) return NotificationSnapshot( - key = sbn.key, - packageName = sbn.packageName, - textContents = extract(notification), - hasImage = extras?.let { - it.containsKey(Notification.EXTRA_PICTURE) || it.containsKey(Notification.EXTRA_PICTURE_ICON) - } == true, - isGroupSummary = notification.flags and Notification.FLAG_GROUP_SUMMARY != 0, - isRoutine = extras?.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER, false) == true || - extras?.containsKey(Notification.EXTRA_PROGRESS) == true, - imageBytes = picture?.toPng() ?: pictureIcon?.let { icon -> icon.loadDrawable(context)?.toBitmap()?.toPng() }, - ) + key = sbn.key, + packageName = sbn.packageName, + textContents = extract(notification), + hasImage = extras?.let { + it.containsKey(Notification.EXTRA_PICTURE) || it.containsKey(Notification.EXTRA_PICTURE_ICON) + } == true, + isGroupSummary = notification.flags and Notification.FLAG_GROUP_SUMMARY != 0, + isRoutine = extras?.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER, false) == true || + extras?.containsKey(Notification.EXTRA_PROGRESS) == true, + ) + } + + /** Called only after the event has passed all capture filters. */ + fun extractImageBytes(notification: Notification, context: Context): ByteArray? { + val extras = notification.extras ?: return null + val picture = extras.getParcelable(Notification.EXTRA_PICTURE, Bitmap::class.java) + val pictureIcon = extras.getParcelable(Notification.EXTRA_PICTURE_ICON, Icon::class.java) + return picture?.toPng() ?: pictureIcon?.let { icon -> icon.loadDrawable(context)?.toBitmap()?.toPng() } } private fun Drawable.toBitmap(): Bitmap? = when (this) { diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingRuleStore.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingRuleStore.kt index a573cb5..887f696 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingRuleStore.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingRuleStore.kt @@ -30,7 +30,7 @@ class LoggingRuleStore(context: Context) { private fun updateListenerComponent() { NotificationListenerComponentController.update( appContext, - LoggingType.entries.map(::ruleFor), + LoggingType.entries.associateWith(::ruleFor), PerAppEventSettingsStore(appContext).hasEnabledEventOverride(), ) } diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingType.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingType.kt index bfc5b11..636f165 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingType.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingType.kt @@ -6,6 +6,12 @@ enum class LoggingType { TEXT_CONTENT, IMAGE_CONTENT, EDITS, + ; + + companion object { + val eventTypes = listOf(APPEARING, DISAPPEARING, EDITS) + val contentTypes = listOf(TEXT_CONTENT, IMAGE_CONTENT) + } } enum class AppRuleMode { diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationListenerComponentController.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationListenerComponentController.kt index 2498a06..06efbad 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationListenerComponentController.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationListenerComponentController.kt @@ -13,7 +13,7 @@ import se.ajpanton.notificationlog.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: Collection, hasEnabledEventOverride: Boolean = false) { + fun update(context: Context, rules: Map, hasEnabledEventOverride: Boolean = false) { val applicationContext = context.applicationContext val component = ComponentName(applicationContext, NotificationCaptureService::class.java) val packageManager = applicationContext.packageManager diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationListenerPolicy.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationListenerPolicy.kt index 29a05e3..262673c 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationListenerPolicy.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationListenerPolicy.kt @@ -2,6 +2,8 @@ package se.ajpanton.notificationlog.settings /** The listener is useful only when at least one event type is enabled. */ object NotificationListenerPolicy { - fun shouldRun(rules: Collection, hasEnabledEventOverride: Boolean = false): Boolean = - rules.any { it.enabled } || hasEnabledEventOverride + fun shouldRun( + rules: Map, + hasEnabledEventOverride: Boolean = false, + ): Boolean = LoggingType.eventTypes.any { rules.getValue(it).enabled } || hasEnabledEventOverride } diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/PerAppEventSettingsStore.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/PerAppEventSettingsStore.kt index 3a90a6f..8332d42 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/settings/PerAppEventSettingsStore.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/PerAppEventSettingsStore.kt @@ -52,7 +52,7 @@ class PerAppEventSettingsStore(context: Context) { } private fun updateListenerComponent() { - val rules = LoggingType.entries.map { LoggingRuleStore(appContext).ruleFor(it) } + val rules = LoggingType.entries.associateWith { LoggingRuleStore(appContext).ruleFor(it) } NotificationListenerComponentController.update(appContext, rules, hasEnabledEventOverride()) } diff --git a/app/src/test/java/se/ajpanton/notificationlog/capture/GroupSummaryPolicyTest.kt b/app/src/test/java/se/ajpanton/notificationlog/capture/GroupSummaryPolicyTest.kt index 4cca09d..5d694d0 100644 --- a/app/src/test/java/se/ajpanton/notificationlog/capture/GroupSummaryPolicyTest.kt +++ b/app/src/test/java/se/ajpanton/notificationlog/capture/GroupSummaryPolicyTest.kt @@ -13,7 +13,6 @@ class GroupSummaryPolicyTest { hasImage = false, isGroupSummary = isGroupSummary, isRoutine = false, - imageBytes = null, ) @Test fun `enabled group-summary logging retains summaries`() { diff --git a/app/src/test/java/se/ajpanton/notificationlog/capture/NotificationChangeClassifierTest.kt b/app/src/test/java/se/ajpanton/notificationlog/capture/NotificationChangeClassifierTest.kt index 5f6dc99..ba043e8 100644 --- a/app/src/test/java/se/ajpanton/notificationlog/capture/NotificationChangeClassifierTest.kt +++ b/app/src/test/java/se/ajpanton/notificationlog/capture/NotificationChangeClassifierTest.kt @@ -7,7 +7,7 @@ import org.junit.Test class NotificationChangeClassifierTest { private fun snapshot(text: String, routine: Boolean = false) = NotificationSnapshot( key = "key", packageName = "example.app", textContents = text, hasImage = false, - isGroupSummary = false, isRoutine = routine, imageBytes = null, + isGroupSummary = false, isRoutine = routine, ) @Test fun `text change is an edit`() { diff --git a/app/src/test/java/se/ajpanton/notificationlog/settings/NotificationListenerPolicyTest.kt b/app/src/test/java/se/ajpanton/notificationlog/settings/NotificationListenerPolicyTest.kt index be3178a..5658595 100644 --- a/app/src/test/java/se/ajpanton/notificationlog/settings/NotificationListenerPolicyTest.kt +++ b/app/src/test/java/se/ajpanton/notificationlog/settings/NotificationListenerPolicyTest.kt @@ -5,20 +5,28 @@ import org.junit.Assert.assertTrue import org.junit.Test class NotificationListenerPolicyTest { - @Test fun `listener runs when any logging type is enabled`() { - assertTrue(NotificationListenerPolicy.shouldRun(listOf(LoggingRule(enabled = false), LoggingRule(enabled = true)))) + @Test fun `listener runs when any event logging type is enabled`() { + assertTrue(NotificationListenerPolicy.shouldRun(rules(LoggingType.EDITS to true))) } @Test fun `listener stays off when every logging type is disabled`() { - assertFalse(NotificationListenerPolicy.shouldRun(LoggingType.entries.map { LoggingRule(enabled = false) })) + assertFalse(NotificationListenerPolicy.shouldRun(rules())) + } + + @Test fun `content settings alone do not keep the listener active`() { + assertFalse(NotificationListenerPolicy.shouldRun(rules(LoggingType.TEXT_CONTENT to true, LoggingType.IMAGE_CONTENT to true))) } @Test fun `listener runs for an enabled per-app event override`() { assertTrue( NotificationListenerPolicy.shouldRun( - LoggingType.entries.map { LoggingRule(enabled = false) }, + rules(), hasEnabledEventOverride = true, ), ) } + + private fun rules(vararg enabled: Pair) = LoggingType.entries.associateWith { type -> + LoggingRule(enabled = enabled.firstOrNull { it.first == type }?.second ?: false) + } }