diff --git a/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt index 7c17cb8..78553c3 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt @@ -16,6 +16,7 @@ import se.ajpanton.notificationlog.settings.LoggingType import se.ajpanton.notificationlog.settings.LoggingRuleStore import se.ajpanton.notificationlog.settings.AppLockStore import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore +import se.ajpanton.notificationlog.data.EncryptedImageStore import se.ajpanton.notificationlog.export.LogExporter import java.util.zip.ZipOutputStream @@ -188,8 +189,20 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) { 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")) - zip.write(LogExporter.html(entries, settings).encodeToByteArray()) + 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() + } } } } diff --git a/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt index b0f9efb..8adf6a5 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt @@ -11,6 +11,7 @@ import android.widget.TextView import androidx.fragment.app.Fragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore +import se.ajpanton.notificationlog.data.EncryptedImageStore import se.ajpanton.notificationlog.databinding.FragmentViewLogsBinding import se.ajpanton.notificationlog.model.NotificationLogEntry import se.ajpanton.notificationlog.settings.LogField @@ -126,8 +127,20 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) { 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")) - zip.write(LogExporter.html(entries, settings).encodeToByteArray()) + 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() + } } } } }.start() diff --git a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt index 79f6408..d390895 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt @@ -77,17 +77,20 @@ class NotificationCaptureService : NotificationListenerService() { ) { if (!NotificationRuleEvaluator.allows(ruleStore.ruleFor(loggingType), snapshot.packageName)) return val appName = appName(snapshot.packageName) + val retainImage = includeContents && snapshot.imageBytes != null && + NotificationRuleEvaluator.allows(ruleStore.ruleFor(LoggingType.IMAGE_CONTENT), snapshot.packageName) val entry = NotificationLogEntry( recordedAtEpochMillis = System.currentTimeMillis(), packageName = snapshot.packageName, appName = appName, action = action, contents = if (includeContents) visibleContents(snapshot) else null, + imageId = if (retainImage) java.util.UUID.randomUUID().toString() else null, ) writeExecutor.execute { try { - if (includeContents && snapshot.imageBytes != null && NotificationRuleEvaluator.allows(ruleStore.ruleFor(LoggingType.IMAGE_CONTENT), snapshot.packageName)) { - imageStore.save(entry.id, snapshot.imageBytes) + if (retainImage) { + imageStore.save(entry.imageId!!, snapshot.imageBytes!!) } logStore.append(entry) } catch (error: Exception) { 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 cc1be06..7254ddd 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedImageStore.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedImageStore.kt @@ -2,6 +2,8 @@ package se.ajpanton.notificationlog.data import android.content.Context import java.io.File +import java.io.DataInputStream +import java.io.FileNotFoundException /** Stores copied notification image bytes independently of the source app's URI lifetime. */ class EncryptedImageStore(context: Context) { @@ -9,6 +11,7 @@ class EncryptedImageStore(context: Context) { private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate()) fun save(id: String, bytes: ByteArray) { + require(id.matches(IMAGE_ID_PATTERN)) { "Invalid notification image ID." } val payload = cipher.encrypt(bytes) File(directory, "$id.bin").outputStream().use { output -> output.write(payload.initializationVector.size) @@ -16,4 +19,26 @@ class EncryptedImageStore(context: Context) { output.write(payload.cipherText) } } + + fun read(id: String): ByteArray? { + require(id.matches(IMAGE_ID_PATTERN)) { "Invalid notification image ID." } + val file = File(directory, "$id.bin") + if (!file.exists()) return null + try { + DataInputStream(file.inputStream()).use { input -> + val ivLength = input.readUnsignedByte() + require(ivLength in 12..32) { "Invalid notification image IV length." } + val initializationVector = ByteArray(ivLength).also(input::readFully) + val cipherText = input.readBytes() + require(cipherText.isNotEmpty()) { "Empty encrypted notification image." } + return cipher.decrypt(EncryptedPayload(initializationVector, cipherText)) + } + } catch (error: FileNotFoundException) { + return null + } + } + + private companion object { + val IMAGE_ID_PATTERN = Regex("[0-9a-f-]{36}") + } } diff --git a/app/src/main/java/se/ajpanton/notificationlog/data/NotificationLogEntryJson.kt b/app/src/main/java/se/ajpanton/notificationlog/data/NotificationLogEntryJson.kt index 71ecf7c..f7f3c58 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/data/NotificationLogEntryJson.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/data/NotificationLogEntryJson.kt @@ -16,7 +16,8 @@ object NotificationLogEntryJson { .put("packageName", entry.packageName) .put("appName", entry.appName) .put("action", entry.action.name) - .put("contents", entry.contents), + .put("contents", entry.contents) + .put("imageId", entry.imageId), ) } }.toString().encodeToByteArray() @@ -32,6 +33,7 @@ object NotificationLogEntryJson { appName = entry.getString("appName"), action = NotificationAction.valueOf(entry.getString("action")), contents = if (entry.isNull("contents")) null else entry.getString("contents"), + imageId = entry.optString("imageId").takeIf { it.isNotEmpty() }, ) } } 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 30e65b2..ab98054 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/export/LogExporter.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/export/LogExporter.kt @@ -18,11 +18,21 @@ object LogExporter { return rows.joinToString("\n") { row -> row.mapIndexed { i, value -> value.padEnd(widths[i]) }.joinToString(" ") } } - fun html(entries: List, settings: LogViewSettings): String = buildString { + fun html( + entries: List, + settings: LogViewSettings, + imagePath: (NotificationLogEntry) -> String? = { null }, + ): String = buildString { append("") settings.order.filter { it in settings.visibleFields }.forEach { append("") } append("") - rows(entries, settings).forEach { row -> append(""); row.forEach { append("") }; append("") } + entries.sortedByDescending { it.recordedAtEpochMillis }.forEach { entry -> + append("") + settings.order.filter { it in settings.visibleFields }.forEach { field -> + append("") + } + append("") + } append("
${escape(header(it))}
${escape(it)}
${htmlValue(field, entry, settings, imagePath(entry))}
") } @@ -37,6 +47,21 @@ object LogExporter { } } } 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) { + LogField.TIMESTAMP -> timestamp(entry.recordedAtEpochMillis, settings.timestampZone) + LogField.APP_NAME -> entry.appName + LogField.PACKAGE_NAME -> entry.packageName + LogField.ACTION -> entry.action.name.lowercase().replace('_', ' ') + LogField.CONTENTS -> entry.contents.orEmpty() + } + val escaped = escape(value) + return if (field == LogField.CONTENTS && imagePath != null) { + escaped.replace("[image]", "\"[image]\"") + } else { + escaped + } + } private fun timestamp(value: Long, zone: TimestampZone): String = DateFormat.getDateTimeInstance().apply { if (zone == TimestampZone.UTC) timeZone = TimeZone.getTimeZone("UTC") }.format(Date(value)) private fun csvValue(value: String) = "\"${value.replace("\"", "\"\"")}\"" private fun escape(value: String) = value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """) diff --git a/app/src/main/java/se/ajpanton/notificationlog/model/NotificationLogEntry.kt b/app/src/main/java/se/ajpanton/notificationlog/model/NotificationLogEntry.kt index 0e0e3ca..d941fb7 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/model/NotificationLogEntry.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/model/NotificationLogEntry.kt @@ -10,6 +10,8 @@ data class NotificationLogEntry( val appName: String, val action: NotificationAction, val contents: String?, + /** ID of a private encrypted PNG copy, when this event retained a readable image. */ + val imageId: String? = null, ) enum class NotificationAction { diff --git a/app/src/test/java/se/ajpanton/notificationlog/export/LogExporterTest.kt b/app/src/test/java/se/ajpanton/notificationlog/export/LogExporterTest.kt new file mode 100644 index 0000000..7d63824 --- /dev/null +++ b/app/src/test/java/se/ajpanton/notificationlog/export/LogExporterTest.kt @@ -0,0 +1,33 @@ +package se.ajpanton.notificationlog.export + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import se.ajpanton.notificationlog.model.NotificationAction +import se.ajpanton.notificationlog.model.NotificationLogEntry +import se.ajpanton.notificationlog.settings.LogViewSettings + +class LogExporterTest { + private val imageEntry = NotificationLogEntry( + recordedAtEpochMillis = 0, + packageName = "example.app", + appName = "Example", + action = NotificationAction.APPEARED, + contents = "A notification [image]", + imageId = "123e4567-e89b-12d3-a456-426614174000", + ) + + @Test fun `text exports retain an image placeholder`() { + assertTrue(LogExporter.csv(listOf(imageEntry), LogViewSettings()).contains("[image]")) + assertTrue(LogExporter.formatted(listOf(imageEntry), LogViewSettings()).contains("[image]")) + } + + @Test fun `html replaces an image placeholder only when a support path exists`() { + val withImage = LogExporter.html(listOf(imageEntry), LogViewSettings()) { "images/${it.imageId}.png" } + val withoutImage = LogExporter.html(listOf(imageEntry), LogViewSettings()) + + assertTrue(withImage.contains("