Streamline encrypted settings and logging

This commit is contained in:
ajp_anton
2026-09-03 00:52:31 +00:00
parent 78bf4bde8c
commit 1d2144e72c
6 changed files with 129 additions and 114 deletions
@@ -1,63 +1,36 @@
package se.ajpanton.notificationsmaster.alerts package se.ajpanton.notificationsmaster.alerts
import android.content.Context import android.content.Context
import android.util.AtomicFile
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import se.ajpanton.notificationsmaster.data.AesGcmCipher import se.ajpanton.notificationsmaster.data.EncryptedAtomicFile
import se.ajpanton.notificationsmaster.data.EncryptedPayload
import se.ajpanton.notificationsmaster.data.LogEncryptionKeyProvider
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
import se.ajpanton.notificationsmaster.module.AlertPolicySync import se.ajpanton.notificationsmaster.module.AlertPolicySync
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.File
import java.io.FileNotFoundException
/** Atomic, encrypted storage for alert settings. An unsupported format fails closed. */ /** Atomic, encrypted storage for alert settings. An unsupported format fails closed. */
class AlertConfigurationStore(context: Context) { class AlertConfigurationStore(context: Context) {
private val context = context.applicationContext private val context = context.applicationContext
private val file = AtomicFile(File(this.context.filesDir, FILE_NAME)) private val file = EncryptedAtomicFile(this.context, FILE_NAME)
private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate())
@Synchronized fun load(): AlertConfiguration = synchronized(lock) {
fun load(): AlertConfiguration = try { if (!file.exists) cached = null
DataInputStream(BufferedInputStream(file.openRead())).use { input -> cached ?: file.read()?.let(AlertConfigurationJson::decode)?.also { cached = it }
val iv = ByteArray(input.readInt().also { require(it in 1..32) }) ?: AlertConfiguration()
input.readFully(iv)
val encrypted = ByteArray(input.readInt().also { require(it in 1..MAX_CIPHER_TEXT_BYTES) })
input.readFully(encrypted)
AlertConfigurationJson.decode(cipher.decrypt(EncryptedPayload(iv, encrypted)))
}
} catch (_: FileNotFoundException) {
AlertConfiguration()
} }
@Synchronized
fun save(configuration: AlertConfiguration) { fun save(configuration: AlertConfiguration) {
val payload = cipher.encrypt(AlertConfigurationJson.encode(configuration)) synchronized(lock) {
val output = file.startWrite() file.write(AlertConfigurationJson.encode(configuration))
try { cached = configuration
val stream = DataOutputStream(BufferedOutputStream(output))
stream.writeInt(payload.initializationVector.size)
stream.write(payload.initializationVector)
stream.writeInt(payload.cipherText.size)
stream.write(payload.cipherText)
stream.flush()
file.finishWrite(output)
NotificationListenerComponentController.synchronize(context)
AlertPolicySync.publish(context, configuration)
} catch (error: Exception) {
file.failWrite(output)
throw error
} }
NotificationListenerComponentController.synchronize(context)
AlertPolicySync.publish(context, configuration)
} }
private companion object { private companion object {
const val FILE_NAME = "alert-configuration.bin" const val FILE_NAME = "alert-configuration.bin"
const val MAX_CIPHER_TEXT_BYTES = 1024 * 1024 val lock = Any()
var cached: AlertConfiguration? = null
} }
} }
@@ -133,9 +133,13 @@ class NotificationCaptureService : NotificationListenerService() {
imageBytes: (() -> ByteArray?)? = null, imageBytes: (() -> ByteArray?)? = null,
) { ) {
if (!GroupSummaryPolicy.shouldLog(snapshot, action, captureSettings.logGroupSummaries)) return if (!GroupSummaryPolicy.shouldLog(snapshot, action, captureSettings.logGroupSummaries)) return
if (!allows(loggingType, snapshot.packageName)) return if (!loggingEnabled(loggingType, snapshot.packageName) ||
!NotificationRuleEvaluator.allows(appFilterStore.load(), snapshot.packageName)
) return
val appName = appName(snapshot.packageName) val appName = appName(snapshot.packageName)
val retainImage = includeContents && snapshot.hasImage && allows(LoggingType.IMAGE_CONTENT, snapshot.packageName) val textEnabled = includeContents && loggingEnabled(LoggingType.TEXT_CONTENT, snapshot.packageName)
val imageEnabled = includeContents && loggingEnabled(LoggingType.IMAGE_CONTENT, snapshot.packageName)
val retainImage = snapshot.hasImage && imageEnabled
val retainedImageBytes = if (retainImage) imageBytes?.invoke() else null val retainedImageBytes = if (retainImage) imageBytes?.invoke() else null
val entry = NotificationLogEntry( val entry = NotificationLogEntry(
recordedAtEpochMillis = System.currentTimeMillis(), recordedAtEpochMillis = System.currentTimeMillis(),
@@ -143,8 +147,8 @@ class NotificationCaptureService : NotificationListenerService() {
packageName = snapshot.packageName, packageName = snapshot.packageName,
appName = appName, appName = appName,
action = action, action = action,
contents = if (includeContents) visibleContents(snapshot) else null, contents = if (includeContents) visibleContents(snapshot, textEnabled, imageEnabled) else null,
previousContents = previousSnapshot?.let(::visibleContents), previousContents = previousSnapshot?.let { visibleContents(it, textEnabled, imageEnabled) },
imageId = if (retainedImageBytes != null) java.util.UUID.randomUUID().toString() else null, imageId = if (retainedImageBytes != null) java.util.UUID.randomUUID().toString() else null,
) )
writeExecutor.execute { writeExecutor.execute {
@@ -166,25 +170,23 @@ class NotificationCaptureService : NotificationListenerService() {
} }
} }
private fun visibleContents(snapshot: NotificationSnapshot): String? { private fun visibleContents(snapshot: NotificationSnapshot, textEnabled: Boolean, imageEnabled: Boolean): String? {
val text = snapshot.textContents?.takeIf { return listOfNotNull(
allows(LoggingType.TEXT_CONTENT, snapshot.packageName) snapshot.textContents?.takeIf { textEnabled },
} if (snapshot.hasImage && imageEnabled) "[image]" else null,
val image = snapshot.hasImage && allows(LoggingType.IMAGE_CONTENT, snapshot.packageName) )
return listOfNotNull(text, if (image) "[image]" else null)
.joinToString("\n") .joinToString("\n")
.take(MAX_CONTENT_CHARACTERS) .take(MAX_CONTENT_CHARACTERS)
.ifEmpty { null } .ifEmpty { null }
} }
private fun allows(type: LoggingType, packageName: String): Boolean { private fun loggingEnabled(type: LoggingType, packageName: String): Boolean {
val globalEnabled = ruleStore.ruleFor(type).enabled val globalEnabled = ruleStore.ruleFor(type).enabled
val eventEnabled = if (type in LoggingType.eventTypes) { return if (type in LoggingType.eventTypes) {
perAppEventSettings.isEnabled(packageName, type, globalEnabled) perAppEventSettings.isEnabled(packageName, type, globalEnabled)
} else { } else {
globalEnabled globalEnabled
} }
return eventEnabled && NotificationRuleEvaluator.allows(appFilterStore.load(), packageName)
} }
private fun dispatchAlert(snapshot: NotificationSnapshot, source: AlertSource) { private fun dispatchAlert(snapshot: NotificationSnapshot, source: AlertSource) {
@@ -26,7 +26,7 @@ object SeenApps {
private fun resetAfterBoot(preferences: android.content.SharedPreferences) { private fun resetAfterBoot(preferences: android.content.SharedPreferences) {
val bootEpoch = System.currentTimeMillis() - SystemClock.elapsedRealtime() val bootEpoch = System.currentTimeMillis() - SystemClock.elapsedRealtime()
if (abs(preferences.getLong(KEY_BOOT_EPOCH, -1) - bootEpoch) > 5_000) { if (abs(preferences.getLong(KEY_BOOT_EPOCH, -1) - bootEpoch) > 5_000) {
preferences.edit().putLong(KEY_BOOT_EPOCH, bootEpoch).remove(KEY_PACKAGES).commit() preferences.edit().putLong(KEY_BOOT_EPOCH, bootEpoch).remove(KEY_PACKAGES).apply()
} }
} }
@@ -0,0 +1,56 @@
package se.ajpanton.notificationsmaster.data
import android.content.Context
import android.util.AtomicFile
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.File
import java.io.FileNotFoundException
internal class EncryptedAtomicFile(context: Context, name: String) {
private val file = AtomicFile(File(context.filesDir, name))
private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate())
val exists get() = file.baseFile.isFile
fun read(): ByteArray? = try {
DataInputStream(BufferedInputStream(file.openRead())).use { input ->
val iv = input.readBytes(MAX_IV_BYTES)
val encrypted = input.readBytes(MAX_CIPHER_TEXT_BYTES)
cipher.decrypt(EncryptedPayload(iv, encrypted))
}
} catch (_: FileNotFoundException) {
null
}
fun write(bytes: ByteArray) {
val payload = cipher.encrypt(bytes)
val output = file.startWrite()
try {
DataOutputStream(BufferedOutputStream(output)).apply {
writeInt(payload.initializationVector.size)
write(payload.initializationVector)
writeInt(payload.cipherText.size)
write(payload.cipherText)
flush()
}
file.finishWrite(output)
} catch (error: Exception) {
file.failWrite(output)
throw error
}
}
private fun DataInputStream.readBytes(maximum: Int): ByteArray {
val length = readInt()
require(length in 1..maximum) { "Invalid encrypted configuration field length." }
return ByteArray(length).also(::readFully)
}
private companion object {
const val MAX_IV_BYTES = 32
const val MAX_CIPHER_TEXT_BYTES = 1024 * 1024
}
}
@@ -80,10 +80,11 @@ class EncryptedNotificationLogStore(context: Context) {
val chunks = chunkFiles() val chunks = chunkFiles()
val newest = chunks.lastOrNull() val newest = chunks.lastOrNull()
val current = newest?.let(::readChunk).orEmpty() val current = newest?.let(::readChunk).orEmpty()
if (newest == null || encodedSize(current + entry) > MAX_CHUNK_PLAINTEXT_BYTES) { val combined = NotificationLogEntryJson.encode(current + entry)
writeChunk(nextChunkFile(chunks), listOf(entry)) if (newest == null || combined.size <= MAX_CHUNK_PLAINTEXT_BYTES) {
writeChunk(newest ?: nextChunkFile(chunks), combined)
} else { } else {
writeChunk(newest, current + entry) writeChunk(nextChunkFile(chunks), NotificationLogEntryJson.encode(listOf(entry)))
} }
enforceLimits() enforceLimits()
} }
@@ -98,7 +99,7 @@ class EncryptedNotificationLogStore(context: Context) {
val entries = readChunk(chunk) val entries = readChunk(chunk)
val entry = entries.firstOrNull { it.id == id } ?: return@forEach val entry = entries.firstOrNull { it.id == id } ?: return@forEach
val retained = entries.filterNot { it.id == id } val retained = entries.filterNot { it.id == id }
if (retained.isEmpty()) chunk.delete() else writeChunk(chunk, retained) if (retained.isEmpty()) chunk.delete() else writeChunk(chunk, NotificationLogEntryJson.encode(retained))
entry.imageId?.let(::deleteImage) entry.imageId?.let(::deleteImage)
return true return true
} }
@@ -137,7 +138,8 @@ class EncryptedNotificationLogStore(context: Context) {
while (totalBytes > limit && retained.isNotEmpty()) { while (totalBytes > limit && retained.isNotEmpty()) {
retained.first().imageId?.let(::deleteImage) retained.first().imageId?.let(::deleteImage)
retained = retained.drop(1) retained = retained.drop(1)
if (retained.isEmpty()) oldest.delete() else writeChunk(oldest, retained) if (retained.isEmpty()) oldest.delete()
else writeChunk(oldest, NotificationLogEntryJson.encode(retained))
totalBytes = chunkFiles().sumOf(File::length) totalBytes = chunkFiles().sumOf(File::length)
} }
} }
@@ -203,8 +205,8 @@ class EncryptedNotificationLogStore(context: Context) {
} }
} }
private fun writeChunk(file: File, entries: List<NotificationLogEntry>) { private fun writeChunk(file: File, encoded: ByteArray) {
val payload = cipher.encrypt(NotificationLogEntryJson.encode(entries)) val payload = cipher.encrypt(encoded)
val atomicFile = AtomicFile(file) val atomicFile = AtomicFile(file)
val output = atomicFile.startWrite() val output = atomicFile.startWrite()
try { try {
@@ -228,8 +230,6 @@ class EncryptedNotificationLogStore(context: Context) {
return ByteArray(length).also(stream::readFully) return ByteArray(length).also(stream::readFully)
} }
private fun encodedSize(entries: List<NotificationLogEntry>) = NotificationLogEntryJson.encode(entries).size
private fun clearChunksOnly() { private fun clearChunksOnly() {
chunkFiles().forEach(File::delete) chunkFiles().forEach(File::delete)
} }
@@ -1,75 +1,59 @@
package se.ajpanton.notificationsmaster.visibility package se.ajpanton.notificationsmaster.visibility
import android.content.Context import android.content.Context
import android.util.AtomicFile import se.ajpanton.notificationsmaster.data.EncryptedAtomicFile
import se.ajpanton.notificationsmaster.data.AesGcmCipher
import se.ajpanton.notificationsmaster.data.EncryptedPayload
import se.ajpanton.notificationsmaster.data.LogEncryptionKeyProvider
import se.ajpanton.notificationsmaster.module.VisibilityPolicySync import se.ajpanton.notificationsmaster.module.VisibilityPolicySync
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.File
import java.io.FileNotFoundException
class VisibilityPolicyStore(context: Context) { class VisibilityPolicyStore(context: Context) {
private val context = context.applicationContext private val context = context.applicationContext
private val file = AtomicFile(File(this.context.filesDir, FILE_NAME)) private val file = EncryptedAtomicFile(this.context, FILE_NAME)
private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate())
@Synchronized fun load(): VisibilityPolicy = synchronized(lock) {
fun load(): VisibilityPolicy = try { loadLocked()
DataInputStream(BufferedInputStream(file.openRead())).use { input ->
val iv = ByteArray(input.readInt().also { require(it in 1..32) })
input.readFully(iv)
val encrypted = ByteArray(input.readInt().also { require(it in 1..MAX_CIPHER_TEXT_BYTES) })
input.readFully(encrypted)
VisibilityPolicyJson.decode(cipher.decrypt(EncryptedPayload(iv, encrypted)))
}
} catch (_: FileNotFoundException) {
VisibilityPolicy()
} }
@Synchronized fun save(policy: VisibilityPolicy) = update { policy }
fun save(policy: VisibilityPolicy): VisibilityPolicy {
require(policy.apps.flatMap { it.exceptions }.all { it.hasValidPattern() }) fun update(change: (VisibilityPolicy) -> VisibilityPolicy): VisibilityPolicy {
val stored = policy.copy(generation = load().generation + 1) val stored = synchronized(lock) {
val encoded = VisibilityPolicyJson.encode(stored) val current = loadLocked()
require(encoded.size <= VisibilityPolicySync.MAX_POLICY_BYTES) { "Visibility policy is too large." } saveLocked(change(current), current.generation + 1)
val payload = cipher.encrypt(encoded)
val output = file.startWrite()
try {
DataOutputStream(BufferedOutputStream(output)).apply {
writeInt(payload.initializationVector.size)
write(payload.initializationVector)
writeInt(payload.cipherText.size)
write(payload.cipherText)
flush()
}
file.finishWrite(output)
} catch (error: Exception) {
file.failWrite(output)
throw error
} }
NotificationListenerComponentController.synchronize(context) return publish(stored)
VisibilityPolicySync.publish(context, stored)
return stored
} }
@Synchronized
fun update(change: (VisibilityPolicy) -> VisibilityPolicy) = save(change(load()))
@Synchronized
fun removeUninstalledPackages(installedPackages: Set<String>) { fun removeUninstalledPackages(installedPackages: Set<String>) {
val current = load() val current = load()
val retained = current.apps.filter { it.packageName in installedPackages } val retained = current.apps.filter { it.packageName in installedPackages }
if (retained.size != current.apps.size) save(current.copy(apps = retained)) if (retained.size != current.apps.size) save(current.copy(apps = retained))
} }
private fun loadLocked(): VisibilityPolicy {
if (!file.exists) cached = null
return cached ?: file.read()?.let(VisibilityPolicyJson::decode)?.also { cached = it }
?: VisibilityPolicy()
}
private fun saveLocked(policy: VisibilityPolicy, generation: Long): VisibilityPolicy {
require(policy.apps.flatMap { it.exceptions }.all { it.hasValidPattern() })
val stored = policy.copy(generation = generation)
val encoded = VisibilityPolicyJson.encode(stored)
require(encoded.size <= VisibilityPolicySync.MAX_POLICY_BYTES) { "Visibility policy is too large." }
file.write(encoded)
cached = stored
return stored
}
private fun publish(policy: VisibilityPolicy): VisibilityPolicy {
NotificationListenerComponentController.synchronize(context)
VisibilityPolicySync.publish(context, policy)
return policy
}
private companion object { private companion object {
const val FILE_NAME = "visibility-policy.bin" const val FILE_NAME = "visibility-policy.bin"
const val MAX_CIPHER_TEXT_BYTES = 1024 * 1024 val lock = Any()
var cached: VisibilityPolicy? = null
} }
} }