package se.ajpanton.notificationlog import android.os.Bundle import android.content.res.ColorStateList 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.ImageButton import android.widget.LinearLayout import android.widget.TextView import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.color.MaterialColors import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.switchmaterial.SwitchMaterial import se.ajpanton.notificationlog.capture.SeenApps import se.ajpanton.notificationlog.databinding.FragmentEventSettingsBinding 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.AppFilterSettings import se.ajpanton.notificationlog.settings.AppFilterSettingsStore import se.ajpanton.notificationlog.settings.LoggingRuleStore import se.ajpanton.notificationlog.settings.LoggingType import se.ajpanton.notificationlog.settings.PerAppEventSettingsStore class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) { private var binding: FragmentEventSettingsBinding? = null private lateinit var store: AppFilterSettingsStore private lateinit var perAppEventSettings: PerAppEventSettingsStore private lateinit var appAdapter: AppListAdapter private var appLoadGeneration = 0 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding = FragmentEventSettingsBinding.bind(view) store = AppFilterSettingsStore(requireContext()) perAppEventSettings = PerAppEventSettingsStore(requireContext()) appAdapter = AppListAdapter(::setPackageSelected, ::showPerAppEventSettings) binding!!.appList.layoutManager = LinearLayoutManager(requireContext()) binding!!.appList.adapter = appAdapter binding!!.appListRefresh.setOnRefreshListener(::reloadAppList) installBottomRefresh() bindRule() } override fun onDestroyView() { appLoadGeneration++ binding = null super.onDestroyView() } 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 } } private fun bindRule() { val rule = store.load() listOf( binding!!.appRuleToggle, binding!!.onlySeenToggle, binding!!.seenFirstToggle, ).forEach { it.setOnCheckedChangeListener(null) } binding!!.appRuleToggle.isChecked = rule.mode == AppRuleMode.WHITELIST binding!!.onlySeenToggle.isChecked = rule.onlySeenApps binding!!.seenFirstToggle.isChecked = rule.seenAppsFirst updateRuleLabels(rule) binding!!.appRuleToggle.setOnCheckedChangeListener { _, checked -> 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) } } reloadAppList() } private fun save(change: AppFilterSettings.() -> AppFilterSettings) { store.save(store.load().change()) bindRule() } private fun updateRuleLabels(rule: AppFilterSettings) { binding!!.appRuleToggle.text = modeLabel(rule.mode) binding!!.onlySeenToggle.text = "Show only seen and checked 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 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.load() val seen = SeenApps.snapshot() val context = requireContext().applicationContext Thread { val apps = context.packageManager.getInstalledApplications(0).map { info -> ListedApp( label = context.packageManager.getApplicationLabel(info).toString(), packageName = info.packageName, seen = info.packageName in seen, selected = info.packageName in rule.selectedPackages, hasEventOverride = perAppEventSettings.hasOverride(info.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 } } }.start() } private fun setPackageSelected(packageName: String, selected: Boolean) { val current = store.load() val packages = current.selectedPackages.toMutableSet().apply { if (selected) add(packageName) else remove(packageName) } store.save(current.copy(selectedPackages = packages)) // Do not reload: an unchecked unseen row remains visible until the next requested refresh. } private fun showPerAppEventSettings(app: ListedApp) { val eventTypes = listOf(LoggingType.APPEARING, LoggingType.DISAPPEARING, LoggingType.EDITS) val rules = LoggingRuleStore(requireContext()) val content = LinearLayout(requireContext()).apply { orientation = LinearLayout.VERTICAL setPadding(dp(24), 0, dp(24), 0) } val useGlobal = SwitchMaterial(requireContext()).apply { text = "Use global" } val eventToggles = eventTypes.associateWith { type -> SwitchMaterial(requireContext()).apply { text = type.label() layoutParams = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT, ).apply { marginStart = dp(24) } }.also(content::addView) } content.addView(useGlobal, 0) fun globalEvents() = eventTypes.associateWith { rules.ruleFor(it).enabled } fun refresh() { val useGlobalSetting = perAppEventSettings.usesGlobal(app.packageName) useGlobal.setOnCheckedChangeListener(null) useGlobal.isChecked = useGlobalSetting eventToggles.forEach { (type, toggle) -> toggle.setOnCheckedChangeListener(null) toggle.isEnabled = !useGlobalSetting toggle.isChecked = perAppEventSettings.isEnabled( app.packageName, type, globalEvents().getValue(type), ) toggle.setOnCheckedChangeListener { _, enabled -> perAppEventSettings.setEnabled(app.packageName, type, enabled) } } useGlobal.setOnCheckedChangeListener { _, enabled -> if (enabled) perAppEventSettings.useGlobal(app.packageName) else perAppEventSettings.startUsingOverride(app.packageName, globalEvents()) appAdapter.updateEventOverride(app.packageName, !enabled) refresh() } } refresh() MaterialAlertDialogBuilder(requireContext()) .setTitle("When logging, only log") .setView(content) .setPositiveButton("Close", null) .show() } private fun LoggingType.label() = when (this) { LoggingType.APPEARING -> "Appearing" LoggingType.DISAPPEARING -> "Disappearing" LoggingType.EDITS -> "Edits" else -> error("Only event types are shown in the per-app dialog") } private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() private class AppListAdapter( private val onSelectedChanged: (String, Boolean) -> Unit, private val onEventFilterClicked: (ListedApp) -> Unit, ) : RecyclerView.Adapter() { private var items: List = emptyList() fun submit(newItems: List) { items = newItems notifyDataSetChanged() } override fun getItemViewType(position: Int): Int = when (items[position]) { is AppListItem.App -> APP is AppListItem.SectionTitle -> SECTION_TITLE AppListItem.Separator -> 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 filter = ImageButton(parent.context).apply { setImageResource(R.drawable.ic_filter_list) contentDescription = "Edit logging events" layoutParams = LinearLayout.LayoutParams(dp(parent, 48), dp(parent, 48)) setPadding(dp(parent, 12), dp(parent, 12), dp(parent, 12), dp(parent, 12)) } 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(filter) row.addView(textColumn) AppHolder(row, checkbox, filter, label, packageName) } else if (viewType == SECTION_TITLE) { SectionTitleHolder(TextView(parent.context).apply { layoutParams = RecyclerView.LayoutParams( RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT, ).apply { setMargins(0, dp(parent, 8), 0, dp(parent, 4)) } setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 12f) setTypeface(typeface, android.graphics.Typeface.BOLD) }) } 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 } } holder.filter.contentDescription = "Edit logging events for ${app.label}" holder.filter.imageTintList = ColorStateList.valueOf( MaterialColors.getColor( holder.filter, if (app.hasEventOverride) com.google.android.material.R.attr.colorPrimary else com.google.android.material.R.attr.colorOnSurface, ), ) holder.filter.setOnClickListener { onEventFilterClicked(app) } } if (holder is SectionTitleHolder && item is AppListItem.SectionTitle) { holder.text.text = item.value } } override fun getItemCount(): Int = items.size fun updateEventOverride(packageName: String, hasEventOverride: Boolean) { items = items.map { item -> if (item is AppListItem.App && item.value.packageName == packageName) { AppListItem.App(item.value.copy(hasEventOverride = hasEventOverride)) } else { item } } notifyDataSetChanged() } private class AppHolder( view: View, val checkbox: CheckBox, val filter: ImageButton, val label: TextView, val packageName: TextView, ) : RecyclerView.ViewHolder(view) private class SeparatorHolder(view: View) : RecyclerView.ViewHolder(view) private class SectionTitleHolder(val text: TextView) : RecyclerView.ViewHolder(text) private companion object { const val APP = 0 const val SEPARATOR = 1 const val SECTION_TITLE = 2 fun dp(parent: ViewGroup, value: Int): Int = (value * parent.resources.displayMetrics.density).toInt() } } companion object { fun newInstance() = FilterAppsFragment() } }