Improve notification capture fidelity and documentation
This commit is contained in:
@@ -35,7 +35,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
||||
override fun onListenerConnected() {
|
||||
super.onListenerConnected()
|
||||
getActiveNotifications()?.forEach { sbn ->
|
||||
val snapshot = NotificationContents.snapshot(sbn)
|
||||
val snapshot = NotificationContents.snapshot(sbn, this)
|
||||
SeenApps.markSeen(snapshot.packageName)
|
||||
activeNotifications[snapshot.key] = snapshot
|
||||
record(snapshot, NotificationAction.ALREADY_ACTIVE, LoggingType.APPEARING, includeContents = true)
|
||||
@@ -43,14 +43,14 @@ class NotificationCaptureService : NotificationListenerService() {
|
||||
}
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification) {
|
||||
val snapshot = NotificationContents.snapshot(sbn)
|
||||
val snapshot = NotificationContents.snapshot(sbn, this)
|
||||
SeenApps.markSeen(snapshot.packageName)
|
||||
val previous = activeNotifications.put(snapshot.key, snapshot)
|
||||
when {
|
||||
previous == null -> record(snapshot, NotificationAction.APPEARED, LoggingType.APPEARING, includeContents = true)
|
||||
(previous.textContents != snapshot.textContents || previous.hasImage != snapshot.hasImage) &&
|
||||
!(snapshot.isRoutine && ruleStore.ruleFor(LoggingType.EDITS).ignoreRoutineUpdates) ->
|
||||
record(snapshot, NotificationAction.EDITED, LoggingType.EDITS, includeContents = true)
|
||||
NotificationChangeClassifier.isMeaningfulEdit(previous, snapshot) &&
|
||||
!NotificationChangeClassifier.shouldIgnoreEdit(previous, snapshot, ruleStore.ruleFor(LoggingType.EDITS).ignoreRoutineUpdates) ->
|
||||
record(snapshot, NotificationAction.EDITED, LoggingType.EDITS, includeContents = true, previousSnapshot = previous)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
||||
rankingMap: RankingMap,
|
||||
reason: Int,
|
||||
) {
|
||||
val snapshot = activeNotifications.remove(sbn.key) ?: NotificationContents.snapshot(sbn)
|
||||
val snapshot = activeNotifications.remove(sbn.key) ?: NotificationContents.snapshot(sbn, this)
|
||||
SeenApps.markSeen(snapshot.packageName)
|
||||
record(snapshot, actionForRemoval(reason), LoggingType.DISAPPEARING, includeContents = false)
|
||||
}
|
||||
@@ -74,6 +74,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
||||
action: NotificationAction,
|
||||
loggingType: LoggingType,
|
||||
includeContents: Boolean,
|
||||
previousSnapshot: NotificationSnapshot? = null,
|
||||
) {
|
||||
if (!NotificationRuleEvaluator.allows(ruleStore.ruleFor(loggingType), snapshot.packageName)) return
|
||||
val appName = appName(snapshot.packageName)
|
||||
@@ -86,6 +87,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
||||
appName = appName,
|
||||
action = action,
|
||||
contents = if (includeContents) visibleContents(snapshot) else null,
|
||||
previousContents = previousSnapshot?.let(::visibleContents),
|
||||
imageId = if (retainImage) java.util.UUID.randomUUID().toString() else null,
|
||||
)
|
||||
writeExecutor.execute {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package se.ajpanton.notificationlog.capture
|
||||
|
||||
/** Keeps update classification deterministic and independently testable. */
|
||||
object NotificationChangeClassifier {
|
||||
fun isMeaningfulEdit(previous: NotificationSnapshot, current: NotificationSnapshot): Boolean =
|
||||
previous.textContents != current.textContents || previous.hasImage != current.hasImage
|
||||
|
||||
fun shouldIgnoreEdit(previous: NotificationSnapshot, current: NotificationSnapshot, ignoreRoutineUpdates: Boolean): Boolean =
|
||||
ignoreRoutineUpdates && current.isRoutine && isMeaningfulEdit(previous, current)
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
package se.ajpanton.notificationlog.capture
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Bundle
|
||||
import android.service.notification.StatusBarNotification
|
||||
|
||||
@@ -32,24 +37,51 @@ object NotificationContents {
|
||||
return parts.takeIf { it.isNotEmpty() }?.joinToString(" — ")
|
||||
}
|
||||
|
||||
fun snapshot(sbn: StatusBarNotification) = NotificationSnapshot(
|
||||
fun snapshot(sbn: StatusBarNotification, context: Context): 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(sbn.notification),
|
||||
hasImage = sbn.notification.extras?.let {
|
||||
textContents = extract(notification),
|
||||
hasImage = extras?.let {
|
||||
it.containsKey(Notification.EXTRA_PICTURE) || it.containsKey(Notification.EXTRA_PICTURE_ICON)
|
||||
} == true,
|
||||
isRoutine = sbn.notification.extras?.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER, false) == true ||
|
||||
sbn.notification.extras?.containsKey(Notification.EXTRA_PROGRESS) == true,
|
||||
imageBytes = sbn.notification.extras?.getParcelable(Notification.EXTRA_PICTURE, Bitmap::class.java)?.let { bitmap ->
|
||||
java.io.ByteArrayOutputStream().use { output ->
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
|
||||
output.toByteArray()
|
||||
}
|
||||
},
|
||||
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() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun Drawable.toBitmap(): Bitmap? = when (this) {
|
||||
is BitmapDrawable -> bitmap
|
||||
else -> runCatching {
|
||||
val sourceWidth = maxOf(1, intrinsicWidth)
|
||||
val sourceHeight = maxOf(1, intrinsicHeight)
|
||||
val scale = minOf(1f, MAX_IMAGE_DIMENSION.toFloat() / maxOf(sourceWidth, sourceHeight))
|
||||
val width = (sourceWidth * scale).toInt()
|
||||
val height = (sourceHeight * scale).toInt()
|
||||
Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { bitmap ->
|
||||
setBounds(0, 0, width, height)
|
||||
draw(Canvas(bitmap))
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun Bitmap.toPng(): ByteArray? = runCatching {
|
||||
val scale = minOf(1f, MAX_IMAGE_DIMENSION.toFloat() / maxOf(width, height))
|
||||
val bitmap = if (scale < 1f) Bitmap.createScaledBitmap(this, (width * scale).toInt(), (height * scale).toInt(), true) else this
|
||||
java.io.ByteArrayOutputStream().use { output ->
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
|
||||
output.toByteArray()
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private fun CharSequence.addTo(parts: MutableSet<String>) {
|
||||
toString().trim().takeIf { it.isNotEmpty() }?.let(parts::add)
|
||||
}
|
||||
|
||||
private const val MAX_IMAGE_DIMENSION = 1600
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user