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