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.LinearLayout
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
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.AppRuleMode
import se.ajpanton.notificationlog.settings.ListedApp
import se.ajpanton.notificationlog.settings.LoggingRule
import se.ajpanton.notificationlog.settings.LoggingRuleStore
import se.ajpanton.notificationlog.settings.LoggingType
import se.ajpanton.notificationlog.settings.AppFilterSettings
import se.ajpanton.notificationlog.settings.AppFilterSettingsStore
class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) {
class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) {
private var binding: FragmentEventSettingsBinding? = null
private lateinit var type: LoggingType
private lateinit var store: LoggingRuleStore
private lateinit var store: AppFilterSettingsStore
private lateinit var appAdapter: AppListAdapter
private var appLoadGeneration = 0
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding = FragmentEventSettingsBinding.bind(view)
type = requireArguments().getSerializable(ARG_TYPE, LoggingType::class.java)!!
store = LoggingRuleStore(requireContext())
store = AppFilterSettingsStore(requireContext())
appAdapter = AppListAdapter(::setPackageSelected)
binding!!.pageTitle.setText(requireArguments().getInt(ARG_TITLE))
binding!!.appList.layoutManager = LinearLayoutManager(requireContext())
binding!!.appList.adapter = appAdapter
binding!!.appListRefresh.setOnRefreshListener(::reloadAppList)
@@ -66,40 +61,32 @@ class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) {
}
private fun bindRule() {
val rule = store.ruleFor(type)
val rule = store.load()
listOf(
binding!!.masterToggle,
binding!!.appRuleToggle,
binding!!.onlySeenToggle,
binding!!.seenFirstToggle,
binding!!.routineUpdatesToggle,
).forEach { it.setOnCheckedChangeListener(null) }
binding!!.masterToggle.isChecked = rule.enabled
binding!!.appRuleToggle.isChecked = rule.appRuleMode == AppRuleMode.WHITELIST
binding!!.appRuleToggle.isChecked = rule.mode == AppRuleMode.WHITELIST
binding!!.onlySeenToggle.isChecked = rule.onlySeenApps
binding!!.seenFirstToggle.isChecked = rule.seenAppsFirst
binding!!.seenFirstToggle.isEnabled = !rule.onlySeenApps
binding!!.routineUpdatesToggle.visibility = if (type == LoggingType.EDITS) View.VISIBLE else View.GONE
binding!!.routineUpdatesToggle.isChecked = rule.ignoreRoutineUpdates
updateRuleLabels(rule)
binding!!.masterToggle.setOnCheckedChangeListener { _, checked -> save { copy(enabled = 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!!.seenFirstToggle.setOnCheckedChangeListener { _, checked -> save { copy(seenAppsFirst = checked) } }
binding!!.routineUpdatesToggle.setOnCheckedChangeListener { _, checked -> save { copy(ignoreRoutineUpdates = checked) } }
reloadAppList()
}
private fun save(change: LoggingRule.() -> LoggingRule) {
store.save(type, store.ruleFor(type).change())
private fun save(change: AppFilterSettings.() -> AppFilterSettings) {
store.save(store.load().change())
bindRule()
}
private fun updateRuleLabels(rule: LoggingRule) {
binding!!.masterToggle.text = "Enable logging"
binding!!.appRuleToggle.text = modeLabel(rule.appRuleMode)
private fun updateRuleLabels(rule: AppFilterSettings) {
binding!!.appRuleToggle.text = modeLabel(rule.mode)
binding!!.onlySeenToggle.text = "Show only seen apps"
binding!!.seenFirstToggle.text = "Show seen apps first"
}
@@ -122,7 +109,7 @@ class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) {
val generation = ++appLoadGeneration
currentBinding.appListLoading.visibility = View.VISIBLE
currentBinding.appListRefresh.isRefreshing = true
val rule = store.ruleFor(type)
val rule = store.load()
val seen = SeenApps.snapshot()
val context = requireContext().applicationContext
Thread {
@@ -146,11 +133,11 @@ class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) {
}
private fun setPackageSelected(packageName: String, selected: Boolean) {
val current = store.ruleFor(type)
val current = store.load()
val packages = current.selectedPackages.toMutableSet().apply {
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.
}
@@ -231,14 +218,6 @@ class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) {
}
companion object {
private const val ARG_TITLE = "title"
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)
}
}
fun newInstance() = FilterAppsFragment()
}
}
@@ -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 com.google.android.material.navigation.NavigationView
import se.ajpanton.notificationlog.databinding.ActivityMainBinding
import se.ajpanton.notificationlog.settings.LoggingType
import se.ajpanton.notificationlog.settings.AppLockStore
class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener {
@@ -137,7 +136,9 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
when {
page == Page.VIEW_LOGS -> ViewLogsFragment()
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)
},
)
@@ -287,15 +288,12 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
private enum class Page(
val menuId: Int,
val titleRes: Int,
val loggingType: LoggingType? = null,
) {
VIEW_LOGS(R.id.nav_view_logs, R.string.page_view_logs),
SETTINGS(R.id.nav_settings, R.string.page_settings),
APPEARING(R.id.nav_appearing, R.string.page_appearing, LoggingType.APPEARING),
DISAPPEARING(R.id.nav_disappearing, R.string.page_disappearing, LoggingType.DISAPPEARING),
TEXT_CONTENT(R.id.nav_text_content, R.string.page_text_content, LoggingType.TEXT_CONTENT),
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),
LOG_DISPLAY(R.id.nav_log_display, R.string.page_log_display),
FILTER_LOGGING(R.id.nav_filter_logging, R.string.page_filter_logging),
FILTER_APPS(R.id.nav_filter_apps, R.string.page_filter_apps),
;
companion object {
@@ -1,30 +1,17 @@
package se.ajpanton.notificationlog
import android.os.Bundle
import android.content.ClipData
import android.net.Uri
import android.content.ComponentName
import android.content.Intent
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.provider.Settings
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 com.google.android.material.switchmaterial.SwitchMaterial
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.color.MaterialColors
import se.ajpanton.notificationlog.databinding.FragmentSettingsBinding
import se.ajpanton.notificationlog.settings.LogField
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.CaptureSettingsStore
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
import se.ajpanton.notificationlog.data.EncryptedImageStore
import se.ajpanton.notificationlog.capture.NotificationCaptureService
@@ -33,7 +20,6 @@ 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)
@@ -41,55 +27,6 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
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!!.clearLogs.setOnClickListener { confirmClearLogs() }
binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) }
@@ -101,114 +38,9 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
override fun onResume() {
super.onResume()
refreshEventMasterToggles()
binding?.notificationAccess?.text = if (isListenerEnabled()) "Notification access enabled" else "Enable notification access"
}
private fun setupEventMasterToggles() {
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)
.isNotificationListenerAccessGranted(ComponentName(requireContext(), NotificationCaptureService::class.java))
@@ -271,9 +103,5 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
}.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") }
}
@@ -17,6 +17,7 @@ import se.ajpanton.notificationlog.settings.LogField
import se.ajpanton.notificationlog.settings.LogViewSettings
import se.ajpanton.notificationlog.settings.LogViewSettingsStore
import se.ajpanton.notificationlog.settings.TimestampZone
import se.ajpanton.notificationlog.settings.DisplayEvent
import java.text.DateFormat
import java.util.Date
import androidx.fragment.app.Fragment
@@ -67,7 +68,8 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
view.logRows.removeAllViews()
view.emptyView.visibility = if (rows.isEmpty()) View.VISIBLE else View.GONE
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 {
@@ -201,6 +203,12 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
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 {
timeZone = when (zone) {
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.NotificationRuleEvaluator
import se.ajpanton.notificationlog.settings.CaptureSettingsStore
import se.ajpanton.notificationlog.settings.AppFilterSettingsStore
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@@ -22,6 +23,7 @@ class NotificationCaptureService : NotificationListenerService() {
private lateinit var writeExecutor: ExecutorService
private lateinit var ruleStore: LoggingRuleStore
private lateinit var captureSettings: CaptureSettingsStore
private lateinit var appFilterStore: AppFilterSettingsStore
private lateinit var imageStore: EncryptedImageStore
override fun onCreate() {
@@ -29,6 +31,7 @@ class NotificationCaptureService : NotificationListenerService() {
logStore = EncryptedNotificationLogStore(this)
ruleStore = LoggingRuleStore(this)
captureSettings = CaptureSettingsStore(this)
appFilterStore = AppFilterSettingsStore(this)
imageStore = EncryptedImageStore(this)
writeExecutor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "notification-log-writer")
@@ -80,10 +83,9 @@ class NotificationCaptureService : NotificationListenerService() {
previousSnapshot: NotificationSnapshot? = null,
) {
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 retainImage = includeContents && snapshot.imageBytes != null &&
NotificationRuleEvaluator.allows(ruleStore.ruleFor(LoggingType.IMAGE_CONTENT), snapshot.packageName)
val retainImage = includeContents && snapshot.imageBytes != null && allows(LoggingType.IMAGE_CONTENT, snapshot.packageName)
val entry = NotificationLogEntry(
recordedAtEpochMillis = System.currentTimeMillis(),
eventTimeZoneId = java.util.TimeZone.getDefault().id,
@@ -115,17 +117,18 @@ class NotificationCaptureService : NotificationListenerService() {
private fun visibleContents(snapshot: NotificationSnapshot): String? {
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(
ruleStore.ruleFor(LoggingType.IMAGE_CONTENT), snapshot.packageName,
)
val image = snapshot.hasImage && allows(LoggingType.IMAGE_CONTENT, snapshot.packageName)
return listOfNotNull(text, if (image) "[image]" else null)
.joinToString("\n")
.take(MAX_CONTENT_CHARACTERS)
.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 {
val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
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 TimestampZone { LOCAL_NOW, UTC, EVENT_LOCAL }
enum class DisplayEvent { APPEARING, DISAPPEARING, EDITS }
data class LogViewSettings(
val visibleFields: Set<LogField> = setOf(LogField.TIMESTAMP, LogField.APP_NAME, LogField.ACTION, LogField.CONTENTS),
val order: List<LogField> = LogField.entries,
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(),
order = prefs.getString("order", null)?.split(',')?.map(LogField::valueOf) ?: LogField.entries,
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 {
putStringSet("visible", settings.visibleFields.map { it.name }.toSet())
putString("order", settings.order.joinToString(",") { it.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(
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),
)
@@ -28,22 +20,8 @@ class LoggingRuleStore(context: Context) {
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) {
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)
}
@@ -57,5 +35,3 @@ class LoggingRuleStore(context: Context) {
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(
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,
)
@@ -1,10 +1,9 @@
package se.ajpanton.notificationlog.settings
object NotificationRuleEvaluator {
fun allows(rule: LoggingRule, packageName: String): Boolean {
if (!rule.enabled) return false
val selected = packageName in rule.selectedPackages
return when (rule.appRuleMode) {
fun allows(filter: AppFilterSettings, packageName: String): Boolean {
val selected = packageName in filter.selectedPackages
return when (filter.mode) {
AppRuleMode.WHITELIST -> selected
AppRuleMode.BLACKLIST -> !selected
}
@@ -5,19 +5,6 @@
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" />
<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
android:id="@+id/app_rule_toggle"
android:layout_width="match_parent"
@@ -35,12 +22,6 @@
android:layout_height="wrap_content"
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
android:layout_width="match_parent"
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: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
android:id="@+id/notification_access"
android:layout_width="wrap_content"
+6 -12
View File
@@ -10,19 +10,13 @@
android:id="@+id/nav_settings"
android:title="@string/navigation_settings_header" />
<item
android:id="@+id/nav_appearing"
android:title="@string/navigation_indented_appearing" />
android:id="@+id/nav_log_display"
android:title="@string/navigation_indented_log_display" />
<item
android:id="@+id/nav_disappearing"
android:title="@string/navigation_indented_disappearing" />
android:id="@+id/nav_filter_logging"
android:title="@string/navigation_indented_filter_logging" />
<item
android:id="@+id/nav_text_content"
android:title="@string/navigation_indented_text_content" />
<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" />
android:id="@+id/nav_filter_apps"
android:title="@string/navigation_indented_filter_apps" />
</group>
</menu>
+6 -10
View File
@@ -7,14 +7,10 @@
<string name="navigation_settings_header">Settings</string>
<string name="page_view_logs">View logs</string>
<string name="page_settings">Settings</string>
<string name="page_appearing">Appearing</string>
<string name="page_disappearing">Disappearing</string>
<string name="page_text_content">Text content</string>
<string name="page_image_content">Image content</string>
<string name="page_edits">Edits</string>
<string name="navigation_indented_appearing">&#160;&#160;&#160;&#160;Appearing</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>
<string name="page_filter_apps">Filter apps</string>
<string name="page_log_display">Log display</string>
<string name="page_filter_logging">Filter logging</string>
<string name="navigation_indented_log_display">&#160;&#160;&#160;&#160;Log display</string>
<string name="navigation_indented_filter_logging">&#160;&#160;&#160;&#160;Filter logging</string>
<string name="navigation_indented_filter_apps">&#160;&#160;&#160;&#160;Filter apps</string>
</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 {
@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`() {
val rule = LoggingRule(selectedPackages = setOf("example.app"))
val rule = AppFilterSettings(selectedPackages = setOf("example.app"))
assertFalse(NotificationRuleEvaluator.allows(rule, "example.app"))
assertTrue(NotificationRuleEvaluator.allows(rule, "other.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"))
assertFalse(NotificationRuleEvaluator.allows(rule, "other.app"))
}
@Test fun `disabled rule rejects selected app`() {
assertFalse(NotificationRuleEvaluator.allows(LoggingRule(enabled = false), "example.app"))
}
}