diff --git a/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt index da55e7f..6653d6a 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt @@ -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?") diff --git a/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt index ab892c4..6408a2d 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt @@ -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() - 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, 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") } } diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml index 5aeb111..1dcf113 100644 --- a/app/src/main/res/layout/fragment_settings.xml +++ b/app/src/main/res/layout/fragment_settings.xml @@ -81,6 +81,12 @@ android:layout_marginTop="24dp" android:text="Logs" /> +