From a72c6b943752cc18b69444f4b7a9d0efb7ed20b0 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Mon, 27 Jul 2026 17:14:33 +0000 Subject: [PATCH] Stream notification log exports --- .../notificationlog/SettingsFragment.kt | 86 ++++++++++++++----- .../data/EncryptedImageStore.kt | 5 ++ .../data/EncryptedNotificationLogStore.kt | 16 ++++ .../notificationlog/export/LogExporter.kt | 69 ++++++++++----- .../notificationlog/export/LogExporterTest.kt | 21 +++++ 5 files changed, 154 insertions(+), 43 deletions(-) diff --git a/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt index 7c00db7..4ee6b67 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt @@ -99,34 +99,80 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) { val format = pendingExport ?: return val context = requireContext().applicationContext Thread { - val entries = EncryptedNotificationLogStore(context).readAll() + val logStore = EncryptedNotificationLogStore(context) val settings = LogViewSettingsStore(context).load() context.contentResolver.openOutputStream(uri)?.use { output -> when (format) { - ExportFormat.CSV -> output.write(LogExporter.csv(entries, settings).encodeToByteArray()) - ExportFormat.FORMATTED -> output.write(LogExporter.formatted(entries, settings).encodeToByteArray()) - ExportFormat.HTML_ZIP -> ZipOutputStream(output).use { zip -> - zip.putNextEntry(java.util.zip.ZipEntry("notification-log.html")) - val imageStore = EncryptedImageStore(context) - val imageEntries = entries.filter { it.imageId != null && LogField.CONTENTS in settings.visibleFields } - val exportedImages = imageEntries.mapNotNull { entry -> - imageStore.read(entry.imageId!!)?.let { entry.imageId to it } - }.toMap() - zip.write(LogExporter.html(entries, settings) { entry -> - entry.imageId?.takeIf(exportedImages::containsKey)?.let { "images/$it.png" } - }.encodeToByteArray()) - zip.closeEntry() - exportedImages.forEach { (imageId, image) -> - zip.putNextEntry(java.util.zip.ZipEntry("images/$imageId.png")) - zip.write(image) - zip.closeEntry() - } - } + ExportFormat.CSV -> writeCsv(output.bufferedWriter(Charsets.UTF_8), logStore, settings) + ExportFormat.FORMATTED -> writeFormatted(output.bufferedWriter(Charsets.UTF_8), logStore, settings) + ExportFormat.HTML_ZIP -> writeHtmlZip(ZipOutputStream(output), logStore, settings, context) } } }.start() } + private fun writeCsv( + writer: java.io.BufferedWriter, + logStore: EncryptedNotificationLogStore, + settings: se.ajpanton.notificationlog.settings.LogViewSettings, + ) = writer.use { + it.write(LogExporter.csvHeader(settings)) + logStore.forEachNewest { entry -> + it.newLine() + it.write(LogExporter.csvRow(entry, settings)) + } + } + + private fun writeFormatted( + writer: java.io.BufferedWriter, + logStore: EncryptedNotificationLogStore, + settings: se.ajpanton.notificationlog.settings.LogViewSettings, + ) = writer.use { + val headers = LogExporter.headers(settings) + val widths = headers.map { header -> header.length }.toMutableList() + logStore.forEachNewest { entry -> + LogExporter.values(entry, settings).forEachIndexed { index, value -> + widths[index] = maxOf(widths[index], value.length) + } + } + it.write(LogExporter.formattedRow(headers, widths)) + logStore.forEachNewest { entry -> + it.newLine() + it.write(LogExporter.formattedRow(LogExporter.values(entry, settings), widths)) + } + } + + private fun writeHtmlZip( + zip: ZipOutputStream, + logStore: EncryptedNotificationLogStore, + settings: se.ajpanton.notificationlog.settings.LogViewSettings, + context: android.content.Context, + ) = zip.use { zipOutput -> + val imageStore = EncryptedImageStore(context) + zipOutput.putNextEntry(java.util.zip.ZipEntry("notification-log.html")) + zipOutput.write(LogExporter.htmlStart(settings).encodeToByteArray()) + logStore.forEachNewest { entry -> + val imagePath = entry.imageId + ?.takeIf { LogField.CONTENTS in settings.visibleFields && imageStore.exists(it) } + ?.let { imageId -> "images/$imageId.png" } + zipOutput.write(LogExporter.htmlRow(entry, settings, imagePath).encodeToByteArray()) + } + zipOutput.write(LogExporter.htmlEnd().encodeToByteArray()) + zipOutput.closeEntry() + + if (LogField.CONTENTS in settings.visibleFields) { + logStore.forEachNewest { entry -> + entry.imageId?.let { imageId -> + imageStore.read(imageId)?.let { image -> + zipOutput.putNextEntry(java.util.zip.ZipEntry("images/$imageId.png")) + zipOutput.write(image) + zipOutput.closeEntry() + } + } + } + } + } + private enum class ExportFormat(val extension: String) { CSV("csv"), FORMATTED("txt"), HTML_ZIP("zip") } private companion object { diff --git a/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedImageStore.kt b/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedImageStore.kt index 1678231..1af8d8d 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedImageStore.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedImageStore.kt @@ -47,6 +47,11 @@ class EncryptedImageStore(context: Context) { } } + fun exists(id: String): Boolean { + require(id.matches(IMAGE_ID_PATTERN)) { "Invalid notification image ID." } + return File(directory, "$id.bin").isFile + } + fun delete(id: String) { require(id.matches(IMAGE_ID_PATTERN)) { "Invalid notification image ID." } File(directory, "$id.bin").delete() diff --git a/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedNotificationLogStore.kt b/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedNotificationLogStore.kt index 349d7d0..63508f6 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedNotificationLogStore.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedNotificationLogStore.kt @@ -48,6 +48,22 @@ class EncryptedNotificationLogStore(context: Context) { result.sortedByDescending { it.recordedAtEpochMillis } } + /** + * Visits the current history newest first without collecting it in memory. + * Each encrypted chunk is decrypted while holding the store lock, then its + * entries are passed to [action] after releasing the lock so capture is not + * held up by a slow export destination. + */ + fun forEachNewest(action: (NotificationLogEntry) -> Unit) { + val chunks = synchronized(lock) { chunkFiles().asReversed() } + chunks.forEach { chunk -> + val entries = synchronized(lock) { + if (chunk.exists()) readChunk(chunk).asReversed() else emptyList() + } + entries.forEach(action) + } + } + fun append(entry: NotificationLogEntry) = synchronized(lock) { val chunks = chunkFiles() val newest = chunks.lastOrNull() diff --git a/app/src/main/java/se/ajpanton/notificationlog/export/LogExporter.kt b/app/src/main/java/se/ajpanton/notificationlog/export/LogExporter.kt index 1a42c02..20c46fd 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/export/LogExporter.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/export/LogExporter.kt @@ -9,43 +9,65 @@ import java.util.Date import java.util.TimeZone object LogExporter { - fun csv(entries: List, settings: LogViewSettings): String = - rows(entries, settings).joinToString("\n") { row -> row.joinToString(",") { csvValue(it) } } + fun csv(entries: List, settings: LogViewSettings): String = ( + listOf(csvHeader(settings)) + entries.sortedByDescending { it.recordedAtEpochMillis }.map { csvRow(it, settings) } + ).joinToString("\n") fun formatted(entries: List, settings: LogViewSettings): String { - val rows = rows(entries, settings) + val rows = listOf(headers(settings)) + entries.sortedByDescending { it.recordedAtEpochMillis }.map { values(it, settings) } val widths = rows.fold(emptyList()) { current, row -> row.mapIndexed { i, value -> maxOf(current.getOrElse(i) { 0 }, value.length) } } - return rows.joinToString("\n") { row -> row.mapIndexed { i, value -> value.padEnd(widths[i]) }.joinToString(" ") } + return rows.joinToString("\n") { formattedRow(it, widths) } } + fun headers(settings: LogViewSettings): List = fields(settings).map(::header) + + fun values(entry: NotificationLogEntry, settings: LogViewSettings): List = fields(settings).map { field -> when (field) { + LogField.TIMESTAMP -> timestamp(entry.recordedAtEpochMillis, settings.timestampZone, entry.eventTimeZoneId) + LogField.APP_NAME -> entry.appName + LogField.PACKAGE_NAME -> entry.packageName + LogField.ACTION -> entry.action.name.lowercase().replace('_', ' ') + LogField.CONTENTS -> entry.contents ?: "" + } } + + fun csvHeader(settings: LogViewSettings): String = csvLine(headers(settings)) + + fun csvRow(entry: NotificationLogEntry, settings: LogViewSettings): String = csvLine(values(entry, settings)) + + fun formattedRow(values: List, widths: List): String = + values.mapIndexed { index, value -> value.padEnd(widths[index]) }.joinToString(" ") + + fun htmlStart(settings: LogViewSettings): String = buildString { + append("") + fields(settings).forEach { append("") } + append("") + } + + fun htmlRow( + entry: NotificationLogEntry, + settings: LogViewSettings, + imagePath: String? = null, + ): String = buildString { + append("") + fields(settings).forEach { field -> append("") } + append("") + } + + fun htmlEnd(): String = "
${escape(header(it))}
${htmlValue(field, entry, settings, imagePath)}
" + fun html( entries: List, settings: LogViewSettings, imagePath: (NotificationLogEntry) -> String? = { null }, ): String = buildString { - append("") - settings.order.filter { it in settings.visibleFields }.forEach { append("") } - append("") + append(htmlStart(settings)) entries.sortedByDescending { it.recordedAtEpochMillis }.forEach { entry -> - append("") - settings.order.filter { it in settings.visibleFields }.forEach { field -> - append("") - } - append("") + append(htmlRow(entry, settings, imagePath(entry))) } - append("
${escape(header(it))}
${htmlValue(field, entry, settings, imagePath(entry))}
") + append(htmlEnd()) } - private fun rows(entries: List, settings: LogViewSettings) = listOf( - settings.order.filter { it in settings.visibleFields }.map(::header), - ) + entries.sortedByDescending { it.recordedAtEpochMillis }.map { entry -> - settings.order.filter { it in settings.visibleFields }.map { field -> when (field) { - LogField.TIMESTAMP -> timestamp(entry.recordedAtEpochMillis, settings.timestampZone, entry.eventTimeZoneId) - LogField.APP_NAME -> entry.appName; LogField.PACKAGE_NAME -> entry.packageName - LogField.ACTION -> entry.action.name.lowercase().replace('_', ' ') - LogField.CONTENTS -> entry.contents ?: "" - } } - } + private fun fields(settings: LogViewSettings): List = settings.order.filter { it in settings.visibleFields } + private fun header(field: LogField) = field.name.lowercase().replace('_', ' ') private fun htmlValue(field: LogField, entry: NotificationLogEntry, settings: LogViewSettings, imagePath: String?): String { val value = when (field) { @@ -69,6 +91,7 @@ object LogExporter { TimestampZone.EVENT_LOCAL -> eventTimeZoneId?.let(TimeZone::getTimeZone) ?: TimeZone.getDefault() } }.format(Date(value)) + private fun csvLine(values: List): String = values.joinToString(",") { csvValue(it) } private fun csvValue(value: String) = "\"${value.replace("\"", "\"\"")}\"" private fun escape(value: String) = value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """) } diff --git a/app/src/test/java/se/ajpanton/notificationlog/export/LogExporterTest.kt b/app/src/test/java/se/ajpanton/notificationlog/export/LogExporterTest.kt index 7d63824..b1b8052 100644 --- a/app/src/test/java/se/ajpanton/notificationlog/export/LogExporterTest.kt +++ b/app/src/test/java/se/ajpanton/notificationlog/export/LogExporterTest.kt @@ -1,6 +1,7 @@ package se.ajpanton.notificationlog.export import org.junit.Assert.assertFalse +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import se.ajpanton.notificationlog.model.NotificationAction @@ -30,4 +31,24 @@ class LogExporterTest { assertFalse(withoutImage.contains(" + widths[index] = maxOf(widths[index], value.length) + } + val formatted = listOf( + LogExporter.formattedRow(LogExporter.headers(settings), widths), + LogExporter.formattedRow(LogExporter.values(imageEntry, settings), widths), + ).joinToString("\n") + val html = LogExporter.htmlStart(settings) + + LogExporter.htmlRow(imageEntry, settings, "images/${imageEntry.imageId}.png") + + LogExporter.htmlEnd() + + assertEquals(LogExporter.csv(listOf(imageEntry), settings), csv) + assertEquals(LogExporter.formatted(listOf(imageEntry), settings), formatted) + assertEquals(LogExporter.html(listOf(imageEntry), settings) { "images/${it.imageId}.png" }, html) + } }