Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
with:
java-version: 17
distribution: "temurin"
cache: "gradle"

- name: Set version
run: |
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jobs:
with:
java-version: 17
distribution: "temurin"
cache: "gradle"

- name: Build APK
run: |
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 📂
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
Expand All @@ -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)
}
}
}
Expand Down
12 changes: 11 additions & 1 deletion app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<resources>
<string name="app_name">NotiFilter</string>
<string name="app_name_launcher">NotiFilter</string>
<string name="app_version">4.1.2</string>
<string name="app_version">4.2.0</string>

<string name="no_filters">No filters added.\nTap + to get started.</string>
<string name="on_weekdays">"on weekdays "</string>
Expand All @@ -13,6 +13,7 @@
<string name="tap_button_short">tap \"%1$s\"</string>
<string name="batch_short">batch every %1$s</string>
<string name="delay_short">delay notifications</string>
<string name="debounce_short">debounce for %1$s</string>
<string name="any_app">Any app</string>
<string name="history_disabled">history disabled</string>
<string name="filter_disabled">filter disabled</string>
Expand Down Expand Up @@ -72,6 +73,10 @@
<string name="batch_long">Batch notifications</string>
<string name="batch_frequency">Every %1$s</string>
<string name="delay_long">Delay notifications</string>
<string name="debounce_long">Debounce notifications</string>
<string name="cooldown">Cooldown for %1$s</string>
<string name="explain_debounce">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.</string>
<string name="cant_debounce_any">Debounce action can\'t be used when targeting all apps</string>
<string name="schedule_page_title">Configure a schedule</string>
<string name="run_from">"Run from "</string>
<string name="to">" to "</string>
Expand All @@ -84,6 +89,7 @@
<string name="skip">Skip</string>
<string name="next">Next</string>
<string name="add">Add</string>
<string name="save">Save</string>

<string name="toggle_history">%1$s history</string>
<string name="disable_history_confirmation">Disable history for this filter? Dismissed notifications will not be saved.</string>
Expand Down Expand Up @@ -141,6 +147,10 @@
<item quantity="one">%1$d hour</item>
<item quantity="other">%1$d hours</item>
</plurals>
<plurals name="minute">
<item quantity="one">%1$d minute</item>
<item quantity="other">%1$d minutes</item>
</plurals>
<plurals name="hit">
<item quantity="one">%1$d hit</item>
<item quantity="other">%1$d hits</item>
Expand Down
1 change: 1 addition & 0 deletions metadata/en-US/changelogs/19.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
feat: Add "DEBOUNCE" action (ensures a minimum delay between consecutive notifications)
2 changes: 1 addition & 1 deletion metadata/en-US/full_description.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<p>NotiFilter listens to all device notifications and quietly manages those that match your filters.</p><p><strong>Features:</strong><ul><li>Filters: Use regex to precisely target annoying notifications from each app.</li><li>Actions: Choose what to do with the filtered notifications - dismiss, tap, delay, or batch.</li><li>Schedule: Choose when filters run (e.g. only during work hours)</li><li>History: Recently dismissed notifications are stored (locally), just in case.</li><li>Export/Import: Backup or transfer your filters as JSON files.</li><li>Private: Fully offline; your data never leaves your device.</li><li>Lightweight: Runs in the background with minimal battery and memory usage.</li></ul></p>
<p>NotiFilter listens to all device notifications and quietly manages those that match your filters.</p><p><strong>Features:</strong><ul><li>Filters: Use regex to precisely target annoying notifications from each app.</li><li>Actions: Choose what to do with the filtered notifications - dismiss, tap, delay, batch, or debounce.</li><li>Schedule: Choose when filters run (e.g. only during work hours)</li><li>History: Recently dismissed notifications are stored (locally), just in case.</li><li>Export/Import: Backup or transfer your filters as JSON files.</li><li>Private: Fully offline; your data never leaves your device.</li><li>Lightweight: Runs in the background with minimal battery and memory usage.</li></ul></p>