From 9268623fdd4ba272314aa0108b7cce3715cddf15 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Thu, 23 Jul 2026 12:29:42 +0000 Subject: [PATCH] Improve notification capture fidelity and documentation --- README.md | 45 ++++++++++++++++ .../notificationlog/EventSettingsFragment.kt | 25 +++------ .../notificationlog/ViewLogsFragment.kt | 7 +++ .../capture/NotificationCaptureService.kt | 14 ++--- .../capture/NotificationChangeClassifier.kt | 10 ++++ .../capture/NotificationSnapshot.kt | 54 +++++++++++++++---- .../data/NotificationLogEntryJson.kt | 2 + .../model/NotificationLogEntry.kt | 2 + .../settings/AppListOrdering.kt | 26 +++++++++ app/src/main/res/layout/fragment_settings.xml | 6 +++ .../NotificationChangeClassifierTest.kt | 25 +++++++++ .../settings/AppListOrderingTest.kt | 18 +++++++ 12 files changed, 199 insertions(+), 35 deletions(-) create mode 100644 README.md create mode 100644 app/src/main/java/se/ajpanton/notificationlog/capture/NotificationChangeClassifier.kt create mode 100644 app/src/main/java/se/ajpanton/notificationlog/settings/AppListOrdering.kt create mode 100644 app/src/test/java/se/ajpanton/notificationlog/capture/NotificationChangeClassifierTest.kt create mode 100644 app/src/test/java/se/ajpanton/notificationlog/settings/AppListOrderingTest.kt diff --git a/README.md b/README.md new file mode 100644 index 0000000..67aea16 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Notification Log + +Notification Log is a private, non-root Android notification listener for API +34–36. It keeps an encrypted local history of notification appearances, edits, +and removal reasons, including a distinction between an app cancelling its own +notification and a user dismissing it. + +## Privacy and capture limits + +The event log and copied image files are encrypted at rest with an Android +Keystore-backed AES-GCM key. Export is deliberately unencrypted only after the +user acknowledges a warning and chooses a destination through Android's Storage +Access Framework. + +Android exposes standard notification extras such as title, text, big text, +inbox lines, messaging-style messages, and readable big-picture content. This +app copies a readable bitmap at post time when Android provides one. It cannot +reconstruct opaque custom `RemoteViews`, OTP content Android redacts, or image +URIs/Icons which Android does not allow it to load. Such images remain marked +as `[image]` without causing the text event to be lost. + +The app declares `QUERY_ALL_PACKAGES` solely to implement the settings pages' +“all installed apps” list. A Play-distributed build must meet Google Play's +restricted package-visibility policy and provide the required declaration; a +future distribution variant may need a narrower app-selection flow. + +## Notification update policy + +Each platform notification key is tracked independently. Group summaries are +logged as their own platform notifications; the app does not invent extra group +events. Ongoing notifications are recorded when Android posts, updates, or +removes them, but are never treated as user-dismissible. Identical reposts are +ignored. With the default **Ignore routine updates** setting, standard +progress/chronometer notifications do not create edit rows; non-routine content +changes do. Disabling that setting records the updates as edits. + +## Development checks + +```bash +./gradlew test assembleDebug +``` + +The local Android test lab has been checked on AOSP API 34/35/36, Google APIs +API 35/36, and LineageOS API 35/36 using clean LSPosed snapshots. Root and +Xposed are not used by this application. diff --git a/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt index 3fdaff2..85ccb0e 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt @@ -12,6 +12,9 @@ import se.ajpanton.notificationlog.databinding.FragmentEventSettingsBinding import se.ajpanton.notificationlog.settings.AppRuleMode import se.ajpanton.notificationlog.settings.LoggingRuleStore import se.ajpanton.notificationlog.settings.LoggingType +import se.ajpanton.notificationlog.settings.AppListItem +import se.ajpanton.notificationlog.settings.AppListOrdering +import se.ajpanton.notificationlog.settings.ListedApp import se.ajpanton.notificationlog.capture.SeenApps class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) { @@ -99,31 +102,19 @@ class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) { val seen = SeenApps.snapshot() val apps = requireContext().packageManager.getInstalledApplications(0) .map { info -> - AppRow( + ListedApp( label = requireContext().packageManager.getApplicationLabel(info).toString(), packageName = info.packageName, seen = info.packageName in seen, selected = info.packageName in rule.selectedPackages, ) } - .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.label }) - val shown = if (rule.onlySeenApps) apps.filter { it.seen || it.selected } else apps - val seenRows = shown.filter { it.seen } - val otherRows = shown.filterNot { it.seen } - if (!rule.onlySeenApps && rule.seenAppsFirst && seenRows.isNotEmpty()) { - seenRows.forEach { container.addView(appView(it)) } - if (otherRows.isNotEmpty()) container.addView(separator()) - otherRows.forEach { container.addView(appView(it)) } - } else if (rule.onlySeenApps && seenRows.isNotEmpty() && otherRows.isNotEmpty()) { - seenRows.forEach { container.addView(appView(it)) } - container.addView(separator()) - otherRows.forEach { container.addView(appView(it)) } - } else { - shown.forEach { container.addView(appView(it)) } + AppListOrdering.items(apps, rule.onlySeenApps, rule.seenAppsFirst).forEach { item -> + container.addView(if (item is AppListItem.App) appView(item.value) else separator()) } } - private fun appView(row: AppRow): View = LinearLayout(requireContext()).apply { + private fun appView(row: ListedApp): View = LinearLayout(requireContext()).apply { orientation = LinearLayout.HORIZONTAL val checkbox = CheckBox(context).apply { isChecked = row.selected } checkbox.setOnCheckedChangeListener { _, checked -> @@ -148,8 +139,6 @@ class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) { setBackgroundColor(0x33000000) } - private data class AppRow(val label: String, val packageName: String, val seen: Boolean, val selected: Boolean) - companion object { private const val ARG_TITLE = "title" private const val ARG_TYPE = "type" diff --git a/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt index 935ae64..ab892c4 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt @@ -102,6 +102,13 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) { }) } } + if (row.entry.action == NotificationAction.EDITED && !row.entry.previousContents.isNullOrEmpty()) { + addView(TextView(context).apply { + text = "Previous: ${row.entry.previousContents}" + setLineSpacing(0f, 0.92f) + setPadding(0, dp(1), 0, dp(1)) + }) + } row.imageBytes?.let { bytes -> BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.let { bitmap -> addView(ImageView(context).apply { 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 debe70f..216f21e 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt @@ -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 { diff --git a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationChangeClassifier.kt b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationChangeClassifier.kt new file mode 100644 index 0000000..ffd1022 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationChangeClassifier.kt @@ -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) +} 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 55326fa..8536639 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationSnapshot.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationSnapshot.kt @@ -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) { toString().trim().takeIf { it.isNotEmpty() }?.let(parts::add) } + + private const val MAX_IMAGE_DIMENSION = 1600 } diff --git a/app/src/main/java/se/ajpanton/notificationlog/data/NotificationLogEntryJson.kt b/app/src/main/java/se/ajpanton/notificationlog/data/NotificationLogEntryJson.kt index d2b142d..ed72900 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/data/NotificationLogEntryJson.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/data/NotificationLogEntryJson.kt @@ -18,6 +18,7 @@ object NotificationLogEntryJson { .put("appName", entry.appName) .put("action", entry.action.name) .put("contents", entry.contents) + .put("previousContents", entry.previousContents) .put("imageId", entry.imageId), ) } @@ -35,6 +36,7 @@ object NotificationLogEntryJson { appName = entry.getString("appName"), action = NotificationAction.valueOf(entry.getString("action")), contents = if (entry.isNull("contents")) null else entry.getString("contents"), + previousContents = entry.optString("previousContents").takeIf { it.isNotEmpty() }, imageId = entry.optString("imageId").takeIf { it.isNotEmpty() }, ) } diff --git a/app/src/main/java/se/ajpanton/notificationlog/model/NotificationLogEntry.kt b/app/src/main/java/se/ajpanton/notificationlog/model/NotificationLogEntry.kt index 5355fda..8951b8f 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/model/NotificationLogEntry.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/model/NotificationLogEntry.kt @@ -12,6 +12,8 @@ data class NotificationLogEntry( val appName: String, val action: NotificationAction, val contents: String?, + /** The prior readable contents for an edit event, when that data was allowed to be logged. */ + val previousContents: String? = null, /** ID of a private encrypted PNG copy, when this event retained a readable image. */ val imageId: String? = null, ) diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/AppListOrdering.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/AppListOrdering.kt new file mode 100644 index 0000000..2048e12 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/AppListOrdering.kt @@ -0,0 +1,26 @@ +package se.ajpanton.notificationlog.settings + +data class ListedApp(val label: String, val packageName: String, val seen: Boolean, val selected: Boolean) +sealed interface AppListItem { + data class App(val value: ListedApp) : AppListItem + data object Separator : AppListItem +} + +object AppListOrdering { + fun items(allApps: List, onlySeen: Boolean, seenAppsFirst: Boolean): List { + val apps = allApps.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.label }) + val shown = if (onlySeen) apps.filter { it.seen || it.selected } else apps + val seen = shown.filter { it.seen } + val other = shown.filterNot { it.seen } + val separate = (onlySeen || seenAppsFirst) && seen.isNotEmpty() && other.isNotEmpty() + return buildList { + if (separate) { + seen.forEach { add(AppListItem.App(it)) } + add(AppListItem.Separator) + other.forEach { add(AppListItem.App(it)) } + } else { + shown.forEach { add(AppListItem.App(it)) } + } + } + } +} diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml index acb0906..5aeb111 100644 --- a/app/src/main/res/layout/fragment_settings.xml +++ b/app/src/main/res/layout/fragment_settings.xml @@ -93,6 +93,12 @@ android:layout_height="wrap_content" android:text="Clear logs" /> + +