Refresh logs and move log controls to settings

This commit is contained in:
ajp_anton
2026-07-26 14:37:20 +00:00
parent 0be41fa085
commit 14a96046f7
4 changed files with 30 additions and 130 deletions
@@ -3,6 +3,9 @@ package se.ajpanton.notificationlog
import android.os.Bundle
import android.content.ClipData
import android.net.Uri
import android.content.ComponentName
import android.content.Intent
import android.provider.Settings
import android.view.View
import android.widget.ArrayAdapter
import androidx.fragment.app.Fragment
@@ -17,6 +20,7 @@ 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.capture.NotificationCaptureService
import se.ajpanton.notificationlog.export.LogExporter
import java.util.zip.ZipOutputStream
@@ -76,6 +80,7 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
setupEventMasterToggles()
binding!!.exportLogs.setOnClickListener { confirmExport() }
binding!!.clearLogs.setOnClickListener { confirmClearLogs() }
binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) }
val lockStore = AppLockStore(requireContext())
binding!!.appLock.isChecked = lockStore.enabled
binding!!.appLock.setOnCheckedChangeListener { _, enabled -> lockStore.enabled = enabled }
@@ -85,6 +90,7 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
override fun onResume() {
super.onResume()
refreshEventMasterToggles()
binding?.notificationAccess?.text = if (isListenerEnabled()) "Notification access enabled" else "Enable notification access"
}
private fun setupEventMasterToggles() {
@@ -159,6 +165,9 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
private fun updateCopyButton() { binding?.copyEventSettings?.isEnabled = selectedCopyTargets.isNotEmpty() }
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?")
@@ -1,23 +1,16 @@
package se.ajpanton.notificationlog
import android.content.ComponentName
import android.content.Intent
import android.graphics.BitmapFactory
import android.net.Uri
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.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
@@ -26,28 +19,22 @@ import se.ajpanton.notificationlog.settings.LogViewSettingsStore
import se.ajpanton.notificationlog.settings.TimestampZone
import java.text.DateFormat
import java.util.Date
import java.util.zip.ZipOutputStream
import androidx.fragment.app.Fragment
class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
private var binding: FragmentViewLogsBinding? = null
private var pendingExport: ExportFormat? = null
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 { confirmExport() }
binding!!.clearLogs.setOnClickListener { confirmClearLogs() }
binding!!.logsRefresh.setOnRefreshListener(::loadLogs)
loadLogs()
}
override fun onResume() {
super.onResume()
binding?.notificationAccess?.text = if (isListenerEnabled()) "Notification access enabled" else "Enable notification access"
loadLogs()
}
override fun onDestroyView() {
@@ -57,12 +44,16 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
private fun loadLogs() {
val context = requireContext().applicationContext
binding?.logsRefresh?.isRefreshing = true
Thread {
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) } }
activity?.runOnUiThread {
binding?.let { render(it, rows) }
binding?.logsRefresh?.isRefreshing = false
}
}.start()
}
@@ -170,77 +161,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
}
}.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 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() {
MaterialAlertDialogBuilder(requireContext())
.setItems(arrayOf("CSV", "Formatted text", "HTML ZIP")) { _, which ->
pendingExport = ExportFormat.entries[which]
createDocument.launch("notification-log.${pendingExport!!.extension}")
}
.show()
}
private fun writeExport(uri: Uri) {
val format = pendingExport ?: return
val context = requireContext().applicationContext
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 -> 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") }
}