Export retained notification images in HTML
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,21 @@ object LogExporter {
|
||||
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>")
|
||||
settings.order.filter { it in settings.visibleFields }.forEach { append("<th>${escape(header(it))}</th>") }
|
||||
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>")
|
||||
}
|
||||
|
||||
@@ -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]", "<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 csvValue(value: String) = "\"${value.replace("\"", "\"\"")}\""
|
||||
private fun escape(value: String) = value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user