Capture notification lifecycle events securely
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user