Avoid inactive listener work and eager image encoding

This commit is contained in:
ajp_anton
2026-07-27 22:51:17 +00:00
parent ebb05acc02
commit f31a7c8efe
11 changed files with 78 additions and 39 deletions
@@ -25,7 +25,7 @@ class NotificationLogApplication : Application() {
val rules = LoggingRuleStore(this) val rules = LoggingRuleStore(this)
NotificationListenerComponentController.update( NotificationListenerComponentController.update(
this, this,
LoggingType.entries.map(rules::ruleFor), LoggingType.entries.associateWith(rules::ruleFor),
PerAppEventSettingsStore(this).hasEnabledEventOverride(), PerAppEventSettingsStore(this).hasEnabledEventOverride(),
) )
} }
@@ -44,22 +44,41 @@ class NotificationCaptureService : NotificationListenerService() {
override fun onListenerConnected() { override fun onListenerConnected() {
super.onListenerConnected() super.onListenerConnected()
getActiveNotifications()?.forEach { sbn -> getActiveNotifications()?.forEach { sbn ->
val snapshot = NotificationContents.snapshot(sbn, this) val snapshot = NotificationContents.snapshot(sbn)
SeenApps.markSeen(snapshot.packageName) SeenApps.markSeen(snapshot.packageName)
activeNotifications[snapshot.key] = snapshot 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) { override fun onNotificationPosted(sbn: StatusBarNotification) {
val snapshot = NotificationContents.snapshot(sbn, this) val snapshot = NotificationContents.snapshot(sbn)
SeenApps.markSeen(snapshot.packageName) SeenApps.markSeen(snapshot.packageName)
val previous = activeNotifications.put(snapshot.key, snapshot) val previous = activeNotifications.put(snapshot.key, snapshot)
when { 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.isMeaningfulEdit(previous, snapshot) &&
!NotificationChangeClassifier.shouldIgnoreEdit(previous, snapshot, ruleStore.ruleFor(LoggingType.EDITS).ignoreRoutineUpdates) -> !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, rankingMap: RankingMap,
reason: Int, 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) SeenApps.markSeen(snapshot.packageName)
record(snapshot, actionForRemoval(reason), LoggingType.DISAPPEARING, includeContents = false) record(snapshot, actionForRemoval(reason), LoggingType.DISAPPEARING, includeContents = false)
} }
@@ -84,11 +103,13 @@ class NotificationCaptureService : NotificationListenerService() {
loggingType: LoggingType, loggingType: LoggingType,
includeContents: Boolean, includeContents: Boolean,
previousSnapshot: NotificationSnapshot? = null, previousSnapshot: NotificationSnapshot? = null,
imageBytes: (() -> ByteArray?)? = null,
) { ) {
if (!GroupSummaryPolicy.shouldLog(snapshot, action, captureSettings.logGroupSummaries)) return if (!GroupSummaryPolicy.shouldLog(snapshot, action, captureSettings.logGroupSummaries)) return
if (!allows(loggingType, snapshot.packageName)) return if (!allows(loggingType, snapshot.packageName)) return
val appName = appName(snapshot.packageName) 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( val entry = NotificationLogEntry(
recordedAtEpochMillis = System.currentTimeMillis(), recordedAtEpochMillis = System.currentTimeMillis(),
eventTimeZoneId = java.util.TimeZone.getDefault().id, eventTimeZoneId = java.util.TimeZone.getDefault().id,
@@ -97,13 +118,13 @@ class NotificationCaptureService : NotificationListenerService() {
action = action, action = action,
contents = if (includeContents) visibleContents(snapshot) else null, contents = if (includeContents) visibleContents(snapshot) else null,
previousContents = previousSnapshot?.let(::visibleContents), 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 { writeExecutor.execute {
try { try {
if (retainImage) { if (retainedImageBytes != null) {
try { try {
imageStore.save(entry.imageId!!, snapshot.imageBytes!!) imageStore.save(entry.imageId!!, retainedImageBytes)
} catch (error: Exception) { } catch (error: Exception) {
Log.w(TAG, "Could not retain notification image; keeping the text event", error) Log.w(TAG, "Could not retain notification image; keeping the text event", error)
logStore.append(entry.copy(imageId = null)) logStore.append(entry.copy(imageId = null))
@@ -131,7 +152,7 @@ class NotificationCaptureService : NotificationListenerService() {
private fun allows(type: LoggingType, packageName: String): Boolean { private fun allows(type: LoggingType, packageName: String): Boolean {
val globalEnabled = ruleStore.ruleFor(type).enabled 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) perAppEventSettings.isEnabled(packageName, type, globalEnabled)
} else { } else {
globalEnabled globalEnabled
@@ -177,6 +198,5 @@ class NotificationCaptureService : NotificationListenerService() {
private companion object { private companion object {
const val TAG = "NotificationCapture" const val TAG = "NotificationCapture"
const val MAX_CONTENT_CHARACTERS = 16_000 const val MAX_CONTENT_CHARACTERS = 16_000
val EVENT_TYPES = setOf(LoggingType.APPEARING, LoggingType.DISAPPEARING, LoggingType.EDITS)
} }
} }
@@ -17,7 +17,6 @@ data class NotificationSnapshot(
val hasImage: Boolean, val hasImage: Boolean,
val isGroupSummary: Boolean, val isGroupSummary: Boolean,
val isRoutine: Boolean, val isRoutine: Boolean,
val imageBytes: ByteArray?,
) )
object NotificationContents { object NotificationContents {
@@ -63,23 +62,28 @@ object NotificationContents {
return parts.takeIf { it.isNotEmpty() }?.joinToString("\n") return parts.takeIf { it.isNotEmpty() }?.joinToString("\n")
} }
fun snapshot(sbn: StatusBarNotification, context: Context): NotificationSnapshot { fun snapshot(sbn: StatusBarNotification): NotificationSnapshot {
val notification = sbn.notification val notification = sbn.notification
val extras = notification.extras 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( return NotificationSnapshot(
key = sbn.key, key = sbn.key,
packageName = sbn.packageName, packageName = sbn.packageName,
textContents = extract(notification), textContents = extract(notification),
hasImage = extras?.let { hasImage = extras?.let {
it.containsKey(Notification.EXTRA_PICTURE) || it.containsKey(Notification.EXTRA_PICTURE_ICON) it.containsKey(Notification.EXTRA_PICTURE) || it.containsKey(Notification.EXTRA_PICTURE_ICON)
} == true, } == true,
isGroupSummary = notification.flags and Notification.FLAG_GROUP_SUMMARY != 0, isGroupSummary = notification.flags and Notification.FLAG_GROUP_SUMMARY != 0,
isRoutine = extras?.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER, false) == true || isRoutine = extras?.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER, false) == true ||
extras?.containsKey(Notification.EXTRA_PROGRESS) == true, extras?.containsKey(Notification.EXTRA_PROGRESS) == true,
imageBytes = picture?.toPng() ?: pictureIcon?.let { icon -> icon.loadDrawable(context)?.toBitmap()?.toPng() }, )
) }
/** 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) { private fun Drawable.toBitmap(): Bitmap? = when (this) {
@@ -30,7 +30,7 @@ class LoggingRuleStore(context: Context) {
private fun updateListenerComponent() { private fun updateListenerComponent() {
NotificationListenerComponentController.update( NotificationListenerComponentController.update(
appContext, appContext,
LoggingType.entries.map(::ruleFor), LoggingType.entries.associateWith(::ruleFor),
PerAppEventSettingsStore(appContext).hasEnabledEventOverride(), PerAppEventSettingsStore(appContext).hasEnabledEventOverride(),
) )
} }
@@ -6,6 +6,12 @@ enum class LoggingType {
TEXT_CONTENT, TEXT_CONTENT,
IMAGE_CONTENT, IMAGE_CONTENT,
EDITS, EDITS,
;
companion object {
val eventTypes = listOf(APPEARING, DISAPPEARING, EDITS)
val contentTypes = listOf(TEXT_CONTENT, IMAGE_CONTENT)
}
} }
enum class AppRuleMode { enum class AppRuleMode {
@@ -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. * is therefore both the no-work battery mode and the no-start-on-boot mode.
*/ */
internal object NotificationListenerComponentController { internal object NotificationListenerComponentController {
fun update(context: Context, rules: Collection<LoggingRule>, hasEnabledEventOverride: Boolean = false) { fun update(context: Context, rules: Map<LoggingType, LoggingRule>, hasEnabledEventOverride: Boolean = false) {
val applicationContext = context.applicationContext val applicationContext = context.applicationContext
val component = ComponentName(applicationContext, NotificationCaptureService::class.java) val component = ComponentName(applicationContext, NotificationCaptureService::class.java)
val packageManager = applicationContext.packageManager val packageManager = applicationContext.packageManager
@@ -2,6 +2,8 @@ package se.ajpanton.notificationlog.settings
/** The listener is useful only when at least one event type is enabled. */ /** The listener is useful only when at least one event type is enabled. */
object NotificationListenerPolicy { object NotificationListenerPolicy {
fun shouldRun(rules: Collection<LoggingRule>, hasEnabledEventOverride: Boolean = false): Boolean = fun shouldRun(
rules.any { it.enabled } || hasEnabledEventOverride rules: Map<LoggingType, LoggingRule>,
hasEnabledEventOverride: Boolean = false,
): Boolean = LoggingType.eventTypes.any { rules.getValue(it).enabled } || hasEnabledEventOverride
} }
@@ -52,7 +52,7 @@ class PerAppEventSettingsStore(context: Context) {
} }
private fun updateListenerComponent() { 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()) NotificationListenerComponentController.update(appContext, rules, hasEnabledEventOverride())
} }
@@ -13,7 +13,6 @@ class GroupSummaryPolicyTest {
hasImage = false, hasImage = false,
isGroupSummary = isGroupSummary, isGroupSummary = isGroupSummary,
isRoutine = false, isRoutine = false,
imageBytes = null,
) )
@Test fun `enabled group-summary logging retains summaries`() { @Test fun `enabled group-summary logging retains summaries`() {
@@ -7,7 +7,7 @@ import org.junit.Test
class NotificationChangeClassifierTest { class NotificationChangeClassifierTest {
private fun snapshot(text: String, routine: Boolean = false) = NotificationSnapshot( private fun snapshot(text: String, routine: Boolean = false) = NotificationSnapshot(
key = "key", packageName = "example.app", textContents = text, hasImage = false, 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`() { @Test fun `text change is an edit`() {
@@ -5,20 +5,28 @@ import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class NotificationListenerPolicyTest { class NotificationListenerPolicyTest {
@Test fun `listener runs when any logging type is enabled`() { @Test fun `listener runs when any event logging type is enabled`() {
assertTrue(NotificationListenerPolicy.shouldRun(listOf(LoggingRule(enabled = false), LoggingRule(enabled = true)))) assertTrue(NotificationListenerPolicy.shouldRun(rules(LoggingType.EDITS to true)))
} }
@Test fun `listener stays off when every logging type is disabled`() { @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`() { @Test fun `listener runs for an enabled per-app event override`() {
assertTrue( assertTrue(
NotificationListenerPolicy.shouldRun( NotificationListenerPolicy.shouldRun(
LoggingType.entries.map { LoggingRule(enabled = false) }, rules(),
hasEnabledEventOverride = true, hasEnabledEventOverride = true,
), ),
) )
} }
private fun rules(vararg enabled: Pair<LoggingType, Boolean>) = LoggingType.entries.associateWith { type ->
LoggingRule(enabled = enabled.firstOrNull { it.first == type }?.second ?: false)
}
} }