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
@@ -74,7 +74,7 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
}
setupCopyControls()
setupEventMasterToggles()
binding!!.exportLogs.setOnClickListener { chooseExportFormat() }
binding!!.exportLogs.setOnClickListener { confirmExport() }
binding!!.clearLogs.setOnClickListener { confirmClearLogs() }
val lockStore = AppLockStore(requireContext())
binding!!.appLock.isChecked = lockStore.enabled
@@ -177,6 +177,15 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
.show()
}
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 writeExport(uri: Uri) {
val format = pendingExport ?: return
val context = requireContext().applicationContext
@@ -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)
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
setOnClickListener {
val expanded = maxLines != 1
maxLines = if (expanded) 1 else Int.MAX_VALUE
ellipsize = if (expanded) TextUtils.TruncateAt.END else null
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))
})
}
}
}
private fun timestamp(value: Long, zone: TimestampZone, eventTimeZoneId: String?): String {
val format = DateFormat.getDateTimeInstance()
format.timeZone = when (zone) {
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 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,16 +203,23 @@ 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) {
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"))
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 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()
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())
@@ -146,9 +230,10 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
zip.closeEntry()
}
}
} }
}.start()
}
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") }
}
@@ -91,10 +91,17 @@ class NotificationCaptureService : NotificationListenerService() {
writeExecutor.execute {
try {
if (retainImage) {
try {
imageStore.save(entry.imageId!!, snapshot.imageBytes!!)
} catch (error: Exception) {
Log.w(TAG, "Could not retain notification image; keeping the text event", error)
logStore.append(entry.copy(imageId = null))
return@execute
}
}
logStore.append(entry)
} catch (error: Exception) {
entry.imageId?.let(imageStore::delete)
Log.e(TAG, "Could not persist notification log entry", error)
}
}
@@ -107,7 +114,10 @@ class NotificationCaptureService : NotificationListenerService() {
val image = snapshot.hasImage && NotificationRuleEvaluator.allows(
ruleStore.ruleFor(LoggingType.IMAGE_CONTENT), snapshot.packageName,
)
return listOfNotNull(text, if (image) "[image]" else null).joinToString("").ifEmpty { null }
return listOfNotNull(text, if (image) "[image]" else null)
.joinToString("")
.take(MAX_CONTENT_CHARACTERS)
.ifEmpty { null }
}
private fun appName(packageName: String): String = try {
@@ -147,5 +157,6 @@ class NotificationCaptureService : NotificationListenerService() {
private companion object {
const val TAG = "NotificationCapture"
const val MAX_CONTENT_CHARACTERS = 16_000
}
}
@@ -1,6 +1,7 @@
package se.ajpanton.notificationlog.data
import android.content.Context
import android.util.AtomicFile
import java.io.File
import java.io.DataInputStream
import java.io.FileNotFoundException
@@ -12,11 +13,19 @@ class EncryptedImageStore(context: Context) {
fun save(id: String, bytes: ByteArray) {
require(id.matches(IMAGE_ID_PATTERN)) { "Invalid notification image ID." }
require(bytes.size <= MAX_IMAGE_BYTES) { "Notification image exceeds the 5 MiB retention limit." }
val payload = cipher.encrypt(bytes)
File(directory, "$id.bin").outputStream().use { output ->
val file = AtomicFile(File(directory, "$id.bin"))
val output = file.startWrite()
try {
output.write(payload.initializationVector.size)
output.write(payload.initializationVector)
output.write(payload.cipherText)
output.flush()
file.finishWrite(output)
} catch (error: Exception) {
file.failWrite(output)
throw error
}
}
@@ -38,7 +47,13 @@ class EncryptedImageStore(context: Context) {
}
}
fun delete(id: String) {
require(id.matches(IMAGE_ID_PATTERN)) { "Invalid notification image ID." }
File(directory, "$id.bin").delete()
}
private companion object {
val IMAGE_ID_PATTERN = Regex("[0-9a-f-]{36}")
const val MAX_IMAGE_BYTES = 5 * 1024 * 1024
}
}
@@ -27,7 +27,9 @@ class EncryptedNotificationLogStore(context: Context) {
fun append(entry: NotificationLogEntry) = synchronized(lock) {
val entries = (readPayloadOrNull()?.let { NotificationLogEntryJson.decode(cipher.decrypt(it)) } ?: emptyList())
write(entries + entry)
val retained = retain(entries + entry)
write(retained)
(entries.mapNotNull { it.imageId } - retained.mapNotNull { it.imageId }.toSet()).forEach(::deleteImage)
}
fun replaceAll(entries: List<NotificationLogEntry>) = synchronized(lock) {
@@ -39,6 +41,14 @@ class EncryptedNotificationLogStore(context: Context) {
imageDirectory.listFiles()?.forEach(File::delete)
}
fun delete(id: String): Boolean = synchronized(lock) {
val entries = readPayloadOrNull()?.let { NotificationLogEntryJson.decode(cipher.decrypt(it)) } ?: return false
val entry = entries.firstOrNull { it.id == id } ?: return false
write(entries.filterNot { it.id == id })
entry.imageId?.let(::deleteImage)
true
}
private fun write(entries: List<NotificationLogEntry>) {
val payload = cipher.encrypt(NotificationLogEntryJson.encode(entries))
val output = file.startWrite()
@@ -57,6 +67,21 @@ class EncryptedNotificationLogStore(context: Context) {
}
}
private fun retain(entries: List<NotificationLogEntry>): List<NotificationLogEntry> {
var retained = entries.takeLast(MAX_ENTRIES)
while (retained.size > 1 && NotificationLogEntryJson.encode(retained).size > MAX_PLAINTEXT_BYTES) {
retained = retained.drop(1)
}
require(NotificationLogEntryJson.encode(retained).size <= MAX_PLAINTEXT_BYTES) {
"A notification event exceeds the encrypted log retention limit."
}
return retained
}
private fun deleteImage(id: String) {
if (id.matches(IMAGE_ID_PATTERN)) File(imageDirectory, "$id.bin").delete()
}
private fun readPayloadOrNull(): EncryptedPayload? {
val input = try {
file.openRead()
@@ -82,6 +107,9 @@ class EncryptedNotificationLogStore(context: Context) {
const val FILE_VERSION = 1
const val MAX_IV_BYTES = 32
const val MAX_CIPHER_TEXT_BYTES = 50 * 1024 * 1024
const val MAX_PLAINTEXT_BYTES = 20 * 1024 * 1024
const val MAX_ENTRIES = 10_000
const val IMAGE_DIRECTORY_NAME = "notification-images"
val IMAGE_ID_PATTERN = Regex("[0-9a-f-]{36}")
}
}
+57 -6
View File
@@ -1,10 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:padding="@dimen/page_padding">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal"><Button android:id="@+id/notification_access" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Enable notification access"/><Button android:id="@+id/export_logs" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Export logs"/><Button android:id="@+id/clear_logs" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Clear logs" /></LinearLayout>
<ScrollView android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical">
<TextView android:id="@+id/empty_view" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:text="No logs yet." />
<LinearLayout android:id="@+id/log_rows" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" />
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="@dimen/page_padding">
<Button
android:id="@+id/notification_access"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Enable notification access" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:id="@+id/export_logs"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Export logs" />
<Button
android:id="@+id/clear_logs"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Clear logs" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Tap a log to expand it. Long-press a log to delete it." />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/empty_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="No logs yet." />
<LinearLayout
android:id="@+id/log_rows"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
</LinearLayout>
</ScrollView>
</LinearLayout>