Complete settings controls and refresh behavior
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<LoggingType>()
|
||||
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") }
|
||||
}
|
||||
|
||||
@@ -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<NotificationLogEntry> = 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<NotificationLogEntry>) {
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<LoggingType>) {
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user