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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 📱
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/co/adityarajput/notifilter/AlarmReceiver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/co/adityarajput/notifilter/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 7 additions & 3 deletions app/src/main/java/co/adityarajput/notifilter/data/Repository.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
}
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/co/adityarajput/notifilter/data/models/Action.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
},
)

Expand All @@ -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}"),
)
}

Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,17 +18,16 @@ 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
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 {
Expand All @@ -39,6 +40,8 @@ class NotificationListener : NotificationListenerService() {
_instance = value
}

val isServiceInitialized get() = _instance != null

const val NOTIFICATION_SOUND_DURATION = 3000L

fun createAlertNotificationChannel() {
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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)
}
Expand All @@ -249,7 +285,7 @@ class NotificationListener : NotificationListenerService() {
.setAutoCancel(true).build(),
)
serviceScope.launch {
delay(2000L)
delay(2.seconds)
notificationManager.cancel(Constants.ALERT_NOTIFICATION_ID)
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ fun permissionsRequired(filters: List<Filter>) = buildList {
if (filters.any { it.action is Action.TAP_NOTIFICATION })
add(Permission.ACCESSIBILITY_SERVICE)

if (filters.any { it.action is Action.ALERT })
if (filters.any { it.action is Action.ALERT || it.action is Action.REPLACE })
add(Permission.POST_NOTIFICATIONS)

if (filters.any { it.action is Action.DISTURB })
Expand Down
11 changes: 10 additions & 1 deletion app/src/main/java/co/adityarajput/notifilter/utils/Regex.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package co.adityarajput.notifilter.utils

import co.adityarajput.notifilter.data.models.App
import co.adityarajput.notifilter.data.models.Notification
import net.fellbaum.jemoji.EmojiManager

private const val EMOJI_PATTERN_DISPLAY = "\\p{Emoji}"
const val EMOJI_PATTERN_DISPLAY = "\\p{Emoji}"
private const val EMOJI_PATTERN_INTERNAL = "\uE010"

fun String.containsMatchIn(input: String): Boolean {
Expand All @@ -17,6 +19,13 @@ fun String.containsMatchIn(input: String): Boolean {
return Regex(pattern).containsMatchIn(text)
}

fun String.replaceWithNotificationData(notification: Notification, allPackages: List<App>) =
this.replace($$"${app}", notification.appNameFrom(allPackages))
.replace($$"${title}", notification.title)
.replace($$"${content}", notification.content)
.replace($$"${postTime}", notification.timestamp.toReadableTime())
.replace($$"${package}", notification.origin)

fun String.isValidRegex() = try {
this
.replace(EMOJI_PATTERN_DISPLAY, EMOJI_PATTERN_INTERNAL)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.milliseconds

class UpsertFilterViewModel(
filter: Filter?,
Expand Down Expand Up @@ -72,10 +73,11 @@ class UpsertFilterViewModel(
var allPackages by mutableStateOf<List<App>>(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 {
Expand All @@ -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)
}
}
}
Expand Down
Loading
Loading