diff --git a/.github/workflows/build-nightly.yml b/.github/workflows/build-nightly.yml
index 7eb0217..7193122 100644
--- a/.github/workflows/build-nightly.yml
+++ b/.github/workflows/build-nightly.yml
@@ -20,6 +20,7 @@ jobs:
with:
java-version: 17
distribution: "temurin"
+ cache: "gradle"
- name: Set version
run: |
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a8c1e8b..c60a7b2 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -18,6 +18,7 @@ jobs:
with:
java-version: 17
distribution: "temurin"
+ cache: "gradle"
- name: Build APK
run: |
diff --git a/README.md b/README.md
index f1ac316..84407de 100644
--- a/README.md
+++ b/README.md
@@ -19,9 +19,11 @@ NotiFilter listens to all device notifications and quietly manages those that ma
each app 🎯
- **Actions** - Choose what to do with the filtered notifications ⚙
1. Dismiss it 🚫
- 2. Tap a button ✅
- 3. Delay it ⏳
- 4. Collect into batches 📦
+ 2. Tap it ✅
+ 3. Tap a button 🔽️
+ 4. Delay it ⏳
+ 5. Collect into batches 📦
+ 6. Debounce it ❄
- **Schedule** - Choose when filters run (e.g. only during work hours) ⏰
- **History** - Recently dismissed notifications are stored (locally), just in case 😉
- **Export/Import** - Backup or transfer your filters as JSON files 📂
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 11a7900..d117f67 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -18,8 +18,8 @@ android {
applicationId = "co.adityarajput.notifilter"
minSdk = 29
targetSdk = 36
- versionCode = 18
- versionName = "4.1.2"
+ versionCode = 19
+ versionName = "4.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
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 98e96c0..21fa68f 100644
--- a/app/src/main/java/co/adityarajput/notifilter/data/AppContainer.kt
+++ b/app/src/main/java/co/adityarajput/notifilter/data/AppContainer.kt
@@ -67,6 +67,13 @@ class AppContainer(private val context: Context) {
schedule = Schedule(start = 9 * 60, end = 17 * 60),
hits = 15,
),
+ Filter(
+ App("WhatsApp", "com.whatsapp"),
+ "Roommate",
+ Action.DEBOUNCE(2),
+ 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 e3ec27d..1863599 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
@@ -25,16 +25,25 @@ sealed class Action {
@Serializable
data object DELAY : Action()
+ @Serializable
+ data class DEBOUNCE(val cooldownLength: Int) : Action()
+
@Composable
fun verb() = when (this) {
is DISMISS -> stringResource(R.string.dismiss_short)
is TAP_NOTIFICATION -> stringResource(R.string.tap_notification_short)
is TAP_BUTTON -> stringResource(R.string.tap_button_short, buttonRegex)
is DELAY -> stringResource(R.string.delay_short)
+
is BATCH -> stringResource(
R.string.batch_short,
pluralStringResource(R.plurals.hour, batchLength, batchLength),
)
+
+ is DEBOUNCE -> stringResource(
+ R.string.debounce_short,
+ pluralStringResource(R.plurals.minute, cooldownLength, cooldownLength),
+ )
}
@Composable
@@ -45,13 +54,19 @@ sealed class Action {
is TAP_BUTTON -> R.string.tap_button_long
is BATCH -> R.string.batch_long
is DELAY -> R.string.delay_long
+ is DEBOUNCE -> R.string.debounce_long
},
)
fun isOfType(it: Action) = this::class == it::class
companion object {
- val entries by lazy { listOf(DISMISS, TAP_NOTIFICATION, TAP_BUTTON(""), BATCH(3), DELAY) }
+ val entries by lazy {
+ listOf(
+ DISMISS, TAP_NOTIFICATION, TAP_BUTTON(""), BATCH(3),
+ DELAY, DEBOUNCE(2),
+ )
+ }
fun fromString(value: String) = when {
value == "DISMISS" -> DISMISS
@@ -68,6 +83,10 @@ sealed class Action {
value.removePrefix("BATCH(batchLength=").removeSuffix(")").toInt(),
)
+ value.startsWith("DEBOUNCE") -> DEBOUNCE(
+ value.removePrefix("DEBOUNCE(cooldownLength=").removeSuffix(")").toInt(),
+ )
+
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 9f0a82f..b58a5e7 100644
--- a/app/src/main/java/co/adityarajput/notifilter/services/NotificationListener.kt
+++ b/app/src/main/java/co/adityarajput/notifilter/services/NotificationListener.kt
@@ -235,6 +235,18 @@ class NotificationListener : NotificationListenerService() {
snoozeNotification(sbn.key, delay)
}
+
+ is Action.DEBOUNCE ->
+ notifications
+ .filter { it.origin == filter.app.name }
+ .maxByOrNull { it.timestamp }
+ ?.let { previousNotification ->
+ val cooldownLength = filter.action.cooldownLength * 60 * 1000L
+ if (System.currentTimeMillis() - previousNotification.timestamp < cooldownLength + 100) {
+ Logger.i("NotificationListener", "Applying cooldown")
+ snoozeNotification(sbn.key, cooldownLength)
+ }
+ }
}
if (!filter.historyEnabled) {
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 624ea48..deed716 100644
--- a/app/src/main/java/co/adityarajput/notifilter/viewmodels/UpsertFilterViewModel.kt
+++ b/app/src/main/java/co/adityarajput/notifilter/viewmodels/UpsertFilterViewModel.kt
@@ -124,15 +124,22 @@ class UpsertFilterViewModel(
}
}
- FormPage.ACTION -> if (values.action is Action.TAP_BUTTON)
- try {
- if (values.action.buttonRegex.isBlank()) return FormError.BLANK_FIELDS
- Regex(values.action.buttonRegex).pattern == values.action.buttonRegex
- } catch (_: Exception) {
- Logger.d("FiltersViewModel.getError", "Button pattern regex invalid")
- return FormError.INVALID_BUTTON_REGEX
+ FormPage.ACTION -> {
+ if (values.action is Action.TAP_BUTTON) {
+ try {
+ if (values.action.buttonRegex.isBlank()) return FormError.BLANK_FIELDS
+ Regex(values.action.buttonRegex).pattern == values.action.buttonRegex
+ } catch (_: Exception) {
+ Logger.d("FiltersViewModel.getError", "Button pattern regex invalid")
+ return FormError.INVALID_BUTTON_REGEX
+ }
}
+ if (values.action is Action.DEBOUNCE && values.app == Any) {
+ return FormError.CANT_DEBOUNCE_ANY
+ }
+ }
+
FormPage.SCHEDULE -> {
if (!values.schedule.isRangeValid()) return FormError.INVALID_TIME_RANGE
if (values.schedule.days.isEmpty()) return FormError.BLANK_FIELDS
@@ -195,7 +202,13 @@ enum class FormPage {
fun previous() = entries[ordinal - 1]
}
-enum class FormError { BLANK_FIELDS, INVALID_NOTIFICATION_REGEX, INVALID_BUTTON_REGEX, INVALID_TIME_RANGE }
+enum class FormError {
+ BLANK_FIELDS,
+ INVALID_NOTIFICATION_REGEX,
+ INVALID_BUTTON_REGEX,
+ CANT_DEBOUNCE_ANY,
+ INVALID_TIME_RANGE,
+}
enum class FormWarning(val description: Int) {
REGEX_DOESNT_MATCH_TITLE(R.string.pattern_doesnt_match_title),
diff --git a/app/src/main/java/co/adityarajput/notifilter/views/components/Tile.kt b/app/src/main/java/co/adityarajput/notifilter/views/components/Tile.kt
index b9774d6..ef88e8e 100644
--- a/app/src/main/java/co/adityarajput/notifilter/views/components/Tile.kt
+++ b/app/src/main/java/co/adityarajput/notifilter/views/components/Tile.kt
@@ -128,6 +128,13 @@ private fun FilterTiles() {
schedule = Schedule(start = 9 * 60, end = 17 * 60),
hits = 15,
),
+ Filter(
+ App("WhatsApp", "com.whatsapp"),
+ "Roommate",
+ Action.DEBOUNCE(2),
+ RegexTarget.TITLE,
+ historyEnabled = false,
+ ),
)
Theme {
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 ee961bc..de33861 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
@@ -155,7 +155,10 @@ fun UpsertFilterScreen(
) {
Text(
if (viewModel.state.page.isFirstPage()) stringResource(R.string.skip)
- else if (viewModel.state.page.isFinalPage()) stringResource(R.string.add)
+ else if (viewModel.state.page.isFinalPage()) {
+ if (viewModel.state.values.filterId == 0) stringResource(R.string.add)
+ else stringResource(R.string.save)
+ }
else stringResource(R.string.next),
style = MaterialTheme.typography.bodyLarge,
)
@@ -595,8 +598,9 @@ private fun ActionPage(viewModel: UpsertFilterViewModel) {
Modifier.fillMaxWidth(),
Arrangement.spacedBy(dimensionResource(R.dimen.padding_medium)),
) {
+ val action = viewModel.state.values.action as? Action.BATCH
Slider(
- (viewModel.state.values.action as? Action.BATCH)?.batchLength?.toFloat() ?: 3F,
+ action?.batchLength?.toFloat() ?: 3F,
{ value ->
viewModel.updateForm(
viewModel.state.page,
@@ -612,12 +616,49 @@ private fun ActionPage(viewModel: UpsertFilterViewModel) {
R.string.batch_frequency,
pluralStringResource(
R.plurals.hour,
- (viewModel.state.values.action as? Action.BATCH)?.batchLength ?: 3,
- (viewModel.state.values.action as? Action.BATCH)?.batchLength ?: 3,
+ action?.batchLength ?: 3,
+ action?.batchLength ?: 3,
+ ),
+ ),
+ Modifier.padding(start = dimensionResource(R.dimen.padding_medium)),
+ )
+ }
+ }
+ AnimatedVisibility(it is Action.DEBOUNCE && viewModel.state.values.action is Action.DEBOUNCE) {
+ Column(
+ Modifier.fillMaxWidth(),
+ Arrangement.spacedBy(dimensionResource(R.dimen.padding_medium)),
+ ) {
+ val action = viewModel.state.values.action as? Action.DEBOUNCE
+ Slider(
+ action?.cooldownLength?.toFloat() ?: 2F,
+ { value ->
+ viewModel.updateForm(
+ viewModel.state.page,
+ viewModel.state.values.copy(action = Action.DEBOUNCE(value.toInt())),
+ )
+ },
+ Modifier.fillMaxWidth(),
+ valueRange = 1F..15F,
+ steps = 13,
+ )
+ Text(
+ stringResource(
+ R.string.cooldown,
+ pluralStringResource(
+ R.plurals.minute,
+ action?.cooldownLength ?: 2,
+ action?.cooldownLength ?: 2,
),
),
Modifier.padding(start = dimensionResource(R.dimen.padding_medium)),
)
+ Text(
+ stringResource(R.string.explain_debounce),
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Normal,
+ )
+ if (viewModel.state.error == FormError.CANT_DEBOUNCE_ANY) ErrorText(R.string.cant_debounce_any)
}
}
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 635072b..d38e071 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,7 +1,7 @@
NotiFilter listens to all device notifications and quietly manages those that match your filters. Features:
NotiFilter listens to all device notifications and quietly manages those that match your filters.
Features: