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.os.Bundle
import android.content.ClipData import android.content.ClipData
import android.net.Uri import android.net.Uri
import android.content.ComponentName
import android.content.Intent
import android.provider.Settings
import android.view.View import android.view.View
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import androidx.fragment.app.Fragment 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.settings.AppLockStore
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
import se.ajpanton.notificationlog.data.EncryptedImageStore import se.ajpanton.notificationlog.data.EncryptedImageStore
import se.ajpanton.notificationlog.capture.NotificationCaptureService
import se.ajpanton.notificationlog.export.LogExporter import se.ajpanton.notificationlog.export.LogExporter
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
@@ -76,6 +80,7 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
setupEventMasterToggles() setupEventMasterToggles()
binding!!.exportLogs.setOnClickListener { confirmExport() } binding!!.exportLogs.setOnClickListener { confirmExport() }
binding!!.clearLogs.setOnClickListener { confirmClearLogs() } binding!!.clearLogs.setOnClickListener { confirmClearLogs() }
binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) }
val lockStore = AppLockStore(requireContext()) val lockStore = AppLockStore(requireContext())
binding!!.appLock.isChecked = lockStore.enabled binding!!.appLock.isChecked = lockStore.enabled
binding!!.appLock.setOnCheckedChangeListener { _, enabled -> lockStore.enabled = enabled } binding!!.appLock.setOnCheckedChangeListener { _, enabled -> lockStore.enabled = enabled }
@@ -85,6 +90,7 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
refreshEventMasterToggles() refreshEventMasterToggles()
binding?.notificationAccess?.text = if (isListenerEnabled()) "Notification access enabled" else "Enable notification access"
} }
private fun setupEventMasterToggles() { private fun setupEventMasterToggles() {
@@ -159,6 +165,9 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
private fun updateCopyButton() { binding?.copyEventSettings?.isEnabled = selectedCopyTargets.isNotEmpty() } 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() { private fun confirmClearLogs() {
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle("Clear logs?") .setTitle("Clear logs?")
@@ -1,23 +1,16 @@
package se.ajpanton.notificationlog package se.ajpanton.notificationlog
import android.content.ComponentName
import android.content.Intent
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import android.text.TextUtils import android.text.TextUtils
import android.view.View import android.view.View
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import se.ajpanton.notificationlog.capture.NotificationCaptureService
import se.ajpanton.notificationlog.data.EncryptedImageStore import se.ajpanton.notificationlog.data.EncryptedImageStore
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
import se.ajpanton.notificationlog.databinding.FragmentViewLogsBinding import se.ajpanton.notificationlog.databinding.FragmentViewLogsBinding
import se.ajpanton.notificationlog.export.LogExporter
import se.ajpanton.notificationlog.model.NotificationAction import se.ajpanton.notificationlog.model.NotificationAction
import se.ajpanton.notificationlog.model.NotificationLogEntry import se.ajpanton.notificationlog.model.NotificationLogEntry
import se.ajpanton.notificationlog.settings.LogField import se.ajpanton.notificationlog.settings.LogField
@@ -26,28 +19,22 @@ import se.ajpanton.notificationlog.settings.LogViewSettingsStore
import se.ajpanton.notificationlog.settings.TimestampZone import se.ajpanton.notificationlog.settings.TimestampZone
import java.text.DateFormat import java.text.DateFormat
import java.util.Date import java.util.Date
import java.util.zip.ZipOutputStream import androidx.fragment.app.Fragment
class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) { class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
private var binding: FragmentViewLogsBinding? = null private var binding: FragmentViewLogsBinding? = null
private var pendingExport: ExportFormat? = null
private val expandedIds = mutableSetOf<String>() 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding = FragmentViewLogsBinding.bind(view) binding = FragmentViewLogsBinding.bind(view)
binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) } binding!!.logsRefresh.setOnRefreshListener(::loadLogs)
binding!!.exportLogs.setOnClickListener { confirmExport() }
binding!!.clearLogs.setOnClickListener { confirmClearLogs() }
loadLogs() loadLogs()
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
binding?.notificationAccess?.text = if (isListenerEnabled()) "Notification access enabled" else "Enable notification access" loadLogs()
} }
override fun onDestroyView() { override fun onDestroyView() {
@@ -57,12 +44,16 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
private fun loadLogs() { private fun loadLogs() {
val context = requireContext().applicationContext val context = requireContext().applicationContext
binding?.logsRefresh?.isRefreshing = true
Thread { Thread {
val imageStore = EncryptedImageStore(context) val imageStore = EncryptedImageStore(context)
val rows = EncryptedNotificationLogStore(context).readAll() val rows = EncryptedNotificationLogStore(context).readAll()
.sortedByDescending { it.recordedAtEpochMillis } .sortedByDescending { it.recordedAtEpochMillis }
.map { entry -> LogRow(entry, entry.imageId?.let(imageStore::read)) } .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() }.start()
} }
@@ -170,77 +161,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
} }
}.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 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 fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
private data class LogRow(val entry: NotificationLogEntry, val imageBytes: ByteArray?) private data class LogRow(val entry: NotificationLogEntry, val imageBytes: ByteArray?)
private enum class ExportFormat(val extension: String) { CSV("csv"), FORMATTED("txt"), HTML_ZIP("zip") }
} }
@@ -81,6 +81,12 @@
android:layout_marginTop="24dp" android:layout_marginTop="24dp"
android:text="Logs" /> android:text="Logs" />
<Button
android:id="@+id/notification_access"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Enable notification access" />
<Button <Button
android:id="@+id/export_logs" android:id="@+id/export_logs"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -93,12 +99,6 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Clear logs" /> android:text="Clear logs" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Capture limits: standard title, text, messaging, inbox, big-text, and readable big-picture content can be logged. Android may withhold OTPs, custom notification layouts, or image data; those cannot be recovered. App lists require broad installed-app visibility and may need a Play policy declaration for distribution." />
<com.google.android.material.switchmaterial.SwitchMaterial <com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/app_lock" android:id="@+id/app_lock"
android:layout_width="match_parent" android:layout_width="match_parent"
+7 -37
View File
@@ -1,48 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/logs_refresh"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="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 <ScrollView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="match_parent">
android:layout_weight="1">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical"> android:orientation="vertical"
android:padding="@dimen/page_padding">
<TextView <TextView
android:id="@+id/empty_view" android:id="@+id/empty_view"
@@ -58,4 +28,4 @@
android:orientation="vertical" /> android:orientation="vertical" />
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>
</LinearLayout> </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>