Refresh logs and move log controls to settings
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user