diff --git a/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt index 7a29c2f..d5c76ae 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt @@ -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() } } diff --git a/app/src/main/java/se/ajpanton/notificationlog/FilterLoggingFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/FilterLoggingFragment.kt new file mode 100644 index 0000000..2ae7479 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/FilterLoggingFragment.kt @@ -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, + 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" + } +} diff --git a/app/src/main/java/se/ajpanton/notificationlog/LogDisplayFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/LogDisplayFragment.kt new file mode 100644 index 0000000..0e8473f --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/LogDisplayFragment.kt @@ -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" + } +} diff --git a/app/src/main/java/se/ajpanton/notificationlog/MainActivity.kt b/app/src/main/java/se/ajpanton/notificationlog/MainActivity.kt index 8cf07e4..90ea5fe 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/MainActivity.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/MainActivity.kt @@ -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 { diff --git a/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt index 3c2f0bf..54168e8 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt @@ -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() 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() - 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") } } diff --git a/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt index b62f691..c16c440 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt @@ -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") diff --git a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt index 9c95a4a..563dac5 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt @@ -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() diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/AppFilterSettings.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/AppFilterSettings.kt new file mode 100644 index 0000000..bea4590 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/AppFilterSettings.kt @@ -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 = 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" + } +} diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/LogViewSettings.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/LogViewSettings.kt index e4e15b9..a92b957 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/settings/LogViewSettings.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/LogViewSettings.kt @@ -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 = setOf(LogField.TIMESTAMP, LogField.APP_NAME, LogField.ACTION, LogField.CONTENTS), val order: List = LogField.entries, val timestampZone: TimestampZone = TimestampZone.LOCAL_NOW, + val visibleEvents: Set = DisplayEvent.entries.toSet(), ) diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/LogViewSettingsStore.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/LogViewSettingsStore.kt index f6ffa74..155c1c0 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/settings/LogViewSettingsStore.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/LogViewSettingsStore.kt @@ -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()) } } diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingRuleStore.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingRuleStore.kt index c6a3988..c2007f6 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingRuleStore.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingRuleStore.kt @@ -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) { - 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()) diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingType.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingType.kt index 8b14c6d..bfc5b11 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingType.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingType.kt @@ -15,9 +15,5 @@ enum class AppRuleMode { data class LoggingRule( val enabled: Boolean = true, - val appRuleMode: AppRuleMode = AppRuleMode.BLACKLIST, - val selectedPackages: Set = emptySet(), - val onlySeenApps: Boolean = false, - val seenAppsFirst: Boolean = true, val ignoreRoutineUpdates: Boolean = true, ) diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationRuleEvaluator.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationRuleEvaluator.kt index da620e6..0ed426f 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationRuleEvaluator.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationRuleEvaluator.kt @@ -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 } diff --git a/app/src/main/res/layout/fragment_event_settings.xml b/app/src/main/res/layout/fragment_event_settings.xml index b21e55c..f18d51d 100644 --- a/app/src/main/res/layout/fragment_event_settings.xml +++ b/app/src/main/res/layout/fragment_event_settings.xml @@ -5,19 +5,6 @@ android:orientation="vertical" android:padding="@dimen/page_padding"> - - - - - - + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_log_display.xml b/app/src/main/res/layout/fragment_log_display.xml new file mode 100644 index 0000000..32bd56d --- /dev/null +++ b/app/src/main/res/layout/fragment_log_display.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml index c137dd8..6299a53 100644 --- a/app/src/main/res/layout/fragment_settings.xml +++ b/app/src/main/res/layout/fragment_settings.xml @@ -9,113 +9,6 @@ android:orientation="vertical" android:padding="@dimen/page_padding"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -