From 1d2144e72c2f98a555fde30cf2ca784002609f79 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Thu, 3 Sep 2026 00:52:31 +0000 Subject: [PATCH] Streamline encrypted settings and logging --- .../alerts/AlertConfigurationStore.kt | 53 +++--------- .../capture/NotificationCaptureService.kt | 28 +++--- .../notificationsmaster/capture/SeenApps.kt | 2 +- .../data/EncryptedAtomicFile.kt | 56 ++++++++++++ .../data/EncryptedNotificationLogStore.kt | 18 ++-- .../visibility/VisibilityPolicyStore.kt | 86 ++++++++----------- 6 files changed, 129 insertions(+), 114 deletions(-) create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/data/EncryptedAtomicFile.kt diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertConfigurationStore.kt b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertConfigurationStore.kt index 50e8f70..4788efc 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertConfigurationStore.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertConfigurationStore.kt @@ -1,63 +1,36 @@ package se.ajpanton.notificationsmaster.alerts import android.content.Context -import android.util.AtomicFile import org.json.JSONArray import org.json.JSONObject -import se.ajpanton.notificationsmaster.data.AesGcmCipher -import se.ajpanton.notificationsmaster.data.EncryptedPayload -import se.ajpanton.notificationsmaster.data.LogEncryptionKeyProvider +import se.ajpanton.notificationsmaster.data.EncryptedAtomicFile import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController 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. */ class AlertConfigurationStore(context: Context) { private val context = context.applicationContext - private val file = AtomicFile(File(this.context.filesDir, FILE_NAME)) - private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate()) + private val file = EncryptedAtomicFile(this.context, FILE_NAME) - @Synchronized - fun load(): AlertConfiguration = try { - 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) - AlertConfigurationJson.decode(cipher.decrypt(EncryptedPayload(iv, encrypted))) - } - } catch (_: FileNotFoundException) { - AlertConfiguration() + fun load(): AlertConfiguration = synchronized(lock) { + if (!file.exists) cached = null + cached ?: file.read()?.let(AlertConfigurationJson::decode)?.also { cached = it } + ?: AlertConfiguration() } - @Synchronized fun save(configuration: AlertConfiguration) { - val payload = cipher.encrypt(AlertConfigurationJson.encode(configuration)) - val output = file.startWrite() - try { - 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 + synchronized(lock) { + file.write(AlertConfigurationJson.encode(configuration)) + cached = configuration } + NotificationListenerComponentController.synchronize(context) + AlertPolicySync.publish(context, configuration) } private companion object { const val FILE_NAME = "alert-configuration.bin" - const val MAX_CIPHER_TEXT_BYTES = 1024 * 1024 + val lock = Any() + var cached: AlertConfiguration? = null } } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/capture/NotificationCaptureService.kt b/app/src/main/java/se/ajpanton/notificationsmaster/capture/NotificationCaptureService.kt index 9d1f14e..c732c8d 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/capture/NotificationCaptureService.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/capture/NotificationCaptureService.kt @@ -133,9 +133,13 @@ class NotificationCaptureService : NotificationListenerService() { imageBytes: (() -> ByteArray?)? = null, ) { 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 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 entry = NotificationLogEntry( recordedAtEpochMillis = System.currentTimeMillis(), @@ -143,8 +147,8 @@ class NotificationCaptureService : NotificationListenerService() { packageName = snapshot.packageName, appName = appName, action = action, - contents = if (includeContents) visibleContents(snapshot) else null, - previousContents = previousSnapshot?.let(::visibleContents), + contents = if (includeContents) visibleContents(snapshot, textEnabled, imageEnabled) else null, + previousContents = previousSnapshot?.let { visibleContents(it, textEnabled, imageEnabled) }, imageId = if (retainedImageBytes != null) java.util.UUID.randomUUID().toString() else null, ) writeExecutor.execute { @@ -166,25 +170,23 @@ class NotificationCaptureService : NotificationListenerService() { } } - private fun visibleContents(snapshot: NotificationSnapshot): String? { - val text = snapshot.textContents?.takeIf { - allows(LoggingType.TEXT_CONTENT, snapshot.packageName) - } - val image = snapshot.hasImage && allows(LoggingType.IMAGE_CONTENT, snapshot.packageName) - return listOfNotNull(text, if (image) "[image]" else null) + private fun visibleContents(snapshot: NotificationSnapshot, textEnabled: Boolean, imageEnabled: Boolean): String? { + return listOfNotNull( + snapshot.textContents?.takeIf { textEnabled }, + if (snapshot.hasImage && imageEnabled) "[image]" else null, + ) .joinToString("\n") .take(MAX_CONTENT_CHARACTERS) .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 eventEnabled = if (type in LoggingType.eventTypes) { + return if (type in LoggingType.eventTypes) { perAppEventSettings.isEnabled(packageName, type, globalEnabled) } else { globalEnabled } - return eventEnabled && NotificationRuleEvaluator.allows(appFilterStore.load(), packageName) } private fun dispatchAlert(snapshot: NotificationSnapshot, source: AlertSource) { diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/capture/SeenApps.kt b/app/src/main/java/se/ajpanton/notificationsmaster/capture/SeenApps.kt index e8f3c43..2d4bc1a 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/capture/SeenApps.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/capture/SeenApps.kt @@ -26,7 +26,7 @@ object SeenApps { private fun resetAfterBoot(preferences: android.content.SharedPreferences) { val bootEpoch = System.currentTimeMillis() - SystemClock.elapsedRealtime() 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() } } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/data/EncryptedAtomicFile.kt b/app/src/main/java/se/ajpanton/notificationsmaster/data/EncryptedAtomicFile.kt new file mode 100644 index 0000000..ec9a2a7 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/data/EncryptedAtomicFile.kt @@ -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 + } +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/data/EncryptedNotificationLogStore.kt b/app/src/main/java/se/ajpanton/notificationsmaster/data/EncryptedNotificationLogStore.kt index 5bd9e64..3d041e2 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/data/EncryptedNotificationLogStore.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/data/EncryptedNotificationLogStore.kt @@ -80,10 +80,11 @@ class EncryptedNotificationLogStore(context: Context) { val chunks = chunkFiles() val newest = chunks.lastOrNull() val current = newest?.let(::readChunk).orEmpty() - if (newest == null || encodedSize(current + entry) > MAX_CHUNK_PLAINTEXT_BYTES) { - writeChunk(nextChunkFile(chunks), listOf(entry)) + val combined = NotificationLogEntryJson.encode(current + entry) + if (newest == null || combined.size <= MAX_CHUNK_PLAINTEXT_BYTES) { + writeChunk(newest ?: nextChunkFile(chunks), combined) } else { - writeChunk(newest, current + entry) + writeChunk(nextChunkFile(chunks), NotificationLogEntryJson.encode(listOf(entry))) } enforceLimits() } @@ -98,7 +99,7 @@ class EncryptedNotificationLogStore(context: Context) { val entries = readChunk(chunk) val entry = entries.firstOrNull { it.id == id } ?: return@forEach 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) return true } @@ -137,7 +138,8 @@ class EncryptedNotificationLogStore(context: Context) { while (totalBytes > limit && retained.isNotEmpty()) { retained.first().imageId?.let(::deleteImage) 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) } } @@ -203,8 +205,8 @@ class EncryptedNotificationLogStore(context: Context) { } } - private fun writeChunk(file: File, entries: List) { - val payload = cipher.encrypt(NotificationLogEntryJson.encode(entries)) + private fun writeChunk(file: File, encoded: ByteArray) { + val payload = cipher.encrypt(encoded) val atomicFile = AtomicFile(file) val output = atomicFile.startWrite() try { @@ -228,8 +230,6 @@ class EncryptedNotificationLogStore(context: Context) { return ByteArray(length).also(stream::readFully) } - private fun encodedSize(entries: List) = NotificationLogEntryJson.encode(entries).size - private fun clearChunksOnly() { chunkFiles().forEach(File::delete) } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStore.kt b/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStore.kt index 8b42226..bec10a5 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStore.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStore.kt @@ -1,75 +1,59 @@ package se.ajpanton.notificationsmaster.visibility import android.content.Context -import android.util.AtomicFile -import se.ajpanton.notificationsmaster.data.AesGcmCipher -import se.ajpanton.notificationsmaster.data.EncryptedPayload -import se.ajpanton.notificationsmaster.data.LogEncryptionKeyProvider +import se.ajpanton.notificationsmaster.data.EncryptedAtomicFile import se.ajpanton.notificationsmaster.module.VisibilityPolicySync 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) { private val context = context.applicationContext - private val file = AtomicFile(File(this.context.filesDir, FILE_NAME)) - private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate()) + private val file = EncryptedAtomicFile(this.context, FILE_NAME) - @Synchronized - fun load(): VisibilityPolicy = try { - 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() + fun load(): VisibilityPolicy = synchronized(lock) { + loadLocked() } - @Synchronized - fun save(policy: VisibilityPolicy): VisibilityPolicy { - require(policy.apps.flatMap { it.exceptions }.all { it.hasValidPattern() }) - val stored = policy.copy(generation = load().generation + 1) - val encoded = VisibilityPolicyJson.encode(stored) - require(encoded.size <= VisibilityPolicySync.MAX_POLICY_BYTES) { "Visibility policy is too large." } - 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 + fun save(policy: VisibilityPolicy) = update { policy } + + fun update(change: (VisibilityPolicy) -> VisibilityPolicy): VisibilityPolicy { + val stored = synchronized(lock) { + val current = loadLocked() + saveLocked(change(current), current.generation + 1) } - NotificationListenerComponentController.synchronize(context) - VisibilityPolicySync.publish(context, stored) - return stored + return publish(stored) } - @Synchronized - fun update(change: (VisibilityPolicy) -> VisibilityPolicy) = save(change(load())) - - @Synchronized fun removeUninstalledPackages(installedPackages: Set) { val current = load() val retained = current.apps.filter { it.packageName in installedPackages } 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 { const val FILE_NAME = "visibility-policy.bin" - const val MAX_CIPHER_TEXT_BYTES = 1024 * 1024 + val lock = Any() + var cached: VisibilityPolicy? = null } }