From 39c8058adf94935e80611d020f710d5d3477fe38 Mon Sep 17 00:00:00 2001 From: Joseph Kuriackal Date: Thu, 22 Oct 2026 20:41:26 +0530 Subject: [PATCH] feat: auto-dismiss notifications per app after a set time Adds an Auto-dismiss notifications page under Notifications. Pick apps and a time for each (1 min to 24 h); their clearable notifications are dismissed once that time has passed since the latest change. Each app's notification categories are learned as they arrive and can be switched off to keep them. Rows have a remove button and show how many notifications were cleared since the app was added (count only). Ongoing notifications are never touched, and a group summary is cleared with its last child. Pending dismissals are resynced when the listener connects and whenever the settings change. Co-Authored-By: Claude Opus 5.5 --- .../data/repository/SettingsRepository.kt | 88 +++++ .../domain/model/AutoDismissAppConfig.kt | 20 ++ .../domain/model/AutoDismissChannel.kt | 20 ++ .../domain/model/AutoDismissStat.kt | 19 + .../domain/registry/FeatureRegistry.kt | 43 +++ .../essentials/services/AutoDismissMath.kt | 31 ++ .../services/NotificationAutoDismisser.kt | 224 ++++++++++++ .../services/NotificationListener.kt | 4 + .../ui/activities/FeatureSettingsActivity.kt | 11 + .../AutoDismissNotificationsSettingsUI.kt | 332 ++++++++++++++++++ .../essentials/viewmodels/MainViewModel.kt | 142 ++++++++ app/src/main/res/values/strings.xml | 24 ++ .../services/AutoDismissMathTest.kt | 239 +++++++++++++ 13 files changed, 1197 insertions(+) create mode 100644 app/src/main/java/com/sameerasw/essentials/domain/model/AutoDismissAppConfig.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/domain/model/AutoDismissChannel.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/domain/model/AutoDismissStat.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/services/AutoDismissMath.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/services/NotificationAutoDismisser.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/features/audio/AutoDismissNotificationsSettingsUI.kt create mode 100644 app/src/test/java/com/sameerasw/essentials/services/AutoDismissMathTest.kt diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index 29a828101..c2ba8fef4 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -23,6 +23,9 @@ import com.sameerasw.essentials.domain.diy.Action import com.sameerasw.essentials.domain.diy.ActionGsonAdapter import com.sameerasw.essentials.domain.model.AppIcon import com.sameerasw.essentials.domain.model.AppSelection +import com.sameerasw.essentials.domain.model.AutoDismissAppConfig +import com.sameerasw.essentials.domain.model.AutoDismissChannel +import com.sameerasw.essentials.domain.model.AutoDismissStat import com.sameerasw.essentials.domain.model.BackGestureAppConfig import com.sameerasw.essentials.domain.model.AppTag import com.sameerasw.essentials.domain.model.DnsPreset @@ -270,6 +273,11 @@ class SettingsRepository( const val KEY_SNOOZE_DISCOVERED_CHANNELS = "snooze_discovered_channels" const val KEY_SNOOZE_BLOCKED_CHANNELS = "snooze_blocked_channels" const val KEY_SNOOZE_HEADS_UP_ENABLED = "snooze_heads_up_enabled" + const val KEY_AUTO_DISMISS_ENABLED = "auto_dismiss_notifications_enabled" + const val KEY_AUTO_DISMISS_APPS = "auto_dismiss_notifications_apps" + const val KEY_AUTO_DISMISS_LAST_MINUTES = "auto_dismiss_notifications_last_minutes" + const val KEY_AUTO_DISMISS_CHANNELS = "auto_dismiss_notifications_channels" + const val KEY_AUTO_DISMISS_STATS = "auto_dismiss_notifications_stats" const val KEY_FLASHLIGHT_ALWAYS_TURN_OFF_ENABLED = "flashlight_always_turn_off_enabled" const val KEY_FLASHLIGHT_FADE_ENABLED = "flashlight_fade_enabled" @@ -1635,6 +1643,86 @@ class SettingsRepository( enabled: Boolean, ) = updateAppSelection(KEY_AOD_WALLPAPER_MEDIA_EXCLUDED_APPS, packageName, enabled) + fun isAutoDismissEnabled(): Boolean = getBoolean(KEY_AUTO_DISMISS_ENABLED, false) + + fun setAutoDismissEnabled(enabled: Boolean) = putBoolean(KEY_AUTO_DISMISS_ENABLED, enabled) + + fun getAutoDismissLastMinutes(): Int = getInt(KEY_AUTO_DISMISS_LAST_MINUTES, 15) + + fun setAutoDismissLastMinutes(minutes: Int) = putInt(KEY_AUTO_DISMISS_LAST_MINUTES, minutes) + + fun loadAutoDismissConfigs(): List { + val json = prefs.getString(KEY_AUTO_DISMISS_APPS, null) + return if (json != null) { + try { + gson + .fromJson( + json, + Array::class.java, + ).toList() + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + } + + fun saveAutoDismissConfigs(configs: List) { + val json = gson.toJson(configs) + putString(KEY_AUTO_DISMISS_APPS, json) + } + + fun loadAutoDismissChannels(): List { + val json = prefs.getString(KEY_AUTO_DISMISS_CHANNELS, null) + return if (json != null) { + try { + gson + .fromJson( + json, + Array::class.java, + ).toList() + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + } + + fun saveAutoDismissChannels(channels: List) { + val json = gson.toJson(channels) + putString(KEY_AUTO_DISMISS_CHANNELS, json) + } + + fun loadAutoDismissStats(): Map { + val json = prefs.getString(KEY_AUTO_DISMISS_STATS, null) ?: return emptyMap() + return try { + val type = object : TypeToken>() {}.type + gson.fromJson>(json, type) ?: emptyMap() + } catch (e: Exception) { + emptyMap() + } + } + + fun saveAutoDismissStats(stats: Map) { + val json = gson.toJson(stats) + putString(KEY_AUTO_DISMISS_STATS, json) + } + + fun incrementAutoDismissCount(packageName: String) { + val current = loadAutoDismissStats().toMutableMap() + val existing = current[packageName] + val updated = + if (existing != null) { + existing.copy(count = existing.count + 1) + } else { + AutoDismissStat(1, System.currentTimeMillis()) + } + current[packageName] = updated + saveAutoDismissStats(current) + } + /** * Executes the load shut up configs operation. * @return The resulting List? = null, +) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/AutoDismissChannel.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/AutoDismissChannel.kt new file mode 100644 index 000000000..d27a3fd90 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/AutoDismissChannel.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Domain Layer Models & Registries + * File: AutoDismissChannel.kt + * Description: Domain model and business logic entry for AutoDismissChannel.kt. + */ + +package com.sameerasw.essentials.domain.model + +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName + +@Keep +data class AutoDismissChannel( + @SerializedName("packageName") val packageName: String, + @SerializedName("id") val id: String, + @SerializedName("name") val name: String, +) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/AutoDismissStat.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/AutoDismissStat.kt new file mode 100644 index 000000000..cbfe4923b --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/AutoDismissStat.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Domain Layer Models & Registries + * File: AutoDismissStat.kt + * Description: Domain model and business logic entry for AutoDismissStat.kt. + */ + +package com.sameerasw.essentials.domain.model + +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName + +@Keep +data class AutoDismissStat( + @SerializedName("count") val count: Int, + @SerializedName("since") val since: Long, +) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt index 49fcfa500..53dc0d69a 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt @@ -1554,6 +1554,49 @@ object FeatureRegistry { context = context, ) }, + object : Feature( + id = "Auto-dismiss notifications", + title = R.string.feat_auto_dismiss_notifications_title, + iconRes = R.drawable.rounded_timer_24, + category = R.string.cat_interface, + description = R.string.feat_auto_dismiss_notifications_desc, + aboutDescription = R.string.about_desc_auto_dismiss_notifications, + permissionKeys = listOf("NOTIFICATION_LISTENER"), + showToggle = true, + hasMoreSettings = true, + searchableSettings = emptyList(), + parentFeatureId = "Notifications", + ) { + override fun isEnabled(viewModel: MainViewModel) = viewModel.isAutoDismissEnabled.value + + override fun isToggleEnabled( + viewModel: MainViewModel, + context: Context, + ) = viewModel.isNotificationListenerEnabled.value + + override fun onToggle( + viewModel: MainViewModel, + context: Context, + enabled: Boolean, + ) = viewModel.setAutoDismissEnabled( + enabled, + context = context, + ) + + override fun onClick( + context: Context, + viewModel: MainViewModel, + ) { + val intent = + Intent( + context, + FeatureSettingsActivity::class.java, + ).apply { + putExtra("feature", "Auto-dismiss notifications") + } + context.startActivity(intent) + } + }, object : Feature( id = "Notification snoozing", title = R.string.feat_notification_snoozing_title, diff --git a/app/src/main/java/com/sameerasw/essentials/services/AutoDismissMath.kt b/app/src/main/java/com/sameerasw/essentials/services/AutoDismissMath.kt new file mode 100644 index 000000000..a9f5886ea --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/AutoDismissMath.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Background Services & Receivers + * File: AutoDismissMath.kt + * Description: Pure calculation logic for notification auto-dismiss deadlines. + */ + +package com.sameerasw.essentials.services + +import com.sameerasw.essentials.domain.model.AutoDismissAppConfig + +val AUTO_DISMISS_MINUTES = listOf(1, 5, 10, 15, 30, 60, 120, 180, 360, 720, 1440) + +fun autoDismissDeadline( + packageName: String, + postTime: Long, + isClearable: Boolean, + isGroupSummary: Boolean, + ownPackage: String, + configs: List, + channelId: String? = null, +): Long? { + if (packageName == ownPackage) return null + if (!isClearable) return null + if (isGroupSummary) return null + val config = configs.firstOrNull { it.packageName == packageName } ?: return null + if (channelId != null && channelId in config.excludedChannels.orEmpty()) return null + return postTime + config.minutes * 60_000L +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/NotificationAutoDismisser.kt b/app/src/main/java/com/sameerasw/essentials/services/NotificationAutoDismisser.kt new file mode 100644 index 000000000..594276842 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/NotificationAutoDismisser.kt @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Service Handlers + * File: NotificationAutoDismisser.kt + * Description: Helper object for auto-dismissing app notifications after a set time. + */ + +package com.sameerasw.essentials.services + +import android.app.Notification +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.service.notification.NotificationListenerService.Ranking +import android.service.notification.StatusBarNotification +import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.domain.model.AutoDismissAppConfig +import com.sameerasw.essentials.domain.model.AutoDismissChannel + +object NotificationAutoDismisser { + private val handler = Handler(Looper.getMainLooper()) + private val pending = HashMap() + private var knownChannels: HashSet? = null + + fun onPosted( + listener: NotificationListener, + sbn: StatusBarNotification, + isRepost: Boolean, + ) { + val repo = SettingsRepository(listener) + val configs = repo.loadAutoDismissConfigs() + learnChannel(listener, sbn, configs, repo) + + if (!repo.isAutoDismissEnabled()) return + val key = sbn.key + if (LiveUpdateSnoozer.isSnoozedByUs(key)) return + if (isRepost && key in pending) return + + remove(key) + + val now = System.currentTimeMillis() + val deadline = deadlineOf(listener, sbn, configs, now) ?: return + + schedule(listener, key, deadline) + } + + fun onRemoved(key: String) { + remove(key) + } + + fun resync(listener: NotificationListener) { + clear() + val repo = SettingsRepository(listener) + if (!repo.isAutoDismissEnabled()) return + + val configs = repo.loadAutoDismissConfigs() + val active = + try { + listener.activeNotifications.orEmpty() + } catch (_: Exception) { + emptyArray() + } + val now = System.currentTimeMillis() + val overdue = mutableListOf() + for (sbn in active) { + if (LiveUpdateSnoozer.isSnoozedByUs(sbn.key)) continue + val deadline = deadlineOf(listener, sbn, configs, sbn.postTime) ?: continue + + if (deadline <= now) { + overdue.add(sbn) + } else { + schedule(listener, sbn.key, deadline) + } + } + + val overdueKeys = overdue.map { it.key }.toSet() + for (sbn in overdue) { + dismiss(listener, sbn.key, sbn, overdueKeys) + } + } + + fun clear() { + pending.values.forEach { handler.removeCallbacks(it) } + pending.clear() + } + + private fun remove(key: String) { + pending.remove(key)?.let { handler.removeCallbacks(it) } + } + + private fun schedule( + listener: NotificationListener, + key: String, + deadline: Long, + ) { + remove(key) + val now = System.currentTimeMillis() + if (deadline <= now) { + dismiss(listener, key) + return + } + val uptimeMillis = SystemClock.uptimeMillis() + (deadline - now) + val runnable = + Runnable { + pending.remove(key) + dismiss(listener, key) + } + pending[key] = runnable + handler.postAtTime(runnable, uptimeMillis) + } + + private fun dismiss( + listener: NotificationListener, + key: String, + sbn: StatusBarNotification? = null, + dismissedKeys: Set = emptySet(), + ) { + if (LiveUpdateSnoozer.isSnoozedByUs(key)) return + val active = + try { + listener.activeNotifications.orEmpty() + } catch (_: Exception) { + emptyArray() + } + val target = sbn ?: active.firstOrNull { it.key == key } ?: return + + try { + listener.cancelNotification(key) + SettingsRepository(listener).incrementAutoDismissCount(target.packageName) + } catch (_: Exception) { + } + + val groupKey = target.groupKey ?: return + val hasOtherChildren = + active.any { other -> + other.key != key && + other.key !in dismissedKeys && + other.packageName == target.packageName && + other.groupKey == groupKey && + !other.isGroupSummary() + } + + if (!hasOtherChildren) { + active + .filter { candidate -> + candidate.key != key && + candidate.packageName == target.packageName && + candidate.groupKey == groupKey && + candidate.isGroupSummary() + }.forEach { summary -> + try { + listener.cancelNotification(summary.key) + } catch (_: Exception) { + } + } + } + } + + private fun ensureKnownChannels(repo: SettingsRepository): HashSet { + val set = knownChannels ?: repo.loadAutoDismissChannels().mapTo(HashSet()) { "${it.packageName}/${it.id}" } + knownChannels = set + return set + } + + private fun learnChannel( + listener: NotificationListener, + sbn: StatusBarNotification, + configs: List, + repo: SettingsRepository, + ) { + val pkg = sbn.packageName + if (configs.none { it.packageName == pkg }) return + val channelId = sbn.notification.channelId + if (channelId.isNullOrBlank()) return + + val known = ensureKnownChannels(repo) + val channelKey = "$pkg/$channelId" + if (channelKey in known) return + + val rankingName = + try { + val map = listener.currentRanking + if (map != null) { + val ranking = Ranking() + if (map.getRanking(sbn.key, ranking)) { + ranking.channel?.name?.toString() + } else { + null + } + } else { + null + } + } catch (_: Exception) { + null + } + + val name = if (!rankingName.isNullOrBlank()) rankingName else channelId + + known.add(channelKey) + val channels = repo.loadAutoDismissChannels().toMutableList() + channels.add(AutoDismissChannel(packageName = pkg, id = channelId, name = name)) + repo.saveAutoDismissChannels(channels) + } + + private fun deadlineOf( + listener: NotificationListener, + sbn: StatusBarNotification, + configs: List, + postTime: Long, + ): Long? = + autoDismissDeadline( + packageName = sbn.packageName, + postTime = postTime, + isClearable = sbn.isClearable, + isGroupSummary = sbn.isGroupSummary(), + ownPackage = listener.packageName, + configs = configs, + channelId = sbn.notification.channelId, + ) + + private fun StatusBarNotification.isGroupSummary(): Boolean = (notification.flags and Notification.FLAG_GROUP_SUMMARY) != 0 +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt index 929b0a581..3a8c2ee97 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt @@ -361,6 +361,7 @@ class NotificationListener : NotificationListenerService() { super.onListenerConnected() instance = this populateActiveUnreadNotifications() + NotificationAutoDismisser.resync(this) try { val pm = getSystemService(POWER_SERVICE) as PowerManager isScreenLocked = !pm.isInteractive @@ -582,6 +583,7 @@ class NotificationListener : NotificationListenerService() { override fun onDestroy() { super.onDestroy() instance = null + NotificationAutoDismisser.clear() progressDestroyed = true progressHandler.removeCallbacks(progressRefreshRunnable) progressExecutor.shutdownNow() @@ -1071,6 +1073,7 @@ class NotificationListener : NotificationListenerService() { LiveUpdateSnoozer.onPosted(this, sbn) val isRepost = NotificationRepostFilter.isUnchangedRepost(sbn) + NotificationAutoDismisser.onPosted(this, sbn, isRepost) if (CallNotificationParser.isCall(sbn)) CallStateRepository.onCallNotificationPosted(applicationContext, sbn) if (ChronometerRepository.isCandidate(sbn)) ChronometerRepository.onPosted(applicationContext, sbn) if (isOngoingScreenCaptureNotification(sbn)) { @@ -1504,6 +1507,7 @@ class NotificationListener : NotificationListenerService() { override fun onNotificationRemoved(sbn: StatusBarNotification) { if (LiveUpdateSnoozer.isSnoozedByUs(sbn.key)) return + NotificationAutoDismisser.onRemoved(sbn.key) NotificationRepostFilter.forget(sbn.key) HilightProgressTracker.onRemoved(sbn.key) CallStateRepository.onCallNotificationRemoved(sbn.key) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt index fecf93bdb..e82c2fb78 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt @@ -124,6 +124,7 @@ import com.sameerasw.essentials.ui.features.system.RemoteLockSettingsUI import com.sameerasw.essentials.ui.features.system.ScreenLockedSecuritySettingsUI import com.sameerasw.essentials.ui.features.system.ScreenOffWidgetSettingsUI import com.sameerasw.essentials.ui.features.system.ShutUpSettingsUI +import com.sameerasw.essentials.ui.features.system.AutoDismissNotificationsSettingsUI import com.sameerasw.essentials.ui.features.system.SnoozeNotificationsSettingsUI import com.sameerasw.essentials.ui.features.system.SoundModeTileSettingsUI import com.sameerasw.essentials.ui.features.system.StandbyAppsSettingsUI @@ -396,6 +397,7 @@ class FeatureSettingsActivity : AppCompatActivity() { "Duo", "Status glance" -> !isAccessibilityEnabled "AOD wallpaper" -> !isAccessibilityEnabled || !viewModel.isStoragePermissionGranted.value "Snooze system notifications" -> !isNotificationListenerEnabled + "Auto-dismiss notifications" -> !isNotificationListenerEnabled "Screen locked security" -> !com.sameerasw.essentials.utils.ShellUtils.hasPermission( context, @@ -858,6 +860,7 @@ class FeatureSettingsActivity : AppCompatActivity() { "Duo", "Status glance" -> !isAccessibilityEnabled "AOD wallpaper" -> !isAccessibilityEnabled || !viewModel.isStoragePermissionGranted.value "Snooze system notifications" -> !isNotificationListenerEnabled + "Auto-dismiss notifications" -> !isNotificationListenerEnabled "Screen locked security" -> !com.sameerasw.essentials.utils.ShellUtils.hasPermission( context, @@ -1159,6 +1162,14 @@ class FeatureSettingsActivity : AppCompatActivity() { ) } + "Auto-dismiss notifications" -> { + AutoDismissNotificationsSettingsUI( + viewModel = viewModel, + modifier = Modifier.padding(top = 16.dp), + highlightSetting = highlightSetting, + ) + } + "Notification snoozing" -> { NotificationSnoozingSettingsUI( viewModel = viewModel, diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/audio/AutoDismissNotificationsSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/audio/AutoDismissNotificationsSettingsUI.kt new file mode 100644 index 000000000..e62f8418e --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/audio/AutoDismissNotificationsSettingsUI.kt @@ -0,0 +1,332 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Feature - Audio + * File: AutoDismissNotificationsSettingsUI.kt + * Description: UI component and settings composable for Auto-dismiss notifications feature. + */ + +package com.sameerasw.essentials.ui.features.system + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.model.AppSelection +import com.sameerasw.essentials.domain.model.AutoDismissAppConfig +import com.sameerasw.essentials.services.AUTO_DISMISS_MINUTES +import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenuItem +import com.sameerasw.essentials.ui.components.sliders.ConfigSliderItem +import com.sameerasw.essentials.ui.core.cards.FeatureCard +import com.sameerasw.essentials.ui.core.cards.IconToggleItem +import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer +import com.sameerasw.essentials.ui.core.sheets.AppSelectionSheet +import com.sameerasw.essentials.utils.AppUtil +import com.sameerasw.essentials.viewmodels.MainViewModel +import java.text.DateFormat +import java.util.Date + +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun AutoDismissNotificationsSettingsUI( + viewModel: MainViewModel, + modifier: Modifier = Modifier, + highlightSetting: String? = null, +) { + val context = LocalContext.current + var isAppSelectionSheetOpen by remember { mutableStateOf(false) } + var selectedAppForEditing by remember { mutableStateOf(null) } + + val configs by viewModel.autoDismissConfigs + val channels by viewModel.autoDismissChannels + val stats by viewModel.autoDismissStats + + Column( + modifier = + modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + RoundedCardContainer( + modifier = Modifier, + spacing = 2.dp, + cornerRadius = 24.dp, + ) { + FeatureCard( + title = stringResource(R.string.auto_dismiss_select_apps_title), + description = stringResource(R.string.auto_dismiss_select_apps_desc), + iconRes = R.drawable.rounded_app_registration_24, + isEnabled = true, + showToggle = false, + hasMoreSettings = true, + onToggle = {}, + onClick = { isAppSelectionSheetOpen = true }, + ) + } + + RoundedCardContainer( + modifier = Modifier, + spacing = 2.dp, + cornerRadius = 24.dp, + ) { + configs.forEach { config -> + val appName = + remember(config.packageName) { + try { + val appInfo = + context.packageManager.getApplicationInfo(config.packageName, 0) + context.packageManager.getApplicationLabel(appInfo).toString() + } catch (e: Exception) { + config.packageName + } + } + + val appIconPainter = + remember(config.packageName) { + try { + val drawable = context.packageManager.getApplicationIcon(config.packageName) + BitmapPainter( + AppUtil.drawableToBitmap(drawable).asImageBitmap(), + ) + } catch (e: Exception) { + null + } + } + + val timeDescription = formatAutoDismissTime(context, config.minutes) + val baseDescription = + if (!config.excludedChannels.isNullOrEmpty()) { + val count = config.excludedChannels.size + val offText = + context.resources.getQuantityString( + R.plurals.auto_dismiss_categories_off, + count, + count, + ) + "$timeDescription · $offText" + } else { + timeDescription + } + val stat = stats[config.packageName] + val clearedCount = stat?.count ?: 0 + val finalDescription = + if (clearedCount > 0) { + val clearedText = + context.resources.getQuantityString( + R.plurals.auto_dismiss_cleared_count, + clearedCount, + clearedCount, + ) + "$baseDescription · $clearedText" + } else { + baseDescription + } + + FeatureCard( + title = appName, + description = finalDescription, + isEnabled = true, + onToggle = {}, + onClick = { selectedAppForEditing = config }, + iconPainter = appIconPainter, + showToggle = false, + hasMoreSettings = true, + customTrailingContent = { + IconButton( + onClick = { + if (selectedAppForEditing?.packageName == config.packageName) { + selectedAppForEditing = null + } + viewModel.removeAutoDismissApp(config.packageName) + }, + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_delete_24), + contentDescription = stringResource(R.string.action_remove), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + additionalMenuItems = { onDismiss -> + SegmentedDropdownMenuItem( + text = { Text(stringResource(R.string.action_remove)) }, + onClick = { + onDismiss() + viewModel.removeAutoDismissApp(config.packageName) + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.rounded_delete_24), + contentDescription = null, + ) + }, + ) + }, + ) + } + } + + Text( + text = stringResource(R.string.auto_dismiss_description), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (isAppSelectionSheetOpen) { + AppSelectionSheet( + onDismissRequest = { isAppSelectionSheetOpen = false }, + onLoadApps = { _ -> + viewModel.autoDismissConfigs.value.map { AppSelection(it.packageName, true) } + }, + onSaveApps = { ctx, apps -> viewModel.saveAutoDismissSelectedApps(ctx, apps) }, + ) + } + + val editing = selectedAppForEditing + if (editing != null) { + val targetPkg = editing.packageName + val currentMinutes = + configs.find { it.packageName == targetPkg }?.minutes + ?: editing.minutes + var sliderIndex by remember(targetPkg) { + val idx = AUTO_DISMISS_MINUTES.indexOf(currentMinutes) + mutableFloatStateOf(if (idx >= 0) idx.toFloat() else 3f) + } + + ModalBottomSheet( + onDismissRequest = { selectedAppForEditing = null }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ConfigSliderItem( + title = stringResource(R.string.auto_dismiss_time_title), + value = sliderIndex, + onValueChange = { sliderIndex = it }, + valueRange = 0f..10f, + steps = 9, + increment = 1f, + valueFormatter = { idx -> + val mins = + AUTO_DISMISS_MINUTES.getOrElse( + idx.toInt().coerceIn(0, AUTO_DISMISS_MINUTES.size - 1), + ) { 15 } + formatAutoDismissTime(context, mins) + }, + onValueChangeFinished = { + val idx = sliderIndex.toInt().coerceIn(0, AUTO_DISMISS_MINUTES.size - 1) + val minutes = AUTO_DISMISS_MINUTES[idx] + viewModel.setAutoDismissMinutes(targetPkg, minutes) + }, + iconRes = R.drawable.rounded_timer_24, + ) + + val appStat = stats[targetPkg] + val count = appStat?.count ?: 0 + val sinceMillis = appStat?.since ?: System.currentTimeMillis() + val formattedDate = formatAutoDismissDate(sinceMillis) + Text( + text = + context.resources.getQuantityString( + R.plurals.auto_dismiss_cleared_since, + count, + count, + formattedDate, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp), + ) + + Text( + text = stringResource(R.string.auto_dismiss_categories_title), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(start = 8.dp), + ) + + val appChannels = + remember(channels, targetPkg) { + channels.filter { it.packageName == targetPkg }.sortedBy { it.name } + } + val currentConfig = configs.find { it.packageName == targetPkg } ?: editing + val excludedChannels = currentConfig.excludedChannels.orEmpty() + + if (appChannels.isNotEmpty()) { + RoundedCardContainer( + spacing = 2.dp, + cornerRadius = 24.dp, + ) { + appChannels.forEach { channel -> + IconToggleItem( + title = channel.name, + isChecked = channel.id !in excludedChannels, + onCheckedChange = { isChecked -> + viewModel.setAutoDismissChannelIncluded( + targetPkg, + channel.id, + isChecked, + ) + }, + ) + } + } + } else { + Text( + text = stringResource(R.string.auto_dismiss_categories_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp), + ) + } + } + } + } + } +} + +private fun formatAutoDismissTime( + context: Context, + minutes: Int, +): String = + if (minutes >= 60) { + context.getString(R.string.auto_dismiss_time_hours, minutes / 60) + } else { + context.getString(R.string.auto_dismiss_time_minutes, minutes) + } + +private fun formatAutoDismissDate(millis: Long): String = DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(millis)) diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index b120323a4..bbec66f9a 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -57,7 +57,12 @@ import com.sameerasw.essentials.domain.controller.CaffeinateController import com.sameerasw.essentials.domain.diy.Action import com.sameerasw.essentials.domain.model.AppIcon import com.sameerasw.essentials.domain.model.AppSelection +import com.sameerasw.essentials.domain.model.AutoDismissAppConfig +import com.sameerasw.essentials.domain.model.AutoDismissChannel +import com.sameerasw.essentials.domain.model.AutoDismissStat import com.sameerasw.essentials.domain.model.BackGestureAppConfig +import com.sameerasw.essentials.services.NotificationAutoDismisser +import com.sameerasw.essentials.services.NotificationListener import com.sameerasw.essentials.domain.model.AppStandbyInfo import com.sameerasw.essentials.domain.model.DnsPreset import com.sameerasw.essentials.domain.model.HilightEffect @@ -369,6 +374,7 @@ class MainViewModel : ViewModel() { val mapsChannels = mutableStateOf>(emptyList()) val isSnoozeHeadsUpEnabled = mutableStateOf(false) + val isAutoDismissEnabled = mutableStateOf(false) val isFlashlightAlwaysTurnOffEnabled = mutableStateOf(false) val isFlashlightFadeEnabled = mutableStateOf(false) val isFlashlightAdjustEnabled = mutableStateOf(false) @@ -489,6 +495,12 @@ class MainViewModel : ViewModel() { val shutUpConfigs = mutableStateOf>(emptyList()) + val autoDismissConfigs = + mutableStateOf>(emptyList()) + val autoDismissChannels = + mutableStateOf>(emptyList()) + val autoDismissStats = + mutableStateOf>(emptyMap()) val isShutUpLoading = mutableStateOf(false) val isShutUpAttemptShizukuRestart = mutableStateOf(true) val shutUpRestoreDelay = mutableIntStateOf(10) @@ -1308,6 +1320,22 @@ class MainViewModel : ViewModel() { isSnoozeHeadsUpEnabled.value = settingsRepository.getBoolean(key) } + SettingsRepository.KEY_AUTO_DISMISS_ENABLED -> { + isAutoDismissEnabled.value = settingsRepository.isAutoDismissEnabled() + } + + SettingsRepository.KEY_AUTO_DISMISS_APPS -> { + loadAutoDismissConfigs() + } + + SettingsRepository.KEY_AUTO_DISMISS_CHANNELS -> { + loadAutoDismissChannels() + } + + SettingsRepository.KEY_AUTO_DISMISS_STATS -> { + loadAutoDismissStats() + } + SettingsRepository.KEY_PINNED_FEATURES -> { pinnedFeatureKeys.value = settingsRepository.getPinnedFeatures() } @@ -1816,6 +1844,115 @@ class MainViewModel : ViewModel() { loadShutUpConfigs() } + fun loadAutoDismissConfigs() { + autoDismissConfigs.value = settingsRepository.loadAutoDismissConfigs() + loadAutoDismissChannels() + loadAutoDismissStats() + } + + fun loadAutoDismissChannels() { + autoDismissChannels.value = settingsRepository.loadAutoDismissChannels() + } + + fun loadAutoDismissStats() { + autoDismissStats.value = settingsRepository.loadAutoDismissStats() + } + + private fun resyncAutoDismiss() = NotificationListener.instance?.let { NotificationAutoDismisser.resync(it) } + + fun setAutoDismissEnabled( + enabled: Boolean, + context: Context, + ) { + isAutoDismissEnabled.value = enabled + settingsRepository.setAutoDismissEnabled(enabled) + resyncAutoDismiss() + } + + fun saveAutoDismissSelectedApps( + context: Context, + apps: List, + ) { + val currentConfigs = settingsRepository.loadAutoDismissConfigs().associateBy { it.packageName } + val lastMinutes = settingsRepository.getAutoDismissLastMinutes() + val enabledApps = apps.filter { it.isEnabled } + val newConfigs = + enabledApps.map { + currentConfigs[it.packageName] ?: AutoDismissAppConfig( + packageName = it.packageName, + minutes = lastMinutes, + ) + } + settingsRepository.saveAutoDismissConfigs(newConfigs) + + val newPackageNames = newConfigs.map { it.packageName }.toSet() + val currentStats = settingsRepository.loadAutoDismissStats().toMutableMap() + for (pkg in newPackageNames) { + if (pkg !in currentConfigs) { + currentStats[pkg] = AutoDismissStat(0, System.currentTimeMillis()) + } + } + currentStats.keys.retainAll(newPackageNames) + settingsRepository.saveAutoDismissStats(currentStats) + + loadAutoDismissConfigs() + resyncAutoDismiss() + } + + fun setAutoDismissMinutes( + packageName: String, + minutes: Int, + ) { + settingsRepository.setAutoDismissLastMinutes(minutes) + val current = autoDismissConfigs.value.toMutableList() + val index = current.indexOfFirst { it.packageName == packageName } + if (index >= 0) { + current[index] = current[index].copy(minutes = minutes) + } else { + current.add(AutoDismissAppConfig(packageName, minutes)) + } + settingsRepository.saveAutoDismissConfigs(current) + loadAutoDismissConfigs() + resyncAutoDismiss() + } + + fun removeAutoDismissApp(packageName: String) { + val current = autoDismissConfigs.value.toMutableList() + current.removeAll { it.packageName == packageName } + settingsRepository.saveAutoDismissConfigs(current) + + val currentStats = settingsRepository.loadAutoDismissStats().toMutableMap() + currentStats.remove(packageName) + settingsRepository.saveAutoDismissStats(currentStats) + + loadAutoDismissConfigs() + resyncAutoDismiss() + } + + fun setAutoDismissChannelIncluded( + packageName: String, + channelId: String, + included: Boolean, + ) { + val current = autoDismissConfigs.value.toMutableList() + val index = current.indexOfFirst { it.packageName == packageName } + if (index >= 0) { + val config = current[index] + val excluded = config.excludedChannels.orEmpty().toMutableList() + if (included) { + excluded.remove(channelId) + } else { + if (channelId !in excluded) { + excluded.add(channelId) + } + } + current[index] = config.copy(excludedChannels = excluded) + settingsRepository.saveAutoDismissConfigs(current) + loadAutoDismissConfigs() + resyncAutoDismiss() + } + } + fun createShutUpShortcut( context: Context, config: com.sameerasw.essentials.domain.model.ShutUpAppConfig, @@ -1996,6 +2133,9 @@ class MainViewModel : ViewModel() { settingsRepository.getLockScreenClockSelectedColorId() lockScreenClockSeedColor.intValue = settingsRepository.getLockScreenClockSeedColor() loadShutUpConfigs() + loadAutoDismissConfigs() + loadAutoDismissChannels() + loadAutoDismissStats() recentSearches.value = settingsRepository.getRecentSearches() loadCachedWallpaper() isDailyWallpaperAutoUpdateEnabled.value = @@ -2641,6 +2781,8 @@ class MainViewModel : ViewModel() { loadMapsChannels(context) isSnoozeHeadsUpEnabled.value = settingsRepository.getBoolean(SettingsRepository.KEY_SNOOZE_HEADS_UP_ENABLED) + isAutoDismissEnabled.value = + settingsRepository.isAutoDismissEnabled() isFlashlightAlwaysTurnOffEnabled.value = settingsRepository.getBoolean(SettingsRepository.KEY_FLASHLIGHT_ALWAYS_TURN_OFF_ENABLED) isFlashlightFadeEnabled.value = diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f97275599..4ba6ca01b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -902,6 +902,30 @@ Restore mode Snooze system notifications Snooze persistent notifications + + Auto-dismiss notifications + Clear app notifications after a set time + Automatically clear notifications from the apps you choose after a set time.\n\n1. Choose the apps whose notifications you want cleared.\n2. Tap an app to set how long its notifications stay, from 1 minute to 24 hours.\n3. Under Categories, switch off any type of notification from that app that you want to keep. Categories show up once the app has sent a notification of that type; until then, all of its notifications are cleared.\n\nEach app shows how many of its notifications were cleared since you added it. Only the number is kept; the notifications themselves are in Android\'s notification history. Removing an app resets its count.\n\nNotifications you can\'t swipe away, like music, downloads or calls, are never cleared. + Choose apps + Apps whose notifications are cleared + Dismiss after + Tap an app to change its time or keep some of its notification categories. Tap the bin to remove it. + %d min + %d h + Categories + Categories appear here after this app sends a notification. Until then, all of its notifications are dismissed. + + %d category off + %d categories off + + + %d cleared + %d cleared + + + %1$d notification cleared since %2$s + %1$d notifications cleared since %2$s + Quick settings tiles View all Button remap diff --git a/app/src/test/java/com/sameerasw/essentials/services/AutoDismissMathTest.kt b/app/src/test/java/com/sameerasw/essentials/services/AutoDismissMathTest.kt new file mode 100644 index 000000000..a742a5989 --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/services/AutoDismissMathTest.kt @@ -0,0 +1,239 @@ +package com.sameerasw.essentials.services + +import com.sameerasw.essentials.domain.model.AutoDismissAppConfig +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AutoDismissMathTest { + private val ownPackage = "com.sameerasw.essentials" + private val configuredPackage = "com.example.chat" + private val configs = + listOf( + AutoDismissAppConfig(configuredPackage, 15), + AutoDismissAppConfig("com.example.other", 60), + ) + + @Test + fun returnsDeadlineForConfiguredPackage() { + val postTime = 1_000_000L + val expected = postTime + 15 * 60_000L + assertEquals( + expected, + autoDismissDeadline( + packageName = configuredPackage, + postTime = postTime, + isClearable = true, + isGroupSummary = false, + ownPackage = ownPackage, + configs = configs, + channelId = "general", + ), + ) + } + + @Test + fun returnsNullForUnconfiguredPackage() { + assertNull( + autoDismissDeadline( + packageName = "com.unconfigured.app", + postTime = 1_000_000L, + isClearable = true, + isGroupSummary = false, + ownPackage = ownPackage, + configs = configs, + channelId = "general", + ), + ) + } + + @Test + fun returnsNullForOwnPackage() { + val configsWithOwn = configs + AutoDismissAppConfig(ownPackage, 10) + assertNull( + autoDismissDeadline( + packageName = ownPackage, + postTime = 1_000_000L, + isClearable = true, + isGroupSummary = false, + ownPackage = ownPackage, + configs = configsWithOwn, + channelId = "general", + ), + ) + } + + @Test + fun returnsNullWhenNotClearable() { + assertNull( + autoDismissDeadline( + packageName = configuredPackage, + postTime = 1_000_000L, + isClearable = false, + isGroupSummary = false, + ownPackage = ownPackage, + configs = configs, + channelId = "general", + ), + ) + } + + @Test + fun returnsNullForGroupSummary() { + assertNull( + autoDismissDeadline( + packageName = configuredPackage, + postTime = 1_000_000L, + isClearable = true, + isGroupSummary = true, + ownPackage = ownPackage, + configs = configs, + channelId = "general", + ), + ) + } + + @Test + fun allMinutesValuesProduceCorrectDeadlines() { + val postTime = 5_000_000L + AUTO_DISMISS_MINUTES.forEach { minutes -> + val appConfigs = listOf(AutoDismissAppConfig(configuredPackage, minutes)) + val deadline = + autoDismissDeadline( + packageName = configuredPackage, + postTime = postTime, + isClearable = true, + isGroupSummary = false, + ownPackage = ownPackage, + configs = appConfigs, + channelId = "general", + ) + assertEquals(postTime + minutes * 60_000L, deadline) + } + assertEquals( + postTime + 1 * 60_000L, + autoDismissDeadline( + packageName = configuredPackage, + postTime = postTime, + isClearable = true, + isGroupSummary = false, + ownPackage = ownPackage, + configs = listOf(AutoDismissAppConfig(configuredPackage, 1)), + channelId = "general", + ), + ) + assertEquals( + postTime + 1440 * 60_000L, + autoDismissDeadline( + packageName = configuredPackage, + postTime = postTime, + isClearable = true, + isGroupSummary = false, + ownPackage = ownPackage, + configs = listOf(AutoDismissAppConfig(configuredPackage, 1440)), + channelId = "general", + ), + ) + } + + @Test + fun returnsNullForExcludedChannel() { + val configsWithExclusion = + listOf( + AutoDismissAppConfig( + packageName = configuredPackage, + minutes = 15, + excludedChannels = listOf("spam", "promotions"), + ), + ) + assertNull( + autoDismissDeadline( + packageName = configuredPackage, + postTime = 1_000_000L, + isClearable = true, + isGroupSummary = false, + ownPackage = ownPackage, + configs = configsWithExclusion, + channelId = "spam", + ), + ) + } + + @Test + fun returnsDeadlineForOtherChannelOfSameApp() { + val configsWithExclusion = + listOf( + AutoDismissAppConfig( + packageName = configuredPackage, + minutes = 15, + excludedChannels = listOf("spam", "promotions"), + ), + ) + val postTime = 1_000_000L + val expected = postTime + 15 * 60_000L + assertEquals( + expected, + autoDismissDeadline( + packageName = configuredPackage, + postTime = postTime, + isClearable = true, + isGroupSummary = false, + ownPackage = ownPackage, + configs = configsWithExclusion, + channelId = "messages", + ), + ) + } + + @Test + fun returnsDeadlineWhenExcludedChannelsIsNull() { + val configsWithNullExclusions = + listOf( + AutoDismissAppConfig( + packageName = configuredPackage, + minutes = 15, + excludedChannels = null, + ), + ) + val postTime = 1_000_000L + val expected = postTime + 15 * 60_000L + assertEquals( + expected, + autoDismissDeadline( + packageName = configuredPackage, + postTime = postTime, + isClearable = true, + isGroupSummary = false, + ownPackage = ownPackage, + configs = configsWithNullExclusions, + channelId = "spam", + ), + ) + } + + @Test + fun returnsDeadlineForNullChannelIdWithExclusionsSet() { + val configsWithExclusion = + listOf( + AutoDismissAppConfig( + packageName = configuredPackage, + minutes = 15, + excludedChannels = listOf("spam"), + ), + ) + val postTime = 1_000_000L + val expected = postTime + 15 * 60_000L + assertEquals( + expected, + autoDismissDeadline( + packageName = configuredPackage, + postTime = postTime, + isClearable = true, + isGroupSummary = false, + ownPackage = ownPackage, + configs = configsWithExclusion, + channelId = null, + ), + ) + } +}