Complete secure log viewing and retention

This commit is contained in:
ajp_anton
2026-07-23 11:42:56 +00:00
parent 0091eb5d49
commit 8621a30225
6 changed files with 293 additions and 94 deletions
@@ -1,115 +1,191 @@
package se.ajpanton.notificationlog
import android.os.Bundle
import android.content.ComponentName
import android.content.Intent
import android.graphics.BitmapFactory
import android.net.Uri
import android.text.TextUtils
import android.os.Bundle
import android.provider.Settings
import android.text.TextUtils
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
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.capture.NotificationCaptureService
import se.ajpanton.notificationlog.data.EncryptedImageStore
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
import se.ajpanton.notificationlog.databinding.FragmentViewLogsBinding
import se.ajpanton.notificationlog.export.LogExporter
import se.ajpanton.notificationlog.model.NotificationAction
import se.ajpanton.notificationlog.model.NotificationLogEntry
import se.ajpanton.notificationlog.settings.LogField
import se.ajpanton.notificationlog.settings.LogViewSettings
import se.ajpanton.notificationlog.settings.LogViewSettingsStore
import se.ajpanton.notificationlog.settings.TimestampZone
import se.ajpanton.notificationlog.capture.NotificationCaptureService
import se.ajpanton.notificationlog.export.LogExporter
import java.util.zip.ZipOutputStream
import java.text.DateFormat
import java.util.Date
import java.util.zip.ZipOutputStream
class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
private var binding: FragmentViewLogsBinding? = null
private var pendingExport: ExportFormat? = null
private val createDocument = registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
uri?.let(::writeExport)
}
private val expandedIds = mutableSetOf<String>()
private val createDocument = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.CreateDocument("application/octet-stream"),
) { uri -> uri?.let(::writeExport) }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding = FragmentViewLogsBinding.bind(view)
binding!!.notificationAccess.setOnClickListener {
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
}
binding!!.exportLogs.setOnClickListener { chooseExportFormat() }
binding!!.clearLogs.setOnClickListener {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Clear logs?")
.setMessage("This permanently removes every stored notification log.")
.setNegativeButton("Cancel", null)
.setPositiveButton("Clear") { _, _ ->
EncryptedNotificationLogStore(requireContext()).clear()
loadLogs()
}
.show()
}
binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) }
binding!!.exportLogs.setOnClickListener { confirmExport() }
binding!!.clearLogs.setOnClickListener { confirmClearLogs() }
loadLogs()
}
override fun onResume() {
super.onResume()
binding?.notificationAccess?.text = if (isListenerEnabled()) {
"Notification access enabled"
} else {
"Enable notification access"
}
binding?.notificationAccess?.text = if (isListenerEnabled()) "Notification access enabled" else "Enable notification access"
}
override fun onDestroyView() { binding = null; super.onDestroyView() }
override fun onDestroyView() {
binding = null
super.onDestroyView()
}
private fun loadLogs() {
val context = requireContext().applicationContext
Thread {
val entries = EncryptedNotificationLogStore(context).readAll().sortedByDescending { it.recordedAtEpochMillis }
activity?.runOnUiThread { binding?.let { render(it, entries) } }
val imageStore = EncryptedImageStore(context)
val rows = EncryptedNotificationLogStore(context).readAll()
.sortedByDescending { it.recordedAtEpochMillis }
.map { entry -> LogRow(entry, entry.imageId?.let(imageStore::read)) }
activity?.runOnUiThread { binding?.let { render(it, rows) } }
}.start()
}
private fun render(view: FragmentViewLogsBinding, entries: List<NotificationLogEntry>) {
private fun render(view: FragmentViewLogsBinding, rows: List<LogRow>) {
view.logRows.removeAllViews()
view.emptyView.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE
view.emptyView.visibility = if (rows.isEmpty()) View.VISIBLE else View.GONE
val settings = LogViewSettingsStore(requireContext()).load()
entries.forEach { entry ->
view.logRows.addView(TextView(requireContext()).apply {
text = settings.order.filter { it in settings.visibleFields }.mapNotNull { 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
} }.joinToString(" · ")
setPadding(0, 12, 0, 12)
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
setOnClickListener {
val expanded = maxLines != 1
maxLines = if (expanded) 1 else Int.MAX_VALUE
ellipsize = if (expanded) TextUtils.TruncateAt.END else null
rows.forEach { row -> view.logRows.addView(logRowView(row, settings)) }
}
private fun logRowView(row: LogRow, settings: LogViewSettings): View = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
bottomMargin = dp(12)
}
isClickable = true
isFocusable = true
fun renderExpanded(expanded: Boolean) {
removeAllViews()
if (!expanded) {
addView(TextView(context).apply {
text = values(row.entry, settings).joinToString(" · ")
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
setPadding(0, dp(8), 0, dp(8))
})
return
}
settings.order.filter { it in settings.visibleFields }.forEach { field ->
val value = fieldValue(field, row.entry, settings).takeUnless { field == LogField.CONTENTS && it == "[image]" }
if (!value.isNullOrEmpty()) {
addView(TextView(context).apply {
text = value
setLineSpacing(0f, 0.92f)
setPadding(0, dp(1), 0, dp(1))
})
}
})
}
row.imageBytes?.let { bytes ->
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.let { bitmap ->
addView(ImageView(context).apply {
setImageBitmap(bitmap)
adjustViewBounds = true
maxHeight = dp(240)
contentDescription = "Notification image"
setPadding(0, dp(4), 0, dp(4))
})
}
}
}
renderExpanded(row.entry.id in expandedIds)
setOnClickListener {
if (!expandedIds.add(row.entry.id)) expandedIds.remove(row.entry.id)
renderExpanded(row.entry.id in expandedIds)
}
setOnLongClickListener {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Delete this log?")
.setMessage("This permanently removes this log and its copied image, if any.")
.setNegativeButton("Cancel", null)
.setPositiveButton("Delete") { _, _ ->
Thread {
EncryptedNotificationLogStore(requireContext().applicationContext).delete(row.entry.id)
activity?.runOnUiThread(::loadLogs)
}.start()
}
.show()
true
}
}
private fun timestamp(value: Long, zone: TimestampZone, eventTimeZoneId: String?): String {
val format = DateFormat.getDateTimeInstance()
format.timeZone = when (zone) {
private fun values(entry: NotificationLogEntry, settings: LogViewSettings): List<String> =
settings.order.filter { it in settings.visibleFields }.mapNotNull { fieldValue(it, entry, settings) }
private fun fieldValue(field: LogField, entry: NotificationLogEntry, settings: LogViewSettings): String? = when (field) {
LogField.TIMESTAMP -> timestamp(entry.recordedAtEpochMillis, settings.timestampZone, entry.eventTimeZoneId)
LogField.APP_NAME -> entry.appName
LogField.PACKAGE_NAME -> entry.packageName
LogField.ACTION -> actionLabel(entry.action)
LogField.CONTENTS -> entry.contents
}
private fun actionLabel(action: NotificationAction): String = when (action) {
NotificationAction.APP_CANCELLED -> "App cancelled notification"
NotificationAction.APP_CANCELLED_ALL -> "App cancelled all notifications"
NotificationAction.USER_DISMISSED -> "User dismissed notification"
NotificationAction.USER_DISMISSED_ALL -> "User dismissed all notifications"
else -> action.name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase)
}
private fun timestamp(value: Long, zone: TimestampZone, eventTimeZoneId: String?): String = DateFormat.getDateTimeInstance().apply {
timeZone = when (zone) {
TimestampZone.UTC -> java.util.TimeZone.getTimeZone("UTC")
TimestampZone.LOCAL_NOW -> java.util.TimeZone.getDefault()
TimestampZone.EVENT_LOCAL -> eventTimeZoneId?.let(java.util.TimeZone::getTimeZone) ?: java.util.TimeZone.getDefault()
}
return format.format(Date(value))
}.format(Date(value))
private fun isListenerEnabled(): Boolean = requireContext().getSystemService(android.app.NotificationManager::class.java)
.isNotificationListenerAccessGranted(ComponentName(requireContext(), NotificationCaptureService::class.java))
private fun confirmClearLogs() {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Clear logs?")
.setMessage("This permanently removes every stored notification log and copied image.")
.setNegativeButton("Cancel", null)
.setPositiveButton("Clear") { _, _ ->
EncryptedNotificationLogStore(requireContext()).clear()
expandedIds.clear()
loadLogs()
}
.show()
}
private fun isListenerEnabled(): Boolean {
val manager = requireContext().getSystemService(android.app.NotificationManager::class.java)
return manager.isNotificationListenerAccessGranted(
ComponentName(requireContext(), NotificationCaptureService::class.java),
)
private fun confirmExport() {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Export unencrypted logs?")
.setMessage("The exported file will not be encrypted. Anyone with access to its destination can read the selected log fields.")
.setNegativeButton("Cancel", null)
.setPositiveButton("Continue") { _, _ -> chooseExportFormat() }
.show()
}
private fun chooseExportFormat() {
@@ -117,7 +193,8 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
.setItems(arrayOf("CSV", "Formatted text", "HTML ZIP")) { _, which ->
pendingExport = ExportFormat.entries[which]
createDocument.launch("notification-log.${pendingExport!!.extension}")
}.show()
}
.show()
}
private fun writeExport(uri: Uri) {
@@ -126,29 +203,37 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
Thread {
val entries = EncryptedNotificationLogStore(context).readAll()
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()
}
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 -> writeHtmlExport(output, entries, settings, context)
}
} }
}
}.start()
}
private fun writeHtmlExport(output: java.io.OutputStream, entries: List<NotificationLogEntry>, settings: LogViewSettings, context: android.content.Context) {
ZipOutputStream(output).use { zip ->
val imageStore = EncryptedImageStore(context)
val exportedImages = entries.filter { it.imageId != null && LogField.CONTENTS in settings.visibleFields }
.mapNotNull { entry -> imageStore.read(entry.imageId!!)?.let { entry.imageId to it } }
.toMap()
zip.putNextEntry(java.util.zip.ZipEntry("notification-log.html"))
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()
}
}
}
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
private data class LogRow(val entry: NotificationLogEntry, val imageBytes: ByteArray?)
private enum class ExportFormat(val extension: String) { CSV("csv"), FORMATTED("txt"), HTML_ZIP("zip") }
}