Skip to content

Commit

Permalink
persist notifications for duplicate filtering, handle group notificat…
Browse files Browse the repository at this point in the history
…ions
  • Loading branch information
crc-32 committed Jul 6, 2024
1 parent 9b3bbad commit 538bb7b
Show file tree
Hide file tree
Showing 11 changed files with 216 additions and 33 deletions.
1 change: 1 addition & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ dependencies {
implementation "androidx.core:core-ktx:$androidxCoreVersion"
implementation "androidx.work:work-runtime-ktx:$workManagerVersion"
implementation "com.squareup.okio:okio:$okioVersion"
implementation libs.androidx.room.runtime

implementation libs.dagger
implementation project(':pebble_bt_transport')
Expand Down
4 changes: 2 additions & 2 deletions android/app/src/main/kotlin/io/rebble/cobble/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,11 @@ class MainActivity : FlutterActivity() {
startService(inCallServiceIntent)


if (context.hasNotificationAccessPermission()) {
/*if (context.hasNotificationAccessPermission()) {
NotificationListenerService.requestRebind(
NotificationListener.getComponentName(context)
)
}
}*/
}

override fun onNewIntent(intent: Intent) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,6 @@ class ConnectionUiFlutterBridge @Inject constructor(
}
}

if (activity.context.hasNotificationAccessPermission()) {
Timber.d("Requesting rebind of notification listener")
NotificationListenerService.requestRebind(
NotificationListener.getComponentName(activity.context)
)
}

openConnectionToWatch(address)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package io.rebble.cobble.data

import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationCompat

data class NotificationGroup(
val groupKey: String,
val summary: StatusBarNotification?,
val children: List<StatusBarNotification>
)

fun List<StatusBarNotification>.toNotificationGroup(): NotificationGroup {
val mutable = toMutableList()
val summary = mutable.firstOrNull { NotificationCompat.isGroupSummary(it.notification) }
summary?.let { mutable.remove(it) }

val groupKey = summary?.groupKey ?: mutable.first().groupKey
?: throw IllegalArgumentException("Notification is not part of a group")

return NotificationGroup(
groupKey,
summary,
mutable
)
}
6 changes: 6 additions & 0 deletions android/app/src/main/kotlin/io/rebble/cobble/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import dagger.Binds
import dagger.Module
import dagger.Provides
import io.rebble.cobble.errors.GlobalExceptionHandler
import io.rebble.cobble.shared.database.AppDatabase
import io.rebble.cobble.shared.database.dao.PersistedNotificationDao
import io.rebble.cobble.shared.datastore.KMPPrefs
import io.rebble.cobble.shared.datastore.createDataStore
import io.rebble.cobble.shared.domain.calendar.CalendarSync
Expand Down Expand Up @@ -44,5 +46,9 @@ abstract class AppModule {
fun provideKMPPrefs(context: Context): KMPPrefs {
return KMPPrefs()
}
@Provides
fun providePersistedNotificationDao(context: Context): PersistedNotificationDao {
return AppDatabase.instance().persistedNotificationDao()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package io.rebble.cobble.notifications

import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationCompat

val StatusBarNotification.shouldDisplayGroupSummary: Boolean
get() {
// Check if the group is from a package that should not display group summaries
return when (packageName) {
"com.google.android.gm" -> false
else -> true
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import io.rebble.cobble.bluetooth.ConnectionLooper
import io.rebble.cobble.bridges.background.NotificationsFlutterBridge
import io.rebble.cobble.data.NotificationAction
import io.rebble.cobble.data.NotificationMessage
import io.rebble.cobble.data.toNotificationGroup
import io.rebble.cobble.datasources.FlutterPreferences
import io.rebble.cobble.shared.datastore.KMPPrefs
import io.rebble.cobble.shared.domain.state.ConnectionState
Expand Down Expand Up @@ -79,6 +80,10 @@ class NotificationListener : NotificationListenerService() {
Timber.d("NotificationListener disconnected")
}

private fun getNotificationGroup(sbn: StatusBarNotification): List<StatusBarNotification> {
return this.activeNotifications.filter { it.groupKey == sbn.groupKey }
}

@OptIn(ExperimentalStdlibApi::class)
override fun onNotificationPosted(sbn: StatusBarNotification?) {
if (isListening && areNotificationsEnabled) {
Expand All @@ -99,17 +104,22 @@ class NotificationListener : NotificationListenerService() {
}
if (NotificationCompat.getLocalOnly(sbn.notification)) return // ignore local notifications TODO: respect user preference
if (sbn.notification.flags and Notification.FLAG_ONGOING_EVENT != 0) return // ignore ongoing notifications
//if (sbn.notification.group != null && !NotificationCompat.isGroupSummary(sbn.notification)) return
if (mutedPackages.contains(sbn.packageName)) return // ignore muted packages

coroutineScope.launch {
if (prefs.sensitiveDataLoggingEnabled.firstOrNull() == true) {
Timber.d("Notification posted: ${sbn.packageName}")
Timber.d("This listener instance is: ${this.hashCode()}")
Timber.d("Notification: ${sbn.notification}\n${sbn.notification.extras}")
}
}

notificationProcessor.processNotification(sbn)
if (sbn.groupKey != null) {
val group = getNotificationGroup(sbn)
notificationProcessor.processGroupNotification(group.toNotificationGroup())
} else {
notificationProcessor.processNotification(sbn)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,58 +2,144 @@ package io.rebble.cobble.notifications

import android.app.Notification
import android.service.notification.StatusBarNotification
import android.text.SpannableString
import androidx.core.app.NotificationCompat
import io.rebble.cobble.bridges.background.NotificationsFlutterBridge
import io.rebble.cobble.data.NotificationAction
import io.rebble.cobble.data.NotificationGroup
import io.rebble.cobble.data.NotificationMessage
import io.rebble.cobble.shared.database.dao.PersistedNotificationDao
import io.rebble.cobble.shared.database.entity.PersistedNotification
import io.rebble.libpebblecommon.packets.blobdb.BlobResponse
import io.rebble.libpebblecommon.packets.blobdb.TimelineItem
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.actor
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.hours

@Singleton
class NotificationProcessor @Inject constructor(
exceptionHandler: CoroutineExceptionHandler,
private val notificationBridge: NotificationsFlutterBridge
private val notificationBridge: NotificationsFlutterBridge,
private val persistedNotifDao: PersistedNotificationDao
) {
val coroutineScope = CoroutineScope(
SupervisorJob() + exceptionHandler
)

private val activeGroups = mutableMapOf<String, NotificationGroup>()

private data class DisplayActorArgs(
val packageId: String,
val notifId: Long, val tagId: String?, val title: String,
val text: String, val category: String, val color: Int, val messages: List<NotificationMessage>,
val actions: List<NotificationAction>,
val sbn: StatusBarNotification
)

private val displayActor = coroutineScope.actor<DisplayActorArgs>(capacity = Channel.UNLIMITED) {
for (notification in channel) {
val (packageId, notifId, tagId, title, text, category, color, messages, actions, sbn) = notification

if (persistedNotifDao.getDuplicates(sbn.key, sbn.packageName, title, text).isNotEmpty()) {
Timber.d("Ignoring duplicate notification ${sbn.key}")
continue
}
persistedNotifDao.insert(PersistedNotification(
sbn.key, sbn.packageName, sbn.postTime, title, text, sbn.groupKey
))

var result = withContext(Dispatchers.Main) {
notificationBridge.handleNotification(
packageId, notifId, tagId, title, text, category, color, messages, actions
)
} ?: continue

while (result.second == BlobResponse.BlobStatus.TryLater) {
Timber.w("BlobDB is busy, retrying in 1s")
delay(1000)
result = withContext(Dispatchers.Main) {
notificationBridge.handleNotification(
packageId, notifId, tagId, title, text, category, color, messages, actions
)
} ?: continue
}
Timber.d(result.second.toString())
persistedNotifDao.deleteOlderThan(System.currentTimeMillis() - 1.hours.inWholeMilliseconds)
withContext(Dispatchers.Main) {
notificationBridge.activeNotifs[result.first.itemId.get()] = sbn
}
delay(10)
}
}

fun processGroupNotification(notificationGroup: NotificationGroup) {
val summary = notificationGroup.summary
if (summary == null) {
getNewGroupItems(notificationGroup).forEach(::processNotification)
} else {
val newItems = getNewGroupItems(notificationGroup).asReversed() // Reverse so latest notification is top
if (!newItems.any { !NotificationCompat.isGroupSummary(it.notification) && it.notification.equals(summary) }) {
newItems.forEach(::processNotification)
} else {
if (summary.shouldDisplayGroupSummary) {
processNotification(summary)
}
newItems.forEach(::processNotification)
}
}
activeGroups[notificationGroup.groupKey] = notificationGroup
}

private fun getNewGroupItems(notificationGroup: NotificationGroup): List<StatusBarNotification> {
val newItems = notificationGroup.children.filter { notif ->
// Notification is functionally equal to an active notification
notificationBridge.activeNotifs.values.find {
it.groupKey == notificationGroup.groupKey &&
it.packageName == notif.packageName &&
it.id == notif.id &&
NotificationCompat.getShowWhen(it.notification) == NotificationCompat.getShowWhen(notif.notification) &&
it.notification.extras.keySet() == notif.notification.extras.keySet()
} == null
}
return newItems
}

fun processNotification(sbn: StatusBarNotification) {
Timber.v("Processing notification ${sbn.key}")
val notification = sbn.notification
val tagId = notification.channelId
val title = notification.extras.getString(Notification.EXTRA_TITLE)
?: notification.extras.getString(Notification.EXTRA_CONVERSATION_TITLE)
val title = notification.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()
?: notification.extras.getCharSequence(Notification.EXTRA_CONVERSATION_TITLE)?.toString()
?: ""
val text = extractBody(notification)

val messages: List<NotificationMessage>? = extractMessages(notification)

val actions = notification.actions?.map {
NotificationAction(it.title.toString(), !it.remoteInputs.isNullOrEmpty())
} ?: listOf()

coroutineScope.launch(Dispatchers.Main) {
var result: Pair<TimelineItem, BlobResponse.BlobStatus>? = notificationBridge.handleNotification(sbn.packageName, sbn.id.toLong(), tagId, title, text, notification.category
?: "", notification.color, messages ?: listOf(), actions)
?: return@launch

while (result!!.second == BlobResponse.BlobStatus.TryLater) {
delay(1000)
result = notificationBridge.handleNotification(sbn.packageName, sbn.id.toLong(), tagId, title, text, notification.category
?: "", notification.color, messages ?: listOf(), actions)
?: return@launch
}
Timber.d(result.second.toString())
notificationBridge.activeNotifs[result.first.itemId.get()] = sbn
}
displayActor.trySend(DisplayActorArgs(
sbn.packageName,
sbn.id.toLong(),
tagId,
title,
text,
notification.category ?: "",
notification.color,
messages ?: listOf(),
actions,
sbn
))
}

private fun extractBody(notification: Notification): String {
var text = notification.extras.getString(Notification.EXTRA_BIG_TEXT)
?: notification.extras.getString(Notification.EXTRA_TEXT)
var text = notification.extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString()
?: notification.extras.getCharSequence(Notification.EXTRA_TEXT)?.toString()
?: ""

// If the text is empty, try to get it from the text lines
Expand All @@ -65,7 +151,7 @@ class NotificationProcessor @Inject constructor(
}
// If the text is still empty, try to get it from the info text
if (text.isBlank()) {
val infoText = notification.extras.getString(Notification.EXTRA_INFO_TEXT)
val infoText = notification.extras.getCharSequence(Notification.EXTRA_INFO_TEXT)?.toString()
infoText?.let {
text = it
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package io.rebble.cobble.shared.database

import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import io.rebble.cobble.shared.database.dao.CalendarDao
import io.rebble.cobble.shared.database.dao.PersistedNotificationDao
import io.rebble.cobble.shared.database.dao.TimelinePinDao
import io.rebble.cobble.shared.database.entity.Calendar
import io.rebble.cobble.shared.database.entity.PersistedNotification
import io.rebble.cobble.shared.database.entity.TimelinePin
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
Expand All @@ -19,14 +22,23 @@ import org.koin.mp.KoinPlatformTools
@Database(
entities = [
Calendar::class,
TimelinePin::class
TimelinePin::class,
PersistedNotification::class
],
version = 1,
version = 2,
autoMigrations = [AutoMigration(1, 2)]
)
@TypeConverters(Converters::class)
abstract class AppDatabase: RoomDatabase() {
abstract fun calendarDao(): CalendarDao
abstract fun timelinePinDao(): TimelinePinDao
abstract fun persistedNotificationDao(): PersistedNotificationDao

companion object {
fun instance(): AppDatabase {
return KoinPlatformTools.defaultContext().get().get()
}
}
}

expect fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package io.rebble.cobble.shared.database.dao

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import io.rebble.cobble.shared.database.entity.PersistedNotification

@Dao
interface PersistedNotificationDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(notification: PersistedNotification)

@Query("SELECT * FROM PersistedNotification WHERE sbnKey = :key")
suspend fun get(key: String): PersistedNotification?

@Query("SELECT * FROM PersistedNotification WHERE sbnKey = :key AND packageName = :packageName AND title = :title AND text = :text")
suspend fun getDuplicates(key:String, packageName: String, title: String, text: String): List<PersistedNotification>

@Query("DELETE FROM PersistedNotification WHERE postTime < :time")
suspend fun deleteOlderThan(time: Long)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package io.rebble.cobble.shared.database.entity

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity
data class PersistedNotification (
@PrimaryKey
val sbnKey: String,
val packageName: String,
val postTime: Long,
val title: String,
val text: String,
val groupKey: String?,
)

0 comments on commit 538bb7b

Please sign in to comment.