diff --git a/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt index 217eee3..3fdaff2 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt @@ -1,6 +1,7 @@ package se.ajpanton.notificationlog import android.os.Bundle +import android.view.MotionEvent import android.view.View import android.widget.CheckBox import android.widget.LinearLayout @@ -25,11 +26,36 @@ class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) { store = LoggingRuleStore(requireContext()) binding!!.pageTitle.setText(requireArguments().getInt(ARG_TITLE)) bindRule() - binding!!.appListRefresh.setOnRefreshListener { reloadAppList(); binding!!.appListRefresh.isRefreshing = false } + binding!!.appListRefresh.setOnRefreshListener(::refreshAppList) + var touchStartY = 0f + val scroll = binding!!.appScroll + scroll.setOnTouchListener { _, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> touchStartY = event.y + MotionEvent.ACTION_UP -> { + val atBottom = scroll.scrollY >= (scroll.getChildAt(0).height - scroll.height).coerceAtLeast(0) + if (atBottom && event.y < touchStartY) { + refreshAppList() + } + } + } + false + } } override fun onDestroyView() { binding = null; super.onDestroyView() } + private fun refreshAppList() { + val currentBinding = binding ?: return + currentBinding.appListRefresh.isRefreshing = true + currentBinding.appListRefresh.post { + if (binding === currentBinding) { + reloadAppList() + currentBinding.appListRefresh.isRefreshing = false + } + } + } + private fun bindRule() { val rule = store.ruleFor(type) listOf( diff --git a/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt index 1fb90f6..7c17cb8 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt @@ -2,10 +2,12 @@ package se.ajpanton.notificationlog import android.os.Bundle import android.content.ClipData +import android.net.Uri import android.view.View import android.widget.ArrayAdapter import androidx.fragment.app.Fragment import com.google.android.material.switchmaterial.SwitchMaterial +import com.google.android.material.dialog.MaterialAlertDialogBuilder import se.ajpanton.notificationlog.databinding.FragmentSettingsBinding import se.ajpanton.notificationlog.settings.LogField import se.ajpanton.notificationlog.settings.LogViewSettingsStore @@ -13,11 +15,17 @@ import se.ajpanton.notificationlog.settings.TimestampZone import se.ajpanton.notificationlog.settings.LoggingType import se.ajpanton.notificationlog.settings.LoggingRuleStore import se.ajpanton.notificationlog.settings.AppLockStore -import com.google.android.material.dialog.MaterialAlertDialogBuilder +import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore +import se.ajpanton.notificationlog.export.LogExporter +import java.util.zip.ZipOutputStream class SettingsFragment : Fragment(R.layout.fragment_settings) { private var binding: FragmentSettingsBinding? = null private var selectedCopyTargets = emptySet() + private var pendingExport: ExportFormat? = null + 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 = FragmentSettingsBinding.bind(view) @@ -64,19 +72,57 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) { } } setupCopyControls() + setupEventMasterToggles() + binding!!.exportLogs.setOnClickListener { chooseExportFormat() } + binding!!.clearLogs.setOnClickListener { confirmClearLogs() } val lockStore = AppLockStore(requireContext()) binding!!.appLock.isChecked = lockStore.enabled binding!!.appLock.setOnCheckedChangeListener { _, enabled -> lockStore.enabled = enabled } } override fun onDestroyView() { binding = null; super.onDestroyView() } + override fun onResume() { + super.onResume() + refreshEventMasterToggles() + } + + private fun setupEventMasterToggles() { + val container = binding!!.eventMasterToggles + LoggingType.entries.forEach { type -> + container.addView(SwitchMaterial(requireContext()).apply { + text = "${type.label()} logging" + tag = type + setOnCheckedChangeListener { _, enabled -> + LoggingRuleStore(requireContext()).save(type, LoggingRuleStore(requireContext()).ruleFor(type).copy(enabled = enabled)) + } + }) + } + refreshEventMasterToggles() + } + + private fun refreshEventMasterToggles() { + val store = LoggingRuleStore(requireContext()) + val container = binding?.eventMasterToggles ?: return + for (index in 0 until container.childCount) { + val toggle = container.getChildAt(index) as SwitchMaterial + toggle.setOnCheckedChangeListener(null) + toggle.isChecked = store.ruleFor(toggle.tag as LoggingType).enabled + toggle.setOnCheckedChangeListener { _, enabled -> + val type = toggle.tag as LoggingType + store.save(type, store.ruleFor(type).copy(enabled = enabled)) + } + } + } + private fun setupCopyControls() { val labels = listOf("From") + LoggingType.entries.map { it.label() } binding!!.copyFrom.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, labels) binding!!.copyFrom.onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener { override fun onNothingSelected(parent: android.widget.AdapterView<*>?) = Unit override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) { - selectedCopyTargets = emptySet(); binding!!.copyTo.isEnabled = position > 0; updateCopyButton() + selectedCopyTargets = emptySet() + binding!!.copyTo.isEnabled = position > 0 + updateCopyButton() } } binding!!.copyTo.setOnClickListener { chooseCopyTargets(LoggingType.entries[binding!!.copyFrom.selectedItemPosition - 1]) } @@ -87,17 +133,70 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) { private fun chooseCopyTargets(source: LoggingType) { val targets = LoggingType.entries.filterNot { it == source } - val checked = BooleanArray(targets.size) - MaterialAlertDialogBuilder(requireContext()) + val checked = BooleanArray(targets.size) { targets[it] in selectedCopyTargets } + val dialog = MaterialAlertDialogBuilder(requireContext()) .setTitle("Copy ${source.label()} settings to") .setMultiChoiceItems(targets.map { it.label() }.toTypedArray(), checked) { _, index, selected -> checked[index] = selected } .setNegativeButton("Cancel", null) - .setPositiveButton("Copy") { _, _ -> - selectedCopyTargets = targets.filterIndexed { index, _ -> checked[index] }.toSet(); updateCopyButton() - }.show() + .setNeutralButton("All", null) + .setPositiveButton("Done") { _, _ -> + selectedCopyTargets = targets.filterIndexed { index, _ -> checked[index] }.toSet() + updateCopyButton() + } + .create() + dialog.setOnShowListener { + dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_NEUTRAL).setOnClickListener { + val selectAll = checked.any { !it } + checked.indices.forEach { index -> + checked[index] = selectAll + dialog.listView.setItemChecked(index, selectAll) + } + } + } + dialog.show() } private fun updateCopyButton() { binding?.copyEventSettings?.isEnabled = selectedCopyTargets.isNotEmpty() } + 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() } + .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 -> ZipOutputStream(output).use { zip -> + zip.putNextEntry(java.util.zip.ZipEntry("notification-log.html")) + zip.write(LogExporter.html(entries, settings).encodeToByteArray()) + zip.closeEntry() + } + } + } + }.start() + } + private fun LoggingType.label() = name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase) + + private enum class ExportFormat(val extension: String) { CSV("csv"), FORMATTED("txt"), HTML_ZIP("zip") } } diff --git a/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedNotificationLogStore.kt b/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedNotificationLogStore.kt index ccca54f..e598ca7 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedNotificationLogStore.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/data/EncryptedNotificationLogStore.kt @@ -8,6 +8,7 @@ import java.io.BufferedOutputStream import java.io.DataInputStream import java.io.DataOutputStream import java.io.FileNotFoundException +import java.io.File /** * An atomically replaced encrypted event log. It keeps every persisted log field @@ -16,6 +17,7 @@ import java.io.FileNotFoundException class EncryptedNotificationLogStore(context: Context) { private val lock = Any() private val file = AtomicFile(context.filesDir.resolve(FILE_NAME)) + private val imageDirectory = File(context.filesDir, IMAGE_DIRECTORY_NAME) private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate()) fun readAll(): List = synchronized(lock) { @@ -34,6 +36,7 @@ class EncryptedNotificationLogStore(context: Context) { fun clear() = synchronized(lock) { file.delete() + imageDirectory.listFiles()?.forEach(File::delete) } private fun write(entries: List) { @@ -79,5 +82,6 @@ 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 IMAGE_DIRECTORY_NAME = "notification-images" } } diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingRuleStore.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingRuleStore.kt index 9ad8ce7..f23841e 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingRuleStore.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingRuleStore.kt @@ -22,18 +22,26 @@ class LoggingRuleStore(context: Context) { fun save(type: LoggingType, rule: LoggingRule) { preferences.edit { - putBoolean(key(type, "enabled"), rule.enabled) - putString(key(type, "app_rule_mode"), rule.appRuleMode.name) - putStringSet(key(type, "selected_packages"), rule.selectedPackages) - putBoolean(key(type, "only_seen_apps"), rule.onlySeenApps) - putBoolean(key(type, "seen_apps_first"), rule.seenAppsFirst) - putBoolean(key(type, "ignore_routine_updates"), rule.ignoreRoutineUpdates) + write(type, rule) } } fun copy(from: LoggingType, targets: Set) { val source = ruleFor(from) - targets.filterNot { it == from }.forEach { save(it, source.copy(selectedPackages = source.selectedPackages.toSet())) } + preferences.edit { + targets.filterNot { it == from }.forEach { target -> + write(target, source.copy(selectedPackages = source.selectedPackages.toSet())) + } + } + } + + private fun android.content.SharedPreferences.Editor.write(type: LoggingType, rule: LoggingRule) { + putBoolean(key(type, "enabled"), rule.enabled) + putString(key(type, "app_rule_mode"), rule.appRuleMode.name) + putStringSet(key(type, "selected_packages"), rule.selectedPackages) + putBoolean(key(type, "only_seen_apps"), rule.onlySeenApps) + putBoolean(key(type, "seen_apps_first"), rule.seenAppsFirst) + putBoolean(key(type, "ignore_routine_updates"), rule.ignoreRoutineUpdates) } private fun key(type: LoggingType, suffix: String) = "${type.name.lowercase()}.$suffix" diff --git a/app/src/main/res/layout/fragment_event_settings.xml b/app/src/main/res/layout/fragment_event_settings.xml index fc60a95..bae3b45 100644 --- a/app/src/main/res/layout/fragment_event_settings.xml +++ b/app/src/main/res/layout/fragment_event_settings.xml @@ -1,5 +1,5 @@ - + diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml index f63919a..acb0906 100644 --- a/app/src/main/res/layout/fragment_settings.xml +++ b/app/src/main/res/layout/fragment_settings.xml @@ -1,2 +1,103 @@ -