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 dc499c7f5..3d676534e 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" @@ -1640,6 +1648,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 41e0e7596..2f1b5192b 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 @@ -372,6 +377,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) @@ -493,6 +499,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) @@ -1318,6 +1330,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() } @@ -1830,6 +1858,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, @@ -2010,6 +2147,9 @@ class MainViewModel : ViewModel() { settingsRepository.getLockScreenClockSelectedColorId() lockScreenClockSeedColor.intValue = settingsRepository.getLockScreenClockSeedColor() loadShutUpConfigs() + loadAutoDismissConfigs() + loadAutoDismissChannels() + loadAutoDismissStats() recentSearches.value = settingsRepository.getRecentSearches() loadCachedWallpaper() isDailyWallpaperAutoUpdateEnabled.value = @@ -2658,6 +2798,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 e08d93c08..ffceebd53 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, + ), + ) + } +}