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.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("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
|
private fun escape(value: String) = value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
|
||||||
|
|||||||
@@ -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]"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user