342 lines
15 KiB
Kotlin
342 lines
15 KiB
Kotlin
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<RecyclerView.ViewHolder>() {
|
|
private var items: List<AppListItem> = emptyList()
|
|
|
|
fun submit(newItems: List<AppListItem>) {
|
|
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()
|
|
}
|
|
}
|