diff --git a/app/src/androidTest/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStoreTest.kt b/app/src/androidTest/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStoreTest.kt index 45d2520..3d16688 100644 --- a/app/src/androidTest/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStoreTest.kt +++ b/app/src/androidTest/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStoreTest.kt @@ -36,4 +36,19 @@ class VisibilityPolicyStoreTest { assertFalse(raw.contains("example.app")) assertTrue(File(context.filesDir, "visibility-policy.bin").length() > 0) } + + @Test fun updateAndPackageCleanupKeepOnlyInstalledPolicies() { + val store = VisibilityPolicyStore(context) + store.update { policy -> + policy.copy(apps = listOf( + AppVisibilityPolicy("installed.app", setOf(NotificationSurface.AOD)), + AppVisibilityPolicy("removed.app", setOf(NotificationSurface.UNLOCKED_STATUSBAR)), + )) + } + + store.removeUninstalledPackages(setOf("installed.app")) + + assertEquals(listOf("installed.app"), store.load().apps.map { it.packageName }) + assertEquals(2, store.load().generation) + } } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/AppVisibilityFragment.kt b/app/src/main/java/se/ajpanton/notificationsmaster/AppVisibilityFragment.kt new file mode 100644 index 0000000..6b4bd38 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/AppVisibilityFragment.kt @@ -0,0 +1,251 @@ +package se.ajpanton.notificationsmaster + +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.switchmaterial.SwitchMaterial +import se.ajpanton.notificationsmaster.databinding.FragmentAppVisibilityBinding +import se.ajpanton.notificationsmaster.visibility.AppVisibilityPolicy +import se.ajpanton.notificationsmaster.visibility.NotificationSurface +import se.ajpanton.notificationsmaster.visibility.VisibilityExceptionRule +import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore +import java.util.Collections + +class AppVisibilityFragment : Fragment(R.layout.fragment_app_visibility) { + private var binding: FragmentAppVisibilityBinding? = null + private lateinit var store: VisibilityPolicyStore + private lateinit var packageName: String + private val adapter = ExceptionAdapter(::editException, ::deleteException) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding = FragmentAppVisibilityBinding.bind(view) + store = VisibilityPolicyStore(requireContext()) + packageName = requireArguments().getString(ARG_PACKAGE)!! + binding!!.appName.text = InstalledApps.name(requireContext(), packageName) + binding!!.packageName.text = packageName + binding!!.exceptions.layoutManager = LinearLayoutManager(requireContext()) + binding!!.exceptions.adapter = adapter + ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(UP or DOWN, 0) { + override fun onMove(list: RecyclerView, from: RecyclerView.ViewHolder, to: RecyclerView.ViewHolder): Boolean { + adapter.move(from.bindingAdapterPosition, to.bindingAdapterPosition) + saveApp { copy(exceptions = adapter.rules()) } + return true + } + + override fun onSwiped(holder: RecyclerView.ViewHolder, direction: Int) = Unit + }).attachToRecyclerView(binding!!.exceptions) + binding!!.addException.setOnClickListener { showExceptionDialog(null) } + bind() + } + + override fun onResume() { + super.onResume() + activity?.title = getString(R.string.page_notification_visibility) + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + private fun bind() { + val policy = appPolicy() + bindSurface(binding!!.hideUnlocked, NotificationSurface.UNLOCKED_STATUSBAR, policy) + bindSurface(binding!!.hideLockscreen, NotificationSurface.LOCKSCREEN_COLLAPSED, policy) + bindSurface(binding!!.hideAod, NotificationSurface.AOD, policy) + adapter.submit(policy.exceptions) + } + + private fun bindSurface(toggle: SwitchMaterial, surface: NotificationSurface, policy: AppVisibilityPolicy) { + toggle.setOnCheckedChangeListener(null) + toggle.isChecked = surface in policy.blockedSurfaces + toggle.setOnCheckedChangeListener { _, hidden -> + saveApp { + copy(blockedSurfaces = blockedSurfaces.toMutableSet().apply { + if (hidden) add(surface) else remove(surface) + }) + } + } + } + + private fun appPolicy() = store.load().apps.firstOrNull { it.packageName == packageName } + ?: AppVisibilityPolicy(packageName) + + private fun saveApp(change: AppVisibilityPolicy.() -> AppVisibilityPolicy) { + store.update { policy -> + val updated = (policy.apps.firstOrNull { it.packageName == packageName } + ?: AppVisibilityPolicy(packageName)).change() + val apps = policy.apps.filterNot { it.packageName == packageName }.toMutableList() + if (updated.blockedSurfaces.isNotEmpty() || updated.exceptions.isNotEmpty()) apps += updated + policy.copy(apps = apps.sortedBy { it.packageName }) + } + } + + private fun editException(position: Int) = showExceptionDialog(position) + + private fun deleteException(position: Int) { + saveApp { copy(exceptions = exceptions.toMutableList().apply { removeAt(position) }) } + bind() + } + + private fun showExceptionDialog(position: Int?) { + val existing = position?.let { appPolicy().exceptions[it] } + val content = LinearLayout(requireContext()).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(24), 0, dp(24), 0) + } + val pattern = EditText(requireContext()).apply { + hint = "Regular expression" + setText(existing?.pattern) + maxLines = 4 + } + content.addView(pattern) + val toggles = listOf( + NotificationSurface.UNLOCKED_STATUSBAR to "Hide in unlocked status bar", + NotificationSurface.LOCKSCREEN_COLLAPSED to "Hide on collapsed lockscreen", + NotificationSurface.AOD to "Hide on always-on display", + ).associate { (surface, label) -> + surface to SwitchMaterial(requireContext()).apply { + text = label + isChecked = surface in existing?.blockedSurfaces.orEmpty() + content.addView(this) + } + } + val dialog = MaterialAlertDialogBuilder(requireContext()) + .setTitle(if (existing == null) "Add text exception" else "Edit text exception") + .setView(content) + .setNegativeButton("Cancel", null) + .setPositiveButton("Save", null) + .create() + dialog.setOnShowListener { + dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener { + val rule = runCatching { + VisibilityExceptionRule( + pattern.text.toString(), + toggles.filterValues { it.isChecked }.keys, + ).also { require(it.hasValidPattern()) } + }.getOrElse { + pattern.error = "Enter a valid, non-empty regular expression" + return@setOnClickListener + } + saveApp { + copy(exceptions = exceptions.toMutableList().apply { + if (position == null) add(rule) else set(position, rule) + }) + } + bind() + dialog.dismiss() + } + } + dialog.show() + } + + private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() + + private class ExceptionAdapter( + private val edit: (Int) -> Unit, + private val delete: (Int) -> Unit, + ) : RecyclerView.Adapter() { + private val items = mutableListOf() + + fun submit(rules: List) { + items.clear() + items.addAll(rules) + notifyDataSetChanged() + } + + fun move(from: Int, to: Int) { + Collections.swap(items, from, to) + notifyItemMoved(from, to) + } + + fun rules() = items.toList() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ExceptionHolder { + val row = LinearLayout(parent.context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = android.view.Gravity.CENTER_VERTICAL + setPadding(0, dp(parent, 6), 0, dp(parent, 6)) + isClickable = true + isFocusable = true + background = context.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground)) + .let { values -> values.getDrawable(0).also { values.recycle() } } + } + val handle = TextView(parent.context).apply { + text = "↕" + textSize = 22f + gravity = android.view.Gravity.CENTER + contentDescription = "Drag to reorder" + layoutParams = LinearLayout.LayoutParams(dp(parent, 40), dp(parent, 48)) + } + val textColumn = LinearLayout(parent.context).apply { + orientation = LinearLayout.VERTICAL + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + } + val pattern = TextView(parent.context).apply { textSize = 15f } + val summary = TextView(parent.context).apply { textSize = 12f } + textColumn.addView(pattern) + textColumn.addView(summary) + val remove = Button(parent.context).apply { + text = "Delete" + isAllCaps = false + } + row.addView(handle) + row.addView(textColumn) + row.addView(remove) + return ExceptionHolder(row, pattern, summary, remove) + } + + override fun onBindViewHolder(holder: ExceptionHolder, position: Int) { + val rule = items[position] + holder.pattern.text = rule.pattern + holder.summary.text = surfaceSummary(rule.blockedSurfaces) + holder.itemView.setOnClickListener { + holder.bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION }?.let(edit) + } + holder.remove.setOnClickListener { + holder.bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION }?.let(delete) + } + } + + override fun getItemCount() = items.size + + private fun surfaceSummary(surfaces: Set): String { + val names = listOf( + NotificationSurface.UNLOCKED_STATUSBAR to "status bar", + NotificationSurface.LOCKSCREEN_COLLAPSED to "lockscreen", + NotificationSurface.AOD to "AOD", + ).filter { it.first in surfaces }.joinToString { it.second } + return if (names.isEmpty()) "Show on all surfaces" else "Hide: $names" + } + + private fun dp(parent: ViewGroup, value: Int) = + (value * parent.resources.displayMetrics.density).toInt() + } + + private class ExceptionHolder( + view: View, + val pattern: TextView, + val summary: TextView, + val remove: Button, + ) : RecyclerView.ViewHolder(view) + + companion object { + private const val ARG_PACKAGE = "package" + private const val UP = ItemTouchHelper.UP + private const val DOWN = ItemTouchHelper.DOWN + + fun newInstance(packageName: String) = AppVisibilityFragment().apply { + arguments = Bundle().apply { putString(ARG_PACKAGE, packageName) } + } + } +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt b/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt index 1324a5a..6eed0c6 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt @@ -9,6 +9,7 @@ import android.view.Menu import android.view.MenuItem import android.view.View import android.view.WindowManager +import android.widget.RelativeLayout import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.AppCompatActivity @@ -166,6 +167,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte page == Page.PROFILES -> ProfilesFragment() page == Page.RULES -> RulesFragment() page == Page.APPS -> AppsFragment() + page == Page.NOTIFICATION_VISIBILITY -> NotificationVisibilityFragment() page == Page.LOG_DISPLAY -> LogDisplayFragment() page == Page.FILTER_LOGGING -> FilterLoggingFragment() page == Page.FILTER_APPS -> FilterAppsFragment.newInstance() @@ -281,6 +283,14 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, GravityCompat.START) binding.navViewDrawer.visibility = View.VISIBLE } + (binding.contentFrame.layoutParams as RelativeLayout.LayoutParams).also { params -> + params.removeRule(if (permanentSidebar) RelativeLayout.BELOW else RelativeLayout.ALIGN_PARENT_TOP) + params.addRule( + if (permanentSidebar) RelativeLayout.ALIGN_PARENT_TOP else RelativeLayout.BELOW, + if (permanentSidebar) RelativeLayout.TRUE else R.id.toolbar, + ) + binding.contentFrame.layoutParams = params + } updateNavigationHeaderHeight(binding.navViewDrawer) updateNavigationHeaderHeight(binding.navViewPermanent) applyPageTitleVisibility() @@ -362,6 +372,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte PROFILES(R.id.nav_profiles, R.string.page_profiles), RULES(R.id.nav_rules, R.string.page_rules), APPS(R.id.nav_apps, R.string.page_apps), + NOTIFICATION_VISIBILITY(R.id.nav_notification_visibility, R.string.page_notification_visibility), LOG_DISPLAY(R.id.nav_log_display, R.string.page_log_display), FILTER_LOGGING(R.id.nav_filter_logging, R.string.page_filter_logging), FILTER_APPS(R.id.nav_filter_apps, R.string.page_filter_apps), diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/NotificationLogApplication.kt b/app/src/main/java/se/ajpanton/notificationsmaster/NotificationLogApplication.kt index 0a2112d..699c180 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/NotificationLogApplication.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/NotificationLogApplication.kt @@ -7,6 +7,7 @@ import se.ajpanton.notificationsmaster.module.VisibilityPolicySync import se.ajpanton.notificationsmaster.settings.AppFilterSettingsStore import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController import se.ajpanton.notificationsmaster.settings.PerAppEventSettingsStore +import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore class NotificationLogApplication : Application() { override fun onCreate() { @@ -31,6 +32,7 @@ class NotificationLogApplication : Application() { ).mapTo(mutableSetOf()) { it.packageName } AppFilterSettingsStore(this).removeUninstalledPackages(installedPackages) PerAppEventSettingsStore(this).removeUninstalledPackages(installedPackages) + VisibilityPolicyStore(this).removeUninstalledPackages(installedPackages) EncryptedNotificationLogStore(this).removeOrphanedImages() } } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/NotificationVisibilityFragment.kt b/app/src/main/java/se/ajpanton/notificationsmaster/NotificationVisibilityFragment.kt new file mode 100644 index 0000000..438ce6b --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/NotificationVisibilityFragment.kt @@ -0,0 +1,157 @@ +package se.ajpanton.notificationsmaster + +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView +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.databinding.FragmentNotificationVisibilityBinding +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) + 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!!.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 + currentBinding.appListLoading.visibility = View.VISIBLE + viewLifecycleOwner.lifecycleScope.launch { + val loaded = withContext(Dispatchers.IO) { InstalledApps.all(context) } + if (binding === currentBinding && generation == loadGeneration) { + apps = loaded + currentBinding.appListLoading.visibility = View.GONE + showApps() + } + } + } + + private fun showApps() { + val currentBinding = binding ?: return + val terms = currentBinding.appSearch.text.toString().lowercase().trim() + .split(Regex("\\s+")).filter(String::isNotEmpty) + adapter.submit(apps.filter { app -> + val searchable = "${app.name}\n${app.packageName}".lowercase() + terms.all(searchable::contains) + }.map { app -> AppRow(app, appPolicies[app.packageName]) }) + } + + private fun openApp(app: InstalledApp) = parentFragmentManager.commit { + replace(R.id.content_frame, AppVisibilityFragment.newInstance(app.packageName)) + addToBackStack(null) + } + + private data class AppRow(val app: InstalledApp, val policy: AppVisibilityPolicy?) + + private class AppAdapter(private val open: (InstalledApp) -> Unit) : RecyclerView.Adapter() { + private var rows = emptyList() + + fun submit(newRows: List) { + rows = newRows + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppHolder { + val row = LinearLayout(parent.context).apply { + orientation = LinearLayout.VERTICAL + isClickable = true + isFocusable = true + setPadding(0, dp(parent, 8), 0, dp(parent, 8)) + background = context.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground)) + .let { values -> values.getDrawable(0).also { values.recycle() } } + } + val name = TextView(parent.context).apply { + textSize = 16f + setTypeface(typeface, android.graphics.Typeface.BOLD) + } + val packageName = TextView(parent.context).apply { textSize = 12f } + val summary = TextView(parent.context).apply { textSize = 13f } + row.addView(name) + row.addView(packageName) + row.addView(summary) + return AppHolder(row, name, packageName, summary) + } + + override fun onBindViewHolder(holder: AppHolder, position: Int) { + val row = rows[position] + holder.name.text = row.app.name + holder.packageName.text = row.app.packageName + holder.summary.text = summary(row.policy) + holder.itemView.setOnClickListener { open(row.app) } + } + + override fun getItemCount() = rows.size + + private fun summary(policy: AppVisibilityPolicy?): String { + if (policy == null) return "Visible on all surfaces" + val hidden = listOf( + NotificationSurface.UNLOCKED_STATUSBAR to "status bar", + NotificationSurface.LOCKSCREEN_COLLAPSED to "lockscreen", + NotificationSurface.AOD to "AOD", + ).filter { it.first in policy.blockedSurfaces }.joinToString { it.second } + val defaults = if (hidden.isEmpty()) "Visible on all surfaces" else "Hidden: $hidden" + return if (policy.exceptions.isEmpty()) defaults else "$defaults • ${policy.exceptions.size} text exception" + + if (policy.exceptions.size == 1) "" else "s" + } + + private fun dp(parent: ViewGroup, value: Int) = + (value * parent.resources.displayMetrics.density).toInt() + } + + private class AppHolder( + view: View, + val name: TextView, + val packageName: TextView, + val summary: TextView, + ) : RecyclerView.ViewHolder(view) +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStore.kt b/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStore.kt index c053e43..8b42226 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStore.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStore.kt @@ -58,6 +58,16 @@ class VisibilityPolicyStore(context: Context) { return stored } + @Synchronized + fun update(change: (VisibilityPolicy) -> VisibilityPolicy) = save(change(load())) + + @Synchronized + fun removeUninstalledPackages(installedPackages: Set) { + val current = load() + val retained = current.apps.filter { it.packageName in installedPackages } + if (retained.size != current.apps.size) save(current.copy(apps = retained)) + } + private companion object { const val FILE_NAME = "visibility-policy.bin" const val MAX_CIPHER_TEXT_BYTES = 1024 * 1024 diff --git a/app/src/main/res/layout/fragment_app_visibility.xml b/app/src/main/res/layout/fragment_app_visibility.xml new file mode 100644 index 0000000..bf6eb90 --- /dev/null +++ b/app/src/main/res/layout/fragment_app_visibility.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + +