Avoid inactive listener work and eager image encoding
This commit is contained in:
@@ -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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-12
@@ -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,11 +62,9 @@ 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,
|
||||||
@@ -78,10 +75,17 @@ object NotificationContents {
|
|||||||
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) {
|
||||||
is BitmapDrawable -> bitmap
|
is BitmapDrawable -> bitmap
|
||||||
else -> runCatching {
|
else -> runCatching {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+4
-2
@@ -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`() {
|
||||||
|
|||||||
+1
-1
@@ -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`() {
|
||||||
|
|||||||
+12
-4
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user