diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 075ba55..ef335c9 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 = 14 - versionName = "3.4.0" + versionCode = 15 + versionName = "4.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d516546..7e94914 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ tools:ignore="ForegroundServicesPolicy" /> + + + + + + + + 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 9fbd65f..98e96c0 100644 --- a/app/src/main/java/co/adityarajput/notifilter/data/AppContainer.kt +++ b/app/src/main/java/co/adityarajput/notifilter/data/AppContainer.kt @@ -34,12 +34,19 @@ class AppContainer(private val context: Context) { "Upcoming alarm", Action.DISMISS, RegexTarget.TITLE, + hits = 87, + ), + Filter( + App("Gmail", "com.google.android.gm"), + "Verify your identity", + Action.TAP_NOTIFICATION, + RegexTarget.CONTENT, enabled = false, ), Filter( App("Software update", "com.wssyncmldm"), "software update", - Action.TAP("Remind me"), + Action.TAP_BUTTON("Remind me"), RegexTarget.CONTENT, schedule = Schedule(days = setOf(2, 3, 4, 5, 6)), hits = 23, diff --git a/app/src/main/java/co/adityarajput/notifilter/data/Database.kt b/app/src/main/java/co/adityarajput/notifilter/data/Database.kt index 6200823..e0b705f 100644 --- a/app/src/main/java/co/adityarajput/notifilter/data/Database.kt +++ b/app/src/main/java/co/adityarajput/notifilter/data/Database.kt @@ -68,8 +68,8 @@ abstract class NotiFilterDatabase : RoomDatabase() { WHEN 'DELAY' THEN 'DELAY' WHEN 'TAP' THEN CASE - WHEN buttonPattern IS NOT NULL THEN 'TAP(buttonRegex=' || buttonPattern || ')' - ELSE 'TAP(buttonRegex=)' + WHEN buttonPattern IS NOT NULL THEN 'TAP_BUTTON(buttonRegex=' || buttonPattern || ')' + ELSE 'TAP_BUTTON(buttonRegex=)' END WHEN 'BATCH' THEN CASE diff --git a/app/src/main/java/co/adityarajput/notifilter/data/FilterDao.kt b/app/src/main/java/co/adityarajput/notifilter/data/FilterDao.kt index 31014f4..d25bd56 100644 --- a/app/src/main/java/co/adityarajput/notifilter/data/FilterDao.kt +++ b/app/src/main/java/co/adityarajput/notifilter/data/FilterDao.kt @@ -12,7 +12,7 @@ interface FilterDao { @Upsert suspend fun upsert(vararg filters: Filter) - @Query("SELECT * from filters ORDER BY app_packageName ASC") + @Query("SELECT * from filters ORDER BY id ASC") fun list(): Flow> @Query("UPDATE filters SET hits = hits + 1 WHERE id = :id") 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 14c9de3..e218e3c 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 @@ -7,13 +7,17 @@ import co.adityarajput.notifilter.R import co.adityarajput.notifilter.utils.Logger import kotlinx.serialization.Serializable +@Suppress("ClassName") @Serializable sealed class Action { @Serializable data object DISMISS : Action() @Serializable - data class TAP(val buttonRegex: String) : Action() + data object TAP_NOTIFICATION : Action() + + @Serializable + data class TAP_BUTTON(val buttonRegex: String) : Action() @Serializable data class BATCH(val batchLength: Int) : Action() @@ -24,7 +28,8 @@ sealed class Action { @Composable fun verb() = when (this) { is DISMISS -> stringResource(R.string.dismiss_short) - is TAP -> stringResource(R.string.tap_short, buttonRegex) + 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, @@ -36,7 +41,8 @@ sealed class Action { fun description() = stringResource( when (this) { is DISMISS -> R.string.dismiss_long - is TAP -> R.string.tap_long + is TAP_NOTIFICATION -> R.string.tap_notification_long + is TAP_BUTTON -> R.string.tap_button_long is BATCH -> R.string.batch_long is DELAY -> R.string.delay_long }, @@ -45,15 +51,17 @@ sealed class Action { fun isOfType(it: Action) = this::class == it::class companion object { - val entries = listOf(DISMISS, TAP(""), BATCH(3), DELAY) + val entries = listOf(DISMISS, TAP_NOTIFICATION, TAP_BUTTON(""), BATCH(3), DELAY) fun fromString(value: String) = when { value == "DISMISS" -> DISMISS + value == "TAP_NOTIFICATION" -> TAP_NOTIFICATION + value == "DELAY" -> DELAY - value.startsWith("TAP") -> TAP( - value.removePrefix("TAP(buttonRegex=").removeSuffix(")"), + value.startsWith("TAP_BUTTON") -> TAP_BUTTON( + value.removePrefix("TAP_BUTTON(buttonRegex=").removeSuffix(")"), ) value.startsWith("BATCH") -> BATCH( diff --git a/app/src/main/java/co/adityarajput/notifilter/services/Accessibility.kt b/app/src/main/java/co/adityarajput/notifilter/services/Accessibility.kt new file mode 100644 index 0000000..45a2e50 --- /dev/null +++ b/app/src/main/java/co/adityarajput/notifilter/services/Accessibility.kt @@ -0,0 +1,12 @@ +package co.adityarajput.notifilter.services + +import android.accessibilityservice.AccessibilityService +import android.annotation.SuppressLint +import android.view.accessibility.AccessibilityEvent + +@SuppressLint("AccessibilityPolicy") +class Accessibility : AccessibilityService() { + override fun onAccessibilityEvent(event: AccessibilityEvent) {} + + override fun onInterrupt() {} +} 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 8333fef..b52319d 100644 --- a/app/src/main/java/co/adityarajput/notifilter/services/NotificationListener.kt +++ b/app/src/main/java/co/adityarajput/notifilter/services/NotificationListener.kt @@ -1,12 +1,12 @@ package co.adityarajput.notifilter.services +import android.app.ActivityOptions import android.app.Notification.FLAG_GROUP_SUMMARY import android.app.NotificationChannel import android.app.NotificationManager import android.content.pm.ApplicationInfo import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE -import android.os.Build.VERSION.SDK_INT -import android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE +import android.os.Build import android.service.notification.NotificationListenerService import android.service.notification.StatusBarNotification import androidx.core.app.NotificationCompat @@ -90,7 +90,7 @@ class NotificationListener : NotificationListenerService() { .setContentTitle(getString(R.string.app_name_launcher)) .setContentText(getString(R.string.foreground_notification_content)) .setOngoing(true).setSilent(true).build(), - if (SDK_INT >= UPSIDE_DOWN_CAKE) FOREGROUND_SERVICE_TYPE_SPECIAL_USE else 0, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) FOREGROUND_SERVICE_TYPE_SPECIAL_USE else 0, ) Logger.i("NotificationListener.startForeground", "Promoted to foreground") } @@ -130,7 +130,28 @@ class NotificationListener : NotificationListenerService() { snoozeNotification(sbn.key, 5 * 60 * 60 * 1000L) } - is Action.TAP -> + is Action.TAP_NOTIFICATION -> + try { + sbn.notification.contentIntent?.run { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.BAKLAVA) send() else + send( + ActivityOptions.makeBasic() + .setPendingIntentBackgroundActivityStartMode( + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS, + ) + .toBundle(), + ) + } + } catch (e: Exception) { + Logger.e( + "NotificationListener.onNotificationPosted", + "Failed to tap notification", + e, + ) + return + } + + is Action.TAP_BUTTON -> try { sbn.notification.actions.find { Regex(filter.action.buttonRegex).containsMatchIn(it.title) 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 9312889..5ea9347 100644 --- a/app/src/main/java/co/adityarajput/notifilter/utils/Permissions.kt +++ b/app/src/main/java/co/adityarajput/notifilter/utils/Permissions.kt @@ -4,14 +4,14 @@ import android.content.Context import android.os.PowerManager import android.provider.Settings -fun Context.hasNotificationListenerPermission(): Boolean { - return Settings.Secure.getString( - this.contentResolver, - "enabled_notification_listeners" - )?.contains(this.packageName) ?: true -} +fun Context.hasNotificationListenerPermission() = + Settings.Secure.getString(contentResolver, "enabled_notification_listeners") + ?.contains(packageName) ?: false -fun Context.hasUnrestrictedBackgroundUsagePermission(): Boolean { - return (getSystemService(Context.POWER_SERVICE) as PowerManager) - .isIgnoringBatteryOptimizations(this.packageName) -} +fun Context.hasAccessibilityServicePermission() = + Settings.Secure.getString(contentResolver, "enabled_accessibility_services") + ?.contains(packageName) ?: false + +fun Context.hasUnrestrictedBackgroundUsagePermission() = + (getSystemService(Context.POWER_SERVICE) as PowerManager) + .isIgnoringBatteryOptimizations(packageName) 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 62dfc5b..37c1191 100644 --- a/app/src/main/java/co/adityarajput/notifilter/viewmodels/UpsertFilterViewModel.kt +++ b/app/src/main/java/co/adityarajput/notifilter/viewmodels/UpsertFilterViewModel.kt @@ -124,7 +124,7 @@ class UpsertFilterViewModel( } } - FormPage.ACTION -> if (values.action is Action.TAP) + 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 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 b5bae7e..b9774d6 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 @@ -107,7 +107,7 @@ private fun FilterTiles() { Filter( App("Software update", "com.wssyncmldm"), "software update", - Action.TAP("Remind me"), + Action.TAP_BUTTON("Remind me"), RegexTarget.CONTENT, schedule = Schedule(days = setOf(2, 3, 4, 5, 6)), hits = 23, 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 19e6cfa..44c3149 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 @@ -1,6 +1,10 @@ package co.adityarajput.notifilter.views.screens import android.app.TimePickerDialog +import android.content.Intent +import android.os.Handler +import android.os.Looper +import android.provider.Settings import androidx.compose.animation.* import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -36,6 +40,7 @@ import co.adityarajput.notifilter.data.models.App import co.adityarajput.notifilter.data.models.RegexTarget import co.adityarajput.notifilter.utils.filterFirst import co.adityarajput.notifilter.utils.getFirst +import co.adityarajput.notifilter.utils.hasAccessibilityServicePermission import co.adityarajput.notifilter.viewmodels.FormError import co.adityarajput.notifilter.viewmodels.FormPage import co.adityarajput.notifilter.viewmodels.Provider @@ -431,6 +436,21 @@ private fun PatternPage(viewModel: UpsertFilterViewModel) { @Composable private fun ActionPage(viewModel: UpsertFilterViewModel) { + val context = LocalContext.current + val handler = remember { Handler(Looper.getMainLooper()) } + var hasAccessibilityPermission by remember { mutableStateOf(context.hasAccessibilityServicePermission()) } + + val watcher = object : Runnable { + override fun run() { + hasAccessibilityPermission = context.hasAccessibilityServicePermission() + if (!hasAccessibilityPermission) handler.postDelayed(this, 500) + } + } + DisposableEffect(Unit) { + handler.post(watcher) + onDispose { handler.removeCallbacksAndMessages(null) } + } + Text( stringResource(R.string.action_page_title), style = MaterialTheme.typography.titleMedium, @@ -459,17 +479,42 @@ private fun ActionPage(viewModel: UpsertFilterViewModel) { fontWeight = FontWeight.Normal, ) } - AnimatedVisibility(it is Action.TAP && viewModel.state.values.action is Action.TAP) { + AnimatedVisibility( + it is Action.TAP_NOTIFICATION + && viewModel.state.values.action is Action.TAP_NOTIFICATION + && !hasAccessibilityPermission, + ) { + Column( + Modifier + .fillMaxWidth() + .padding(start = dimensionResource(R.dimen.padding_medium)), + Arrangement.spacedBy(dimensionResource(R.dimen.padding_medium)), + ) { + ErrorText(R.string.accessibility_service_description) + Button( + { context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) }, + Modifier.align(Alignment.CenterHorizontally), + colors = ButtonDefaults.buttonColors(contentColor = MaterialTheme.colorScheme.onPrimaryContainer), + ) { + Text( + stringResource(R.string.enable_service), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Normal, + ) + } + } + } + AnimatedVisibility(it is Action.TAP_BUTTON && viewModel.state.values.action is Action.TAP_BUTTON) { Column( Modifier.fillMaxWidth(), Arrangement.spacedBy(dimensionResource(R.dimen.padding_medium)), ) { OutlinedTextField( - (viewModel.state.values.action as? Action.TAP)?.buttonRegex ?: "", + (viewModel.state.values.action as? Action.TAP_BUTTON)?.buttonRegex ?: "", { value -> viewModel.updateForm( viewModel.state.page, - viewModel.state.values.copy(action = Action.TAP(value)), + viewModel.state.values.copy(action = Action.TAP_BUTTON(value)), ) }, Modifier.fillMaxWidth(), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4b6ebc6..906ce07 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,7 +1,7 @@ NotiFilter NotiFilter - 3.4.0 + 4.0.0 No filters added.\nTap + to get started. "on weekdays " @@ -9,7 +9,8 @@ "on %1$s " from %1$s to %2$s dismiss notification - tap \"%1$s\" + tap notification + tap \"%1$s\" batch every %1$s delay notifications history disabled @@ -59,7 +60,10 @@ Regex pattern doesn\'t match selected notification\'s content Choose the action to take Dismiss the notification - Tap a button + Tap the notification + NotiFilter requires an Accessibility Service to tap notifications + Enable service + Tap a button Button pattern Batch notifications Every %1$s @@ -100,7 +104,7 @@ About " listens to all device notifications and quietly manages those that match your filters." GPLv3
Source code available on GitHub
Visit the project wiki for more]]>
-
  • BIND_NOTIFICATION_LISTENER_SERVICE: to listen and react to notifications
  • QUERY_ALL_PACKAGES: to search through installed apps while adding filters
  • REQUEST_IGNORE_BATTERY_OPTIMIZATIONS: to request exemption from background usage restrictions
  • FOREGROUND_SERVICE_SPECIAL_USE: to optionally promote to the core service to foreground, avoiding aggressive OS optimizations
]]>
+
  • BIND_NOTIFICATION_LISTENER_SERVICE: to listen and react to notifications
  • QUERY_ALL_PACKAGES: to search through installed apps while adding filters
  • BIND_ACCESSIBILITY_SERVICE: to accurately simulate taps on notifications
  • REQUEST_IGNORE_BATTERY_OPTIMIZATIONS: to request exemption from background usage restrictions
  • FOREGROUND_SERVICE_SPECIAL_USE: to optionally promote to the core service to foreground, avoiding aggressive OS optimizations
]]>
BURG3R5]]> App logo diff --git a/app/src/main/res/xml/accessibility_service_config.xml b/app/src/main/res/xml/accessibility_service_config.xml new file mode 100644 index 0000000..d1a0339 --- /dev/null +++ b/app/src/main/res/xml/accessibility_service_config.xml @@ -0,0 +1,10 @@ + + diff --git a/metadata/en-US/changelogs/15.txt b/metadata/en-US/changelogs/15.txt new file mode 100644 index 0000000..a98a28a --- /dev/null +++ b/metadata/en-US/changelogs/15.txt @@ -0,0 +1,8 @@ +• feat: Display app names instead of package strings +• feat: Store INFO and ERROR logs +• feat: Add "TAP_NOTIFICATION" action (requires an Accessibility Service) +• fix: Sort filters by id + +Users can now copy recent info and error logs from the settings screen, making it easier to diagnose issues. + +IMPORTANT: The database schema has changed significantly. Updating the app will migrate existing filters & notifications automatically, but v3 exports cannot be imported into v4. Please re-export your filters after updating. diff --git a/metadata/en-US/images/phoneScreenshots/1.png b/metadata/en-US/images/phoneScreenshots/1.png index e97d22d..28743ed 100644 Binary files a/metadata/en-US/images/phoneScreenshots/1.png and b/metadata/en-US/images/phoneScreenshots/1.png differ diff --git a/metadata/en-US/images/phoneScreenshots/2.png b/metadata/en-US/images/phoneScreenshots/2.png index 8b6646f..6772bdd 100644 Binary files a/metadata/en-US/images/phoneScreenshots/2.png and b/metadata/en-US/images/phoneScreenshots/2.png differ