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)