From 6d77a43c55fbd7c822e8d045b5db2ade41eab1eb Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Thu, 23 Jul 2026 08:08:11 +0000 Subject: [PATCH] Capture notification lifecycle events securely --- app/src/main/AndroidManifest.xml | 12 ++ .../capture/NotificationCaptureService.kt | 115 ++++++++++++++++++ .../capture/NotificationSnapshot.kt | 45 +++++++ .../notificationlog/data/AesGcmCipher.kt | 7 +- app/src/main/res/values/strings.xml | 1 + .../notificationlog/data/AesGcmCipherTest.kt | 3 +- 6 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt create mode 100644 app/src/main/java/se/ajpanton/notificationlog/capture/NotificationSnapshot.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 020dd91..6da3e2e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + + + + + + + diff --git a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt new file mode 100644 index 0000000..3767dbb --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt @@ -0,0 +1,115 @@ +package se.ajpanton.notificationlog.capture + +import android.app.Notification +import android.content.pm.PackageManager +import android.service.notification.NotificationListenerService +import android.service.notification.StatusBarNotification +import android.util.Log +import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore +import se.ajpanton.notificationlog.model.NotificationAction +import se.ajpanton.notificationlog.model.NotificationLogEntry +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +class NotificationCaptureService : NotificationListenerService() { + private val activeNotifications = mutableMapOf() + private lateinit var logStore: EncryptedNotificationLogStore + private lateinit var writeExecutor: ExecutorService + + override fun onCreate() { + super.onCreate() + logStore = EncryptedNotificationLogStore(this) + writeExecutor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "notification-log-writer") + } + } + + override fun onListenerConnected() { + super.onListenerConnected() + getActiveNotifications()?.forEach { sbn -> + val snapshot = NotificationContents.snapshot(sbn) + activeNotifications[snapshot.key] = snapshot + record(snapshot, NotificationAction.ALREADY_ACTIVE, includeContents = true) + } + } + + override fun onNotificationPosted(sbn: StatusBarNotification) { + val snapshot = NotificationContents.snapshot(sbn) + val previous = activeNotifications.put(snapshot.key, snapshot) + when { + previous == null -> record(snapshot, NotificationAction.APPEARED, includeContents = true) + previous.contents != snapshot.contents -> record(snapshot, NotificationAction.EDITED, includeContents = true) + } + } + + override fun onNotificationRemoved( + sbn: StatusBarNotification, + rankingMap: RankingMap, + reason: Int, + ) { + val snapshot = activeNotifications.remove(sbn.key) ?: NotificationContents.snapshot(sbn) + record(snapshot, actionForRemoval(reason), includeContents = false) + } + + override fun onDestroy() { + writeExecutor.shutdown() + super.onDestroy() + } + + private fun record(snapshot: NotificationSnapshot, action: NotificationAction, includeContents: Boolean) { + val appName = appName(snapshot.packageName) + val entry = NotificationLogEntry( + recordedAtEpochMillis = System.currentTimeMillis(), + packageName = snapshot.packageName, + appName = appName, + action = action, + contents = if (includeContents) snapshot.contents else null, + ) + writeExecutor.execute { + try { + logStore.append(entry) + } catch (error: Exception) { + Log.e(TAG, "Could not persist notification log entry", error) + } + } + } + + private fun appName(packageName: String): String = try { + val applicationInfo = packageManager.getApplicationInfo(packageName, 0) + packageManager.getApplicationLabel(applicationInfo).toString() + } catch (error: PackageManager.NameNotFoundException) { + Log.w(TAG, "Package disappeared while its notification was being recorded: $packageName", error) + packageName + } + + private fun actionForRemoval(reason: Int): NotificationAction = when (reason) { + REASON_APP_CANCEL -> NotificationAction.APP_CANCELLED + REASON_APP_CANCEL_ALL -> NotificationAction.APP_CANCELLED_ALL + REASON_CANCEL -> NotificationAction.USER_DISMISSED + REASON_CANCEL_ALL -> NotificationAction.USER_DISMISSED_ALL + REASON_CLICK -> NotificationAction.USER_CLICKED + REASON_LISTENER_CANCEL -> NotificationAction.LISTENER_DISMISSED + REASON_LISTENER_CANCEL_ALL -> NotificationAction.LISTENER_DISMISSED_ALL + REASON_SNOOZED -> NotificationAction.SNOOZED + REASON_TIMEOUT -> NotificationAction.TIMED_OUT + REASON_CHANNEL_BANNED -> NotificationAction.CHANNEL_BANNED + REASON_CHANNEL_REMOVED -> NotificationAction.CHANNEL_REMOVED + REASON_PACKAGE_BANNED -> NotificationAction.PACKAGE_BANNED + REASON_PACKAGE_CHANGED -> NotificationAction.PACKAGE_CHANGED + REASON_PACKAGE_SUSPENDED -> NotificationAction.PACKAGE_SUSPENDED + REASON_PROFILE_TURNED_OFF -> NotificationAction.PROFILE_TURNED_OFF + REASON_USER_STOPPED -> NotificationAction.USER_STOPPED + REASON_GROUP_SUMMARY_CANCELED -> NotificationAction.GROUP_SUMMARY_CANCELLED + REASON_GROUP_OPTIMIZATION -> NotificationAction.GROUP_OPTIMIZED + REASON_UNAUTOBUNDLED -> NotificationAction.UNAUTOBUNDLED + REASON_CLEAR_DATA -> NotificationAction.CLEAR_DATA + REASON_ASSISTANT_CANCEL -> NotificationAction.ASSISTANT_CANCELLED + REASON_LOCKDOWN -> NotificationAction.LOCKDOWN + REASON_ERROR -> NotificationAction.SYSTEM_ERROR + else -> NotificationAction.OTHER_REMOVAL + } + + private companion object { + const val TAG = "NotificationCapture" + } +} diff --git a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationSnapshot.kt b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationSnapshot.kt new file mode 100644 index 0000000..b95cdd8 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationSnapshot.kt @@ -0,0 +1,45 @@ +package se.ajpanton.notificationlog.capture + +import android.app.Notification +import android.os.Bundle +import android.service.notification.StatusBarNotification + +data class NotificationSnapshot( + val key: String, + val packageName: String, + val contents: String?, +) + +object NotificationContents { + fun extract(notification: Notification): String? { + val parts = linkedSetOf() + val extras = notification.extras ?: Bundle.EMPTY + extras.getCharSequence(Notification.EXTRA_TITLE)?.addTo(parts) + extras.getCharSequence(Notification.EXTRA_TEXT)?.addTo(parts) + extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.addTo(parts) + extras.getCharSequence(Notification.EXTRA_SUB_TEXT)?.addTo(parts) + extras.getCharSequence(Notification.EXTRA_SUMMARY_TEXT)?.addTo(parts) + extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES)?.forEach { it?.addTo(parts) } + Notification.MessagingStyle.Message.getMessagesFromBundleArray( + extras.getParcelableArray(Notification.EXTRA_MESSAGES, Bundle::class.java), + ).forEach { message -> + listOfNotNull(message.senderPerson?.name, message.text).joinToString(": ").addTo(parts) + } + if (extras.containsKey(Notification.EXTRA_PICTURE) || + extras.containsKey(Notification.EXTRA_PICTURE_ICON) + ) { + "[image]".addTo(parts) + } + return parts.takeIf { it.isNotEmpty() }?.joinToString(" — ") + } + + fun snapshot(sbn: StatusBarNotification) = NotificationSnapshot( + key = sbn.key, + packageName = sbn.packageName, + contents = extract(sbn.notification), + ) + + private fun CharSequence.addTo(parts: MutableSet) { + toString().trim().takeIf { it.isNotEmpty() }?.let(parts::add) + } +} diff --git a/app/src/main/java/se/ajpanton/notificationlog/data/AesGcmCipher.kt b/app/src/main/java/se/ajpanton/notificationlog/data/AesGcmCipher.kt index 317a46f..4c5a922 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/data/AesGcmCipher.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/data/AesGcmCipher.kt @@ -1,6 +1,5 @@ package se.ajpanton.notificationlog.data -import java.security.SecureRandom import javax.crypto.Cipher import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec @@ -8,13 +7,11 @@ import javax.crypto.spec.GCMParameterSpec /** Small, format-neutral AES-GCM codec used for every persisted log payload. */ class AesGcmCipher( private val key: SecretKey, - private val secureRandom: SecureRandom = SecureRandom(), ) { fun encrypt(plainText: ByteArray): EncryptedPayload { - val initializationVector = ByteArray(IV_LENGTH_BYTES).also(secureRandom::nextBytes) val cipher = Cipher.getInstance(TRANSFORMATION) - cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(TAG_LENGTH_BITS, initializationVector)) - return EncryptedPayload(initializationVector, cipher.doFinal(plainText)) + cipher.init(Cipher.ENCRYPT_MODE, key) + return EncryptedPayload(cipher.iv, cipher.doFinal(plainText)) } fun decrypt(payload: EncryptedPayload): ByteArray { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 65d3ac8..8a0e56e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,6 +2,7 @@ Notification Log Notification history + Notification Log listener Open navigation Close navigation Settings: diff --git a/app/src/test/java/se/ajpanton/notificationlog/data/AesGcmCipherTest.kt b/app/src/test/java/se/ajpanton/notificationlog/data/AesGcmCipherTest.kt index d71b28c..b272921 100644 --- a/app/src/test/java/se/ajpanton/notificationlog/data/AesGcmCipherTest.kt +++ b/app/src/test/java/se/ajpanton/notificationlog/data/AesGcmCipherTest.kt @@ -3,7 +3,6 @@ package se.ajpanton.notificationlog.data import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -import java.security.SecureRandom import javax.crypto.KeyGenerator class AesGcmCipherTest { @@ -21,7 +20,7 @@ class AesGcmCipherTest { @Test(expected = javax.crypto.AEADBadTagException::class) fun rejectsTamperedCipherText() { val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() - val encrypted = AesGcmCipher(key, SecureRandom()).encrypt("message".encodeToByteArray()) + val encrypted = AesGcmCipher(key).encrypt("message".encodeToByteArray()) encrypted.cipherText[0] = (encrypted.cipherText[0].toInt() xor 1).toByte() AesGcmCipher(key).decrypt(encrypted)