Simplify and organize settings pages

This commit is contained in:
ajp_anton
2026-07-27 12:59:26 +00:00
parent 3dc87b417a
commit cad73e33a0
21 changed files with 293 additions and 426 deletions
@@ -11,7 +11,6 @@ import android.view.ViewGroup
import android.widget.CheckBox import android.widget.CheckBox
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@@ -22,24 +21,20 @@ import se.ajpanton.notificationlog.settings.AppListItem
import se.ajpanton.notificationlog.settings.AppListOrdering import se.ajpanton.notificationlog.settings.AppListOrdering
import se.ajpanton.notificationlog.settings.AppRuleMode import se.ajpanton.notificationlog.settings.AppRuleMode
import se.ajpanton.notificationlog.settings.ListedApp import se.ajpanton.notificationlog.settings.ListedApp
import se.ajpanton.notificationlog.settings.LoggingRule import se.ajpanton.notificationlog.settings.AppFilterSettings
import se.ajpanton.notificationlog.settings.LoggingRuleStore import se.ajpanton.notificationlog.settings.AppFilterSettingsStore
import se.ajpanton.notificationlog.settings.LoggingType
class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) { class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) {
private var binding: FragmentEventSettingsBinding? = null private var binding: FragmentEventSettingsBinding? = null
private lateinit var type: LoggingType private lateinit var store: AppFilterSettingsStore
private lateinit var store: LoggingRuleStore
private lateinit var appAdapter: AppListAdapter private lateinit var appAdapter: AppListAdapter
private var appLoadGeneration = 0 private var appLoadGeneration = 0
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding = FragmentEventSettingsBinding.bind(view) binding = FragmentEventSettingsBinding.bind(view)
type = requireArguments().getSerializable(ARG_TYPE, LoggingType::class.java)!! store = AppFilterSettingsStore(requireContext())
store = LoggingRuleStore(requireContext())
appAdapter = AppListAdapter(::setPackageSelected) appAdapter = AppListAdapter(::setPackageSelected)
binding!!.pageTitle.setText(requireArguments().getInt(ARG_TITLE))
binding!!.appList.layoutManager = LinearLayoutManager(requireContext()) binding!!.appList.layoutManager = LinearLayoutManager(requireContext())
binding!!.appList.adapter = appAdapter binding!!.appList.adapter = appAdapter
binding!!.appListRefresh.setOnRefreshListener(::reloadAppList) binding!!.appListRefresh.setOnRefreshListener(::reloadAppList)
@@ -66,40 +61,32 @@ class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) {
} }
private fun bindRule() { private fun bindRule() {
val rule = store.ruleFor(type) val rule = store.load()
listOf( listOf(
binding!!.masterToggle,
binding!!.appRuleToggle, binding!!.appRuleToggle,
binding!!.onlySeenToggle, binding!!.onlySeenToggle,
binding!!.seenFirstToggle, binding!!.seenFirstToggle,
binding!!.routineUpdatesToggle,
).forEach { it.setOnCheckedChangeListener(null) } ).forEach { it.setOnCheckedChangeListener(null) }
binding!!.masterToggle.isChecked = rule.enabled binding!!.appRuleToggle.isChecked = rule.mode == AppRuleMode.WHITELIST
binding!!.appRuleToggle.isChecked = rule.appRuleMode == AppRuleMode.WHITELIST
binding!!.onlySeenToggle.isChecked = rule.onlySeenApps binding!!.onlySeenToggle.isChecked = rule.onlySeenApps
binding!!.seenFirstToggle.isChecked = rule.seenAppsFirst binding!!.seenFirstToggle.isChecked = rule.seenAppsFirst
binding!!.seenFirstToggle.isEnabled = !rule.onlySeenApps binding!!.seenFirstToggle.isEnabled = !rule.onlySeenApps
binding!!.routineUpdatesToggle.visibility = if (type == LoggingType.EDITS) View.VISIBLE else View.GONE
binding!!.routineUpdatesToggle.isChecked = rule.ignoreRoutineUpdates
updateRuleLabels(rule) updateRuleLabels(rule)
binding!!.masterToggle.setOnCheckedChangeListener { _, checked -> save { copy(enabled = checked) } }
binding!!.appRuleToggle.setOnCheckedChangeListener { _, checked -> binding!!.appRuleToggle.setOnCheckedChangeListener { _, checked ->
save { copy(appRuleMode = if (checked) AppRuleMode.WHITELIST else AppRuleMode.BLACKLIST) } save { copy(mode = if (checked) AppRuleMode.WHITELIST else AppRuleMode.BLACKLIST) }
} }
binding!!.onlySeenToggle.setOnCheckedChangeListener { _, checked -> save { copy(onlySeenApps = checked) } } binding!!.onlySeenToggle.setOnCheckedChangeListener { _, checked -> save { copy(onlySeenApps = checked) } }
binding!!.seenFirstToggle.setOnCheckedChangeListener { _, checked -> save { copy(seenAppsFirst = checked) } } binding!!.seenFirstToggle.setOnCheckedChangeListener { _, checked -> save { copy(seenAppsFirst = checked) } }
binding!!.routineUpdatesToggle.setOnCheckedChangeListener { _, checked -> save { copy(ignoreRoutineUpdates = checked) } }
reloadAppList() reloadAppList()
} }
private fun save(change: LoggingRule.() -> LoggingRule) { private fun save(change: AppFilterSettings.() -> AppFilterSettings) {
store.save(type, store.ruleFor(type).change()) store.save(store.load().change())
bindRule() bindRule()
} }
private fun updateRuleLabels(rule: LoggingRule) { private fun updateRuleLabels(rule: AppFilterSettings) {
binding!!.masterToggle.text = "Enable logging" binding!!.appRuleToggle.text = modeLabel(rule.mode)
binding!!.appRuleToggle.text = modeLabel(rule.appRuleMode)
binding!!.onlySeenToggle.text = "Show only seen apps" binding!!.onlySeenToggle.text = "Show only seen apps"
binding!!.seenFirstToggle.text = "Show seen apps first" binding!!.seenFirstToggle.text = "Show seen apps first"
} }
@@ -122,7 +109,7 @@ class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) {
val generation = ++appLoadGeneration val generation = ++appLoadGeneration
currentBinding.appListLoading.visibility = View.VISIBLE currentBinding.appListLoading.visibility = View.VISIBLE
currentBinding.appListRefresh.isRefreshing = true currentBinding.appListRefresh.isRefreshing = true
val rule = store.ruleFor(type) val rule = store.load()
val seen = SeenApps.snapshot() val seen = SeenApps.snapshot()
val context = requireContext().applicationContext val context = requireContext().applicationContext
Thread { Thread {
@@ -146,11 +133,11 @@ class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) {
} }
private fun setPackageSelected(packageName: String, selected: Boolean) { private fun setPackageSelected(packageName: String, selected: Boolean) {
val current = store.ruleFor(type) val current = store.load()
val packages = current.selectedPackages.toMutableSet().apply { val packages = current.selectedPackages.toMutableSet().apply {
if (selected) add(packageName) else remove(packageName) if (selected) add(packageName) else remove(packageName)
} }
store.save(type, current.copy(selectedPackages = packages)) store.save(current.copy(selectedPackages = packages))
// Do not reload: an unchecked unseen row remains visible until the next requested refresh. // Do not reload: an unchecked unseen row remains visible until the next requested refresh.
} }
@@ -231,14 +218,6 @@ class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) {
} }
companion object { companion object {
private const val ARG_TITLE = "title" fun newInstance() = FilterAppsFragment()
private const val ARG_TYPE = "type"
fun newInstance(@StringRes title: Int, type: LoggingType) = EventSettingsFragment().apply {
arguments = Bundle().apply {
putInt(ARG_TITLE, title)
putSerializable(ARG_TYPE, type)
}
}
} }
} }
@@ -0,0 +1,50 @@
package se.ajpanton.notificationlog
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import com.google.android.material.switchmaterial.SwitchMaterial
import se.ajpanton.notificationlog.databinding.FragmentFilterLoggingBinding
import se.ajpanton.notificationlog.settings.CaptureSettingsStore
import se.ajpanton.notificationlog.settings.LoggingRuleStore
import se.ajpanton.notificationlog.settings.LoggingType
class FilterLoggingFragment : Fragment(R.layout.fragment_filter_logging) {
private var binding: FragmentFilterLoggingBinding? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentFilterLoggingBinding.bind(view)
val rules = LoggingRuleStore(requireContext())
addToggles(binding!!.eventToggles, listOf(LoggingType.APPEARING, LoggingType.DISAPPEARING, LoggingType.EDITS), rules)
addToggles(binding!!.contentToggles, listOf(LoggingType.TEXT_CONTENT, LoggingType.IMAGE_CONTENT), rules)
val capture = CaptureSettingsStore(requireContext())
binding!!.groupSummaries.isChecked = capture.logGroupSummaries
binding!!.groupSummaries.setOnCheckedChangeListener { _, checked -> capture.logGroupSummaries = checked }
binding!!.routineUpdates.isChecked = rules.ruleFor(LoggingType.EDITS).ignoreRoutineUpdates
binding!!.routineUpdates.setOnCheckedChangeListener { _, checked -> rules.save(LoggingType.EDITS, rules.ruleFor(LoggingType.EDITS).copy(ignoreRoutineUpdates = checked)) }
}
private fun addToggles(
container: android.widget.LinearLayout,
types: List<LoggingType>,
rules: LoggingRuleStore,
) = types.forEach { type ->
container.addView(SwitchMaterial(requireContext()).apply {
text = type.label()
isChecked = rules.ruleFor(type).enabled
setOnCheckedChangeListener { _, checked ->
rules.save(type, rules.ruleFor(type).copy(enabled = checked))
}
})
}
override fun onDestroyView() { binding = null; super.onDestroyView() }
private fun LoggingType.label() = when (this) {
LoggingType.APPEARING -> "Appearing"
LoggingType.DISAPPEARING -> "Disappearing"
LoggingType.EDITS -> "Edits"
LoggingType.TEXT_CONTENT -> "Text content"
LoggingType.IMAGE_CONTENT -> "Image content"
}
}
@@ -0,0 +1,102 @@
package se.ajpanton.notificationlog
import android.content.ClipData
import android.os.Bundle
import android.view.View
import android.widget.ArrayAdapter
import androidx.fragment.app.Fragment
import com.google.android.material.switchmaterial.SwitchMaterial
import se.ajpanton.notificationlog.databinding.FragmentLogDisplayBinding
import se.ajpanton.notificationlog.settings.DisplayEvent
import se.ajpanton.notificationlog.settings.LogField
import se.ajpanton.notificationlog.settings.LogViewSettingsStore
import se.ajpanton.notificationlog.settings.TimestampZone
class LogDisplayFragment : Fragment(R.layout.fragment_log_display) {
private var binding: FragmentLogDisplayBinding? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentLogDisplayBinding.bind(view)
val store = LogViewSettingsStore(requireContext())
var settings = store.load()
DisplayEvent.entries.forEach { event ->
binding!!.eventToggles.addView(SwitchMaterial(requireContext()).apply {
text = event.name.lowercase().replaceFirstChar(Char::uppercase)
isChecked = event in settings.visibleEvents
setOnCheckedChangeListener { _, checked ->
settings = settings.copy(
visibleEvents = settings.visibleEvents.toMutableSet().apply {
if (checked) add(event) else remove(event)
},
)
store.save(settings)
}
})
}
settings.order.forEach { field ->
binding!!.fieldToggles.addView(SwitchMaterial(requireContext()).apply {
text = field.label()
isChecked = field in settings.visibleFields
tag = field
setOnLongClickListener {
startDragAndDrop(
ClipData.newPlainText("field", field.name),
View.DragShadowBuilder(this),
this,
0,
)
true
}
setOnCheckedChangeListener { _, checked ->
settings = settings.copy(
visibleFields = settings.visibleFields.toMutableSet().apply {
if (checked) add(field) else remove(field)
},
)
store.save(settings)
}
})
}
binding!!.fieldToggles.setOnDragListener { _, event ->
if (event.action != android.view.DragEvent.ACTION_DROP) return@setOnDragListener true
val source = event.localState as? View ?: return@setOnDragListener false
val box = binding!!.fieldToggles
val index = (0 until box.childCount).firstOrNull { event.y < box.getChildAt(it).bottom } ?: box.childCount
box.removeView(source)
box.addView(source, index.coerceAtMost(box.childCount))
settings = settings.copy(order = (0 until box.childCount).map { box.getChildAt(it).tag as LogField })
store.save(settings)
true
}
binding!!.timezone.adapter = ArrayAdapter(
requireContext(),
android.R.layout.simple_spinner_dropdown_item,
listOf("Current local timezone", "UTC", "Local timezone when event happened"),
)
binding!!.timezone.setSelection(TimestampZone.entries.indexOf(settings.timestampZone))
binding!!.timezone.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,
) {
settings = settings.copy(timestampZone = TimestampZone.entries[position])
store.save(settings)
}
}
}
override fun onDestroyView() { binding = null; super.onDestroyView() }
private fun LogField.label() = when (this) {
LogField.TIMESTAMP -> "Timestamp"
LogField.APP_NAME -> "App name"
LogField.PACKAGE_NAME -> "Package name"
LogField.ACTION -> "What the app did"
LogField.CONTENTS -> "Contents of the notification"
}
}
@@ -21,7 +21,6 @@ import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.commit import androidx.fragment.app.commit
import com.google.android.material.navigation.NavigationView import com.google.android.material.navigation.NavigationView
import se.ajpanton.notificationlog.databinding.ActivityMainBinding import se.ajpanton.notificationlog.databinding.ActivityMainBinding
import se.ajpanton.notificationlog.settings.LoggingType
import se.ajpanton.notificationlog.settings.AppLockStore import se.ajpanton.notificationlog.settings.AppLockStore
class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener { class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener {
@@ -137,7 +136,9 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
when { when {
page == Page.VIEW_LOGS -> ViewLogsFragment() page == Page.VIEW_LOGS -> ViewLogsFragment()
page == Page.SETTINGS -> SettingsFragment() page == Page.SETTINGS -> SettingsFragment()
page.loggingType != null -> EventSettingsFragment.newInstance(page.titleRes, page.loggingType) page == Page.LOG_DISPLAY -> LogDisplayFragment()
page == Page.FILTER_LOGGING -> FilterLoggingFragment()
page == Page.FILTER_APPS -> FilterAppsFragment.newInstance()
else -> PageFragment.newInstance(page.titleRes) else -> PageFragment.newInstance(page.titleRes)
}, },
) )
@@ -287,15 +288,12 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
private enum class Page( private enum class Page(
val menuId: Int, val menuId: Int,
val titleRes: Int, val titleRes: Int,
val loggingType: LoggingType? = null,
) { ) {
VIEW_LOGS(R.id.nav_view_logs, R.string.page_view_logs), VIEW_LOGS(R.id.nav_view_logs, R.string.page_view_logs),
SETTINGS(R.id.nav_settings, R.string.page_settings), SETTINGS(R.id.nav_settings, R.string.page_settings),
APPEARING(R.id.nav_appearing, R.string.page_appearing, LoggingType.APPEARING), LOG_DISPLAY(R.id.nav_log_display, R.string.page_log_display),
DISAPPEARING(R.id.nav_disappearing, R.string.page_disappearing, LoggingType.DISAPPEARING), FILTER_LOGGING(R.id.nav_filter_logging, R.string.page_filter_logging),
TEXT_CONTENT(R.id.nav_text_content, R.string.page_text_content, LoggingType.TEXT_CONTENT), FILTER_APPS(R.id.nav_filter_apps, R.string.page_filter_apps),
IMAGE_CONTENT(R.id.nav_image_content, R.string.page_image_content, LoggingType.IMAGE_CONTENT),
EDITS(R.id.nav_edits, R.string.page_edits, LoggingType.EDITS),
; ;
companion object { companion object {
@@ -1,30 +1,17 @@
package se.ajpanton.notificationlog package se.ajpanton.notificationlog
import android.os.Bundle import android.os.Bundle
import android.content.ClipData
import android.net.Uri import android.net.Uri
import android.content.ComponentName import android.content.ComponentName
import android.content.Intent import android.content.Intent
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.provider.Settings import android.provider.Settings
import android.view.View import android.view.View
import android.widget.ArrayAdapter
import android.widget.CheckBox
import android.widget.LinearLayout
import android.widget.PopupWindow
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.google.android.material.switchmaterial.SwitchMaterial
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.color.MaterialColors
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
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 se.ajpanton.notificationlog.settings.AppLockStore
import se.ajpanton.notificationlog.settings.CaptureSettingsStore
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.capture.NotificationCaptureService
@@ -33,7 +20,6 @@ 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 pendingExport: ExportFormat? = null private var pendingExport: ExportFormat? = null
private val createDocument = registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.CreateDocument("application/octet-stream")) { uri -> private val createDocument = registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
uri?.let(::writeExport) uri?.let(::writeExport)
@@ -41,55 +27,6 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
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)
val store = LogViewSettingsStore(requireContext())
var settings = store.load()
settings.order.forEach { field ->
binding!!.fieldToggles.addView(SwitchMaterial(requireContext()).apply {
text = when (field) {
LogField.TIMESTAMP -> "Timestamp"; LogField.APP_NAME -> "App name"
LogField.PACKAGE_NAME -> "Package name"; LogField.ACTION -> "What the app did"
LogField.CONTENTS -> "Contents of the notification"
}
isChecked = field in settings.visibleFields
tag = field
setOnLongClickListener {
startDragAndDrop(ClipData.newPlainText("field", field.name), View.DragShadowBuilder(this), this, 0)
true
}
setOnCheckedChangeListener { _, checked ->
settings = settings.copy(visibleFields = settings.visibleFields.toMutableSet().apply {
if (checked) add(field) else remove(field)
})
store.save(settings)
}
})
}
binding!!.fieldToggles.setOnDragListener { _, event ->
if (event.action != android.view.DragEvent.ACTION_DROP) return@setOnDragListener true
val source = event.localState as? View ?: return@setOnDragListener false
val container = binding!!.fieldToggles
val index = (0 until container.childCount).firstOrNull { event.y < container.getChildAt(it).bottom } ?: container.childCount
container.removeView(source); container.addView(source, index.coerceAtMost(container.childCount))
settings = settings.copy(order = (0 until container.childCount).map { container.getChildAt(it).tag as LogField })
store.save(settings)
true
}
val labels = listOf("Current local timezone", "UTC", "Local timezone when event happened")
binding!!.timezone.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, labels)
binding!!.timezone.setSelection(TimestampZone.entries.indexOf(settings.timestampZone))
binding!!.timezone.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) {
settings = settings.copy(timestampZone = TimestampZone.entries[position]); store.save(settings)
}
}
setupCopyControls()
setupEventMasterToggles()
val captureSettings = CaptureSettingsStore(requireContext())
binding!!.groupSummaries.isChecked = captureSettings.logGroupSummaries
binding!!.groupSummaries.setOnCheckedChangeListener { _, checked ->
captureSettings.logGroupSummaries = checked
}
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)) } binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) }
@@ -101,114 +38,9 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
refreshEventMasterToggles()
binding?.notificationAccess?.text = if (isListenerEnabled()) "Notification access enabled" else "Enable notification access" binding?.notificationAccess?.text = if (isListenerEnabled()) "Notification access enabled" else "Enable notification access"
} }
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("Source") + 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
binding!!.copyTo.text = "Destination"
updateCopyControls()
}
}
binding!!.copyTo.setOnClickListener { showDestinationDropdown(LoggingType.entries[binding!!.copyFrom.selectedItemPosition - 1]) }
binding!!.copyEventSettings.setOnClickListener {
LoggingRuleStore(requireContext()).copy(LoggingType.entries[binding!!.copyFrom.selectedItemPosition - 1], selectedCopyTargets)
}
}
private fun showDestinationDropdown(source: LoggingType) {
val targets = LoggingType.entries.filterNot { it == source }
val popupContent = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(8), dp(8), dp(8), dp(8))
}
val popup = PopupWindow(popupContent, binding!!.copyTo.width.coerceAtLeast(dp(200)), LinearLayout.LayoutParams.WRAP_CONTENT, true).apply {
isOutsideTouchable = true
elevation = dp(8).toFloat()
setBackgroundDrawable(ColorDrawable(MaterialColors.getColor(requireContext(), com.google.android.material.R.attr.colorSurface, Color.WHITE)))
}
val boxes = mutableListOf<CheckBox>()
popupContent.addView(android.widget.Button(requireContext()).apply {
text = "All"
setOnClickListener {
val selectAll = selectedCopyTargets.size != targets.size
selectedCopyTargets = if (selectAll) targets.toSet() else emptySet()
boxes.forEachIndexed { index, box -> box.isChecked = targets[index] in selectedCopyTargets }
updateCopyControls()
}
})
popupContent.addView(View(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1).apply {
setMargins(0, dp(4), 0, dp(4))
}
setBackgroundColor(0x33000000)
})
targets.forEach { target ->
val box = CheckBox(requireContext()).apply {
text = target.label()
isChecked = target in selectedCopyTargets
setOnCheckedChangeListener { _, checked ->
selectedCopyTargets = selectedCopyTargets.toMutableSet().apply {
if (checked) add(target) else remove(target)
}
updateCopyControls()
}
}
boxes += box
popupContent.addView(box)
}
popup.showAsDropDown(binding!!.copyTo)
}
private fun updateCopyControls() {
val currentBinding = binding ?: return
val sourceChosen = currentBinding.copyFrom.selectedItemPosition > 0
currentBinding.copyTo.isEnabled = sourceChosen
currentBinding.copyTo.text = destinationLabel()
currentBinding.copyEventSettings.isEnabled = sourceChosen && selectedCopyTargets.isNotEmpty()
}
private fun destinationLabel(): String = when (selectedCopyTargets.size) {
0 -> "Destination"
1 -> selectedCopyTargets.first().label()
else -> "${selectedCopyTargets.sortedBy { it.ordinal }.first().label()}..."
}
private fun isListenerEnabled(): Boolean = requireContext().getSystemService(android.app.NotificationManager::class.java) private fun isListenerEnabled(): Boolean = requireContext().getSystemService(android.app.NotificationManager::class.java)
.isNotificationListenerAccessGranted(ComponentName(requireContext(), NotificationCaptureService::class.java)) .isNotificationListenerAccessGranted(ComponentName(requireContext(), NotificationCaptureService::class.java))
@@ -271,9 +103,5 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
}.start() }.start()
} }
private fun LoggingType.label() = name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase)
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
private enum class ExportFormat(val extension: String) { CSV("csv"), FORMATTED("txt"), HTML_ZIP("zip") } private enum class ExportFormat(val extension: String) { CSV("csv"), FORMATTED("txt"), HTML_ZIP("zip") }
} }
@@ -17,6 +17,7 @@ import se.ajpanton.notificationlog.settings.LogField
import se.ajpanton.notificationlog.settings.LogViewSettings import se.ajpanton.notificationlog.settings.LogViewSettings
import se.ajpanton.notificationlog.settings.LogViewSettingsStore import se.ajpanton.notificationlog.settings.LogViewSettingsStore
import se.ajpanton.notificationlog.settings.TimestampZone import se.ajpanton.notificationlog.settings.TimestampZone
import se.ajpanton.notificationlog.settings.DisplayEvent
import java.text.DateFormat import java.text.DateFormat
import java.util.Date import java.util.Date
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
@@ -67,7 +68,8 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
view.logRows.removeAllViews() view.logRows.removeAllViews()
view.emptyView.visibility = if (rows.isEmpty()) View.VISIBLE else View.GONE view.emptyView.visibility = if (rows.isEmpty()) View.VISIBLE else View.GONE
val settings = LogViewSettingsStore(requireContext()).load() val settings = LogViewSettingsStore(requireContext()).load()
rows.forEach { row -> view.logRows.addView(logRowView(row, settings)) } rows.filter { eventFor(it.entry.action) in settings.visibleEvents }
.forEach { row -> view.logRows.addView(logRowView(row, settings)) }
} }
private fun logRowView(row: LogRow, settings: LogViewSettings): View = LinearLayout(requireContext()).apply { private fun logRowView(row: LogRow, settings: LogViewSettings): View = LinearLayout(requireContext()).apply {
@@ -201,6 +203,12 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
else -> action.name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase) else -> action.name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase)
} }
private fun eventFor(action: NotificationAction): DisplayEvent = when (action) {
NotificationAction.APPEARED, NotificationAction.ALREADY_ACTIVE -> DisplayEvent.APPEARING
NotificationAction.EDITED -> DisplayEvent.EDITS
else -> DisplayEvent.DISAPPEARING
}
private fun timestamp(value: Long, zone: TimestampZone, eventTimeZoneId: String?): String = DateFormat.getDateTimeInstance().apply { private fun timestamp(value: Long, zone: TimestampZone, eventTimeZoneId: String?): String = DateFormat.getDateTimeInstance().apply {
timeZone = when (zone) { timeZone = when (zone) {
TimestampZone.UTC -> java.util.TimeZone.getTimeZone("UTC") TimestampZone.UTC -> java.util.TimeZone.getTimeZone("UTC")
@@ -13,6 +13,7 @@ import se.ajpanton.notificationlog.settings.LoggingRuleStore
import se.ajpanton.notificationlog.settings.LoggingType import se.ajpanton.notificationlog.settings.LoggingType
import se.ajpanton.notificationlog.settings.NotificationRuleEvaluator import se.ajpanton.notificationlog.settings.NotificationRuleEvaluator
import se.ajpanton.notificationlog.settings.CaptureSettingsStore import se.ajpanton.notificationlog.settings.CaptureSettingsStore
import se.ajpanton.notificationlog.settings.AppFilterSettingsStore
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
@@ -22,6 +23,7 @@ class NotificationCaptureService : NotificationListenerService() {
private lateinit var writeExecutor: ExecutorService private lateinit var writeExecutor: ExecutorService
private lateinit var ruleStore: LoggingRuleStore private lateinit var ruleStore: LoggingRuleStore
private lateinit var captureSettings: CaptureSettingsStore private lateinit var captureSettings: CaptureSettingsStore
private lateinit var appFilterStore: AppFilterSettingsStore
private lateinit var imageStore: EncryptedImageStore private lateinit var imageStore: EncryptedImageStore
override fun onCreate() { override fun onCreate() {
@@ -29,6 +31,7 @@ class NotificationCaptureService : NotificationListenerService() {
logStore = EncryptedNotificationLogStore(this) logStore = EncryptedNotificationLogStore(this)
ruleStore = LoggingRuleStore(this) ruleStore = LoggingRuleStore(this)
captureSettings = CaptureSettingsStore(this) captureSettings = CaptureSettingsStore(this)
appFilterStore = AppFilterSettingsStore(this)
imageStore = EncryptedImageStore(this) imageStore = EncryptedImageStore(this)
writeExecutor = Executors.newSingleThreadExecutor { runnable -> writeExecutor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "notification-log-writer") Thread(runnable, "notification-log-writer")
@@ -80,10 +83,9 @@ class NotificationCaptureService : NotificationListenerService() {
previousSnapshot: NotificationSnapshot? = null, previousSnapshot: NotificationSnapshot? = null,
) { ) {
if (!GroupSummaryPolicy.shouldLog(snapshot, captureSettings.logGroupSummaries)) return if (!GroupSummaryPolicy.shouldLog(snapshot, captureSettings.logGroupSummaries)) return
if (!NotificationRuleEvaluator.allows(ruleStore.ruleFor(loggingType), snapshot.packageName)) return if (!allows(loggingType, snapshot.packageName)) return
val appName = appName(snapshot.packageName) val appName = appName(snapshot.packageName)
val retainImage = includeContents && snapshot.imageBytes != null && val retainImage = includeContents && snapshot.imageBytes != null && allows(LoggingType.IMAGE_CONTENT, snapshot.packageName)
NotificationRuleEvaluator.allows(ruleStore.ruleFor(LoggingType.IMAGE_CONTENT), snapshot.packageName)
val entry = NotificationLogEntry( val entry = NotificationLogEntry(
recordedAtEpochMillis = System.currentTimeMillis(), recordedAtEpochMillis = System.currentTimeMillis(),
eventTimeZoneId = java.util.TimeZone.getDefault().id, eventTimeZoneId = java.util.TimeZone.getDefault().id,
@@ -115,17 +117,18 @@ class NotificationCaptureService : NotificationListenerService() {
private fun visibleContents(snapshot: NotificationSnapshot): String? { private fun visibleContents(snapshot: NotificationSnapshot): String? {
val text = snapshot.textContents?.takeIf { val text = snapshot.textContents?.takeIf {
NotificationRuleEvaluator.allows(ruleStore.ruleFor(LoggingType.TEXT_CONTENT), snapshot.packageName) allows(LoggingType.TEXT_CONTENT, snapshot.packageName)
} }
val image = snapshot.hasImage && NotificationRuleEvaluator.allows( val image = snapshot.hasImage && allows(LoggingType.IMAGE_CONTENT, snapshot.packageName)
ruleStore.ruleFor(LoggingType.IMAGE_CONTENT), snapshot.packageName,
)
return listOfNotNull(text, if (image) "[image]" else null) return listOfNotNull(text, if (image) "[image]" else null)
.joinToString("\n") .joinToString("\n")
.take(MAX_CONTENT_CHARACTERS) .take(MAX_CONTENT_CHARACTERS)
.ifEmpty { null } .ifEmpty { null }
} }
private fun allows(type: LoggingType, packageName: String): Boolean =
ruleStore.ruleFor(type).enabled && NotificationRuleEvaluator.allows(appFilterStore.load(), packageName)
private fun appName(packageName: String): String = try { private fun appName(packageName: String): String = try {
val applicationInfo = packageManager.getApplicationInfo(packageName, 0) val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
packageManager.getApplicationLabel(applicationInfo).toString() packageManager.getApplicationLabel(applicationInfo).toString()
@@ -0,0 +1,54 @@
package se.ajpanton.notificationlog.settings
import android.content.Context
import androidx.core.content.edit
data class AppFilterSettings(
val mode: AppRuleMode = AppRuleMode.BLACKLIST,
val selectedPackages: Set<String> = emptySet(),
val onlySeenApps: Boolean = false,
val seenAppsFirst: Boolean = true,
)
class AppFilterSettingsStore(context: Context) {
private val appContext = context.applicationContext
private val preferences = appContext.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
fun load(): AppFilterSettings {
migrateLegacyFiltersIfNeeded()
return AppFilterSettings(
mode = AppRuleMode.valueOf(preferences.getString(MODE, AppRuleMode.BLACKLIST.name)!!),
selectedPackages = preferences.getStringSet(SELECTED_PACKAGES, emptySet())?.toSet() ?: emptySet(),
onlySeenApps = preferences.getBoolean(ONLY_SEEN_APPS, false),
seenAppsFirst = preferences.getBoolean(SEEN_APPS_FIRST, true),
)
}
fun save(settings: AppFilterSettings) = preferences.edit {
putString(MODE, settings.mode.name)
putStringSet(SELECTED_PACKAGES, settings.selectedPackages)
putBoolean(ONLY_SEEN_APPS, settings.onlySeenApps)
putBoolean(SEEN_APPS_FIRST, settings.seenAppsFirst)
}
private fun migrateLegacyFiltersIfNeeded() {
if (preferences.getBoolean(MIGRATED, false)) return
val legacy = appContext.getSharedPreferences("logging-rules", Context.MODE_PRIVATE)
val blacklistedPackages = LoggingType.entries.flatMap { type ->
legacy.getStringSet("${type.name.lowercase()}.selected_packages", emptySet()).orEmpty()
.takeIf { legacy.getString("${type.name.lowercase()}.app_rule_mode", AppRuleMode.BLACKLIST.name) == AppRuleMode.BLACKLIST.name }
.orEmpty()
}.toSet()
save(AppFilterSettings(selectedPackages = blacklistedPackages))
preferences.edit { putBoolean(MIGRATED, true) }
}
private companion object {
const val FILE_NAME = "app-filter-settings"
const val MIGRATED = "migrated_from_per_event_filters"
const val MODE = "mode"
const val SELECTED_PACKAGES = "selected_packages"
const val ONLY_SEEN_APPS = "only_seen_apps"
const val SEEN_APPS_FIRST = "seen_apps_first"
}
}
@@ -2,9 +2,11 @@ package se.ajpanton.notificationlog.settings
enum class LogField { TIMESTAMP, APP_NAME, PACKAGE_NAME, ACTION, CONTENTS } enum class LogField { TIMESTAMP, APP_NAME, PACKAGE_NAME, ACTION, CONTENTS }
enum class TimestampZone { LOCAL_NOW, UTC, EVENT_LOCAL } enum class TimestampZone { LOCAL_NOW, UTC, EVENT_LOCAL }
enum class DisplayEvent { APPEARING, DISAPPEARING, EDITS }
data class LogViewSettings( data class LogViewSettings(
val visibleFields: Set<LogField> = setOf(LogField.TIMESTAMP, LogField.APP_NAME, LogField.ACTION, LogField.CONTENTS), val visibleFields: Set<LogField> = setOf(LogField.TIMESTAMP, LogField.APP_NAME, LogField.ACTION, LogField.CONTENTS),
val order: List<LogField> = LogField.entries, val order: List<LogField> = LogField.entries,
val timestampZone: TimestampZone = TimestampZone.LOCAL_NOW, val timestampZone: TimestampZone = TimestampZone.LOCAL_NOW,
val visibleEvents: Set<DisplayEvent> = DisplayEvent.entries.toSet(),
) )
@@ -10,10 +10,13 @@ class LogViewSettingsStore(context: Context) {
.map(LogField::valueOf).toSet(), .map(LogField::valueOf).toSet(),
order = prefs.getString("order", null)?.split(',')?.map(LogField::valueOf) ?: LogField.entries, order = prefs.getString("order", null)?.split(',')?.map(LogField::valueOf) ?: LogField.entries,
timestampZone = TimestampZone.valueOf(prefs.getString("zone", TimestampZone.LOCAL_NOW.name)!!), timestampZone = TimestampZone.valueOf(prefs.getString("zone", TimestampZone.LOCAL_NOW.name)!!),
visibleEvents = prefs.getStringSet("visible_events", DisplayEvent.entries.map { it.name }.toSet())!!
.map(DisplayEvent::valueOf).toSet(),
) )
fun save(settings: LogViewSettings) = prefs.edit { fun save(settings: LogViewSettings) = prefs.edit {
putStringSet("visible", settings.visibleFields.map { it.name }.toSet()) putStringSet("visible", settings.visibleFields.map { it.name }.toSet())
putString("order", settings.order.joinToString(",") { it.name }) putString("order", settings.order.joinToString(",") { it.name })
putString("zone", settings.timestampZone.name) putString("zone", settings.timestampZone.name)
putStringSet("visible_events", settings.visibleEvents.map { it.name }.toSet())
} }
} }
@@ -10,14 +10,6 @@ class LoggingRuleStore(context: Context) {
fun ruleFor(type: LoggingType): LoggingRule = LoggingRule( fun ruleFor(type: LoggingType): LoggingRule = LoggingRule(
enabled = preferences.getBoolean(key(type, "enabled"), true), enabled = preferences.getBoolean(key(type, "enabled"), true),
appRuleMode = AppRuleMode.valueOf(
preferences.getString(key(type, "app_rule_mode"), AppRuleMode.BLACKLIST.name)
?: AppRuleMode.BLACKLIST.name,
),
selectedPackages = preferences.getStringSet(key(type, "selected_packages"), emptySet())?.toSet()
?: emptySet(),
onlySeenApps = preferences.getBoolean(key(type, "only_seen_apps"), false),
seenAppsFirst = preferences.getBoolean(key(type, "seen_apps_first"), true),
ignoreRoutineUpdates = preferences.getBoolean(key(type, "ignore_routine_updates"), true), ignoreRoutineUpdates = preferences.getBoolean(key(type, "ignore_routine_updates"), true),
) )
@@ -28,22 +20,8 @@ class LoggingRuleStore(context: Context) {
updateListenerComponent() updateListenerComponent()
} }
fun copy(from: LoggingType, targets: Set<LoggingType>) {
val source = ruleFor(from).copyForTarget()
preferences.edit {
targets.filterNot { it == from }.forEach { target ->
write(target, source.copy(selectedPackages = source.selectedPackages.toSet()))
}
}
updateListenerComponent()
}
private fun android.content.SharedPreferences.Editor.write(type: LoggingType, rule: LoggingRule) { 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)
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) putBoolean(key(type, "ignore_routine_updates"), rule.ignoreRoutineUpdates)
} }
@@ -57,5 +35,3 @@ class LoggingRuleStore(context: Context) {
const val FILE_NAME = "logging-rules" const val FILE_NAME = "logging-rules"
} }
} }
internal fun LoggingRule.copyForTarget(): LoggingRule = copy(selectedPackages = selectedPackages.toSet())
@@ -15,9 +15,5 @@ enum class AppRuleMode {
data class LoggingRule( data class LoggingRule(
val enabled: Boolean = true, val enabled: Boolean = true,
val appRuleMode: AppRuleMode = AppRuleMode.BLACKLIST,
val selectedPackages: Set<String> = emptySet(),
val onlySeenApps: Boolean = false,
val seenAppsFirst: Boolean = true,
val ignoreRoutineUpdates: Boolean = true, val ignoreRoutineUpdates: Boolean = true,
) )
@@ -1,10 +1,9 @@
package se.ajpanton.notificationlog.settings package se.ajpanton.notificationlog.settings
object NotificationRuleEvaluator { object NotificationRuleEvaluator {
fun allows(rule: LoggingRule, packageName: String): Boolean { fun allows(filter: AppFilterSettings, packageName: String): Boolean {
if (!rule.enabled) return false val selected = packageName in filter.selectedPackages
val selected = packageName in rule.selectedPackages return when (filter.mode) {
return when (rule.appRuleMode) {
AppRuleMode.WHITELIST -> selected AppRuleMode.WHITELIST -> selected
AppRuleMode.BLACKLIST -> !selected AppRuleMode.BLACKLIST -> !selected
} }
@@ -5,19 +5,6 @@
android:orientation="vertical" android:orientation="vertical"
android:padding="@dimen/page_padding"> 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" />
<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="Enable logging" />
<com.google.android.material.switchmaterial.SwitchMaterial <com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/app_rule_toggle" android:id="@+id/app_rule_toggle"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -35,12 +22,6 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Show seen apps first" /> android:text="Show seen apps first" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/routine_updates_toggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Ignore routine updates" />
<FrameLayout <FrameLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
@@ -0,0 +1,12 @@
<?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="Events to log" />
<LinearLayout android:id="@+id/event_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="Content to log" />
<LinearLayout android:id="@+id/content_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="Special filters" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/group_summaries" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Log Android group summaries" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/routine_updates" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Ignore detected routine updates" />
</LinearLayout>
</ScrollView>
@@ -0,0 +1,11 @@
<?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="Filter events" />
<LinearLayout android:id="@+id/event_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="On each 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" />
</LinearLayout>
</ScrollView>
@@ -9,113 +9,6 @@
android:orientation="vertical" android:orientation="vertical"
android:padding="@dimen/page_padding"> 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" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/group_summaries"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Log Android group summaries" />
<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 settings between pages" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Copy from" />
<Spinner
android:id="@+id/copy_from"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="to" />
<Button
android:id="@+id/copy_to"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:enabled="false"
android:gravity="start|center_vertical"
android:text="Destination" />
</LinearLayout>
<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 <Button
android:id="@+id/notification_access" android:id="@+id/notification_access"
android:layout_width="wrap_content" android:layout_width="wrap_content"
+6 -12
View File
@@ -10,19 +10,13 @@
android:id="@+id/nav_settings" android:id="@+id/nav_settings"
android:title="@string/navigation_settings_header" /> android:title="@string/navigation_settings_header" />
<item <item
android:id="@+id/nav_appearing" android:id="@+id/nav_log_display"
android:title="@string/navigation_indented_appearing" /> android:title="@string/navigation_indented_log_display" />
<item <item
android:id="@+id/nav_disappearing" android:id="@+id/nav_filter_logging"
android:title="@string/navigation_indented_disappearing" /> android:title="@string/navigation_indented_filter_logging" />
<item <item
android:id="@+id/nav_text_content" android:id="@+id/nav_filter_apps"
android:title="@string/navigation_indented_text_content" /> android:title="@string/navigation_indented_filter_apps" />
<item
android:id="@+id/nav_image_content"
android:title="@string/navigation_indented_image_content" />
<item
android:id="@+id/nav_edits"
android:title="@string/navigation_indented_edits" />
</group> </group>
</menu> </menu>
+6 -10
View File
@@ -7,14 +7,10 @@
<string name="navigation_settings_header">Settings</string> <string name="navigation_settings_header">Settings</string>
<string name="page_view_logs">View logs</string> <string name="page_view_logs">View logs</string>
<string name="page_settings">Settings</string> <string name="page_settings">Settings</string>
<string name="page_appearing">Appearing</string> <string name="page_filter_apps">Filter apps</string>
<string name="page_disappearing">Disappearing</string> <string name="page_log_display">Log display</string>
<string name="page_text_content">Text content</string> <string name="page_filter_logging">Filter logging</string>
<string name="page_image_content">Image content</string> <string name="navigation_indented_log_display">&#160;&#160;&#160;&#160;Log display</string>
<string name="page_edits">Edits</string> <string name="navigation_indented_filter_logging">&#160;&#160;&#160;&#160;Filter logging</string>
<string name="navigation_indented_appearing">&#160;&#160;&#160;&#160;Appearing</string> <string name="navigation_indented_filter_apps">&#160;&#160;&#160;&#160;Filter apps</string>
<string name="navigation_indented_disappearing">&#160;&#160;&#160;&#160;Disappearing</string>
<string name="navigation_indented_text_content">&#160;&#160;&#160;&#160;Text content</string>
<string name="navigation_indented_image_content">&#160;&#160;&#160;&#160;Image content</string>
<string name="navigation_indented_edits">&#160;&#160;&#160;&#160;Edits</string>
</resources> </resources>
@@ -1,15 +0,0 @@
package se.ajpanton.notificationlog.settings
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotSame
import org.junit.Test
class LoggingRuleCopyTest {
@Test fun `copy retains every rule field and owns its selected-package set`() {
val source = LoggingRule(false, AppRuleMode.WHITELIST, setOf("example.app"), true, false, false)
val target = source.copyForTarget()
assertEquals(source, target)
assertNotSame(source.selectedPackages, target.selectedPackages)
}
}
@@ -6,22 +6,19 @@ import org.junit.Test
class NotificationRuleEvaluatorTest { class NotificationRuleEvaluatorTest {
@Test fun `default blacklist allows every app`() { @Test fun `default blacklist allows every app`() {
assertTrue(NotificationRuleEvaluator.allows(LoggingRule(), "example.app")) assertTrue(NotificationRuleEvaluator.allows(AppFilterSettings(), "example.app"))
} }
@Test fun `blacklist excludes selected app`() { @Test fun `blacklist excludes selected app`() {
val rule = LoggingRule(selectedPackages = setOf("example.app")) val rule = AppFilterSettings(selectedPackages = setOf("example.app"))
assertFalse(NotificationRuleEvaluator.allows(rule, "example.app")) assertFalse(NotificationRuleEvaluator.allows(rule, "example.app"))
assertTrue(NotificationRuleEvaluator.allows(rule, "other.app")) assertTrue(NotificationRuleEvaluator.allows(rule, "other.app"))
} }
@Test fun `whitelist accepts only selected app`() { @Test fun `whitelist accepts only selected app`() {
val rule = LoggingRule(appRuleMode = AppRuleMode.WHITELIST, selectedPackages = setOf("example.app")) val rule = AppFilterSettings(mode = AppRuleMode.WHITELIST, selectedPackages = setOf("example.app"))
assertTrue(NotificationRuleEvaluator.allows(rule, "example.app")) assertTrue(NotificationRuleEvaluator.allows(rule, "example.app"))
assertFalse(NotificationRuleEvaluator.allows(rule, "other.app")) assertFalse(NotificationRuleEvaluator.allows(rule, "other.app"))
} }
@Test fun `disabled rule rejects selected app`() {
assertFalse(NotificationRuleEvaluator.allows(LoggingRule(enabled = false), "example.app"))
}
} }