Complete settings controls and refresh behavior

This commit is contained in:
ajp_anton
2026-07-23 10:48:46 +00:00
parent a65fa80903
commit 6eb9058595
6 changed files with 255 additions and 17 deletions
@@ -1,6 +1,7 @@
package se.ajpanton.notificationlog package se.ajpanton.notificationlog
import android.os.Bundle import android.os.Bundle
import android.view.MotionEvent
import android.view.View import android.view.View
import android.widget.CheckBox import android.widget.CheckBox
import android.widget.LinearLayout import android.widget.LinearLayout
@@ -25,11 +26,36 @@ class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) {
store = LoggingRuleStore(requireContext()) store = LoggingRuleStore(requireContext())
binding!!.pageTitle.setText(requireArguments().getInt(ARG_TITLE)) binding!!.pageTitle.setText(requireArguments().getInt(ARG_TITLE))
bindRule() 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() } 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() { private fun bindRule() {
val rule = store.ruleFor(type) val rule = store.ruleFor(type)
listOf( listOf(
@@ -2,10 +2,12 @@ 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.view.View import android.view.View
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.google.android.material.switchmaterial.SwitchMaterial import com.google.android.material.switchmaterial.SwitchMaterial
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import se.ajpanton.notificationlog.databinding.FragmentSettingsBinding import se.ajpanton.notificationlog.databinding.FragmentSettingsBinding
import se.ajpanton.notificationlog.settings.LogField import se.ajpanton.notificationlog.settings.LogField
import se.ajpanton.notificationlog.settings.LogViewSettingsStore 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.LoggingType
import se.ajpanton.notificationlog.settings.LoggingRuleStore import se.ajpanton.notificationlog.settings.LoggingRuleStore
import se.ajpanton.notificationlog.settings.AppLockStore 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) { class SettingsFragment : Fragment(R.layout.fragment_settings) {
private var binding: FragmentSettingsBinding? = null private var binding: FragmentSettingsBinding? = null
private var selectedCopyTargets = emptySet<LoggingType>() 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding = FragmentSettingsBinding.bind(view) binding = FragmentSettingsBinding.bind(view)
@@ -64,19 +72,57 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
} }
} }
setupCopyControls() setupCopyControls()
setupEventMasterToggles()
binding!!.exportLogs.setOnClickListener { chooseExportFormat() }
binding!!.clearLogs.setOnClickListener { confirmClearLogs() }
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 }
} }
override fun onDestroyView() { binding = null; super.onDestroyView() } 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() { private fun setupCopyControls() {
val labels = listOf("From") + LoggingType.entries.map { it.label() } val labels = listOf("From") + LoggingType.entries.map { it.label() }
binding!!.copyFrom.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, labels) binding!!.copyFrom.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, labels)
binding!!.copyFrom.onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener { binding!!.copyFrom.onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener {
override fun onNothingSelected(parent: android.widget.AdapterView<*>?) = Unit override fun onNothingSelected(parent: android.widget.AdapterView<*>?) = Unit
override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) { 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]) } 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) { private fun chooseCopyTargets(source: LoggingType) {
val targets = LoggingType.entries.filterNot { it == source } val targets = LoggingType.entries.filterNot { it == source }
val checked = BooleanArray(targets.size) val checked = BooleanArray(targets.size) { targets[it] in selectedCopyTargets }
MaterialAlertDialogBuilder(requireContext()) val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle("Copy ${source.label()} settings to") .setTitle("Copy ${source.label()} settings to")
.setMultiChoiceItems(targets.map { it.label() }.toTypedArray(), checked) { _, index, selected -> checked[index] = selected } .setMultiChoiceItems(targets.map { it.label() }.toTypedArray(), checked) { _, index, selected -> checked[index] = selected }
.setNegativeButton("Cancel", null) .setNegativeButton("Cancel", null)
.setPositiveButton("Copy") { _, _ -> .setNeutralButton("All", null)
selectedCopyTargets = targets.filterIndexed { index, _ -> checked[index] }.toSet(); updateCopyButton() .setPositiveButton("Done") { _, _ ->
}.show() 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 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 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.DataInputStream
import java.io.DataOutputStream import java.io.DataOutputStream
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.File
/** /**
* An atomically replaced encrypted event log. It keeps every persisted log field * An atomically replaced encrypted event log. It keeps every persisted log field
@@ -16,6 +17,7 @@ import java.io.FileNotFoundException
class EncryptedNotificationLogStore(context: Context) { class EncryptedNotificationLogStore(context: Context) {
private val lock = Any() private val lock = Any()
private val file = AtomicFile(context.filesDir.resolve(FILE_NAME)) private val file = AtomicFile(context.filesDir.resolve(FILE_NAME))
private val imageDirectory = File(context.filesDir, IMAGE_DIRECTORY_NAME)
private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate()) private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate())
fun readAll(): List<NotificationLogEntry> = synchronized(lock) { fun readAll(): List<NotificationLogEntry> = synchronized(lock) {
@@ -34,6 +36,7 @@ class EncryptedNotificationLogStore(context: Context) {
fun clear() = synchronized(lock) { fun clear() = synchronized(lock) {
file.delete() file.delete()
imageDirectory.listFiles()?.forEach(File::delete)
} }
private fun write(entries: List<NotificationLogEntry>) { private fun write(entries: List<NotificationLogEntry>) {
@@ -79,5 +82,6 @@ class EncryptedNotificationLogStore(context: Context) {
const val FILE_VERSION = 1 const val FILE_VERSION = 1
const val MAX_IV_BYTES = 32 const val MAX_IV_BYTES = 32
const val MAX_CIPHER_TEXT_BYTES = 50 * 1024 * 1024 const val MAX_CIPHER_TEXT_BYTES = 50 * 1024 * 1024
const val IMAGE_DIRECTORY_NAME = "notification-images"
} }
} }
@@ -22,6 +22,20 @@ class LoggingRuleStore(context: Context) {
fun save(type: LoggingType, rule: LoggingRule) { fun save(type: LoggingType, rule: LoggingRule) {
preferences.edit { preferences.edit {
write(type, rule)
}
}
fun copy(from: LoggingType, targets: Set<LoggingType>) {
val source = ruleFor(from)
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) putBoolean(key(type, "enabled"), rule.enabled)
putString(key(type, "app_rule_mode"), rule.appRuleMode.name) putString(key(type, "app_rule_mode"), rule.appRuleMode.name)
putStringSet(key(type, "selected_packages"), rule.selectedPackages) putStringSet(key(type, "selected_packages"), rule.selectedPackages)
@@ -29,12 +43,6 @@ class LoggingRuleStore(context: Context) {
putBoolean(key(type, "seen_apps_first"), rule.seenAppsFirst) putBoolean(key(type, "seen_apps_first"), rule.seenAppsFirst)
putBoolean(key(type, "ignore_routine_updates"), rule.ignoreRoutineUpdates) putBoolean(key(type, "ignore_routine_updates"), rule.ignoreRoutineUpdates)
} }
}
fun copy(from: LoggingType, targets: Set<LoggingType>) {
val source = ruleFor(from)
targets.filterNot { it == from }.forEach { save(it, source.copy(selectedPackages = source.selectedPackages.toSet())) }
}
private fun key(type: LoggingType, suffix: String) = "${type.name.lowercase()}.$suffix" private fun key(type: LoggingType, suffix: String) = "${type.name.lowercase()}.$suffix"
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/app_list_refresh" android:layout_width="match_parent" android:layout_height="match_parent"><ScrollView android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/app_list_refresh" android:layout_width="match_parent" android:layout_height="match_parent"><ScrollView android:id="@+id/app_scroll" android:layout_width="match_parent" android:layout_height="match_parent">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="@dimen/page_padding"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="@dimen/page_padding">
<TextView android:id="@+id/page_title" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="?attr/textAppearanceHeadline5" /> <TextView android:id="@+id/page_title" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="?attr/textAppearanceHeadline5" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/master_toggle" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:text="Logging enabled" /> <com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/master_toggle" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:text="Logging enabled" />
+102 -1
View File
@@ -1,2 +1,103 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"><LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="@dimen/page_padding"><TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Settings" android:textAppearance="?attr/textAppearanceHeadline5"/><TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:text="Show on each log line (long-press and drag to reorder)"/><LinearLayout android:id="@+id/field_toggles" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical"/><TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:text="Timestamp timezone"/><Spinner android:id="@+id/timezone" android:layout_width="match_parent" android:layout_height="wrap_content"/><com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/app_lock" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:text="Lock app with phone unlock"/><TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:text="Copy event settings"/><Spinner android:id="@+id/copy_from" android:layout_width="match_parent" android:layout_height="wrap_content"/><Button android:id="@+id/copy_to" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Choose targets" android:enabled="false"/><Button android:id="@+id/copy_event_settings" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Copy settings" android:enabled="false"/></LinearLayout></ScrollView> <ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/page_padding">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Settings"
android:textAppearance="?attr/textAppearanceHeadline5" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Event logging" />
<LinearLayout
android:id="@+id/event_master_toggles"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Show on each log line (long-press and drag to reorder)" />
<LinearLayout
android:id="@+id/field_toggles"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Timestamp timezone" />
<Spinner
android:id="@+id/timezone"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Copy event settings" />
<Spinner
android:id="@+id/copy_from"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<Button
android:id="@+id/copy_to"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:enabled="false"
android:text="Choose targets" />
<Button
android:id="@+id/copy_event_settings"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:enabled="false"
android:text="Copy settings" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Logs" />
<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" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/app_lock"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Lock app with phone unlock" />
</LinearLayout>
</ScrollView>