232 lines
10 KiB
Kotlin
232 lines
10 KiB
Kotlin
package se.ajpanton.notificationsmaster
|
|
|
|
import android.graphics.Color
|
|
import android.graphics.drawable.Drawable
|
|
import android.os.Bundle
|
|
import android.view.LayoutInflater
|
|
import android.view.View
|
|
import android.view.ViewGroup
|
|
import android.widget.ImageView
|
|
import android.widget.TextView
|
|
import androidx.core.content.ContextCompat
|
|
import androidx.core.widget.doAfterTextChanged
|
|
import androidx.fragment.app.Fragment
|
|
import androidx.fragment.app.commit
|
|
import androidx.lifecycle.lifecycleScope
|
|
import androidx.recyclerview.widget.LinearLayoutManager
|
|
import androidx.recyclerview.widget.RecyclerView
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
import se.ajpanton.notificationsmaster.capture.SeenApps
|
|
import se.ajpanton.notificationsmaster.databinding.FragmentNotificationVisibilityBinding
|
|
import se.ajpanton.notificationsmaster.databinding.ItemNotificationVisibilityAppBinding
|
|
import se.ajpanton.notificationsmaster.visibility.AppVisibilityPolicy
|
|
import se.ajpanton.notificationsmaster.visibility.NotificationSurface
|
|
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore
|
|
|
|
class NotificationVisibilityFragment : Fragment(R.layout.fragment_notification_visibility) {
|
|
private var binding: FragmentNotificationVisibilityBinding? = null
|
|
private lateinit var store: VisibilityPolicyStore
|
|
private val adapter = AppAdapter(::openApp, ::toggleSurface)
|
|
private var apps = emptyList<AppSnapshot>()
|
|
private var appPolicies = emptyMap<String, AppVisibilityPolicy>()
|
|
private var loadGeneration = 0
|
|
|
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
binding = FragmentNotificationVisibilityBinding.bind(view)
|
|
store = VisibilityPolicyStore(requireContext())
|
|
binding!!.appList.layoutManager = LinearLayoutManager(requireContext())
|
|
binding!!.appList.adapter = adapter
|
|
binding!!.appListRefresh.setOnRefreshListener(::loadApps)
|
|
binding!!.appSearch.doAfterTextChanged { showApps() }
|
|
bindEnabled()
|
|
loadApps()
|
|
}
|
|
|
|
override fun onResume() {
|
|
super.onResume()
|
|
activity?.title = getString(R.string.page_notification_visibility)
|
|
if (binding != null) {
|
|
bindEnabled()
|
|
showApps()
|
|
}
|
|
}
|
|
|
|
override fun onDestroyView() {
|
|
loadGeneration++
|
|
binding = null
|
|
super.onDestroyView()
|
|
}
|
|
|
|
private fun bindEnabled() {
|
|
val toggle = binding?.visibilityEnabled ?: return
|
|
val policy = store.load()
|
|
appPolicies = policy.apps.associateBy(AppVisibilityPolicy::packageName)
|
|
toggle.setOnCheckedChangeListener(null)
|
|
toggle.isChecked = policy.enabled
|
|
toggle.setOnCheckedChangeListener { _, enabled -> store.update { it.copy(enabled = enabled) } }
|
|
}
|
|
|
|
private fun loadApps() {
|
|
val currentBinding = binding ?: return
|
|
val generation = ++loadGeneration
|
|
val context = requireContext().applicationContext
|
|
val policies = appPolicies
|
|
currentBinding.appListLoading.visibility = View.VISIBLE
|
|
currentBinding.appListRefresh.isRefreshing = true
|
|
viewLifecycleOwner.lifecycleScope.launch {
|
|
val loaded = withContext(Dispatchers.IO) {
|
|
val manager = context.packageManager
|
|
SeenApps.snapshot(context).mapNotNull { packageName ->
|
|
runCatching {
|
|
val info = manager.getApplicationInfo(packageName, 0)
|
|
AppSnapshot(
|
|
InstalledApp(manager.getApplicationLabel(info).toString(), packageName),
|
|
manager.getApplicationIcon(info),
|
|
)
|
|
}.getOrNull()
|
|
}.sortedWith(
|
|
compareBy<AppSnapshot> { policies[it.app.packageName]?.isEdited() != true }
|
|
.thenBy { it.app.name.lowercase() }
|
|
.thenBy { it.app.packageName },
|
|
)
|
|
}
|
|
if (binding === currentBinding && generation == loadGeneration) {
|
|
apps = loaded
|
|
currentBinding.appListLoading.visibility = View.GONE
|
|
currentBinding.appListRefresh.isRefreshing = false
|
|
showApps()
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun showApps() {
|
|
val currentBinding = binding ?: return
|
|
val terms = currentBinding.appSearch.text.toString().lowercase().trim()
|
|
.split(Regex("\\s+")).filter(String::isNotEmpty)
|
|
val rows = apps.filter { app ->
|
|
val searchable = "${app.app.name}\n${app.app.packageName}".lowercase()
|
|
terms.all(searchable::contains)
|
|
}.map { app -> AppRow(app, appPolicies[app.app.packageName]) }
|
|
adapter.submit(rows)
|
|
currentBinding.appListEmpty.visibility = if (
|
|
rows.isEmpty() && currentBinding.appListLoading.visibility != View.VISIBLE
|
|
) View.VISIBLE else View.GONE
|
|
}
|
|
|
|
private fun toggleSurface(row: AppRow, surface: NotificationSurface) {
|
|
val saved = store.update { policy ->
|
|
val current = policy.apps.firstOrNull { it.packageName == row.app.app.packageName }
|
|
?: AppVisibilityPolicy(row.app.app.packageName)
|
|
val updated = current.copy(blockedSurfaces = current.blockedSurfaces.toMutableSet().apply {
|
|
if (surface in current.blockedSurfaces) remove(surface) else add(surface)
|
|
})
|
|
val apps = policy.apps.filterNot { it.packageName == current.packageName }.toMutableList()
|
|
if (updated.isEdited()) apps += updated
|
|
policy.copy(apps = apps.sortedBy(AppVisibilityPolicy::packageName))
|
|
}
|
|
appPolicies = saved.apps.associateBy(AppVisibilityPolicy::packageName)
|
|
adapter.updatePolicy(row.app.app.packageName, appPolicies[row.app.app.packageName])
|
|
}
|
|
|
|
private fun openApp(app: InstalledApp) = parentFragmentManager.commit {
|
|
replace(R.id.content_frame, AppVisibilityFragment.newInstance(app.packageName))
|
|
addToBackStack(null)
|
|
}
|
|
|
|
private data class AppSnapshot(val app: InstalledApp, val icon: Drawable)
|
|
private data class AppRow(val app: AppSnapshot, val policy: AppVisibilityPolicy?)
|
|
|
|
private class AppAdapter(
|
|
private val open: (InstalledApp) -> Unit,
|
|
private val toggle: (AppRow, NotificationSurface) -> Unit,
|
|
) : RecyclerView.Adapter<AppHolder>() {
|
|
private var rows = emptyList<AppRow>()
|
|
|
|
fun submit(newRows: List<AppRow>) {
|
|
rows = newRows
|
|
notifyDataSetChanged()
|
|
}
|
|
|
|
fun updatePolicy(packageName: String, policy: AppVisibilityPolicy?) {
|
|
val position = rows.indexOfFirst { it.app.app.packageName == packageName }
|
|
if (position < 0) return
|
|
rows = rows.toMutableList().also { it[position] = it[position].copy(policy = policy) }
|
|
notifyItemChanged(position)
|
|
}
|
|
|
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = AppHolder(
|
|
ItemNotificationVisibilityAppBinding.inflate(LayoutInflater.from(parent.context), parent, false),
|
|
)
|
|
|
|
override fun onBindViewHolder(holder: AppHolder, position: Int) {
|
|
val row = rows[position]
|
|
val binding = holder.binding
|
|
val policy = row.policy
|
|
binding.visibilityAppName.text = row.app.app.name
|
|
binding.visibilityAppPackage.text = row.app.app.packageName
|
|
val exceptionCount = policy?.exceptions?.size ?: 0
|
|
binding.visibilityAppExceptions.visibility = if (exceptionCount > 0) View.VISIBLE else View.GONE
|
|
binding.visibilityAppExceptions.text = "$exceptionCount text exception" + if (exceptionCount == 1) "" else "s"
|
|
binding.visibilityAppRoot.setBackgroundColor(
|
|
if (policy?.isEdited() == true) ContextCompat.getColor(
|
|
binding.root.context,
|
|
R.color.visibility_edited_background,
|
|
) else Color.TRANSPARENT,
|
|
)
|
|
bindSurface(binding.visibilityAodIcon, binding.visibilityAodCross, row, NotificationSurface.AOD)
|
|
bindSurface(
|
|
binding.visibilityLockscreenIcon,
|
|
binding.visibilityLockscreenCross,
|
|
row,
|
|
NotificationSurface.LOCKSCREEN_COLLAPSED,
|
|
)
|
|
bindSurface(
|
|
binding.visibilityUnlockedIcon,
|
|
binding.visibilityUnlockedCross,
|
|
row,
|
|
NotificationSurface.UNLOCKED_STATUSBAR,
|
|
)
|
|
bindSurface(
|
|
binding.visibilityDrawerIcon,
|
|
binding.visibilityDrawerCross,
|
|
row,
|
|
NotificationSurface.DRAWER,
|
|
)
|
|
binding.visibilityAodCell.setOnClickListener { toggle(row, NotificationSurface.AOD) }
|
|
binding.visibilityLockscreenCell.setOnClickListener {
|
|
toggle(row, NotificationSurface.LOCKSCREEN_COLLAPSED)
|
|
}
|
|
binding.visibilityUnlockedCell.setOnClickListener {
|
|
toggle(row, NotificationSurface.UNLOCKED_STATUSBAR)
|
|
}
|
|
binding.visibilityDrawerCell.setOnClickListener {
|
|
toggle(row, NotificationSurface.DRAWER)
|
|
}
|
|
binding.visibilityAppRoot.setOnClickListener { open(row.app.app) }
|
|
}
|
|
|
|
override fun getItemCount() = rows.size
|
|
|
|
private fun bindSurface(
|
|
icon: ImageView,
|
|
cross: TextView,
|
|
row: AppRow,
|
|
surface: NotificationSurface,
|
|
) {
|
|
icon.setImageDrawable(row.app.icon.constantState?.newDrawable()?.mutate() ?: row.app.icon)
|
|
val hidden = surface in row.policy?.blockedSurfaces.orEmpty()
|
|
icon.alpha = if (hidden) 0.25f else 1f
|
|
cross.visibility = if (hidden) View.VISIBLE else View.GONE
|
|
}
|
|
}
|
|
|
|
private class AppHolder(val binding: ItemNotificationVisibilityAppBinding) :
|
|
RecyclerView.ViewHolder(binding.root)
|
|
|
|
private companion object {
|
|
fun AppVisibilityPolicy.isEdited() = blockedSurfaces.isNotEmpty() || exceptions.isNotEmpty()
|
|
}
|
|
}
|