Load event app lists asynchronously

This commit is contained in:
ajp_anton
2026-07-26 14:41:37 +00:00
parent c5f3dade9f
commit d6868b6927
6 changed files with 257 additions and 84 deletions
+1
View File
@@ -88,6 +88,7 @@ dependencies {
implementation(libs.fragment.ktx) implementation(libs.fragment.ktx)
implementation(libs.material) implementation(libs.material)
implementation(libs.drawerlayout) implementation(libs.drawerlayout)
implementation(libs.recyclerview)
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
testImplementation(libs.junit) testImplementation(libs.junit)
@@ -1,64 +1,70 @@
package se.ajpanton.notificationlog package se.ajpanton.notificationlog
import android.os.Bundle 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.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox import android.widget.CheckBox
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment 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.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.AppListItem
import se.ajpanton.notificationlog.settings.AppListOrdering import se.ajpanton.notificationlog.settings.AppListOrdering
import se.ajpanton.notificationlog.settings.AppRuleMode
import se.ajpanton.notificationlog.settings.ListedApp 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) { class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) {
private var binding: FragmentEventSettingsBinding? = null private var binding: FragmentEventSettingsBinding? = null
private lateinit var type: LoggingType private lateinit var type: LoggingType
private lateinit var store: LoggingRuleStore private lateinit var store: LoggingRuleStore
private lateinit var appAdapter: AppListAdapter
private var appLoadGeneration = 0
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding = FragmentEventSettingsBinding.bind(view) binding = FragmentEventSettingsBinding.bind(view)
type = requireArguments().getSerializable(ARG_TYPE, LoggingType::class.java)!! type = requireArguments().getSerializable(ARG_TYPE, LoggingType::class.java)!!
store = LoggingRuleStore(requireContext()) store = LoggingRuleStore(requireContext())
appAdapter = AppListAdapter(::setPackageSelected)
binding!!.pageTitle.setText(requireArguments().getInt(ARG_TITLE)) binding!!.pageTitle.setText(requireArguments().getInt(ARG_TITLE))
binding!!.appList.layoutManager = LinearLayoutManager(requireContext())
binding!!.appList.adapter = appAdapter
binding!!.appListRefresh.setOnRefreshListener(::reloadAppList)
installBottomRefresh()
bindRule() bindRule()
binding!!.appListRefresh.setOnRefreshListener(::refreshAppList) }
override fun onDestroyView() {
appLoadGeneration++
binding = null
super.onDestroyView()
}
private fun installBottomRefresh() {
val list = binding!!.appList
var touchStartY = 0f var touchStartY = 0f
val scroll = binding!!.appScroll list.setOnTouchListener { _, event ->
scroll.setOnTouchListener { _, event ->
when (event.actionMasked) { when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> touchStartY = event.y MotionEvent.ACTION_DOWN -> touchStartY = event.y
MotionEvent.ACTION_UP -> { MotionEvent.ACTION_UP -> if (!list.canScrollVertically(1) && event.y < touchStartY) reloadAppList()
val atBottom = scroll.scrollY >= (scroll.getChildAt(0).height - scroll.height).coerceAtLeast(0)
if (atBottom && event.y < touchStartY) {
refreshAppList()
}
}
} }
false false
} }
} }
override fun onDestroyView() { binding = null; super.onDestroyView() }
private fun refreshAppList() {
val currentBinding = binding ?: return
currentBinding.appListRefresh.isRefreshing = true
currentBinding.appListRefresh.post {
if (binding === currentBinding) {
reloadAppList()
currentBinding.appListRefresh.isRefreshing = false
}
}
}
private fun bindRule() { private fun bindRule() {
val rule = store.ruleFor(type) val rule = store.ruleFor(type)
listOf( listOf(
@@ -75,75 +81,164 @@ class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) {
binding!!.seenFirstToggle.isEnabled = !rule.onlySeenApps binding!!.seenFirstToggle.isEnabled = !rule.onlySeenApps
binding!!.routineUpdatesToggle.visibility = if (type == LoggingType.EDITS) View.VISIBLE else View.GONE binding!!.routineUpdatesToggle.visibility = if (type == LoggingType.EDITS) View.VISIBLE else View.GONE
binding!!.routineUpdatesToggle.isChecked = rule.ignoreRoutineUpdates binding!!.routineUpdatesToggle.isChecked = rule.ignoreRoutineUpdates
updateRuleLabels() updateRuleLabels(rule)
binding!!.masterToggle.setOnCheckedChangeListener { _, checked -> save { copy(enabled = checked) } } 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!!.onlySeenToggle.setOnCheckedChangeListener { _, checked -> save { copy(onlySeenApps = checked) } }
binding!!.seenFirstToggle.setOnCheckedChangeListener { _, checked -> save { copy(seenAppsFirst = checked) } } binding!!.seenFirstToggle.setOnCheckedChangeListener { _, checked -> save { copy(seenAppsFirst = checked) } }
binding!!.routineUpdatesToggle.setOnCheckedChangeListener { _, checked -> save { copy(ignoreRoutineUpdates = checked) } } binding!!.routineUpdatesToggle.setOnCheckedChangeListener { _, checked -> save { copy(ignoreRoutineUpdates = checked) } }
reloadAppList() 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()) store.save(type, store.ruleFor(type).change())
bindRule() bindRule()
} }
private fun updateRuleLabels() { private fun updateRuleLabels(rule: LoggingRule) {
val rule = store.ruleFor(type) binding!!.masterToggle.text = "Enable logging"
binding!!.appRuleToggle.text = if (rule.appRuleMode == AppRuleMode.WHITELIST) "Whitelist chosen" else "Blacklist chosen" binding!!.appRuleToggle.text = modeLabel(rule.appRuleMode)
binding!!.onlySeenToggle.text = if (rule.onlySeenApps) "Show only seen apps" else "Show all apps" 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() { private fun reloadAppList() {
val container = binding!!.appList val currentBinding = binding ?: return
container.removeAllViews() 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 rule = store.ruleFor(type)
val seen = SeenApps.snapshot() val seen = SeenApps.snapshot()
val apps = requireContext().packageManager.getInstalledApplications(0) val context = requireContext().applicationContext
.map { info -> Thread {
val apps = context.packageManager.getInstalledApplications(0).map { info ->
ListedApp( ListedApp(
label = requireContext().packageManager.getApplicationLabel(info).toString(), label = context.packageManager.getApplicationLabel(info).toString(),
packageName = info.packageName, packageName = info.packageName,
seen = info.packageName in seen, seen = info.packageName in seen,
selected = info.packageName in rule.selectedPackages, selected = info.packageName in rule.selectedPackages,
) )
} }
AppListOrdering.items(apps, rule.onlySeenApps, rule.seenAppsFirst).forEach { item -> val items = AppListOrdering.items(apps, rule.onlySeenApps, rule.seenAppsFirst)
container.addView(if (item is AppListItem.App) appView(item.value) else separator()) activity?.runOnUiThread {
if (binding === currentBinding && generation == appLoadGeneration) {
appAdapter.submit(items)
currentBinding.appListLoading.visibility = View.GONE
currentBinding.appListRefresh.isRefreshing = false
} }
} }
}.start()
}
private fun appView(row: ListedApp): View = LinearLayout(requireContext()).apply { private fun setPackageSelected(packageName: String, selected: Boolean) {
orientation = LinearLayout.HORIZONTAL
val checkbox = CheckBox(context).apply { isChecked = row.selected }
checkbox.setOnCheckedChangeListener { _, checked ->
val current = store.ruleFor(type) val current = store.ruleFor(type)
val packages = current.selectedPackages.toMutableSet().apply { val packages = current.selectedPackages.toMutableSet().apply {
if (checked) add(row.packageName) else remove(row.packageName) if (selected) add(packageName) else remove(packageName)
} }
store.save(type, current.copy(selectedPackages = packages)) store.save(type, current.copy(selectedPackages = packages))
// Deliberately do not reload: an unchecked unseen row must remain reachable. // Do not reload: an unchecked unseen row remains visible until the next requested refresh.
}
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 })
})
} }
private fun separator() = View(requireContext()).apply { private class AppListAdapter(
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1).apply { setMargins(0, 12, 0, 12) } private val onSelectedChanged: (String, Boolean) -> Unit,
setBackgroundColor(0x33000000) ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var items: List<AppListItem> = emptyList()
fun submit(newItems: List<AppListItem>) {
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 { companion object {
private const val ARG_TITLE = "title" private const val ARG_TITLE = "title"
private const val ARG_TYPE = "type" private const val ARG_TYPE = "type"
fun newInstance(@StringRes title: Int, type: LoggingType) = EventSettingsFragment().apply { 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)
}
} }
} }
} }
@@ -11,14 +11,18 @@ object AppListOrdering {
val apps = allApps.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.label }) val apps = allApps.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.label })
val shown = if (onlySeen) apps.filter { it.seen || it.selected } else apps val shown = if (onlySeen) apps.filter { it.seen || it.selected } else apps
val seen = shown.filter { it.seen } val seen = shown.filter { it.seen }
val other = shown.filterNot { it.seen } val selectedUnseen = shown.filter { !it.seen }
val separate = (onlySeen || seenAppsFirst) && seen.isNotEmpty() && other.isNotEmpty() if (!seenAppsFirst || seen.isEmpty()) return shown.map(AppListItem::App)
return buildList { return buildList {
if (separate) {
seen.forEach { add(AppListItem.App(it)) } seen.forEach { add(AppListItem.App(it)) }
if (onlySeen) {
if (selectedUnseen.isNotEmpty()) {
add(AppListItem.Separator) add(AppListItem.Separator)
other.forEach { add(AppListItem.App(it)) } selectedUnseen.forEach { add(AppListItem.App(it)) }
}
} else { } else {
add(AppListItem.Separator)
// The full alphabetical list intentionally repeats seen apps after its quick-access group.
shown.forEach { add(AppListItem.App(it)) } shown.forEach { add(AppListItem.App(it)) }
} }
} }
@@ -1,12 +1,83 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/app_list_refresh" android:layout_width="match_parent" android:layout_height="match_parent"><ScrollView android:id="@+id/app_scroll" android:layout_width="match_parent" android:layout_height="match_parent"> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="@dimen/page_padding"> android:layout_width="match_parent"
<TextView android:id="@+id/page_title" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="?attr/textAppearanceHeadline5" /> android:layout_height="match_parent"
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/master_toggle" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:text="Logging enabled" /> android:orientation="vertical"
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/app_rule_toggle" android:layout_width="match_parent" android:layout_height="wrap_content" /> android:padding="@dimen/page_padding">
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/only_seen_toggle" android:layout_width="match_parent" android:layout_height="wrap_content" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/seen_first_toggle" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Show seen apps at the top" /> <TextView
<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" /> android:id="@+id/page_title"
<LinearLayout android:id="@+id/app_list" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:orientation="vertical" /> 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"
android:layout_height="wrap_content" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/only_seen_toggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Show only seen apps" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/seen_first_toggle"
android:layout_width="match_parent"
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"
android:layout_marginTop="16dp"
android:layout_weight="1">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/app_list_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/app_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:fastScrollAlwaysVisible="true"
android:fastScrollEnabled="true"
android:paddingBottom="16dp" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<LinearLayout
android:id="@+id/app_list_loading"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Loading apps…" />
</LinearLayout>
</FrameLayout>
</LinearLayout> </LinearLayout>
</ScrollView></androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
@@ -6,9 +6,9 @@ import org.junit.Test
class AppListOrderingTest { class AppListOrderingTest {
private fun app(label: String, seen: Boolean = false, selected: Boolean = false) = ListedApp(label, "pkg.$label", seen, selected) 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) 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`() { @Test fun `selected unseen app remains after seen apps in only-seen mode`() {
+2
View File
@@ -6,6 +6,7 @@ coreKtx = "1.13.1"
fragmentKtx = "1.8.5" fragmentKtx = "1.8.5"
material = "1.10.0" material = "1.10.0"
drawerLayout = "1.2.0" drawerLayout = "1.2.0"
recyclerView = "1.3.2"
junit = "4.13.2" junit = "4.13.2"
[libraries] [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" } fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" } material = { group = "com.google.android.material", name = "material", version.ref = "material" }
drawerlayout = { group = "androidx.drawerlayout", name = "drawerlayout", version.ref = "drawerLayout" } 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" } junit = { group = "junit", name = "junit", version.ref = "junit" }
[plugins] [plugins]