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 NotiFilter - 4.1.2 + 4.2.0 No filters added.\nTap + to get started. "on weekdays " @@ -13,6 +13,7 @@ tap \"%1$s\" batch every %1$s delay notifications + debounce for %1$s Any app history disabled filter disabled @@ -72,6 +73,10 @@ Batch notifications Every %1$s Delay notifications + Debounce notifications + Cooldown for %1$s + If notifications are received in quick succession, they will be snoozed for the cooldown duration.\n\nNote: For conversation-type notifications, this may lead to this original notification being snoozed too. + Debounce action can\'t be used when targeting all apps Configure a schedule "Run from " " to " @@ -84,6 +89,7 @@ Skip Next Add + Save %1$s history Disable history for this filter? Dismissed notifications will not be saved. @@ -141,6 +147,10 @@ %1$d hour %1$d hours + + %1$d minute + %1$d minutes + %1$d hit %1$d hits diff --git a/metadata/en-US/changelogs/19.txt b/metadata/en-US/changelogs/19.txt new file mode 100644 index 0000000..06e7b72 --- /dev/null +++ b/metadata/en-US/changelogs/19.txt @@ -0,0 +1 @@ +feat: Add "DEBOUNCE" action (ensures a minimum delay between consecutive notifications) diff --git a/metadata/en-US/full_description.txt b/metadata/en-US/full_description.txt index 2ff051e..58d91b0 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:

+

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

Features: