Export retained notification images in HTML

This commit is contained in:
ajp_anton
2026-07-23 10:51:07 +00:00
parent 6eb9058595
commit dd1c9e5a01
8 changed files with 123 additions and 7 deletions
@@ -16,6 +16,7 @@ import se.ajpanton.notificationlog.settings.LoggingType
import se.ajpanton.notificationlog.settings.LoggingRuleStore import se.ajpanton.notificationlog.settings.LoggingRuleStore
import se.ajpanton.notificationlog.settings.AppLockStore import se.ajpanton.notificationlog.settings.AppLockStore
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
import se.ajpanton.notificationlog.data.EncryptedImageStore
import se.ajpanton.notificationlog.export.LogExporter import se.ajpanton.notificationlog.export.LogExporter
import java.util.zip.ZipOutputStream 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.FORMATTED -> output.write(LogExporter.formatted(entries, settings).encodeToByteArray())
ExportFormat.HTML_ZIP -> ZipOutputStream(output).use { zip -> ExportFormat.HTML_ZIP -> ZipOutputStream(output).use { zip ->
zip.putNextEntry(java.util.zip.ZipEntry("notification-log.html")) 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() zip.closeEntry()
exportedImages.forEach { (imageId, image) ->
zip.putNextEntry(java.util.zip.ZipEntry("images/$imageId.png"))
zip.write(image)
zip.closeEntry()
}
} }
} }
} }
@@ -11,6 +11,7 @@ import android.widget.TextView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
import se.ajpanton.notificationlog.data.EncryptedImageStore
import se.ajpanton.notificationlog.databinding.FragmentViewLogsBinding import se.ajpanton.notificationlog.databinding.FragmentViewLogsBinding
import se.ajpanton.notificationlog.model.NotificationLogEntry import se.ajpanton.notificationlog.model.NotificationLogEntry
import se.ajpanton.notificationlog.settings.LogField 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.FORMATTED -> output.write(LogExporter.formatted(entries, settings).encodeToByteArray())
ExportFormat.HTML_ZIP -> ZipOutputStream(output).use { zip -> ExportFormat.HTML_ZIP -> ZipOutputStream(output).use { zip ->
zip.putNextEntry(java.util.zip.ZipEntry("notification-log.html")) 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() zip.closeEntry()
exportedImages.forEach { (imageId, image) ->
zip.putNextEntry(java.util.zip.ZipEntry("images/$imageId.png"))
zip.write(image)
zip.closeEntry()
}
} }
} } } }
}.start() }.start()
@@ -77,17 +77,20 @@ class NotificationCaptureService : NotificationListenerService() {
) { ) {
if (!NotificationRuleEvaluator.allows(ruleStore.ruleFor(loggingType), snapshot.packageName)) return if (!NotificationRuleEvaluator.allows(ruleStore.ruleFor(loggingType), snapshot.packageName)) return
val appName = appName(snapshot.packageName) val appName = appName(snapshot.packageName)
val retainImage = includeContents && snapshot.imageBytes != null &&
NotificationRuleEvaluator.allows(ruleStore.ruleFor(LoggingType.IMAGE_CONTENT), snapshot.packageName)
val entry = NotificationLogEntry( val entry = NotificationLogEntry(
recordedAtEpochMillis = System.currentTimeMillis(), recordedAtEpochMillis = System.currentTimeMillis(),
packageName = snapshot.packageName, packageName = snapshot.packageName,
appName = appName, appName = appName,
action = action, action = action,
contents = if (includeContents) visibleContents(snapshot) else null, contents = if (includeContents) visibleContents(snapshot) else null,
imageId = if (retainImage) java.util.UUID.randomUUID().toString() else null,
) )
writeExecutor.execute { writeExecutor.execute {
try { try {
if (includeContents && snapshot.imageBytes != null && NotificationRuleEvaluator.allows(ruleStore.ruleFor(LoggingType.IMAGE_CONTENT), snapshot.packageName)) { if (retainImage) {
imageStore.save(entry.id, snapshot.imageBytes) imageStore.save(entry.imageId!!, snapshot.imageBytes!!)
} }
logStore.append(entry) logStore.append(entry)
} catch (error: Exception) { } catch (error: Exception) {
@@ -2,6 +2,8 @@ package se.ajpanton.notificationlog.data
import android.content.Context import android.content.Context
import java.io.File 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. */ /** Stores copied notification image bytes independently of the source app's URI lifetime. */
class EncryptedImageStore(context: Context) { class EncryptedImageStore(context: Context) {
@@ -9,6 +11,7 @@ class EncryptedImageStore(context: Context) {
private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate()) private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate())
fun save(id: String, bytes: ByteArray) { fun save(id: String, bytes: ByteArray) {
require(id.matches(IMAGE_ID_PATTERN)) { "Invalid notification image ID." }
val payload = cipher.encrypt(bytes) val payload = cipher.encrypt(bytes)
File(directory, "$id.bin").outputStream().use { output -> File(directory, "$id.bin").outputStream().use { output ->
output.write(payload.initializationVector.size) output.write(payload.initializationVector.size)
@@ -16,4 +19,26 @@ class EncryptedImageStore(context: Context) {
output.write(payload.cipherText) 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}")
}
} }
@@ -16,7 +16,8 @@ object NotificationLogEntryJson {
.put("packageName", entry.packageName) .put("packageName", entry.packageName)
.put("appName", entry.appName) .put("appName", entry.appName)
.put("action", entry.action.name) .put("action", entry.action.name)
.put("contents", entry.contents), .put("contents", entry.contents)
.put("imageId", entry.imageId),
) )
} }
}.toString().encodeToByteArray() }.toString().encodeToByteArray()
@@ -32,6 +33,7 @@ object NotificationLogEntryJson {
appName = entry.getString("appName"), appName = entry.getString("appName"),
action = NotificationAction.valueOf(entry.getString("action")), action = NotificationAction.valueOf(entry.getString("action")),
contents = if (entry.isNull("contents")) null else entry.getString("contents"), contents = if (entry.isNull("contents")) null else entry.getString("contents"),
imageId = entry.optString("imageId").takeIf { it.isNotEmpty() },
) )
} }
} }
@@ -18,11 +18,21 @@ object LogExporter {
return rows.joinToString("\n") { row -> row.mapIndexed { i, value -> value.padEnd(widths[i]) }.joinToString(" ") } return rows.joinToString("\n") { row -> row.mapIndexed { i, value -> value.padEnd(widths[i]) }.joinToString(" ") }
} }
fun html(entries: List<NotificationLogEntry>, settings: LogViewSettings): String = buildString { fun html(
entries: List<NotificationLogEntry>,
settings: LogViewSettings,
imagePath: (NotificationLogEntry) -> String? = { null },
): String = buildString {
append("<!doctype html><meta charset=\"utf-8\"><table><thead><tr>") append("<!doctype html><meta charset=\"utf-8\"><table><thead><tr>")
settings.order.filter { it in settings.visibleFields }.forEach { append("<th>${escape(header(it))}</th>") } settings.order.filter { it in settings.visibleFields }.forEach { append("<th>${escape(header(it))}</th>") }
append("</tr></thead><tbody>") append("</tr></thead><tbody>")
rows(entries, settings).forEach { row -> append("<tr>"); row.forEach { append("<td>${escape(it)}</td>") }; append("</tr>") } entries.sortedByDescending { it.recordedAtEpochMillis }.forEach { entry ->
append("<tr>")
settings.order.filter { it in settings.visibleFields }.forEach { field ->
append("<td>${htmlValue(field, entry, settings, imagePath(entry))}</td>")
}
append("</tr>")
}
append("</tbody></table>") append("</tbody></table>")
} }
@@ -37,6 +47,21 @@ object LogExporter {
} } } }
} }
private fun header(field: LogField) = field.name.lowercase().replace('_', ' ') 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]", "<img src=\"${escape(imagePath)}\" alt=\"[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 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 csvValue(value: String) = "\"${value.replace("\"", "\"\"")}\""
private fun escape(value: String) = value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;") private fun escape(value: String) = value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
@@ -10,6 +10,8 @@ data class NotificationLogEntry(
val appName: String, val appName: String,
val action: NotificationAction, val action: NotificationAction,
val contents: String?, val contents: String?,
/** ID of a private encrypted PNG copy, when this event retained a readable image. */
val imageId: String? = null,
) )
enum class NotificationAction { enum class NotificationAction {
@@ -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("<img src=\"images/123e4567-e89b-12d3-a456-426614174000.png\""))
assertFalse(withoutImage.contains("<img"))
assertTrue(withoutImage.contains("[image]"))
}
}