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/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/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/Constants.kt b/app/src/main/java/co/adityarajput/notifilter/Constants.kt
index 3c0cbe9..86d09f2 100644
--- a/app/src/main/java/co/adityarajput/notifilter/Constants.kt
+++ b/app/src/main/java/co/adityarajput/notifilter/Constants.kt
@@ -14,10 +14,13 @@ 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"
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/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/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)
}
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 cd5c4b5..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
@@ -27,6 +26,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 +40,8 @@ class NotificationListener : NotificationListenerService() {
_instance = value
}
+ val isServiceInitialized get() = _instance != null
+
const val NOTIFICATION_SOUND_DURATION = 3000L
fun createAlertNotificationChannel() {
@@ -55,12 +58,45 @@ class NotificationListener : NotificationListenerService() {
}
}
- fun updateForegroundStatus(runInForeground: Boolean) {
+ 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(
+ "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 +268,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 +285,7 @@ class NotificationListener : NotificationListenerService() {
.setAutoCancel(true).build(),
)
serviceScope.launch {
- delay(2000L)
+ delay(2.seconds)
notificationManager.cancel(Constants.ALERT_NOTIFICATION_ID)
}
}
@@ -285,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 {
@@ -317,14 +378,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 +409,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/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 NotiFilter listens to all device notifications and quietly manages those that match your filters. Features:>(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..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,8 +23,10 @@ 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
import co.adityarajput.notifilter.utils.getFirst
import co.adityarajput.notifilter.utils.getToggleString
import co.adityarajput.notifilter.utils.isGranted
@@ -36,7 +38,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 +61,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 +101,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(),
@@ -143,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/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/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 @@
+
NotiFilter listens to all device notifications and quietly manages those that match your filters.
Features: