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() private var appPolicies = emptyMap() 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 { 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() { private var rows = emptyList() fun submit(newRows: List) { 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() } }