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,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<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 {
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)
}
}
}
}
@@ -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)) }
}
}
@@ -1,12 +1,83 @@
<?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 android:layout_width="match_parent" android:layout_height="wrap_content" 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="Logging enabled" />
<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" />
<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" />
<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" />
<LinearLayout android:id="@+id/app_list" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:orientation="vertical" />
</LinearLayout>
</ScrollView></androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
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"
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>