From fd21fc857598e2df3a6af0c10a17dc831332f26a Mon Sep 17 00:00:00 2001 From: Aditya Rajput Date: Sun, 14 Jun 2026 19:36:49 +0530 Subject: [PATCH 1/7] Warn if `NotificationListener` is uninitialized --- .../adityarajput/notifilter/AlarmReceiver.kt | 8 ++++++ .../services/NotificationListener.kt | 28 ++++++++++++++----- .../viewmodels/UpsertFilterViewModel.kt | 21 ++++++++------ .../notifilter/views/screens/FiltersScreen.kt | 21 ++++++++++++++ .../views/screens/SettingsScreen.kt | 11 ++++++-- app/src/main/res/values/strings.xml | 1 + 6 files changed, 71 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/co/adityarajput/notifilter/AlarmReceiver.kt b/app/src/main/java/co/adityarajput/notifilter/AlarmReceiver.kt index eaa3bfa..45710db 100644 --- a/app/src/main/java/co/adityarajput/notifilter/AlarmReceiver.kt +++ b/app/src/main/java/co/adityarajput/notifilter/AlarmReceiver.kt @@ -14,6 +14,14 @@ class AlarmReceiver : BroadcastReceiver() { val key = intent.getStringExtra(Constants.EXTRA_SBN_KEY) ?: return val isClearable = intent.getBooleanExtra(Constants.EXTRA_SBN_IS_CLEARABLE, false) + if (!NotificationListener.isServiceInitialized) { + Logger.i( + "AlarmReceiver", + "Skipping `DISMISS_STALE` because listener is not initialized", + ) + return + } + NotificationListener.instance.dismissNotification(key, isClearable) } } diff --git a/app/src/main/java/co/adityarajput/notifilter/services/NotificationListener.kt b/app/src/main/java/co/adityarajput/notifilter/services/NotificationListener.kt index cd5c4b5..7a66d2e 100644 --- a/app/src/main/java/co/adityarajput/notifilter/services/NotificationListener.kt +++ b/app/src/main/java/co/adityarajput/notifilter/services/NotificationListener.kt @@ -27,6 +27,8 @@ import java.time.ZoneId import java.time.ZonedDateTime import java.time.temporal.ChronoUnit.MILLIS import kotlin.math.min +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds class NotificationListener : NotificationListenerService() { companion object { @@ -39,6 +41,8 @@ class NotificationListener : NotificationListenerService() { _instance = value } + val isServiceInitialized get() = _instance != null + const val NOTIFICATION_SOUND_DURATION = 3000L fun createAlertNotificationChannel() { @@ -55,12 +59,22 @@ class NotificationListener : NotificationListenerService() { } } - fun updateForegroundStatus(runInForeground: Boolean) { + fun updateForegroundStatus(runInForeground: Boolean): Boolean { + if (!isServiceInitialized) { + Logger.i( + "NotificationListener", + "Skipping foreground toggle because service is not initialized", + ) + return false + } + if (runInForeground) { instance.startForeground() } else { instance.stopForeground(STOP_FOREGROUND_REMOVE) } + + return true } } @@ -232,10 +246,10 @@ class NotificationListener : NotificationListenerService() { } is Action.MUTE -> serviceScope.launch { - delay(300L) + delay(300.milliseconds) Logger.i("NotificationListener", "Muting") requestListenerHints(HINT_HOST_DISABLE_NOTIFICATION_EFFECTS) - delay(NOTIFICATION_SOUND_DURATION) + delay(NOTIFICATION_SOUND_DURATION.milliseconds) Logger.d("NotificationListener", "Unmuting") requestListenerHints(0) } @@ -249,7 +263,7 @@ class NotificationListener : NotificationListenerService() { .setAutoCancel(true).build(), ) serviceScope.launch { - delay(2000L) + delay(2.seconds) notificationManager.cancel(Constants.ALERT_NOTIFICATION_ID) } } @@ -317,14 +331,14 @@ class NotificationListener : NotificationListenerService() { private fun muteNotificationsWhileCooldown(filter: Filter) { serviceScope.launch { - delay(NOTIFICATION_SOUND_DURATION) // INFO: Wait for original notification sound + delay(NOTIFICATION_SOUND_DURATION.milliseconds) // INFO: Wait for original notification sound Logger.d("NotificationListener", "Applying cooldown") requestListenerHints(HINT_HOST_DISABLE_NOTIFICATION_EFFECTS) try { while (true) { val cooldownEnd = cooldowns[filter.id] ?: break if (System.currentTimeMillis() > cooldownEnd) break - delay(500L) + delay(500.milliseconds) } } finally { Logger.d("NotificationListener", "Cooldown ended") @@ -348,7 +362,7 @@ class NotificationListener : NotificationListenerService() { while (true) { val cooldownEnd = cooldowns[filter.id] ?: break if (System.currentTimeMillis() > cooldownEnd) break - delay(500L) + delay(500.milliseconds) } } finally { Logger.d("NotificationListener", "Disturbance ended") diff --git a/app/src/main/java/co/adityarajput/notifilter/viewmodels/UpsertFilterViewModel.kt b/app/src/main/java/co/adityarajput/notifilter/viewmodels/UpsertFilterViewModel.kt index f22e553..fc1cfb5 100644 --- a/app/src/main/java/co/adityarajput/notifilter/viewmodels/UpsertFilterViewModel.kt +++ b/app/src/main/java/co/adityarajput/notifilter/viewmodels/UpsertFilterViewModel.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.milliseconds class UpsertFilterViewModel( filter: Filter?, @@ -72,10 +73,11 @@ class UpsertFilterViewModel( var allPackages by mutableStateOf>(emptyList()) var activeNotifications by mutableStateOf( - NotificationListener.instance - .activeNotifications - .filter { it.notification.flags and FLAG_GROUP_SUMMARY == 0 } - .mapIndexed { i, sbn -> Notification(sbn, id = i) }, + if (!NotificationListener.isServiceInitialized) emptyList() else + NotificationListener.instance + .activeNotifications + .filter { it.notification.flags and FLAG_GROUP_SUMMARY == 0 } + .mapIndexed { i, sbn -> Notification(sbn, id = i) }, ) init { @@ -86,11 +88,12 @@ class UpsertFilterViewModel( } while (true) { - activeNotifications = NotificationListener.instance - .activeNotifications - .filter { it.notification.flags and FLAG_GROUP_SUMMARY == 0 } - .mapIndexed { i, sbn -> Notification(sbn, id = i) } - delay(500) + activeNotifications = + if (!NotificationListener.isServiceInitialized) emptyList() else + NotificationListener.instance.activeNotifications + .filter { it.notification.flags and FLAG_GROUP_SUMMARY == 0 } + .mapIndexed { i, sbn -> Notification(sbn, id = i) } + delay(500.milliseconds) } } } diff --git a/app/src/main/java/co/adityarajput/notifilter/views/screens/FiltersScreen.kt b/app/src/main/java/co/adityarajput/notifilter/views/screens/FiltersScreen.kt index bd4b055..e4d2e23 100644 --- a/app/src/main/java/co/adityarajput/notifilter/views/screens/FiltersScreen.kt +++ b/app/src/main/java/co/adityarajput/notifilter/views/screens/FiltersScreen.kt @@ -25,6 +25,7 @@ import co.adityarajput.notifilter.Constants.SHOW_MISSING_PERMISSIONS_DIALOG import co.adityarajput.notifilter.R import co.adityarajput.notifilter.data.models.Any import co.adityarajput.notifilter.data.models.RegexTarget +import co.adityarajput.notifilter.services.NotificationListener import co.adityarajput.notifilter.utils.getFirst import co.adityarajput.notifilter.utils.getToggleString import co.adityarajput.notifilter.utils.isGranted @@ -36,7 +37,9 @@ import co.adityarajput.notifilter.views.components.AppBar import co.adityarajput.notifilter.views.components.ManageFilterDialog import co.adityarajput.notifilter.views.components.MissingPermissionsDialog import co.adityarajput.notifilter.views.components.Tile +import kotlinx.coroutines.delay import kotlinx.serialization.json.Json +import kotlin.time.Duration.Companion.seconds @Composable fun FiltersScreen( @@ -57,6 +60,16 @@ fun FiltersScreen( ) } var isAdjustingPriorities by remember { mutableStateOf(false) } + var isListenerServiceInitialized by remember { + mutableStateOf(NotificationListener.isServiceInitialized) + } + + LaunchedEffect(Unit) { + while (!isListenerServiceInitialized) { + isListenerServiceInitialized = NotificationListener.isServiceInitialized + delay(1.seconds) + } + } Scaffold( topBar = { @@ -87,6 +100,14 @@ fun FiltersScreen( ) { paddingValues -> if (state.value.filters == null) { Box(Modifier.fillMaxSize(), Alignment.Center) { CircularProgressIndicator() } + } else if (!isListenerServiceInitialized) { + Box(Modifier.fillMaxSize(), Alignment.Center) { + Text( + stringResource(R.string.listener_uninitialized), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + ) + } } else if (state.value.filters!!.isEmpty()) { Box( Modifier.fillMaxSize(), diff --git a/app/src/main/java/co/adityarajput/notifilter/views/screens/SettingsScreen.kt b/app/src/main/java/co/adityarajput/notifilter/views/screens/SettingsScreen.kt index bb7d1d9..73ac108 100644 --- a/app/src/main/java/co/adityarajput/notifilter/views/screens/SettingsScreen.kt +++ b/app/src/main/java/co/adityarajput/notifilter/views/screens/SettingsScreen.kt @@ -141,12 +141,17 @@ fun SettingsScreen( style = MaterialTheme.typography.bodySmall, ) } + val toastText = stringResource(R.string.listener_uninitialized) Switch( isRunningInForeground, { - isRunningInForeground = it - sharedPreferences.edit { putBoolean(RUN_IN_FOREGROUND, it) } - NotificationListener.updateForegroundStatus(it) + val result = NotificationListener.updateForegroundStatus(it) + if (!result) { + Toast.makeText(context, toastText, Toast.LENGTH_SHORT).show() + } else { + isRunningInForeground = it + sharedPreferences.edit { putBoolean(RUN_IN_FOREGROUND, it) } + } }, ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index acad3d0..4811642 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,6 +4,7 @@ NotiFilter + Waiting for OS to initialize\nnotification listener service… No filters added.\nTap + to get started. "on weekdays " "on weekends " From 188c2242de84ddece56c970a6b9e295d3716b266 Mon Sep 17 00:00:00 2001 From: Aditya Rajput Date: Tue, 16 Jun 2026 00:21:03 +0530 Subject: [PATCH 2/7] Add "REPLACE" action --- README.md | 1 + .../co/adityarajput/notifilter/Constants.kt | 2 + .../notifilter/data/AppContainer.kt | 7 ++ .../notifilter/data/models/Action.kt | 13 ++++ .../services/NotificationListener.kt | 55 +++++++++++++-- .../notifilter/utils/Permissions.kt | 2 +- .../co/adityarajput/notifilter/utils/Regex.kt | 9 +++ .../notifilter/views/screens/FiltersScreen.kt | 15 ++++ .../views/screens/UpsertFilterScreen.kt | 68 +++++++++++++++++++ .../res/drawable/notification_settings.xml | 10 +++ app/src/main/res/values/strings.xml | 7 ++ 11 files changed, 184 insertions(+), 5 deletions(-) create mode 100644 app/src/main/res/drawable/notification_settings.xml diff --git a/README.md b/README.md index 603666b..96bbd1e 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ NotiFilter listens to all device notifications and quietly manages those that ma 8. Play an alert πŸ”” 9. Disable DND mode πŸ”Š 10. Remove after a delay ⏲️ + 11. Replace with a custom notification πŸ“ - **Schedule** - Choose when filters run (e.g. only during work hours) ⏰ - **History** - Recently dismissed notifications are stored locally, for reference and retrieval 🧾 - **Widget** - Configure filters to send notifications to a home screen widget πŸ“± diff --git a/app/src/main/java/co/adityarajput/notifilter/Constants.kt b/app/src/main/java/co/adityarajput/notifilter/Constants.kt index 3c0cbe9..036f2e7 100644 --- a/app/src/main/java/co/adityarajput/notifilter/Constants.kt +++ b/app/src/main/java/co/adityarajput/notifilter/Constants.kt @@ -14,6 +14,8 @@ object Constants { const val ALERT_NOTIFICATION_CHANNEL_ID = "notifilter_alert" const val FOREGROUND_NOTIFICATION_ID = 1001 const val FOREGROUND_NOTIFICATION_CHANNEL_ID = "notifilter_foreground" + fun getReplaceNotificationId(filterId: Int) = 1002 + filterId + fun getReplaceNotificationChannelId(filterId: Int) = "notifilter_replace_$filterId" const val ACTION_DISMISS_STALE = "co.adityarajput.notifilter.DISMISS_STALE" const val EXTRA_SBN_KEY = "extra_sbn_key" diff --git a/app/src/main/java/co/adityarajput/notifilter/data/AppContainer.kt b/app/src/main/java/co/adityarajput/notifilter/data/AppContainer.kt index 1de86dc..0370996 100644 --- a/app/src/main/java/co/adityarajput/notifilter/data/AppContainer.kt +++ b/app/src/main/java/co/adityarajput/notifilter/data/AppContainer.kt @@ -105,6 +105,13 @@ class AppContainer(private val context: Context) { RegexTarget.OR, historyEnabled = false, ), + Filter( + App("GitHub", "com.github.android"), + "approved your pull request", + Action.REPLACE("PR approved", $$"${content}"), + RegexTarget.TITLE, + historyEnabled = false, + ), ) repository.upsert( Notification( diff --git a/app/src/main/java/co/adityarajput/notifilter/data/models/Action.kt b/app/src/main/java/co/adityarajput/notifilter/data/models/Action.kt index a8eda10..a53c258 100644 --- a/app/src/main/java/co/adityarajput/notifilter/data/models/Action.kt +++ b/app/src/main/java/co/adityarajput/notifilter/data/models/Action.kt @@ -40,6 +40,9 @@ sealed class Action { @Serializable data class DISMISS_STALE(val retentionLength: Int) : Action() + @Serializable + data class REPLACE(val titleTemplate: String, val contentTemplate: String) : Action() + @Composable fun verb() = when (this) { is DISMISS -> stringResource(R.string.dismiss_short) @@ -68,6 +71,8 @@ sealed class Action { R.string.dismiss_stale_short, pluralStringResource(R.plurals.minute, retentionLength, retentionLength), ) + + is REPLACE -> stringResource(R.string.replace_short, titleTemplate, contentTemplate) } @Composable @@ -83,6 +88,7 @@ sealed class Action { is ALERT -> R.string.alert_long is DISTURB -> R.string.disturb_long is DISMISS_STALE -> R.string.dismiss_stale_long + is REPLACE -> R.string.replace_long }, ) @@ -93,6 +99,7 @@ sealed class Action { listOf( DISMISS, TAP_NOTIFICATION, TAP_BUTTON(""), BATCH(3), DELAY, DEBOUNCE(2), MUTE, ALERT, DISTURB(5), DISMISS_STALE(15), + REPLACE($$"${app} - ${title}", $$"${content}"), ) } @@ -127,6 +134,12 @@ sealed class Action { value.removePrefix("DISMISS_STALE(retentionLength=").removeSuffix(")").toInt(), ) + value.startsWith("REPLACE") -> { + val params = value.removePrefix("REPLACE(titleTemplate=").removeSuffix(")") + .split(", contentTemplate=") + REPLACE(params[0], params[1]) + } + else -> { Logger.e("Action.fromString", value) throw IllegalArgumentException("Can't convert value to Action, unknown value: $value") diff --git a/app/src/main/java/co/adityarajput/notifilter/services/NotificationListener.kt b/app/src/main/java/co/adityarajput/notifilter/services/NotificationListener.kt index 7a66d2e..59b89cc 100644 --- a/app/src/main/java/co/adityarajput/notifilter/services/NotificationListener.kt +++ b/app/src/main/java/co/adityarajput/notifilter/services/NotificationListener.kt @@ -4,10 +4,12 @@ import android.app.AlarmManager import android.app.Notification.FLAG_GROUP_SUMMARY import android.app.NotificationChannel import android.app.NotificationManager +import android.content.Intent import android.content.pm.ApplicationInfo import android.media.AudioManager import android.os.Build import android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM +import android.provider.Settings import android.service.notification.NotificationListenerService import android.service.notification.StatusBarNotification import androidx.core.app.NotificationCompat @@ -16,10 +18,7 @@ import co.adityarajput.notifilter.R import co.adityarajput.notifilter.data.AppContainer import co.adityarajput.notifilter.data.Cache import co.adityarajput.notifilter.data.models.* -import co.adityarajput.notifilter.utils.Logger -import co.adityarajput.notifilter.utils.containsMatchIn -import co.adityarajput.notifilter.utils.printable -import co.adityarajput.notifilter.utils.sendIntent +import co.adityarajput.notifilter.utils.* import kotlinx.coroutines.* import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.first @@ -59,6 +58,29 @@ class NotificationListener : NotificationListenerService() { } } + fun createReplaceNotificationChannel(filterId: Int, openSettings: Boolean = false) { + val channelId = Constants.getReplaceNotificationChannelId(filterId) + if (instance.notificationManager.getNotificationChannel(channelId) == null) { + instance.notificationManager.createNotificationChannel( + NotificationChannel( + channelId, + "NotiFilter Replace Notifications for Filter #$filterId", + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = "Required for REPLACE Actions" + }, + ) + } + if (openSettings) { + instance.startActivity( + Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, instance.packageName) + .putExtra(Settings.EXTRA_CHANNEL_ID, channelId) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } + } + fun updateForegroundStatus(runInForeground: Boolean): Boolean { if (!isServiceInitialized) { Logger.i( @@ -299,6 +321,31 @@ class NotificationListener : NotificationListenerService() { } } } + + is Action.REPLACE -> { + createReplaceNotificationChannel(filter.id) + val allPackages = Cache.getAllPackages(packageManager) + + cancelNotification(sbn.key) + notificationManager.notify( + Constants.getReplaceNotificationId(filter.id), + NotificationCompat + .Builder(this, Constants.getReplaceNotificationChannelId(filter.id)) + .setContentTitle( + filter.action.titleTemplate.replaceWithNotificationData( + notification, allPackages, + ), + ) + .setContentText( + filter.action.contentTemplate.replaceWithNotificationData( + notification, allPackages, + ), + ) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentIntent(sbn.notification.contentIntent) + .build(), + ) + } } serviceScope.launch { diff --git a/app/src/main/java/co/adityarajput/notifilter/utils/Permissions.kt b/app/src/main/java/co/adityarajput/notifilter/utils/Permissions.kt index 70571b5..7626c61 100644 --- a/app/src/main/java/co/adityarajput/notifilter/utils/Permissions.kt +++ b/app/src/main/java/co/adityarajput/notifilter/utils/Permissions.kt @@ -102,7 +102,7 @@ fun permissionsRequired(filters: List) = buildList { if (filters.any { it.action is Action.TAP_NOTIFICATION }) add(Permission.ACCESSIBILITY_SERVICE) - if (filters.any { it.action is Action.ALERT }) + if (filters.any { it.action is Action.ALERT || it.action is Action.REPLACE }) add(Permission.POST_NOTIFICATIONS) if (filters.any { it.action is Action.DISTURB }) diff --git a/app/src/main/java/co/adityarajput/notifilter/utils/Regex.kt b/app/src/main/java/co/adityarajput/notifilter/utils/Regex.kt index 204449e..15db36a 100644 --- a/app/src/main/java/co/adityarajput/notifilter/utils/Regex.kt +++ b/app/src/main/java/co/adityarajput/notifilter/utils/Regex.kt @@ -1,5 +1,7 @@ package co.adityarajput.notifilter.utils +import co.adityarajput.notifilter.data.models.App +import co.adityarajput.notifilter.data.models.Notification import net.fellbaum.jemoji.EmojiManager private const val EMOJI_PATTERN_DISPLAY = "\\p{Emoji}" @@ -17,6 +19,13 @@ fun String.containsMatchIn(input: String): Boolean { return Regex(pattern).containsMatchIn(text) } +fun String.replaceWithNotificationData(notification: Notification, allPackages: List) = + this.replace($$"${app}", notification.appNameFrom(allPackages)) + .replace($$"${title}", notification.title) + .replace($$"${content}", notification.content) + .replace($$"${postTime}", notification.timestamp.toReadableTime()) + .replace($$"${package}", notification.origin) + fun String.isValidRegex() = try { this .replace(EMOJI_PATTERN_DISPLAY, EMOJI_PATTERN_INTERNAL) diff --git a/app/src/main/java/co/adityarajput/notifilter/views/screens/FiltersScreen.kt b/app/src/main/java/co/adityarajput/notifilter/views/screens/FiltersScreen.kt index e4d2e23..a597b31 100644 --- a/app/src/main/java/co/adityarajput/notifilter/views/screens/FiltersScreen.kt +++ b/app/src/main/java/co/adityarajput/notifilter/views/screens/FiltersScreen.kt @@ -23,6 +23,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import co.adityarajput.notifilter.Constants.SETTINGS import co.adityarajput.notifilter.Constants.SHOW_MISSING_PERMISSIONS_DIALOG import co.adityarajput.notifilter.R +import co.adityarajput.notifilter.data.models.Action import co.adityarajput.notifilter.data.models.Any import co.adityarajput.notifilter.data.models.RegexTarget import co.adityarajput.notifilter.services.NotificationListener @@ -164,6 +165,20 @@ fun FiltersScreen( stringResource(R.string.adjust_priorities), ) } + if (it.action is Action.REPLACE) + IconButton( + { + NotificationListener.createReplaceNotificationChannel( + it.id, + true, + ) + }, + ) { + Icon( + painterResource(R.drawable.notification_settings), + stringResource(R.string.adjust_priorities), + ) + } IconButton( { viewModel.dialogState = FilterDialogState.TOGGLE_HISTORY diff --git a/app/src/main/java/co/adityarajput/notifilter/views/screens/UpsertFilterScreen.kt b/app/src/main/java/co/adityarajput/notifilter/views/screens/UpsertFilterScreen.kt index b0a345b..1ffbcf3 100644 --- a/app/src/main/java/co/adityarajput/notifilter/views/screens/UpsertFilterScreen.kt +++ b/app/src/main/java/co/adityarajput/notifilter/views/screens/UpsertFilterScreen.kt @@ -681,6 +681,74 @@ private fun ColumnScope.ActionPage(viewModel: UpsertFilterViewModel) { } } } + AnimatedVisibility(it is Action.REPLACE && viewModel.state.values.action is Action.REPLACE) { + val action = (viewModel.state.values.action as? Action.REPLACE) + ?: Action.REPLACE($$"${app} - ${title}", $$"${content}") + Column( + Modifier.fillMaxWidth(), + Arrangement.spacedBy(dimensionResource(R.dimen.padding_medium)), + ) { + OutlinedTextField( + action.titleTemplate, + { value -> + viewModel.updateForm( + viewModel.state.page, + viewModel.state.values.copy(action = action.copy(titleTemplate = value)), + ) + }, + Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.title_template)) }, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer, + unfocusedContainerColor = MaterialTheme.colorScheme.secondaryContainer, + disabledContainerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) + OutlinedTextField( + action.contentTemplate, + { value -> + viewModel.updateForm( + viewModel.state.page, + viewModel.state.values.copy(action = action.copy(contentTemplate = value)), + ) + }, + Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.content_template)) }, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer, + unfocusedContainerColor = MaterialTheme.colorScheme.secondaryContainer, + disabledContainerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) + Text( + AnnotatedString.fromHtml( + stringResource(R.string.notification_template_advice), + TextLinkStyles( + SpanStyle( + MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + ), + ), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Normal, + ) + if (!hasPermissions.getValue(Permission.POST_NOTIFICATIONS)) { + ErrorText(R.string.replace_notifications_description) + Button( + { context.request(Permission.POST_NOTIFICATIONS) }, + Modifier.align(Alignment.CenterHorizontally), + colors = ButtonDefaults.buttonColors(contentColor = MaterialTheme.colorScheme.onPrimaryContainer), + ) { + Text( + stringResource(R.string.grant_permission), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Normal, + ) + } + } + } + } } HorizontalDivider() Text( diff --git a/app/src/main/res/drawable/notification_settings.xml b/app/src/main/res/drawable/notification_settings.xml new file mode 100644 index 0000000..6d2a2fd --- /dev/null +++ b/app/src/main/res/drawable/notification_settings.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4811642..922650a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -20,6 +20,7 @@ play an alert disable DND for %1$s dismiss after %1$s + replace with %1$s | %2$s Any app history disabled filter disabled @@ -27,6 +28,7 @@ Adjust priorities Move up Move down + Configure notifications %1$s history Disable history and reset hits for this filter? Dismissed notifications will not be saved. Enable history for this filter? @@ -120,6 +122,11 @@ Remove stale notifications Dismiss after %1$s mins NotiFilter requires exemption from battery optimization to execute delayed dismissal. + Replace with custom notification + Title template + Content template + hereβ†— for more templates.]]> + NotiFilter dismisses the original notification and sends a new one with your chosen values. Choose where to display caught notification In-app History screen Homescreen Log widget From eb8a4ac49e4a80587e2e1ffb9559ef10cbeef886 Mon Sep 17 00:00:00 2001 From: Aditya Rajput Date: Tue, 16 Jun 2026 00:31:12 +0530 Subject: [PATCH 3/7] Update version --- app/build.gradle.kts | 4 ++-- metadata/en-US/changelogs/33.txt | 2 ++ metadata/en-US/full_description.txt | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 metadata/en-US/changelogs/33.txt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9e62f4e..33133cb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,8 +19,8 @@ android { applicationId = "co.adityarajput.notifilter" minSdk = 29 targetSdk = 36 - versionCode = 32 - versionName = "4.11.0" + versionCode = 33 + versionName = "4.12.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/metadata/en-US/changelogs/33.txt b/metadata/en-US/changelogs/33.txt new file mode 100644 index 0000000..0c36bac --- /dev/null +++ b/metadata/en-US/changelogs/33.txt @@ -0,0 +1,2 @@ +β€’ fix: Warn if `NotificationListener` is uninitialized +β€’ feat: Add "REPLACE" action diff --git a/metadata/en-US/full_description.txt b/metadata/en-US/full_description.txt index 5bedb54..2de3dc0 100644 --- a/metadata/en-US/full_description.txt +++ b/metadata/en-US/full_description.txt @@ -1 +1 @@ -

NotiFilter listens to all device notifications and quietly manages those that match your filters.

Features:

  • Filters: Use regex to precisely target annoying notifications from each app.
  • Actions: Choose what to do with the filtered notifications - dismiss, tap, delay, batch, debounce, mute, alert, disable DND, or remove stale.
  • Schedule: Choose when filters run (e.g. only during work hours)
  • History: Recently dismissed notifications are stored locally, for reference and retrieval.
  • Widget: Configure filters to send notifications to a home screen widget.
  • Export/Import: Backup or transfer your filters as JSON files.
  • Private: Fully offline; your data never leaves your device.
  • Lightweight: Runs in the background with minimal battery and memory usage.

+

NotiFilter listens to all device notifications and quietly manages those that match your filters.

Features:

  • Filters: Use regex to precisely target annoying notifications from each app.
  • Actions: Choose what to do with the filtered notifications - dismiss, tap, delay, batch, debounce, mute, alert, disable DND, remove stale or replace.
  • Schedule: Choose when filters run (e.g. only during work hours)
  • History: Recently dismissed notifications are stored locally, for reference and retrieval.
  • Widget: Configure filters to send notifications to a home screen widget.
  • Export/Import: Backup or transfer your filters as JSON files.
  • Private: Fully offline; your data never leaves your device.
  • Lightweight: Runs in the background with minimal battery and memory usage.

From bd74fad3fd7d4a84708a4592230b68fd54e7f097 Mon Sep 17 00:00:00 2001 From: Aditya Rajput Date: Tue, 16 Jun 2026 01:55:03 +0530 Subject: [PATCH 4/7] Add unit tests --- .../co/adityarajput/notifilter/utils/Regex.kt | 2 +- .../notifilter/data/ConvertersTest.kt | 30 ++++++++++ .../notifilter/data/models/FilterTest.kt | 59 +++++++++++++++++++ .../notifilter/data/models/ScheduleTest.kt | 12 ++-- .../notifilter/utils/RegexTest.kt | 34 +++++++++++ 5 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/co/adityarajput/notifilter/data/ConvertersTest.kt create mode 100644 app/src/test/java/co/adityarajput/notifilter/data/models/FilterTest.kt create mode 100644 app/src/test/java/co/adityarajput/notifilter/utils/RegexTest.kt diff --git a/app/src/main/java/co/adityarajput/notifilter/utils/Regex.kt b/app/src/main/java/co/adityarajput/notifilter/utils/Regex.kt index 15db36a..e8bf810 100644 --- a/app/src/main/java/co/adityarajput/notifilter/utils/Regex.kt +++ b/app/src/main/java/co/adityarajput/notifilter/utils/Regex.kt @@ -4,7 +4,7 @@ import co.adityarajput.notifilter.data.models.App import co.adityarajput.notifilter.data.models.Notification import net.fellbaum.jemoji.EmojiManager -private const val EMOJI_PATTERN_DISPLAY = "\\p{Emoji}" +const val EMOJI_PATTERN_DISPLAY = "\\p{Emoji}" private const val EMOJI_PATTERN_INTERNAL = "\uE010" fun String.containsMatchIn(input: String): Boolean { diff --git a/app/src/test/java/co/adityarajput/notifilter/data/ConvertersTest.kt b/app/src/test/java/co/adityarajput/notifilter/data/ConvertersTest.kt new file mode 100644 index 0000000..d0beb91 --- /dev/null +++ b/app/src/test/java/co/adityarajput/notifilter/data/ConvertersTest.kt @@ -0,0 +1,30 @@ +package co.adityarajput.notifilter.data + +import co.adityarajput.notifilter.data.models.Action +import org.junit.Assert.assertTrue +import org.junit.Test + +@Suppress("TestFunctionName") +class ConvertersTest { + @Test + fun Converters_Action_roundtrip() { + Converters().run { + Action.entries.forEach { + assertTrue(it == toAction(fromAction(it))) + } + } + } + + @Test + fun Converters_Days_roundtrip() { + Converters().run { + listOf( + setOf(1, 2), setOf(2, 1), + setOf(2, 4, 6), setOf(2, 3, 5), + setOf(0, 1, 2, 3, 4, 5, 6), + ).forEach { + assertTrue(it == toDays(fromDays(it))) + } + } + } +} diff --git a/app/src/test/java/co/adityarajput/notifilter/data/models/FilterTest.kt b/app/src/test/java/co/adityarajput/notifilter/data/models/FilterTest.kt new file mode 100644 index 0000000..51b98fc --- /dev/null +++ b/app/src/test/java/co/adityarajput/notifilter/data/models/FilterTest.kt @@ -0,0 +1,59 @@ +package co.adityarajput.notifilter.data.models + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@Suppress("TestFunctionName") +class FilterTest { + private val pollResultNotification = Notification( + "Poll finale alert", + """Check the poll results for "Do you pronounce Z as Zed?".""", + "Tumblr", + 0, + ) + + private val withoutContent = pollResultNotification.copy(content = "...") + + private val withoutTitle = pollResultNotification.copy(title = "...") + + private val emptyNotification = pollResultNotification.copy(title = "...", content = "...") + + private val titleFilter = Filter(Any, "Poll", Action.MUTE, RegexTarget.TITLE) + + private val contentFilter = Filter(Any, "poll", Action.MUTE, RegexTarget.CONTENT) + + private val orFilter = Filter(Any, "Poll", Action.MUTE, RegexTarget.OR) + + private val andFilter = Filter(Any, "Poll", Action.MUTE, RegexTarget.AND, "poll") + + @Test + fun Filter_matchesTextOf_correct() { + assertTrue(titleFilter.matchesTextOf(pollResultNotification)) + assertTrue(titleFilter.matchesTextOf(withoutContent)) + + assertTrue(contentFilter.matchesTextOf(pollResultNotification)) + assertTrue(contentFilter.matchesTextOf(withoutTitle)) + + assertTrue(orFilter.matchesTextOf(pollResultNotification)) + assertTrue(orFilter.matchesTextOf(withoutContent)) + + assertTrue(andFilter.matchesTextOf(pollResultNotification)) + } + + @Test + fun Filter_matchesTextOf_incorrect() { + assertFalse(titleFilter.matchesTextOf(withoutTitle)) + assertFalse(titleFilter.matchesTextOf(emptyNotification)) + + assertFalse(contentFilter.matchesTextOf(withoutContent)) + assertFalse(contentFilter.matchesTextOf(emptyNotification)) + + assertFalse(orFilter.matchesTextOf(withoutTitle)) + assertFalse(orFilter.matchesTextOf(emptyNotification)) + + assertFalse(andFilter.matchesTextOf(withoutContent)) + assertFalse(andFilter.matchesTextOf(withoutTitle)) + assertFalse(andFilter.matchesTextOf(emptyNotification)) + } +} diff --git a/app/src/test/java/co/adityarajput/notifilter/data/models/ScheduleTest.kt b/app/src/test/java/co/adityarajput/notifilter/data/models/ScheduleTest.kt index a21a40b..aaa4b3e 100644 --- a/app/src/test/java/co/adityarajput/notifilter/data/models/ScheduleTest.kt +++ b/app/src/test/java/co/adityarajput/notifilter/data/models/ScheduleTest.kt @@ -7,25 +7,25 @@ import java.util.Calendar @Suppress("TestFunctionName") class ScheduleTest { - val mondayTenAM: Calendar = Calendar.getInstance().apply { + private val mondayTenAM: Calendar = Calendar.getInstance().apply { set(Calendar.DAY_OF_WEEK, Calendar.MONDAY) set(Calendar.HOUR_OF_DAY, 10) set(Calendar.MINUTE, 0) } - val tuesdayNinePM: Calendar = Calendar.getInstance().apply { + private val tuesdayNinePM: Calendar = Calendar.getInstance().apply { set(Calendar.DAY_OF_WEEK, Calendar.TUESDAY) set(Calendar.HOUR_OF_DAY, 21) set(Calendar.MINUTE, 0) } - val saturdayElevenPM: Calendar = Calendar.getInstance().apply { + private val saturdayElevenPM: Calendar = Calendar.getInstance().apply { set(Calendar.DAY_OF_WEEK, Calendar.SATURDAY) set(Calendar.HOUR_OF_DAY, 23) set(Calendar.MINUTE, 0) } - val atWork = Schedule( + private val atWork = Schedule( 9 * 60, (12 + 5) * 60, setOf( @@ -37,7 +37,7 @@ class ScheduleTest { ), ) - val atHome = Schedule( + private val atHome = Schedule( (12 + 5) * 60, 9 * 60, setOf( @@ -49,7 +49,7 @@ class ScheduleTest { ), ) - val weekend = Schedule(days = setOf(Calendar.SATURDAY, Calendar.SUNDAY)) + private val weekend = Schedule(days = setOf(Calendar.SATURDAY, Calendar.SUNDAY)) @Test fun Schedule_includesNow_correct() { diff --git a/app/src/test/java/co/adityarajput/notifilter/utils/RegexTest.kt b/app/src/test/java/co/adityarajput/notifilter/utils/RegexTest.kt new file mode 100644 index 0000000..08be684 --- /dev/null +++ b/app/src/test/java/co/adityarajput/notifilter/utils/RegexTest.kt @@ -0,0 +1,34 @@ +package co.adityarajput.notifilter.utils + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@Suppress("TestFunctionName") +class RegexTest { + @Test + fun Regex_containsMatchIn_normal() { + assertTrue("sample".containsMatchIn("This is a sample string.")) + assertFalse("test".containsMatchIn("This is a sample string.")) + assertTrue("\\w{6}".containsMatchIn("This is a sample string.")) + assertFalse("\\w{7}".containsMatchIn("This is a sample string.")) + } + + @Test + fun Regex_containsMatchIn_emoji() { + assertTrue("🌍".containsMatchIn("Hello, 🌍")) + assertFalse("πŸ‘‹".containsMatchIn("Hello, 🌍")) + assertFalse("🌍".containsMatchIn("Hello, world")) + + assertTrue(EMOJI_PATTERN_DISPLAY.containsMatchIn("Hello, 🌍")) + assertTrue("${EMOJI_PATTERN_DISPLAY}+".containsMatchIn("πŸ‘‹πŸŒ")) + assertFalse(EMOJI_PATTERN_DISPLAY.containsMatchIn("Hello, world")) + } + + @Test + fun Regex_generateRegex() { + assertTrue("test".generateRegex() == "^test$") + assertTrue("tom@newsletter.tomscott.com".generateRegex() == "^tom@newsletter\\.tomscott\\.com$") + assertTrue("assertTrue(0 == 0)".generateRegex() == "^assertTrue\\(0 == 0\\)$") + } +} From 1dd3e8a2bfa49b30564dbc064f8b00960fbe5ebc Mon Sep 17 00:00:00 2001 From: Aditya Rajput Date: Tue, 16 Jun 2026 01:55:46 +0530 Subject: [PATCH 5/7] Update gradle --- gradle/wrapper/gradle-wrapper.properties | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8a84887..3e172f1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,8 @@ +#Tue Jun 16 01:11:18 IST 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=72f44c9f8ebcb1af43838f45ee5c4aa9c5444898b3468ab3f4af7b6076c5bc3f -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From e198b478009b0abb67f5c784a9f47a6cc906170a Mon Sep 17 00:00:00 2001 From: Aditya Rajput Date: Tue, 16 Jun 2026 02:02:14 +0530 Subject: [PATCH 6/7] Increase size of notification history --- .../main/java/co/adityarajput/notifilter/Constants.kt | 1 + .../java/co/adityarajput/notifilter/data/Repository.kt | 10 +++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/co/adityarajput/notifilter/Constants.kt b/app/src/main/java/co/adityarajput/notifilter/Constants.kt index 036f2e7..86d09f2 100644 --- a/app/src/main/java/co/adityarajput/notifilter/Constants.kt +++ b/app/src/main/java/co/adityarajput/notifilter/Constants.kt @@ -21,5 +21,6 @@ object Constants { const val EXTRA_SBN_KEY = "extra_sbn_key" const val EXTRA_SBN_IS_CLEARABLE = "extra_sbn_is_clearable" + const val HISTORY_SIZE = 200 const val LOG_SIZE = 100 } diff --git a/app/src/main/java/co/adityarajput/notifilter/data/Repository.kt b/app/src/main/java/co/adityarajput/notifilter/data/Repository.kt index 8ec19de..90edd43 100644 --- a/app/src/main/java/co/adityarajput/notifilter/data/Repository.kt +++ b/app/src/main/java/co/adityarajput/notifilter/data/Repository.kt @@ -1,5 +1,6 @@ package co.adityarajput.notifilter.data +import co.adityarajput.notifilter.Constants import co.adityarajput.notifilter.data.models.Filter import co.adityarajput.notifilter.data.models.Notification import co.adityarajput.notifilter.utils.Logger @@ -25,9 +26,12 @@ class Repository( notificationDao.upsert(notification) val count = notificationDao.count() - if (count > 50) { - Logger.d("Repository.registerHit", "Deleting oldest ${count - 50} notification(s)") - notificationDao.listOldestN(count - 50).forEach { + if (count > Constants.HISTORY_SIZE) { + Logger.d( + "Repository", + "Deleting oldest ${count - Constants.HISTORY_SIZE} notification(s)", + ) + notificationDao.listOldestN(count - Constants.HISTORY_SIZE).forEach { Cache.intents.remove(it.data.hashCode()) notificationDao.delete(it) } From 04bcb3f76db2e8203fedb1a90326431250a80464 Mon Sep 17 00:00:00 2001 From: Aditya Rajput Date: Tue, 16 Jun 2026 02:03:01 +0530 Subject: [PATCH 7/7] Update metadata --- metadata/en-US/changelogs/33.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/metadata/en-US/changelogs/33.txt b/metadata/en-US/changelogs/33.txt index 0c36bac..c928282 100644 --- a/metadata/en-US/changelogs/33.txt +++ b/metadata/en-US/changelogs/33.txt @@ -1,2 +1,3 @@ β€’ fix: Warn if `NotificationListener` is uninitialized β€’ feat: Add "REPLACE" action +β€’ feat: Increase size of notification history