From 7331ac8f0138b3d87643453ee10cb3ad76e4b73c Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Sun, 30 Aug 2026 01:40:16 +0000 Subject: [PATCH] Reorganize notification visibility settings --- .../AlertStatusFragment.kt | 59 ------- .../DebugNotificationsFragment.kt | 82 +++++++++ .../EventSettingsFragment.kt | 2 +- .../notificationsmaster/MainActivity.kt | 2 + .../NotificationVisibilityFragment.kt | 167 ++++++++++++------ .../capture/NotificationCaptureService.kt | 6 +- .../notificationsmaster/capture/SeenApps.kt | 39 +++- .../main/res/layout/fragment_alert_status.xml | 75 -------- .../res/layout/fragment_app_visibility.xml | 8 +- .../layout/fragment_debug_notifications.xml | 79 +++++++++ .../fragment_notification_visibility.xml | 39 +++- .../item_notification_visibility_app.xml | 129 ++++++++++++++ app/src/main/res/menu/drawer_menu.xml | 7 +- app/src/main/res/values-night/colors.xml | 2 + app/src/main/res/values/colors.xml | 2 + app/src/main/res/values/strings.xml | 9 +- 16 files changed, 496 insertions(+), 211 deletions(-) create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/DebugNotificationsFragment.kt create mode 100644 app/src/main/res/layout/fragment_debug_notifications.xml create mode 100644 app/src/main/res/layout/item_notification_visibility_app.xml diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/AlertStatusFragment.kt b/app/src/main/java/se/ajpanton/notificationsmaster/AlertStatusFragment.kt index d159b02..11f4133 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/AlertStatusFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/AlertStatusFragment.kt @@ -1,55 +1,21 @@ package se.ajpanton.notificationsmaster -import android.Manifest import android.content.ComponentName import android.content.Intent import android.os.Bundle import android.provider.Settings import android.view.View -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.Fragment import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore import se.ajpanton.notificationsmaster.capture.NotificationCaptureService import se.ajpanton.notificationsmaster.databinding.FragmentAlertStatusBinding -import se.ajpanton.notificationsmaster.debug.DebugNotifications class AlertStatusFragment : Fragment(R.layout.fragment_alert_status) { private var binding: FragmentAlertStatusBinding? = null - private var pendingCount: Int? = null - private var pendingCycle = false - private var updatingCycle = false - private val permissionRequest = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - val context = context ?: return@registerForActivityResult - if (granted) { - pendingCount?.let { DebugNotifications.setCount(context, it) } - if (pendingCycle) DebugNotifications.setCycling(context, true) - refreshDebugControls() - } else { - Toast.makeText(context, R.string.debug_notification_permission_required, Toast.LENGTH_SHORT).show() - refreshDebugControls() - } - pendingCount = null - pendingCycle = false - } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { binding = FragmentAlertStatusBinding.bind(view) binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) } - binding!!.debugMinus10.setOnClickListener { changeDebugCount(-10) } - binding!!.debugMinus1.setOnClickListener { changeDebugCount(-1) } - binding!!.debugPlus1.setOnClickListener { changeDebugCount(1) } - binding!!.debugPlus10.setOnClickListener { changeDebugCount(10) } - binding!!.debugReset.setOnClickListener { setDebugCount(0) } - binding!!.debugCycle.setOnCheckedChangeListener { _, enabled -> - if (updatingCycle) return@setOnCheckedChangeListener - if (enabled && !DebugNotifications.hasPermission(requireContext())) { - pendingCycle = true - permissionRequest.launch(Manifest.permission.POST_NOTIFICATIONS) - } else { - DebugNotifications.setCycling(requireContext(), enabled) - } - } } override fun onResume() { @@ -66,31 +32,6 @@ class AlertStatusFragment : Fragment(R.layout.fragment_alert_status) { append(if (config.rules.count { it.enabled } == 1) "." else "s.") } binding?.notificationAccess?.text = if (access) "Notification access enabled" else "Enable notification access" - DebugNotifications.apply(context) - refreshDebugControls() - } - - private fun changeDebugCount(delta: Int) = setDebugCount(DebugNotifications.count(requireContext()) + delta) - - private fun setDebugCount(count: Int) { - val desired = count.coerceIn(0, DebugNotifications.MAX_COUNT) - if (desired > 0 && !DebugNotifications.hasPermission(requireContext())) { - pendingCount = desired - permissionRequest.launch(Manifest.permission.POST_NOTIFICATIONS) - return - } - DebugNotifications.setCount(requireContext(), desired) - refreshDebugControls() - } - - private fun refreshDebugControls() { - val context = context ?: return - val count = DebugNotifications.count(context) - binding?.debugCount?.text = count.toString() - binding?.debugCycle?.isEnabled = count > 0 - updatingCycle = true - binding?.debugCycle?.isChecked = DebugNotifications.isCycling(context) - updatingCycle = false } override fun onDestroyView() { binding = null; super.onDestroyView() } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/DebugNotificationsFragment.kt b/app/src/main/java/se/ajpanton/notificationsmaster/DebugNotificationsFragment.kt new file mode 100644 index 0000000..024837e --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/DebugNotificationsFragment.kt @@ -0,0 +1,82 @@ +package se.ajpanton.notificationsmaster + +import android.Manifest +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.Fragment +import se.ajpanton.notificationsmaster.databinding.FragmentDebugNotificationsBinding +import se.ajpanton.notificationsmaster.debug.DebugNotifications + +class DebugNotificationsFragment : Fragment(R.layout.fragment_debug_notifications) { + private var binding: FragmentDebugNotificationsBinding? = null + private var pendingCount: Int? = null + private var pendingCycle = false + private var updatingCycle = false + private val permissionRequest = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + val context = context ?: return@registerForActivityResult + if (granted) { + pendingCount?.let { DebugNotifications.setCount(context, it) } + if (pendingCycle) DebugNotifications.setCycling(context, true) + } else { + Toast.makeText(context, R.string.debug_notification_permission_required, Toast.LENGTH_SHORT).show() + } + pendingCount = null + pendingCycle = false + refresh() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding = FragmentDebugNotificationsBinding.bind(view) + binding!!.debugMinus10.setOnClickListener { changeCount(-10) } + binding!!.debugMinus1.setOnClickListener { changeCount(-1) } + binding!!.debugPlus1.setOnClickListener { changeCount(1) } + binding!!.debugPlus10.setOnClickListener { changeCount(10) } + binding!!.debugReset.setOnClickListener { setCount(0) } + binding!!.debugCycle.setOnCheckedChangeListener { _, enabled -> + if (updatingCycle) return@setOnCheckedChangeListener + if (enabled && !DebugNotifications.hasPermission(requireContext())) { + pendingCycle = true + permissionRequest.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + DebugNotifications.setCycling(requireContext(), enabled) + } + } + } + + override fun onResume() { + super.onResume() + activity?.title = getString(R.string.page_debug_notifications) + DebugNotifications.apply(requireContext()) + refresh() + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + private fun changeCount(delta: Int) = setCount(DebugNotifications.count(requireContext()) + delta) + + private fun setCount(count: Int) { + val desired = count.coerceIn(0, DebugNotifications.MAX_COUNT) + if (desired > 0 && !DebugNotifications.hasPermission(requireContext())) { + pendingCount = desired + permissionRequest.launch(Manifest.permission.POST_NOTIFICATIONS) + return + } + DebugNotifications.setCount(requireContext(), desired) + refresh() + } + + private fun refresh() { + val context = context ?: return + val count = DebugNotifications.count(context) + binding?.debugCount?.text = count.toString() + binding?.debugCycle?.isEnabled = count > 0 + updatingCycle = true + binding?.debugCycle?.isChecked = DebugNotifications.isCycling(context) + updatingCycle = false + } +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/EventSettingsFragment.kt b/app/src/main/java/se/ajpanton/notificationsmaster/EventSettingsFragment.kt index df44c27..5fd115a 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/EventSettingsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/EventSettingsFragment.kt @@ -123,7 +123,7 @@ class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) { currentBinding.appListLoading.visibility = View.VISIBLE currentBinding.appListRefresh.isRefreshing = true val rule = store.load() - val seen = SeenApps.snapshot() + val seen = SeenApps.snapshot(requireContext()) val context = requireContext().applicationContext viewLifecycleOwner.lifecycleScope.launch { val apps = withContext(Dispatchers.IO) { diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt b/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt index 15b9185..4ba787a 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt @@ -169,6 +169,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte page == Page.APPS -> AppsFragment() page == Page.NOTIFICATION_VISIBILITY -> NotificationVisibilityFragment() page == Page.CARDS_LAYOUT -> CardsLayoutFragment() + page == Page.DEBUG_NOTIFICATIONS -> DebugNotificationsFragment() page == Page.LOG_DISPLAY -> LogDisplayFragment() page == Page.FILTER_LOGGING -> FilterLoggingFragment() page == Page.FILTER_APPS -> FilterAppsFragment.newInstance() @@ -375,6 +376,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte APPS(R.id.nav_apps, R.string.page_apps), NOTIFICATION_VISIBILITY(R.id.nav_notification_visibility, R.string.page_notification_visibility), CARDS_LAYOUT(R.id.nav_cards_layout, R.string.page_cards_layout), + DEBUG_NOTIFICATIONS(R.id.nav_debug_notifications, R.string.page_debug_notifications), 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/NotificationVisibilityFragment.kt b/app/src/main/java/se/ajpanton/notificationsmaster/NotificationVisibilityFragment.kt index 438ce6b..5491b6f 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/NotificationVisibilityFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/NotificationVisibilityFragment.kt @@ -1,10 +1,14 @@ 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.LinearLayout +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 @@ -14,7 +18,9 @@ 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 @@ -22,8 +28,8 @@ 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 val adapter = AppAdapter(::openApp, ::toggleSurface) + private var apps = emptyList() private var appPolicies = emptyMap() private var loadGeneration = 0 @@ -32,6 +38,7 @@ class NotificationVisibilityFragment : Fragment(R.layout.fragment_notification_v store = VisibilityPolicyStore(requireContext()) binding!!.appList.layoutManager = LinearLayoutManager(requireContext()) binding!!.appList.adapter = adapter + binding!!.appListRefresh.setOnRefreshListener(::loadApps) binding!!.appSearch.doAfterTextChanged { showApps() } bindEnabled() loadApps() @@ -65,12 +72,30 @@ class NotificationVisibilityFragment : Fragment(R.layout.fragment_notification_v 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) { InstalledApps.all(context) } + 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() } } @@ -80,10 +105,29 @@ class NotificationVisibilityFragment : Fragment(R.layout.fragment_notification_v 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() + 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.packageName]) }) + }.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 { @@ -91,9 +135,13 @@ class NotificationVisibilityFragment : Fragment(R.layout.fragment_notification_v addToBackStack(null) } - private data class AppRow(val app: InstalledApp, val policy: AppVisibilityPolicy?) + 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) : RecyclerView.Adapter() { + private class AppAdapter( + private val open: (InstalledApp) -> Unit, + private val toggle: (AppRow, NotificationSurface) -> Unit, + ) : RecyclerView.Adapter() { private var rows = emptyList() fun submit(newRows: List) { @@ -101,57 +149,74 @@ class NotificationVisibilityFragment : Fragment(R.layout.fragment_notification_v 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) + 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] - holder.name.text = row.app.name - holder.packageName.text = row.app.packageName - holder.summary.text = summary(row.policy) - holder.itemView.setOnClickListener { open(row.app) } + 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, + ) + binding.visibilityAodCell.setOnClickListener { toggle(row, NotificationSurface.AOD) } + binding.visibilityLockscreenCell.setOnClickListener { + toggle(row, NotificationSurface.LOCKSCREEN_COLLAPSED) + } + binding.visibilityUnlockedCell.setOnClickListener { + toggle(row, NotificationSurface.UNLOCKED_STATUSBAR) + } + binding.visibilityAppRoot.setOnClickListener { open(row.app.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 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 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) + private class AppHolder(val binding: ItemNotificationVisibilityAppBinding) : + RecyclerView.ViewHolder(binding.root) + + private companion object { + fun AppVisibilityPolicy.isEdited() = blockedSurfaces.isNotEmpty() || exceptions.isNotEmpty() + } } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/capture/NotificationCaptureService.kt b/app/src/main/java/se/ajpanton/notificationsmaster/capture/NotificationCaptureService.kt index fced1ab..9d1f14e 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/capture/NotificationCaptureService.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/capture/NotificationCaptureService.kt @@ -62,7 +62,7 @@ class NotificationCaptureService : NotificationListenerService() { getActiveNotifications()?.forEach { sbn -> if (DebugNotifications.isManaged(this, sbn)) return@forEach val snapshot = NotificationContents.snapshot(sbn) - SeenApps.markSeen(snapshot.packageName) + SeenApps.markSeen(this, snapshot.packageName) activeNotifications[snapshot.key] = snapshot record( snapshot, @@ -77,7 +77,7 @@ class NotificationCaptureService : NotificationListenerService() { override fun onNotificationPosted(sbn: StatusBarNotification) { if (DebugNotifications.isManaged(this, sbn)) return val snapshot = NotificationContents.snapshot(sbn) - SeenApps.markSeen(snapshot.packageName) + SeenApps.markSeen(this, snapshot.packageName) val previous = activeNotifications.put(snapshot.key, snapshot) when { previous == null -> { @@ -113,7 +113,7 @@ class NotificationCaptureService : NotificationListenerService() { ) { if (DebugNotifications.isManaged(this, sbn)) return val snapshot = activeNotifications.remove(sbn.key) ?: NotificationContents.snapshot(sbn) - SeenApps.markSeen(snapshot.packageName) + SeenApps.markSeen(this, snapshot.packageName) record(snapshot, actionForRemoval(reason), LoggingType.DISAPPEARING, includeContents = false) } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/capture/SeenApps.kt b/app/src/main/java/se/ajpanton/notificationsmaster/capture/SeenApps.kt index be40fe8..e8f3c43 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/capture/SeenApps.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/capture/SeenApps.kt @@ -1,10 +1,39 @@ package se.ajpanton.notificationsmaster.capture -import java.util.concurrent.ConcurrentHashMap +import android.content.Context +import android.os.SystemClock +import kotlin.math.abs -/** Process/boot-lifetime record of packages seen by the listener. */ +/** Persists packages seen by the listener for the duration of the current boot. */ object SeenApps { - private val packages = ConcurrentHashMap.newKeySet() - fun markSeen(packageName: String) { packages += packageName } - fun snapshot(): Set = packages.toSet() + @Synchronized + fun markSeen(context: Context, packageName: String) { + val preferences = preferences(context) + resetAfterBoot(preferences) + val packages = preferences.getStringSet(KEY_PACKAGES, emptySet()).orEmpty() + if (packageName !in packages) { + preferences.edit().putStringSet(KEY_PACKAGES, packages + packageName).apply() + } + } + + @Synchronized + fun snapshot(context: Context): Set { + val preferences = preferences(context) + resetAfterBoot(preferences) + return preferences.getStringSet(KEY_PACKAGES, emptySet()).orEmpty().toSet() + } + + private fun resetAfterBoot(preferences: android.content.SharedPreferences) { + val bootEpoch = System.currentTimeMillis() - SystemClock.elapsedRealtime() + if (abs(preferences.getLong(KEY_BOOT_EPOCH, -1) - bootEpoch) > 5_000) { + preferences.edit().putLong(KEY_BOOT_EPOCH, bootEpoch).remove(KEY_PACKAGES).commit() + } + } + + private fun preferences(context: Context) = + context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + + private const val PREFERENCES = "seen_notification_apps" + private const val KEY_PACKAGES = "packages" + private const val KEY_BOOT_EPOCH = "boot_epoch" } diff --git a/app/src/main/res/layout/fragment_alert_status.xml b/app/src/main/res/layout/fragment_alert_status.xml index 23d7629..3d4a949 100644 --- a/app/src/main/res/layout/fragment_alert_status.xml +++ b/app/src/main/res/layout/fragment_alert_status.xml @@ -28,80 +28,5 @@ android:text="Without optional system integration, Android keeps the source app’s own sound and vibration. Matching custom alerts play in addition; they do not replace it." android:textSize="14sp" /> - - - - - - - - - - -