From d6868b69277a7f4ba7be99c3c8c4752a53ad4587 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Sun, 26 Jul 2026 14:41:37 +0000 Subject: [PATCH] Load event app lists asynchronously --- app/build.gradle.kts | 1 + .../notificationlog/EventSettingsFragment.kt | 225 +++++++++++++----- .../settings/AppListOrdering.kt | 16 +- .../res/layout/fragment_event_settings.xml | 93 +++++++- .../settings/AppListOrderingTest.kt | 4 +- gradle/libs.versions.toml | 2 + 6 files changed, 257 insertions(+), 84 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0c252a7..82f389c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -88,6 +88,7 @@ dependencies { implementation(libs.fragment.ktx) implementation(libs.material) implementation(libs.drawerlayout) + implementation(libs.recyclerview) implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") testImplementation(libs.junit) diff --git a/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt index 85ccb0e..7a29c2f 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt @@ -1,61 +1,67 @@ package se.ajpanton.notificationlog import android.os.Bundle +import android.text.SpannableString +import android.text.Spanned +import android.text.style.StyleSpan +import android.text.style.UnderlineSpan import android.view.MotionEvent import android.view.View +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 +import androidx.recyclerview.widget.RecyclerView +import se.ajpanton.notificationlog.capture.SeenApps import se.ajpanton.notificationlog.databinding.FragmentEventSettingsBinding -import se.ajpanton.notificationlog.settings.AppRuleMode -import se.ajpanton.notificationlog.settings.LoggingRuleStore -import se.ajpanton.notificationlog.settings.LoggingType 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.capture.SeenApps +import se.ajpanton.notificationlog.settings.LoggingRule +import se.ajpanton.notificationlog.settings.LoggingRuleStore +import se.ajpanton.notificationlog.settings.LoggingType class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) { private var binding: FragmentEventSettingsBinding? = null private lateinit var type: LoggingType private lateinit var store: LoggingRuleStore + 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()) + appAdapter = AppListAdapter(::setPackageSelected) binding!!.pageTitle.setText(requireArguments().getInt(ARG_TITLE)) + binding!!.appList.layoutManager = LinearLayoutManager(requireContext()) + binding!!.appList.adapter = appAdapter + binding!!.appListRefresh.setOnRefreshListener(::reloadAppList) + installBottomRefresh() bindRule() - binding!!.appListRefresh.setOnRefreshListener(::refreshAppList) - var touchStartY = 0f - val scroll = binding!!.appScroll - scroll.setOnTouchListener { _, event -> - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> touchStartY = event.y - MotionEvent.ACTION_UP -> { - val atBottom = scroll.scrollY >= (scroll.getChildAt(0).height - scroll.height).coerceAtLeast(0) - if (atBottom && event.y < touchStartY) { - refreshAppList() - } - } - } - false - } } - override fun onDestroyView() { binding = null; super.onDestroyView() } + override fun onDestroyView() { + appLoadGeneration++ + binding = null + super.onDestroyView() + } - private fun refreshAppList() { - val currentBinding = binding ?: return - currentBinding.appListRefresh.isRefreshing = true - currentBinding.appListRefresh.post { - if (binding === currentBinding) { - reloadAppList() - currentBinding.appListRefresh.isRefreshing = false + private fun installBottomRefresh() { + val list = binding!!.appList + var touchStartY = 0f + list.setOnTouchListener { _, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> touchStartY = event.y + MotionEvent.ACTION_UP -> if (!list.canScrollVertically(1) && event.y < touchStartY) reloadAppList() } + false } } @@ -75,75 +81,164 @@ class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) { binding!!.seenFirstToggle.isEnabled = !rule.onlySeenApps binding!!.routineUpdatesToggle.visibility = if (type == LoggingType.EDITS) View.VISIBLE else View.GONE binding!!.routineUpdatesToggle.isChecked = rule.ignoreRoutineUpdates - updateRuleLabels() + updateRuleLabels(rule) binding!!.masterToggle.setOnCheckedChangeListener { _, checked -> save { copy(enabled = checked) } } - binding!!.appRuleToggle.setOnCheckedChangeListener { _, checked -> save { copy(appRuleMode = if (checked) AppRuleMode.WHITELIST else AppRuleMode.BLACKLIST) } } + binding!!.appRuleToggle.setOnCheckedChangeListener { _, checked -> + save { copy(appRuleMode = 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: se.ajpanton.notificationlog.settings.LoggingRule.() -> se.ajpanton.notificationlog.settings.LoggingRule) { + private fun save(change: LoggingRule.() -> LoggingRule) { store.save(type, store.ruleFor(type).change()) bindRule() } - private fun updateRuleLabels() { - val rule = store.ruleFor(type) - binding!!.appRuleToggle.text = if (rule.appRuleMode == AppRuleMode.WHITELIST) "Whitelist chosen" else "Blacklist chosen" - binding!!.onlySeenToggle.text = if (rule.onlySeenApps) "Show only seen apps" else "Show all apps" + private fun updateRuleLabels(rule: LoggingRule) { + binding!!.masterToggle.text = "Enable logging" + binding!!.appRuleToggle.text = modeLabel(rule.appRuleMode) + binding!!.onlySeenToggle.text = "Show only seen apps" + binding!!.seenFirstToggle.text = "Show seen apps first" + } + + private fun modeLabel(mode: AppRuleMode): CharSequence { + val text = "Blacklist / Whitelist" + val selected = if (mode == AppRuleMode.BLACKLIST) "Blacklist" else "Whitelist" + val start = text.indexOf(selected) + return SpannableString(text).apply { + setSpan(StyleSpan(android.graphics.Typeface.BOLD), start, start + selected.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + setSpan(UnderlineSpan(), start, start + selected.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } } private fun reloadAppList() { - val container = binding!!.appList - container.removeAllViews() + val currentBinding = binding ?: return + if (currentBinding.appListRefresh.isRefreshing) { + // A fresh swipe replaces the current snapshot; no live updates occur otherwise. + } + val generation = ++appLoadGeneration + currentBinding.appListLoading.visibility = View.VISIBLE + currentBinding.appListRefresh.isRefreshing = true val rule = store.ruleFor(type) val seen = SeenApps.snapshot() - val apps = requireContext().packageManager.getInstalledApplications(0) - .map { info -> + val context = requireContext().applicationContext + Thread { + val apps = context.packageManager.getInstalledApplications(0).map { info -> ListedApp( - label = requireContext().packageManager.getApplicationLabel(info).toString(), + label = context.packageManager.getApplicationLabel(info).toString(), packageName = info.packageName, seen = info.packageName in seen, selected = info.packageName in rule.selectedPackages, ) } - AppListOrdering.items(apps, rule.onlySeenApps, rule.seenAppsFirst).forEach { item -> - container.addView(if (item is AppListItem.App) appView(item.value) else separator()) - } - } - - private fun appView(row: ListedApp): View = LinearLayout(requireContext()).apply { - orientation = LinearLayout.HORIZONTAL - val checkbox = CheckBox(context).apply { isChecked = row.selected } - checkbox.setOnCheckedChangeListener { _, checked -> - val current = store.ruleFor(type) - val packages = current.selectedPackages.toMutableSet().apply { - if (checked) add(row.packageName) else remove(row.packageName) + val items = AppListOrdering.items(apps, rule.onlySeenApps, rule.seenAppsFirst) + activity?.runOnUiThread { + if (binding === currentBinding && generation == appLoadGeneration) { + appAdapter.submit(items) + currentBinding.appListLoading.visibility = View.GONE + currentBinding.appListRefresh.isRefreshing = false + } } - store.save(type, current.copy(selectedPackages = packages)) - // Deliberately do not reload: an unchecked unseen row must remain reachable. - } - addView(checkbox) - addView(LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) - addView(TextView(context).apply { text = row.label; textSize = 16f; setTypeface(typeface, 1) }) - addView(TextView(context).apply { text = row.packageName; textSize = 12f }) - }) + }.start() } - private fun separator() = View(requireContext()).apply { - layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1).apply { setMargins(0, 12, 0, 12) } - setBackgroundColor(0x33000000) + private fun setPackageSelected(packageName: String, selected: Boolean) { + val current = store.ruleFor(type) + val packages = current.selectedPackages.toMutableSet().apply { + if (selected) add(packageName) else remove(packageName) + } + store.save(type, current.copy(selectedPackages = packages)) + // Do not reload: an unchecked unseen row remains visible until the next requested refresh. + } + + private class AppListAdapter( + private val onSelectedChanged: (String, Boolean) -> Unit, + ) : RecyclerView.Adapter() { + private var items: List = emptyList() + + fun submit(newItems: List) { + items = newItems + notifyDataSetChanged() + } + + override fun getItemViewType(position: Int): Int = if (items[position] is AppListItem.App) APP else SEPARATOR + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = if (viewType == APP) { + val row = LinearLayout(parent.context).apply { + orientation = LinearLayout.HORIZONTAL + setPadding(0, dp(parent, 4), 0, dp(parent, 4)) + } + val checkbox = CheckBox(parent.context) + val textColumn = LinearLayout(parent.context).apply { + orientation = LinearLayout.VERTICAL + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + } + val label = TextView(parent.context).apply { textSize = 16f; setTypeface(typeface, android.graphics.Typeface.BOLD) } + val packageName = TextView(parent.context).apply { textSize = 12f } + textColumn.addView(label) + textColumn.addView(packageName) + row.addView(checkbox) + row.addView(textColumn) + AppHolder(row, checkbox, label, packageName) + } else { + val divider = View(parent.context).apply { + layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, dp(parent, 1)).apply { + setMargins(0, dp(parent, 12), 0, dp(parent, 12)) + } + setBackgroundColor(ContextCompat.getColor(parent.context, android.R.color.darker_gray)) + } + SeparatorHolder(divider) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val item = items[position] + if (holder is AppHolder && item is AppListItem.App) { + val app = item.value + holder.label.text = app.label + holder.packageName.text = app.packageName + holder.checkbox.setOnCheckedChangeListener(null) + holder.checkbox.isChecked = app.selected + holder.checkbox.setOnCheckedChangeListener { _, checked -> + onSelectedChanged(app.packageName, checked) + items = items.map { current -> + if (current is AppListItem.App && current.value.packageName == app.packageName) { + AppListItem.App(current.value.copy(selected = checked)) + } else current + } + } + } + } + + override fun getItemCount(): Int = items.size + + private class AppHolder( + view: View, + val checkbox: CheckBox, + val label: TextView, + val packageName: TextView, + ) : RecyclerView.ViewHolder(view) + + private class SeparatorHolder(view: View) : RecyclerView.ViewHolder(view) + + private companion object { + const val APP = 0 + const val SEPARATOR = 1 + fun dp(parent: ViewGroup, value: Int): Int = (value * parent.resources.displayMetrics.density).toInt() + } } 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) } + arguments = Bundle().apply { + putInt(ARG_TITLE, title) + putSerializable(ARG_TYPE, type) + } } } } diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/AppListOrdering.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/AppListOrdering.kt index 2048e12..1e00aea 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/settings/AppListOrdering.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/AppListOrdering.kt @@ -11,14 +11,18 @@ object AppListOrdering { val apps = allApps.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.label }) val shown = if (onlySeen) apps.filter { it.seen || it.selected } else apps val seen = shown.filter { it.seen } - val other = shown.filterNot { it.seen } - val separate = (onlySeen || seenAppsFirst) && seen.isNotEmpty() && other.isNotEmpty() + val selectedUnseen = shown.filter { !it.seen } + if (!seenAppsFirst || seen.isEmpty()) return shown.map(AppListItem::App) return buildList { - if (separate) { - seen.forEach { add(AppListItem.App(it)) } - add(AppListItem.Separator) - other.forEach { add(AppListItem.App(it)) } + seen.forEach { add(AppListItem.App(it)) } + if (onlySeen) { + if (selectedUnseen.isNotEmpty()) { + add(AppListItem.Separator) + selectedUnseen.forEach { add(AppListItem.App(it)) } + } } else { + add(AppListItem.Separator) + // The full alphabetical list intentionally repeats seen apps after its quick-access group. shown.forEach { add(AppListItem.App(it)) } } } diff --git a/app/src/main/res/layout/fragment_event_settings.xml b/app/src/main/res/layout/fragment_event_settings.xml index bae3b45..b21e55c 100644 --- a/app/src/main/res/layout/fragment_event_settings.xml +++ b/app/src/main/res/layout/fragment_event_settings.xml @@ -1,12 +1,83 @@ - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/test/java/se/ajpanton/notificationlog/settings/AppListOrderingTest.kt b/app/src/test/java/se/ajpanton/notificationlog/settings/AppListOrderingTest.kt index 42f84d3..0fb845f 100644 --- a/app/src/test/java/se/ajpanton/notificationlog/settings/AppListOrderingTest.kt +++ b/app/src/test/java/se/ajpanton/notificationlog/settings/AppListOrderingTest.kt @@ -6,9 +6,9 @@ import org.junit.Test class AppListOrderingTest { private fun app(label: String, seen: Boolean = false, selected: Boolean = false) = ListedApp(label, "pkg.$label", seen, selected) - @Test fun `seen apps are first with a separator when requested`() { + @Test fun `seen apps are repeated before the full list when requested`() { val items = AppListOrdering.items(listOf(app("Zulu"), app("Beta", seen = true), app("Alpha", seen = true)), false, true) - assertEquals(listOf("Alpha", "Beta", "|", "Zulu"), items.map { if (it is AppListItem.App) it.value.label else "|" }) + assertEquals(listOf("Alpha", "Beta", "|", "Alpha", "Beta", "Zulu"), items.map { if (it is AppListItem.App) it.value.label else "|" }) } @Test fun `selected unseen app remains after seen apps in only-seen mode`() { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7f1dde9..a2c491c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ coreKtx = "1.13.1" fragmentKtx = "1.8.5" material = "1.10.0" drawerLayout = "1.2.0" +recyclerView = "1.3.2" junit = "4.13.2" [libraries] @@ -14,6 +15,7 @@ core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } drawerlayout = { group = "androidx.drawerlayout", name = "drawerlayout", version.ref = "drawerLayout" } +recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerView" } junit = { group = "junit", name = "junit", version.ref = "junit" } [plugins]