Capture notification lifecycle events securely
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:icon="@drawable/ic_launcher"
|
android:icon="@drawable/ic_launcher"
|
||||||
@@ -16,6 +18,16 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".capture.NotificationCaptureService"
|
||||||
|
android:exported="false"
|
||||||
|
android:label="@string/notification_listener_label"
|
||||||
|
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.notification.NotificationListenerService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -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
|
package se.ajpanton.notificationlog.data
|
||||||
|
|
||||||
import java.security.SecureRandom
|
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.SecretKey
|
import javax.crypto.SecretKey
|
||||||
import javax.crypto.spec.GCMParameterSpec
|
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. */
|
/** Small, format-neutral AES-GCM codec used for every persisted log payload. */
|
||||||
class AesGcmCipher(
|
class AesGcmCipher(
|
||||||
private val key: SecretKey,
|
private val key: SecretKey,
|
||||||
private val secureRandom: SecureRandom = SecureRandom(),
|
|
||||||
) {
|
) {
|
||||||
fun encrypt(plainText: ByteArray): EncryptedPayload {
|
fun encrypt(plainText: ByteArray): EncryptedPayload {
|
||||||
val initializationVector = ByteArray(IV_LENGTH_BYTES).also(secureRandom::nextBytes)
|
|
||||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(TAG_LENGTH_BITS, initializationVector))
|
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||||
return EncryptedPayload(initializationVector, cipher.doFinal(plainText))
|
return EncryptedPayload(cipher.iv, cipher.doFinal(plainText))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun decrypt(payload: EncryptedPayload): ByteArray {
|
fun decrypt(payload: EncryptedPayload): ByteArray {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Notification Log</string>
|
<string name="app_name">Notification Log</string>
|
||||||
<string name="drawer_subtitle">Notification history</string>
|
<string name="drawer_subtitle">Notification history</string>
|
||||||
|
<string name="notification_listener_label">Notification Log listener</string>
|
||||||
<string name="navigation_open">Open navigation</string>
|
<string name="navigation_open">Open navigation</string>
|
||||||
<string name="navigation_close">Close navigation</string>
|
<string name="navigation_close">Close navigation</string>
|
||||||
<string name="navigation_settings_header">Settings:</string>
|
<string name="navigation_settings_header">Settings:</string>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package se.ajpanton.notificationlog.data
|
|||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.security.SecureRandom
|
|
||||||
import javax.crypto.KeyGenerator
|
import javax.crypto.KeyGenerator
|
||||||
|
|
||||||
class AesGcmCipherTest {
|
class AesGcmCipherTest {
|
||||||
@@ -21,7 +20,7 @@ class AesGcmCipherTest {
|
|||||||
@Test(expected = javax.crypto.AEADBadTagException::class)
|
@Test(expected = javax.crypto.AEADBadTagException::class)
|
||||||
fun rejectsTamperedCipherText() {
|
fun rejectsTamperedCipherText() {
|
||||||
val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
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()
|
encrypted.cipherText[0] = (encrypted.cipherText[0].toInt() xor 1).toByte()
|
||||||
|
|
||||||
AesGcmCipher(key).decrypt(encrypted)
|
AesGcmCipher(key).decrypt(encrypted)
|
||||||
|
|||||||
Reference in New Issue
Block a user