Complete secure log viewing and retention

This commit is contained in:
ajp_anton
2026-07-23 11:42:56 +00:00
parent 0091eb5d49
commit 8621a30225
6 changed files with 293 additions and 94 deletions
@@ -1,6 +1,7 @@
package se.ajpanton.notificationlog.data
import android.content.Context
import android.util.AtomicFile
import java.io.File
import java.io.DataInputStream
import java.io.FileNotFoundException
@@ -12,11 +13,19 @@ class EncryptedImageStore(context: Context) {
fun save(id: String, bytes: ByteArray) {
require(id.matches(IMAGE_ID_PATTERN)) { "Invalid notification image ID." }
require(bytes.size <= MAX_IMAGE_BYTES) { "Notification image exceeds the 5 MiB retention limit." }
val payload = cipher.encrypt(bytes)
File(directory, "$id.bin").outputStream().use { output ->
val file = AtomicFile(File(directory, "$id.bin"))
val output = file.startWrite()
try {
output.write(payload.initializationVector.size)
output.write(payload.initializationVector)
output.write(payload.cipherText)
output.flush()
file.finishWrite(output)
} catch (error: Exception) {
file.failWrite(output)
throw error
}
}
@@ -38,7 +47,13 @@ class EncryptedImageStore(context: Context) {
}
}
fun delete(id: String) {
require(id.matches(IMAGE_ID_PATTERN)) { "Invalid notification image ID." }
File(directory, "$id.bin").delete()
}
private companion object {
val IMAGE_ID_PATTERN = Regex("[0-9a-f-]{36}")
const val MAX_IMAGE_BYTES = 5 * 1024 * 1024
}
}
@@ -27,7 +27,9 @@ class EncryptedNotificationLogStore(context: Context) {
fun append(entry: NotificationLogEntry) = synchronized(lock) {
val entries = (readPayloadOrNull()?.let { NotificationLogEntryJson.decode(cipher.decrypt(it)) } ?: emptyList())
write(entries + entry)
val retained = retain(entries + entry)
write(retained)
(entries.mapNotNull { it.imageId } - retained.mapNotNull { it.imageId }.toSet()).forEach(::deleteImage)
}
fun replaceAll(entries: List<NotificationLogEntry>) = synchronized(lock) {
@@ -39,6 +41,14 @@ class EncryptedNotificationLogStore(context: Context) {
imageDirectory.listFiles()?.forEach(File::delete)
}
fun delete(id: String): Boolean = synchronized(lock) {
val entries = readPayloadOrNull()?.let { NotificationLogEntryJson.decode(cipher.decrypt(it)) } ?: return false
val entry = entries.firstOrNull { it.id == id } ?: return false
write(entries.filterNot { it.id == id })
entry.imageId?.let(::deleteImage)
true
}
private fun write(entries: List<NotificationLogEntry>) {
val payload = cipher.encrypt(NotificationLogEntryJson.encode(entries))
val output = file.startWrite()
@@ -57,6 +67,21 @@ class EncryptedNotificationLogStore(context: Context) {
}
}
private fun retain(entries: List<NotificationLogEntry>): List<NotificationLogEntry> {
var retained = entries.takeLast(MAX_ENTRIES)
while (retained.size > 1 && NotificationLogEntryJson.encode(retained).size > MAX_PLAINTEXT_BYTES) {
retained = retained.drop(1)
}
require(NotificationLogEntryJson.encode(retained).size <= MAX_PLAINTEXT_BYTES) {
"A notification event exceeds the encrypted log retention limit."
}
return retained
}
private fun deleteImage(id: String) {
if (id.matches(IMAGE_ID_PATTERN)) File(imageDirectory, "$id.bin").delete()
}
private fun readPayloadOrNull(): EncryptedPayload? {
val input = try {
file.openRead()
@@ -82,6 +107,9 @@ class EncryptedNotificationLogStore(context: Context) {
const val FILE_VERSION = 1
const val MAX_IV_BYTES = 32
const val MAX_CIPHER_TEXT_BYTES = 50 * 1024 * 1024
const val MAX_PLAINTEXT_BYTES = 20 * 1024 * 1024
const val MAX_ENTRIES = 10_000
const val IMAGE_DIRECTORY_NAME = "notification-images"
val IMAGE_ID_PATTERN = Regex("[0-9a-f-]{36}")
}
}