Stream notification log exports
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -9,43 +9,65 @@ import java.util.Date
|
||||
import java.util.TimeZone
|
||||
|
||||
object LogExporter {
|
||||
fun csv(entries: List<NotificationLogEntry>, settings: LogViewSettings): String =
|
||||
rows(entries, settings).joinToString("\n") { row -> row.joinToString(",") { csvValue(it) } }
|
||||
fun csv(entries: List<NotificationLogEntry>, settings: LogViewSettings): String = (
|
||||
listOf(csvHeader(settings)) + entries.sortedByDescending { it.recordedAtEpochMillis }.map { csvRow(it, settings) }
|
||||
).joinToString("\n")
|
||||
|
||||
fun formatted(entries: List<NotificationLogEntry>, 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<Int>()) { 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<String> = fields(settings).map(::header)
|
||||
|
||||
fun values(entry: NotificationLogEntry, settings: LogViewSettings): List<String> = 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<String>, widths: List<Int>): String =
|
||||
values.mapIndexed { index, value -> value.padEnd(widths[index]) }.joinToString(" ")
|
||||
|
||||
fun htmlStart(settings: LogViewSettings): String = buildString {
|
||||
append("<!doctype html><meta charset=\"utf-8\"><table><thead><tr>")
|
||||
fields(settings).forEach { append("<th>${escape(header(it))}</th>") }
|
||||
append("</tr></thead><tbody>")
|
||||
}
|
||||
|
||||
fun htmlRow(
|
||||
entry: NotificationLogEntry,
|
||||
settings: LogViewSettings,
|
||||
imagePath: String? = null,
|
||||
): String = buildString {
|
||||
append("<tr>")
|
||||
fields(settings).forEach { field -> append("<td>${htmlValue(field, entry, settings, imagePath)}</td>") }
|
||||
append("</tr>")
|
||||
}
|
||||
|
||||
fun htmlEnd(): String = "</tbody></table>"
|
||||
|
||||
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>")
|
||||
append(htmlStart(settings))
|
||||
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(htmlRow(entry, settings, imagePath(entry)))
|
||||
}
|
||||
append("</tbody></table>")
|
||||
append(htmlEnd())
|
||||
}
|
||||
|
||||
private fun rows(entries: List<NotificationLogEntry>, 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<LogField> = 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>): String = values.joinToString(",") { csvValue(it) }
|
||||
private fun csvValue(value: String) = "\"${value.replace("\"", "\"\"")}\""
|
||||
private fun escape(value: String) = value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user