Capture notification lifecycle events securely

This commit is contained in:
ajp_anton
2026-07-23 08:08:11 +00:00
parent 7da14dd10f
commit 6d77a43c55
6 changed files with 176 additions and 7 deletions
@@ -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<String, NotificationSnapshot>()
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"
}
}
@@ -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<String>()
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<String>) {
toString().trim().takeIf { it.isNotEmpty() }?.let(parts::add)
}
}
@@ -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 {