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
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)
synchronized(lock) {
file.write(AlertConfigurationJson.encode(configuration))
cached = configuration
}
NotificationListenerComponentController.synchronize(context)
AlertPolicySync.publish(context, configuration)
} catch (error: Exception) {
file.failWrite(output)
throw error
}
}
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
}
}
@@ -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) {
@@ -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()
}
}
@@ -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 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<NotificationLogEntry>) {
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<NotificationLogEntry>) = NotificationLogEntryJson.encode(entries).size
private fun clearChunksOnly() {
chunkFiles().forEach(File::delete)
}
@@ -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()
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)
}
file.finishWrite(output)
} catch (error: Exception) {
file.failWrite(output)
throw error
}
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<String>) {
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
}
}