Notifications: simplify the flow by removing persistence (#2924)

* Notifications: simplify the flow by removing persistence. 
* Bump of minSdk to `24` (Android 7).
* Add migration to remove `notification.bin` file
This commit is contained in:
Jorge Martin Espinosa 2024-05-29 10:03:23 +02:00 committed by GitHub
parent 17678add86
commit 04e503177b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 2028 additions and 2618 deletions

View file

@ -0,0 +1,34 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.di
import android.content.Context
import androidx.core.app.NotificationManagerCompat
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
@Module
@ContributesTo(AppScope::class)
object PushModule {
@Provides
fun provideNotificationCompatManager(@ApplicationContext context: Context): NotificationManagerCompat {
return NotificationManagerCompat.from(context)
}
}

View file

@ -0,0 +1,73 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationManagerCompat
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import javax.inject.Inject
interface ActiveNotificationsProvider {
fun getAllNotifications(): List<StatusBarNotification>
fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification>
fun getNotificationsForSession(sessionId: SessionId): List<StatusBarNotification>
fun getMembershipNotificationForSession(sessionId: SessionId): List<StatusBarNotification>
fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification>
fun getSummaryNotification(sessionId: SessionId): StatusBarNotification?
fun count(sessionId: SessionId): Int
}
@ContributesBinding(AppScope::class)
class DefaultActiveNotificationsProvider @Inject constructor(
private val notificationManager: NotificationManagerCompat,
private val notificationIdProvider: NotificationIdProvider,
) : ActiveNotificationsProvider {
override fun getAllNotifications(): List<StatusBarNotification> {
return notificationManager.activeNotifications
}
override fun getNotificationsForSession(sessionId: SessionId): List<StatusBarNotification> {
return notificationManager.activeNotifications.filter { it.groupKey == sessionId.value }
}
override fun getMembershipNotificationForSession(sessionId: SessionId): List<StatusBarNotification> {
val notificationId = notificationIdProvider.getRoomInvitationNotificationId(sessionId)
return getNotificationsForSession(sessionId).filter { it.id == notificationId }
}
override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
val notificationId = notificationIdProvider.getRoomMessagesNotificationId(sessionId)
return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == roomId.value }
}
override fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
val notificationId = notificationIdProvider.getRoomInvitationNotificationId(sessionId)
return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == roomId.value }
}
override fun getSummaryNotification(sessionId: SessionId): StatusBarNotification? {
val summaryId = notificationIdProvider.getSummaryNotificationId(sessionId)
return getNotificationsForSession(sessionId).find { it.id == summaryId }
}
override fun count(sessionId: SessionId): Int {
return getNotificationsForSession(sessionId).size
}
}

View file

@ -16,14 +16,13 @@
package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.androidutils.throttler.FirstThrottler
import io.element.android.libraries.core.cache.CircularCache
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import androidx.annotation.VisibleForTesting
import androidx.core.app.NotificationManagerCompat
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@ -33,45 +32,36 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
import io.element.android.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.api.NavigationState
import io.element.android.services.appnavstate.api.currentSessionId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
private val loggerTag = LoggerTag("DefaultNotificationDrawerManager", LoggerTag.NotificationLoggerTag)
/**
* The NotificationDrawerManager receives notification events as they arrived (from event stream or fcm) and
* The NotificationDrawerManager receives notification events as they arrive (from event stream or fcm) and
* organise them in order to display them in the notification drawer.
* Events can be grouped into the same notification, old (already read) events can be removed to do some cleaning.
*/
@SingleIn(AppScope::class)
class DefaultNotificationDrawerManager @Inject constructor(
private val notifiableEventProcessor: NotifiableEventProcessor,
private val notificationManager: NotificationManagerCompat,
private val notificationRenderer: NotificationRenderer,
private val notificationEventPersistence: NotificationEventPersistence,
private val filteredEventDetector: FilteredEventDetector,
private val notificationIdProvider: NotificationIdProvider,
private val appNavigationStateService: AppNavigationStateService,
private val coroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
private val buildMeta: BuildMeta,
coroutineScope: CoroutineScope,
private val matrixClientProvider: MatrixClientProvider,
private val imageLoaderHolder: ImageLoaderHolder,
private val activeNotificationsProvider: ActiveNotificationsProvider,
) : NotificationDrawerManager {
private var appNavigationStateObserver: Job? = null
/**
* Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events.
*/
private val notificationState by lazy { createInitialNotificationState() }
private val firstThrottler = FirstThrottler(200)
// TODO EAx add a setting per user for this
private var useCompleteNotificationFormat = true
@ -84,7 +74,8 @@ class DefaultNotificationDrawerManager @Inject constructor(
}
// For test only
fun destroy() {
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun destroy() {
appNavigationStateObserver?.cancel()
}
@ -105,7 +96,6 @@ class DefaultNotificationDrawerManager @Inject constructor(
clearMessagesForRoom(
sessionId = navigationState.parentSpace.parentSession.sessionId,
roomId = navigationState.roomId,
doRender = true,
)
}
is NavigationState.Thread -> {
@ -119,95 +109,71 @@ class DefaultNotificationDrawerManager @Inject constructor(
currentAppNavigationState = navigationState
}
private fun createInitialNotificationState(): NotificationState {
val queuedEvents = notificationEventPersistence.loadEvents(factory = { rawEvents ->
NotificationEventQueue(rawEvents.toMutableList(), seenEventIds = CircularCache.create(cacheSize = 25))
})
val renderedEvents = queuedEvents.rawEvents().map { ProcessedEvent(ProcessedEvent.Type.KEEP, it) }.toMutableList()
return NotificationState(queuedEvents, renderedEvents)
}
private fun NotificationEventQueue.onNotifiableEventReceived(notifiableEvent: NotifiableEvent) {
if (buildMeta.lowPrivacyLoggingEnabled) {
Timber.tag(loggerTag.value).d("onNotifiableEventReceived(): $notifiableEvent")
} else {
Timber.tag(loggerTag.value).d("onNotifiableEventReceived(): is push: ${notifiableEvent.canBeReplaced}")
}
if (filteredEventDetector.shouldBeIgnored(notifiableEvent)) {
Timber.tag(loggerTag.value).d("onNotifiableEventReceived(): ignore the event")
return
}
add(notifiableEvent)
}
/**
* Should be called as soon as a new event is ready to be displayed.
* The notification corresponding to this event will not be displayed until
* #refreshNotificationDrawer() is called.
* Should be called as soon as a new event is ready to be displayed, filtering out notifications that shouldn't be displayed.
* Events might be grouped and there might not be one notification per event!
*/
fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) {
updateEvents(doRender = true) {
it.onNotifiableEventReceived(notifiableEvent)
suspend fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) {
if (notifiableEvent.shouldIgnoreEventInRoom(appNavigationStateService.appNavigationState.value)) {
return
}
renderEvents(listOf(notifiableEvent))
}
/**
* Clear all known events and refresh the notification drawer.
* Clear all known message events for a [sessionId].
*/
fun clearAllMessagesEvents(sessionId: SessionId, doRender: Boolean) {
updateEvents(doRender = doRender) {
it.clearMessagesForSession(sessionId)
}
fun clearAllMessagesEvents(sessionId: SessionId) {
notificationManager.cancel(null, notificationIdProvider.getRoomMessagesNotificationId(sessionId))
clearSummaryNotificationIfNeeded(sessionId)
}
/**
* Clear all notifications related to the session and refresh the notification drawer.
* Clear all notifications related to the session.
*/
fun clearAllEvents(sessionId: SessionId) {
updateEvents(doRender = true) {
it.clearAllForSession(sessionId)
}
activeNotificationsProvider.getNotificationsForSession(sessionId)
.forEach { notificationManager.cancel(it.tag, it.id) }
}
/**
* Should be called when the application is currently opened and showing timeline for the given roomId.
* Should be called when the application is currently opened and showing timeline for the given [roomId].
* Used to ignore events related to that room (no need to display notification) and clean any existing notification on this room.
* Can also be called when a notification for this room is dismissed by the user.
*/
fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId, doRender: Boolean) {
updateEvents(doRender = doRender) {
it.clearMessagesForRoom(sessionId, roomId)
}
fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) {
notificationManager.cancel(roomId.value, notificationIdProvider.getRoomMessagesNotificationId(sessionId))
clearSummaryNotificationIfNeeded(sessionId)
}
override fun clearMembershipNotificationForSession(sessionId: SessionId) {
updateEvents(doRender = true) {
it.clearMembershipNotificationForSession(sessionId)
}
activeNotificationsProvider.getMembershipNotificationForSession(sessionId)
.forEach { notificationManager.cancel(it.tag, it.id) }
clearSummaryNotificationIfNeeded(sessionId)
}
/**
* Clear invitation notification for the provided room.
*/
override fun clearMembershipNotificationForRoom(
sessionId: SessionId,
roomId: RoomId,
doRender: Boolean,
) {
updateEvents(doRender = doRender) {
it.clearMembershipNotificationForRoom(sessionId, roomId)
}
override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) {
activeNotificationsProvider.getMembershipNotificationForRoom(sessionId, roomId)
.forEach { notificationManager.cancel(it.tag, it.id) }
clearSummaryNotificationIfNeeded(sessionId)
}
/**
* Clear the notifications for a single event.
*/
fun clearEvent(sessionId: SessionId, eventId: EventId, doRender: Boolean) {
updateEvents(doRender = doRender) {
it.clearEvent(sessionId, eventId)
fun clearEvent(sessionId: SessionId, eventId: EventId) {
val id = notificationIdProvider.getRoomEventNotificationId(sessionId)
notificationManager.cancel(eventId.value, id)
clearSummaryNotificationIfNeeded(sessionId)
}
private fun clearSummaryNotificationIfNeeded(sessionId: SessionId) {
val summaryNotification = activeNotificationsProvider.getSummaryNotification(sessionId)
if (summaryNotification != null && activeNotificationsProvider.count(sessionId) == 1) {
notificationManager.cancel(null, summaryNotification.id)
}
}
@ -215,69 +181,19 @@ class DefaultNotificationDrawerManager @Inject constructor(
* Should be called when the application is currently opened and showing timeline for the given threadId.
* Used to ignore events related to that thread (no need to display notification) and clean any existing notification on this room.
*/
@Suppress("UNUSED_PARAMETER")
private fun onEnteringThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) {
updateEvents(doRender = true) {
it.clearMessagesForThread(sessionId, roomId, threadId)
}
// TODO maybe we'll have to embed more data in the tag to get a threadId
// Do nothing for now
}
private fun updateEvents(
doRender: Boolean,
action: (NotificationEventQueue) -> Unit,
) {
notificationState.updateQueuedEvents { queuedEvents, _ ->
action(queuedEvents)
}
coroutineScope.refreshNotificationDrawer(doRender)
}
private fun CoroutineScope.refreshNotificationDrawer(doRender: Boolean) = launch {
// Implement last throttler
val canHandle = firstThrottler.canHandle()
Timber.tag(loggerTag.value).v("refreshNotificationDrawer($doRender), delay: ${canHandle.waitMillis()} ms")
withContext(dispatchers.io) {
delay(canHandle.waitMillis())
try {
refreshNotificationDrawerBg(doRender)
} catch (throwable: Throwable) {
// It can happen if for instance session has been destroyed. It's a bit ugly to try catch like this, but it's safer
Timber.tag(loggerTag.value).w(throwable, "refreshNotificationDrawerBg failure")
}
}
}
private suspend fun refreshNotificationDrawerBg(doRender: Boolean) {
Timber.tag(loggerTag.value).v("refreshNotificationDrawerBg($doRender)")
val eventsToRender = notificationState.updateQueuedEvents { queuedEvents, renderedEvents ->
notifiableEventProcessor.process(queuedEvents.rawEvents(), renderedEvents).also {
queuedEvents.clearAndAdd(it.onlyKeptEvents())
}
}
if (notificationState.hasAlreadyRendered(eventsToRender)) {
Timber.tag(loggerTag.value).d("Skipping notification update due to event list not changing")
} else {
notificationState.clearAndAddRenderedEvents(eventsToRender)
if (doRender) {
renderEvents(eventsToRender)
}
persistEvents()
}
}
private fun persistEvents() {
notificationState.queuedEvents { queuedEvents ->
notificationEventPersistence.persistEvents(queuedEvents)
}
}
private suspend fun renderEvents(eventsToRender: List<ProcessedEvent<NotifiableEvent>>) {
private suspend fun renderEvents(eventsToRender: List<NotifiableEvent>) {
// Group by sessionId
val eventsForSessions = eventsToRender.groupBy {
it.event.sessionId
it.sessionId
}
eventsForSessions.forEach { (sessionId, notifiableEvents) ->
for ((sessionId, notifiableEvents) in eventsForSessions) {
val client = matrixClientProvider.getOrRestore(sessionId).getOrThrow()
val imageLoader = imageLoaderHolder.get(client)
val userFromCache = client.userProfile.value
@ -285,27 +201,29 @@ class DefaultNotificationDrawerManager @Inject constructor(
// We have an avatar and a display name, use it
userFromCache
} else {
tryOrNull(
onError = { Timber.tag(loggerTag.value).e(it, "Unable to retrieve info for user ${sessionId.value}") },
operation = {
client.getUserProfile().getOrNull()
?.let {
// displayName cannot be empty else NotificationCompat.MessagingStyle() will crash
if (it.displayName.isNullOrEmpty()) {
it.copy(displayName = sessionId.value)
} else {
it
}
}
}
) ?: MatrixUser(
userId = sessionId,
displayName = sessionId.value,
avatarUrl = null
)
client.getSafeUserProfile()
}
notificationRenderer.render(currentUser, useCompleteNotificationFormat, notifiableEvents, imageLoader)
}
}
private suspend fun MatrixClient.getSafeUserProfile(): MatrixUser {
return tryOrNull(
onError = { Timber.tag(loggerTag.value).e(it, "Unable to retrieve info for user ${sessionId.value}") },
operation = {
val profile = getUserProfile().getOrNull()
// displayName cannot be empty else NotificationCompat.MessagingStyle() will crash
if (profile?.displayName.isNullOrEmpty()) {
profile?.copy(displayName = sessionId.value)
} else {
profile
}
}
) ?: MatrixUser(
userId = sessionId,
displayName = sessionId.value,
avatarUrl = null
)
}
}

View file

@ -1,94 +0,0 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import android.content.Context
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.androidutils.file.EncryptedFileFactory
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import timber.log.Timber
import java.io.File
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
import javax.inject.Inject
private const val ROOMS_NOTIFICATIONS_FILE_NAME_LEGACY = "im.vector.notifications.cache"
private const val FILE_NAME = "notifications.bin"
private val loggerTag = LoggerTag("NotificationEventPersistence", LoggerTag.NotificationLoggerTag)
@ContributesBinding(AppScope::class)
class DefaultNotificationEventPersistence @Inject constructor(
@ApplicationContext private val context: Context,
) : NotificationEventPersistence {
private val file by lazy {
deleteLegacyFileIfAny()
context.getDatabasePath(FILE_NAME)
}
private val encryptedFile by lazy {
EncryptedFileFactory(context).create(file)
}
override fun loadEvents(factory: (List<NotifiableEvent>) -> NotificationEventQueue): NotificationEventQueue {
val rawEvents: ArrayList<NotifiableEvent>? = file
.takeIf { it.exists() }
?.let {
try {
encryptedFile.openFileInput().use { fis ->
ObjectInputStream(fis).use { ois ->
@Suppress("UNCHECKED_CAST")
ois.readObject() as? ArrayList<NotifiableEvent>
}
}.also {
Timber.tag(loggerTag.value).d("Deserializing ${it?.size} NotifiableEvent(s)")
}
} catch (e: Throwable) {
Timber.tag(loggerTag.value).e(e, "## Failed to load cached notification info")
null
}
}
return factory(rawEvents.orEmpty())
}
override fun persistEvents(queuedEvents: NotificationEventQueue) {
Timber.tag(loggerTag.value).d("Serializing ${queuedEvents.rawEvents().size} NotifiableEvent(s)")
// Always delete file before writing, or encryptedFile.openFileOutput() will throw
file.safeDelete()
if (queuedEvents.isEmpty()) return
try {
encryptedFile.openFileOutput().use { fos ->
ObjectOutputStream(fos).use { oos ->
oos.writeObject(queuedEvents.rawEvents())
}
}
} catch (e: Throwable) {
Timber.tag(loggerTag.value).e(e, "## Failed to save cached notification info")
}
}
private fun deleteLegacyFileIfAny() {
tryOrNull {
File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME_LEGACY).delete()
}
}
}

View file

@ -1,57 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import javax.inject.Inject
class FilteredEventDetector @Inject constructor(
// private val activeSessionDataSource: ActiveSessionDataSource
) {
/**
* Returns true if the given event should be ignored.
* Used to skip notifications if a non expected message is received.
*/
fun shouldBeIgnored(@Suppress("UNUSED_PARAMETER") notifiableEvent: NotifiableEvent): Boolean {
/* TODO EAx
val session = activeSessionDataSource.currentValue?.orNull() ?: return false
if (notifiableEvent is NotifiableMessageEvent) {
val room = session.getRoom(notifiableEvent.roomId) ?: return false
val timelineEvent = room.getTimelineEvent(notifiableEvent.eventId) ?: return false
return timelineEvent.shouldBeIgnored()
}
*/
return false
}
/*
/**
* Whether the timeline event should be ignored.
*/
private fun TimelineEvent.shouldBeIgnored(): Boolean {
if (root.isVoiceMessage()) {
val audioEvent = root.asMessageAudioEvent()
// if the event is a voice message related to a voice broadcast, only show the event on the first chunk.
return audioEvent.isVoiceBroadcast() && audioEvent?.sequence != 1
}
return false
}
*/
}

View file

@ -1,77 +0,0 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
import io.element.android.services.appnavstate.api.AppNavigationStateService
import timber.log.Timber
import javax.inject.Inject
private typealias ProcessedEvents = List<ProcessedEvent<NotifiableEvent>>
private val loggerTag = LoggerTag("NotifiableEventProcessor", LoggerTag.NotificationLoggerTag)
class NotifiableEventProcessor @Inject constructor(
private val outdatedDetector: OutdatedEventDetector,
private val appNavigationStateService: AppNavigationStateService,
) {
fun process(
queuedEvents: List<NotifiableEvent>,
renderedEvents: ProcessedEvents,
): ProcessedEvents {
val appState = appNavigationStateService.appNavigationState.value
val processedEvents = queuedEvents.map {
val type = when (it) {
is InviteNotifiableEvent -> ProcessedEvent.Type.KEEP
is NotifiableMessageEvent -> when {
it.shouldIgnoreEventInRoom(appState) -> {
ProcessedEvent.Type.REMOVE
.also { Timber.tag(loggerTag.value).d("notification message removed due to currently viewing the same room or thread") }
}
outdatedDetector.isMessageOutdated(it) -> ProcessedEvent.Type.REMOVE
.also { Timber.tag(loggerTag.value).d("notification message removed due to being read") }
else -> ProcessedEvent.Type.KEEP
}
is SimpleNotifiableEvent -> when (it.type) {
EventType.REDACTION -> ProcessedEvent.Type.REMOVE
else -> ProcessedEvent.Type.KEEP
}
is FallbackNotifiableEvent -> when {
it.shouldIgnoreEventInRoom(appState) -> {
ProcessedEvent.Type.REMOVE
.also { Timber.tag(loggerTag.value).d("notification fallback removed due to currently viewing the same room or thread") }
}
else -> ProcessedEvent.Type.KEEP
}
}
ProcessedEvent(type, it)
}
val removedEventsDiff = renderedEvents.filter { renderedEvent ->
queuedEvents.none { it.eventId == renderedEvent.event.eventId }
}.map { ProcessedEvent(ProcessedEvent.Type.REMOVE, it.event) }
return removedEventsDiff + processedEvents
}
}

View file

@ -19,11 +19,17 @@ package io.element.android.libraries.push.impl.notifications
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import io.element.android.features.preferences.api.store.SessionPreferencesStoreFactory
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ -33,87 +39,69 @@ private val loggerTag = LoggerTag("NotificationBroadcastReceiver", LoggerTag.Not
* Receives actions broadcast by notification (on click, on dismiss, inline replies, etc.).
*/
class NotificationBroadcastReceiver : BroadcastReceiver() {
@Inject lateinit var appCoroutineScope: CoroutineScope
@Inject lateinit var matrixClientProvider: MatrixClientProvider
@Inject lateinit var sessionPreferencesStore: SessionPreferencesStoreFactory
@Inject lateinit var defaultNotificationDrawerManager: DefaultNotificationDrawerManager
@Inject lateinit var actionIds: NotificationActionIds
override fun onReceive(context: Context?, intent: Intent?) {
if (intent == null || context == null) return
context.bindings<NotificationBroadcastReceiverBindings>().inject(this)
val sessionId = intent.extras?.getString(KEY_SESSION_ID)?.let(::SessionId) ?: return
val roomId = intent.getStringExtra(KEY_ROOM_ID)?.let(::RoomId)
val eventId = intent.getStringExtra(KEY_EVENT_ID)?.let(::EventId)
context.bindings<NotificationBroadcastReceiverBindings>().inject(this)
Timber.tag(loggerTag.value).d("onReceive: ${intent.action} ${intent.data} for: ${roomId?.value}/${eventId?.value}")
when (intent.action) {
actionIds.smartReply ->
handleSmartReply(intent, context)
actionIds.dismissRoom -> if (roomId != null) {
defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId, doRender = false)
defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
}
actionIds.dismissSummary ->
defaultNotificationDrawerManager.clearAllMessagesEvents(sessionId, doRender = false)
defaultNotificationDrawerManager.clearAllMessagesEvents(sessionId)
actionIds.dismissInvite -> if (roomId != null) {
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId, doRender = false)
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
}
actionIds.dismissEvent -> if (eventId != null) {
defaultNotificationDrawerManager.clearEvent(sessionId, eventId, doRender = false)
defaultNotificationDrawerManager.clearEvent(sessionId, eventId)
}
actionIds.markRoomRead -> if (roomId != null) {
defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId, doRender = true)
defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
handleMarkAsRead(sessionId, roomId)
}
actionIds.join -> if (roomId != null) {
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId, doRender = true)
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
handleJoinRoom(sessionId, roomId)
}
actionIds.reject -> if (roomId != null) {
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId, doRender = true)
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
handleRejectRoom(sessionId, roomId)
}
}
}
@Suppress("UNUSED_PARAMETER")
private fun handleJoinRoom(sessionId: SessionId, roomId: RoomId) {
/*
activeSessionHolder.getSafeActiveSession()?.let { session ->
val room = session.getRoom(roomId)
if (room != null) {
session.coroutineScope.launch {
tryOrNull {
session.roomService().joinRoom(room.roomId)
analyticsTracker.capture(room.roomSummary().toAnalyticsJoinedRoom(JoinedRoom.Trigger.Notification))
}
}
}
}
*/
private fun handleJoinRoom(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch {
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch
client.joinRoom(roomId)
}
@Suppress("UNUSED_PARAMETER")
private fun handleRejectRoom(sessionId: SessionId, roomId: RoomId) {
/*
activeSessionHolder.getSafeActiveSession()?.let { session ->
session.coroutineScope.launch {
tryOrNull { session.roomService().leaveRoom(roomId) }
}
}
*/
private fun handleRejectRoom(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch {
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch
client.getRoom(roomId)?.leave()
}
@Suppress("UNUSED_PARAMETER")
private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId) {
/*
activeSessionHolder.getActiveSession().let { session ->
val room = session.getRoom(roomId)
if (room != null) {
session.coroutineScope.launch {
tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT, mainTimeLineOnly = false) }
}
}
private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch {
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch
val isRenderReadReceiptsEnabled = sessionPreferencesStore.get(sessionId, this).isRenderReadReceiptsEnabled().first()
val receiptType = if (isRenderReadReceiptsEnabled) {
ReceiptType.READ
} else {
ReceiptType.READ_PRIVATE
}
*/
client.getRoom(roomId)?.markAsRead(receiptType = receiptType)
}
@Suppress("UNUSED_PARAMETER")

View file

@ -0,0 +1,239 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import android.app.Notification
import android.graphics.Typeface
import android.text.style.StyleSpan
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import coil.ImageLoader
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
interface NotificationDataFactory {
suspend fun toNotifications(
messages: List<NotifiableMessageEvent>,
currentUser: MatrixUser,
imageLoader: ImageLoader,
): List<RoomNotification>
@JvmName("toNotificationInvites")
@Suppress("INAPPLICABLE_JVM_NAME")
fun toNotifications(invites: List<InviteNotifiableEvent>): List<OneShotNotification>
@JvmName("toNotificationSimpleEvents")
@Suppress("INAPPLICABLE_JVM_NAME")
fun toNotifications(simpleEvents: List<SimpleNotifiableEvent>): List<OneShotNotification>
fun toNotifications(fallback: List<FallbackNotifiableEvent>): List<OneShotNotification>
fun createSummaryNotification(
currentUser: MatrixUser,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
useCompleteNotificationFormat: Boolean
): SummaryNotification
}
@ContributesBinding(AppScope::class)
class DefaultNotificationDataFactory @Inject constructor(
private val notificationCreator: NotificationCreator,
private val roomGroupMessageCreator: RoomGroupMessageCreator,
private val summaryGroupMessageCreator: SummaryGroupMessageCreator,
private val activeNotificationsProvider: ActiveNotificationsProvider,
private val stringProvider: StringProvider,
) : NotificationDataFactory {
override suspend fun toNotifications(
messages: List<NotifiableMessageEvent>,
currentUser: MatrixUser,
imageLoader: ImageLoader,
): List<RoomNotification> {
val messagesToDisplay = messages.filterNot { it.canNotBeDisplayed() }
.groupBy { it.roomId }
return messagesToDisplay.map { (roomId, events) ->
val roomName = events.lastOrNull()?.roomName ?: roomId.value
val isDirect = events.lastOrNull()?.roomIsDirect ?: false
val notification = roomGroupMessageCreator.createRoomMessage(
currentUser = currentUser,
events = events,
roomId = roomId,
imageLoader = imageLoader,
existingNotification = getExistingNotificationForMessages(currentUser.userId, roomId),
)
RoomNotification(
notification = notification,
roomId = roomId,
summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, isDirect),
messageCount = events.size,
latestTimestamp = events.maxOf { it.timestamp },
shouldBing = events.any { it.noisy }
)
}
}
private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted
private fun getExistingNotificationForMessages(sessionId: SessionId, roomId: RoomId): Notification? {
return activeNotificationsProvider.getMessageNotificationsForRoom(sessionId, roomId).firstOrNull()?.notification
}
@JvmName("toNotificationInvites")
@Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications(invites: List<InviteNotifiableEvent>): List<OneShotNotification> {
return invites.map { event ->
OneShotNotification(
key = event.roomId.value,
notification = notificationCreator.createRoomInvitationNotification(event),
summaryLine = event.description,
isNoisy = event.noisy,
timestamp = event.timestamp
)
}
}
@JvmName("toNotificationSimpleEvents")
@Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications(simpleEvents: List<SimpleNotifiableEvent>): List<OneShotNotification> {
return simpleEvents.map { event ->
OneShotNotification(
key = event.eventId.value,
notification = notificationCreator.createSimpleEventNotification(event),
summaryLine = event.description,
isNoisy = event.noisy,
timestamp = event.timestamp
)
}
}
override fun toNotifications(fallback: List<FallbackNotifiableEvent>): List<OneShotNotification> {
return fallback.map { event ->
OneShotNotification(
key = event.eventId.value,
notification = notificationCreator.createFallbackNotification(event),
summaryLine = event.description.orEmpty(),
isNoisy = false,
timestamp = event.timestamp
)
}
}
override fun createSummaryNotification(
currentUser: MatrixUser,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
useCompleteNotificationFormat: Boolean
): SummaryNotification {
return when {
roomNotifications.isEmpty() && invitationNotifications.isEmpty() && simpleNotifications.isEmpty() -> SummaryNotification.Removed
else -> SummaryNotification.Update(
summaryGroupMessageCreator.createSummaryNotification(
currentUser = currentUser,
roomNotifications = roomNotifications,
invitationNotifications = invitationNotifications,
simpleNotifications = simpleNotifications,
fallbackNotifications = fallbackNotifications,
useCompleteNotificationFormat = useCompleteNotificationFormat
)
)
}
}
private fun createRoomMessagesGroupSummaryLine(events: List<NotifiableMessageEvent>, roomName: String, roomIsDirect: Boolean): CharSequence {
return when (events.size) {
1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDirect)
else -> {
stringProvider.getQuantityString(
R.plurals.notification_compat_summary_line_for_room,
events.size,
roomName,
events.size
)
}
}
}
private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): CharSequence {
return if (roomIsDirect) {
buildSpannedString {
event.senderDisambiguatedDisplayName?.let {
inSpans(StyleSpan(Typeface.BOLD)) {
append(it)
append(": ")
}
}
append(event.description)
}
} else {
buildSpannedString {
inSpans(StyleSpan(Typeface.BOLD)) {
append(roomName)
append(": ")
event.senderDisambiguatedDisplayName?.let {
append(it)
append(" ")
}
}
append(event.description)
}
}
}
}
data class RoomNotification(
val notification: Notification,
val roomId: RoomId,
val summaryLine: CharSequence,
val messageCount: Int,
val latestTimestamp: Long,
val shouldBing: Boolean,
) {
fun isDataEqualTo(other: RoomNotification): Boolean {
return notification == other.notification &&
roomId == other.roomId &&
summaryLine.toString() == other.summaryLine.toString() &&
messageCount == other.messageCount &&
latestTimestamp == other.latestTimestamp &&
shouldBing == other.shouldBing
}
}
data class OneShotNotification(
val notification: Notification,
val key: String,
val summaryLine: CharSequence,
val isNoisy: Boolean,
val timestamp: Long,
)
sealed interface SummaryNotification {
data object Removed : SummaryNotification
data class Update(val notification: Notification) : SummaryNotification
}

View file

@ -22,16 +22,25 @@ import android.content.Context
import android.content.pm.PackageManager
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationManagerCompat
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import timber.log.Timber
import javax.inject.Inject
class NotificationDisplayer @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val notificationManager = NotificationManagerCompat.from(context)
interface NotificationDisplayer {
fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean
fun cancelNotificationMessage(tag: String?, id: Int)
fun displayDiagnosticNotification(notification: Notification): Boolean
fun dismissDiagnosticNotification()
}
fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean {
@ContributesBinding(AppScope::class)
class DefaultNotificationDisplayer @Inject constructor(
@ApplicationContext private val context: Context,
private val notificationManager: NotificationManagerCompat
) : NotificationDisplayer {
override fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
Timber.w("Not allowed to notify.")
return false
@ -40,20 +49,11 @@ class NotificationDisplayer @Inject constructor(
return true
}
fun cancelNotificationMessage(tag: String?, id: Int) {
override fun cancelNotificationMessage(tag: String?, id: Int) {
notificationManager.cancel(tag, id)
}
fun cancelAllNotifications() {
// Keep this try catch (reported by GA)
try {
notificationManager.cancelAll()
} catch (e: Exception) {
Timber.e(e, "## cancelAllNotifications() failed")
}
}
fun displayDiagnosticNotification(notification: Notification): Boolean {
override fun displayDiagnosticNotification(notification: Notification): Boolean {
return showNotificationMessage(
tag = "DIAGNOSTIC",
id = NOTIFICATION_ID_DIAGNOSTIC,
@ -61,33 +61,17 @@ class NotificationDisplayer @Inject constructor(
)
}
fun dismissDiagnosticNotification() {
override fun dismissDiagnosticNotification() {
cancelNotificationMessage(
tag = "DIAGNOSTIC",
id = NOTIFICATION_ID_DIAGNOSTIC
)
}
/**
* Cancel the foreground notification service.
*/
fun cancelNotificationForegroundService() {
notificationManager.cancel(NOTIFICATION_ID_FOREGROUND_SERVICE)
}
companion object {
/* ==========================================================================================
* IDs for notifications
* ========================================================================================== */
/**
* Identifier of the foreground notification used to keep the application alive
* when it runs in background.
* This notification, which is not removable by the end user, displays what
* the application is doing while in background.
*/
private const val NOTIFICATION_ID_FOREGROUND_SERVICE = 61
private const val NOTIFICATION_ID_DIAGNOSTIC = 888
}
}

View file

@ -1,24 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
interface NotificationEventPersistence {
fun loadEvents(factory: (List<NotifiableEvent>) -> NotificationEventQueue): NotificationEventQueue
fun persistEvents(queuedEvents: NotificationEventQueue)
}

View file

@ -1,186 +0,0 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.core.cache.CircularCache
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import timber.log.Timber
data class NotificationEventQueue(
private val queue: MutableList<NotifiableEvent>,
/**
* An in memory FIFO cache of the seen events.
* Acts as a notification debouncer to stop already dismissed push notifications from
* displaying again when the /sync response is delayed.
* TODO Should be per session, so the key must be Pair<SessionId, EventId>.
*/
private val seenEventIds: CircularCache<EventId>
) {
fun markRedacted(eventIds: List<EventId>) {
eventIds.forEach { redactedId ->
queue.replace(redactedId) {
when (it) {
is InviteNotifiableEvent -> it.copy(isRedacted = true)
is NotifiableMessageEvent -> it.copy(isRedacted = true)
is SimpleNotifiableEvent -> it.copy(isRedacted = true)
is FallbackNotifiableEvent -> it.copy(isRedacted = true)
}
}
}
}
// TODO EAx call this
fun syncRoomEvents(roomsLeft: Collection<RoomId>, roomsJoined: Collection<RoomId>) {
if (roomsLeft.isNotEmpty() || roomsJoined.isNotEmpty()) {
queue.removeAll {
when (it) {
is NotifiableMessageEvent -> roomsLeft.contains(it.roomId)
is InviteNotifiableEvent -> roomsLeft.contains(it.roomId) || roomsJoined.contains(it.roomId)
is SimpleNotifiableEvent -> false
is FallbackNotifiableEvent -> roomsLeft.contains(it.roomId)
}
}
}
}
fun isEmpty() = queue.isEmpty()
fun clearAndAdd(events: List<NotifiableEvent>) {
queue.clear()
queue.addAll(events)
}
fun clear() {
queue.clear()
}
fun add(notifiableEvent: NotifiableEvent) {
val existing = findExistingById(notifiableEvent)
val edited = findEdited(notifiableEvent)
when {
existing != null -> {
if (existing.canBeReplaced) {
// Use the event coming from the event stream as it may contains more info than
// the fcm one (like type/content/clear text) (e.g when an encrypted message from
// FCM should be update with clear text after a sync)
// In this case the message has already been notified, and might have done some noise
// So we want the notification to be updated even if it has already been displayed
// Use setOnlyAlertOnce to ensure update notification does not interfere with sound
// from first notify invocation as outlined in:
// https://developer.android.com/training/notify-user/build-notification#Updating
replace(replace = existing, with = notifiableEvent)
} else {
// keep the existing one, do not replace
}
}
edited != null -> {
// Replace the existing notification with the new content
replace(replace = edited, with = notifiableEvent)
}
seenEventIds.contains(notifiableEvent.eventId) -> {
// we've already seen the event, lets skip
Timber.d("onNotifiableEventReceived(): skipping event, already seen")
}
else -> {
seenEventIds.put(notifiableEvent.eventId)
queue.add(notifiableEvent)
}
}
}
private fun findExistingById(notifiableEvent: NotifiableEvent): NotifiableEvent? {
return queue.firstOrNull { it.sessionId == notifiableEvent.sessionId && it.eventId == notifiableEvent.eventId }
}
private fun findEdited(notifiableEvent: NotifiableEvent): NotifiableEvent? {
return notifiableEvent.editedEventId?.let { editedId ->
queue.firstOrNull {
it.eventId == editedId || it.editedEventId == editedId
}
}
}
private fun replace(replace: NotifiableEvent, with: NotifiableEvent) {
queue.remove(replace)
queue.add(
when (with) {
is InviteNotifiableEvent -> with.copy(isUpdated = true)
is NotifiableMessageEvent -> with.copy(isUpdated = true)
is SimpleNotifiableEvent -> with.copy(isUpdated = true)
is FallbackNotifiableEvent -> with.copy(isUpdated = true)
}
)
}
fun clearEvent(sessionId: SessionId, eventId: EventId) {
val isFallback = queue.firstOrNull { it.sessionId == sessionId && it.eventId == eventId } is FallbackNotifiableEvent
if (isFallback) {
Timber.d("Removing all the fallbacks")
queue.removeAll { it.sessionId == sessionId && it is FallbackNotifiableEvent }
} else {
queue.removeAll { it.sessionId == sessionId && it.eventId == eventId }
}
}
fun clearMembershipNotificationForSession(sessionId: SessionId) {
Timber.d("clearMemberShipOfSession $sessionId")
queue.removeAll { it is InviteNotifiableEvent && it.sessionId == sessionId }
}
fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) {
Timber.d("clearMemberShipOfRoom $sessionId, $roomId")
queue.removeAll { it is InviteNotifiableEvent && it.sessionId == sessionId && it.roomId == roomId }
}
fun clearMessagesForSession(sessionId: SessionId) {
Timber.d("clearMessagesForSession $sessionId")
queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId }
}
fun clearAllForSession(sessionId: SessionId) {
Timber.d("clearAllForSession $sessionId")
queue.removeAll { it.sessionId == sessionId }
}
fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) {
Timber.d("clearMessageEventOfRoom $sessionId, $roomId")
queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId && it.roomId == roomId }
}
fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) {
Timber.d("clearMessageEventOfThread $sessionId, $roomId, $threadId")
queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId && it.roomId == roomId && it.threadId == threadId }
}
fun rawEvents(): List<NotifiableEvent> = queue
}
private fun MutableList<NotifiableEvent>.replace(eventId: EventId, block: (NotifiableEvent) -> NotifiableEvent) {
val indexToReplace = indexOfFirst { it.eventId == eventId }
if (indexToReplace == -1) {
return
}
set(indexToReplace, block(get(indexToReplace)))
}

View file

@ -1,172 +0,0 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import android.app.Notification
import coil.ImageLoader
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import javax.inject.Inject
private typealias ProcessedMessageEvents = List<ProcessedEvent<NotifiableMessageEvent>>
class NotificationFactory @Inject constructor(
private val notificationCreator: NotificationCreator,
private val roomGroupMessageCreator: RoomGroupMessageCreator,
private val summaryGroupMessageCreator: SummaryGroupMessageCreator
) {
suspend fun Map<RoomId, ProcessedMessageEvents>.toNotifications(
currentUser: MatrixUser,
imageLoader: ImageLoader,
): List<RoomNotification> {
return map { (roomId, events) ->
when {
events.hasNoEventsToDisplay() -> RoomNotification.Removed(roomId)
else -> {
val messageEvents = events.onlyKeptEvents().filterNot { it.isRedacted }
roomGroupMessageCreator.createRoomMessage(
currentUser = currentUser,
events = messageEvents,
roomId = roomId,
imageLoader = imageLoader,
)
}
}
}
}
private fun ProcessedMessageEvents.hasNoEventsToDisplay() = isEmpty() || all {
it.type == ProcessedEvent.Type.REMOVE || it.event.canNotBeDisplayed()
}
private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted
@JvmName("toNotificationsInviteNotifiableEvent")
fun List<ProcessedEvent<InviteNotifiableEvent>>.toNotifications(): List<OneShotNotification> {
return map { (processed, event) ->
when (processed) {
ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.roomId.value)
ProcessedEvent.Type.KEEP -> OneShotNotification.Append(
notificationCreator.createRoomInvitationNotification(event),
OneShotNotification.Append.Meta(
key = event.roomId.value,
summaryLine = event.description,
isNoisy = event.noisy,
timestamp = event.timestamp
)
)
}
}
}
@JvmName("toNotificationsSimpleNotifiableEvent")
fun List<ProcessedEvent<SimpleNotifiableEvent>>.toNotifications(): List<OneShotNotification> {
return map { (processed, event) ->
when (processed) {
ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId.value)
ProcessedEvent.Type.KEEP -> OneShotNotification.Append(
notificationCreator.createSimpleEventNotification(event),
OneShotNotification.Append.Meta(
key = event.eventId.value,
summaryLine = event.description,
isNoisy = event.noisy,
timestamp = event.timestamp
)
)
}
}
}
fun List<ProcessedEvent<FallbackNotifiableEvent>>.toNotifications(): List<OneShotNotification> {
return map { (processed, event) ->
when (processed) {
ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId.value)
ProcessedEvent.Type.KEEP -> OneShotNotification.Append(
notificationCreator.createFallbackNotification(event),
OneShotNotification.Append.Meta(
key = event.eventId.value,
summaryLine = event.description.orEmpty(),
isNoisy = false,
timestamp = event.timestamp
)
)
}
}
}
fun createSummaryNotification(
currentUser: MatrixUser,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
useCompleteNotificationFormat: Boolean
): SummaryNotification {
val roomMeta = roomNotifications.filterIsInstance<RoomNotification.Message>().map { it.meta }
val invitationMeta = invitationNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
val simpleMeta = simpleNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
val fallbackMeta = fallbackNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
return when {
roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed
else -> SummaryNotification.Update(
summaryGroupMessageCreator.createSummaryNotification(
currentUser = currentUser,
roomNotifications = roomMeta,
invitationNotifications = invitationMeta,
simpleNotifications = simpleMeta,
fallbackNotifications = fallbackMeta,
useCompleteNotificationFormat = useCompleteNotificationFormat
)
)
}
}
}
sealed interface RoomNotification {
data class Removed(val roomId: RoomId) : RoomNotification
data class Message(val notification: Notification, val meta: Meta) : RoomNotification {
data class Meta(
val roomId: RoomId,
val summaryLine: CharSequence,
val messageCount: Int,
val latestTimestamp: Long,
val shouldBing: Boolean
)
}
}
sealed interface OneShotNotification {
data class Removed(val key: String) : OneShotNotification
data class Append(val notification: Notification, val meta: Meta) : OneShotNotification {
data class Meta(
val key: String,
val summaryLine: CharSequence,
val isNoisy: Boolean,
val timestamp: Long,
)
}
}
sealed interface SummaryNotification {
data object Removed : SummaryNotification
data class Update(val notification: Notification) : SummaryNotification
}

View file

@ -18,7 +18,6 @@ package io.element.android.libraries.push.impl.notifications
import coil.ImageLoader
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
@ -33,20 +32,20 @@ private val loggerTag = LoggerTag("NotificationRenderer", LoggerTag.Notification
class NotificationRenderer @Inject constructor(
private val notificationIdProvider: NotificationIdProvider,
private val notificationDisplayer: NotificationDisplayer,
private val notificationFactory: NotificationFactory,
private val notificationDataFactory: NotificationDataFactory,
) {
suspend fun render(
currentUser: MatrixUser,
useCompleteNotificationFormat: Boolean,
eventsToProcess: List<ProcessedEvent<NotifiableEvent>>,
eventsToProcess: List<NotifiableEvent>,
imageLoader: ImageLoader,
) {
val groupedEvents = eventsToProcess.groupByType()
with(notificationFactory) {
val roomNotifications = groupedEvents.roomEvents.toNotifications(currentUser, imageLoader)
val invitationNotifications = groupedEvents.invitationEvents.toNotifications()
val simpleNotifications = groupedEvents.simpleEvents.toNotifications()
val fallbackNotifications = groupedEvents.fallbackEvents.toNotifications()
with(notificationDataFactory) {
val roomNotifications = toNotifications(groupedEvents.roomEvents, currentUser, imageLoader)
val invitationNotifications = toNotifications(groupedEvents.invitationEvents)
val simpleNotifications = toNotifications(groupedEvents.simpleEvents)
val fallbackNotifications = toNotifications(groupedEvents.fallbackEvents)
val summaryNotification = createSummaryNotification(
currentUser = currentUser,
roomNotifications = roomNotifications,
@ -65,101 +64,43 @@ class NotificationRenderer @Inject constructor(
)
}
roomNotifications.forEach { wrapper ->
when (wrapper) {
is RoomNotification.Removed -> {
Timber.tag(loggerTag.value).d("Removing room messages notification ${wrapper.roomId}")
notificationDisplayer.cancelNotificationMessage(
tag = wrapper.roomId.value,
id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId)
)
}
is RoomNotification.Message -> if (useCompleteNotificationFormat) {
Timber.tag(loggerTag.value).d("Updating room messages notification ${wrapper.meta.roomId}")
notificationDisplayer.showNotificationMessage(
tag = wrapper.meta.roomId.value,
id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId),
notification = wrapper.notification
)
}
}
}
invitationNotifications.forEach { wrapper ->
when (wrapper) {
is OneShotNotification.Removed -> {
Timber.tag(loggerTag.value).d("Removing invitation notification ${wrapper.key}")
notificationDisplayer.cancelNotificationMessage(
tag = wrapper.key,
id = notificationIdProvider.getRoomInvitationNotificationId(currentUser.userId)
)
}
is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
Timber.tag(loggerTag.value).d("Updating invitation notification ${wrapper.meta.key}")
notificationDisplayer.showNotificationMessage(
tag = wrapper.meta.key,
id = notificationIdProvider.getRoomInvitationNotificationId(currentUser.userId),
notification = wrapper.notification
)
}
}
}
simpleNotifications.forEach { wrapper ->
when (wrapper) {
is OneShotNotification.Removed -> {
Timber.tag(loggerTag.value).d("Removing simple notification ${wrapper.key}")
notificationDisplayer.cancelNotificationMessage(
tag = wrapper.key,
id = notificationIdProvider.getRoomEventNotificationId(currentUser.userId)
)
}
is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
Timber.tag(loggerTag.value).d("Updating simple notification ${wrapper.meta.key}")
notificationDisplayer.showNotificationMessage(
tag = wrapper.meta.key,
id = notificationIdProvider.getRoomEventNotificationId(currentUser.userId),
notification = wrapper.notification
)
}
}
}
/*
fallbackNotifications.forEach { wrapper ->
when (wrapper) {
is OneShotNotification.Removed -> {
Timber.tag(loggerTag.value).d("Removing fallback notification ${wrapper.key}")
notificationDisplayer.cancelNotificationMessage(
tag = wrapper.key,
id = notificationIdProvider.getFallbackNotificationId(currentUser.userId)
)
}
is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
Timber.tag(loggerTag.value).d("Updating fallback notification ${wrapper.meta.key}")
notificationDisplayer.showNotificationMessage(
tag = wrapper.meta.key,
id = notificationIdProvider.getFallbackNotificationId(currentUser.userId),
notification = wrapper.notification
)
}
}
}
*/
val removedFallback = fallbackNotifications.filterIsInstance<OneShotNotification.Removed>()
val appendFallback = fallbackNotifications.filterIsInstance<OneShotNotification.Append>()
if (appendFallback.isEmpty() && removedFallback.isNotEmpty()) {
Timber.tag(loggerTag.value).d("Removing global fallback notification")
notificationDisplayer.cancelNotificationMessage(
tag = "FALLBACK",
id = notificationIdProvider.getFallbackNotificationId(currentUser.userId)
roomNotifications.forEach { notificationData ->
notificationDisplayer.showNotificationMessage(
tag = notificationData.roomId.value,
id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId),
notification = notificationData.notification
)
} else if (appendFallback.isNotEmpty()) {
}
invitationNotifications.forEach { notificationData ->
if (useCompleteNotificationFormat) {
Timber.tag(loggerTag.value).d("Updating invitation notification ${notificationData.key}")
notificationDisplayer.showNotificationMessage(
tag = notificationData.key,
id = notificationIdProvider.getRoomInvitationNotificationId(currentUser.userId),
notification = notificationData.notification
)
}
}
simpleNotifications.forEach { notificationData ->
if (useCompleteNotificationFormat) {
Timber.tag(loggerTag.value).d("Updating simple notification ${notificationData.key}")
notificationDisplayer.showNotificationMessage(
tag = notificationData.key,
id = notificationIdProvider.getRoomEventNotificationId(currentUser.userId),
notification = notificationData.notification
)
}
}
// Show only the first fallback notification
if (fallbackNotifications.isNotEmpty()) {
Timber.tag(loggerTag.value).d("Showing fallback notification")
notificationDisplayer.showNotificationMessage(
tag = "FALLBACK",
id = notificationIdProvider.getFallbackNotificationId(currentUser.userId),
notification = appendFallback.first().notification
notification = fallbackNotifications.first().notification
)
}
@ -174,39 +115,30 @@ class NotificationRenderer @Inject constructor(
}
}
}
fun cancelAllNotifications() {
notificationDisplayer.cancelAllNotifications()
}
}
private fun List<ProcessedEvent<NotifiableEvent>>.groupByType(): GroupedNotificationEvents {
val roomIdToEventMap: MutableMap<RoomId, MutableList<ProcessedEvent<NotifiableMessageEvent>>> = LinkedHashMap()
val simpleEvents: MutableList<ProcessedEvent<SimpleNotifiableEvent>> = ArrayList()
val invitationEvents: MutableList<ProcessedEvent<InviteNotifiableEvent>> = ArrayList()
val fallbackEvents: MutableList<ProcessedEvent<FallbackNotifiableEvent>> = ArrayList()
forEach {
when (val event = it.event) {
is InviteNotifiableEvent -> invitationEvents.add(it.castedToEventType())
is NotifiableMessageEvent -> {
val roomEvents = roomIdToEventMap.getOrPut(event.roomId) { ArrayList() }
roomEvents.add(it.castedToEventType())
}
is SimpleNotifiableEvent -> simpleEvents.add(it.castedToEventType())
is FallbackNotifiableEvent -> {
fallbackEvents.add(it.castedToEventType())
}
private fun List<NotifiableEvent>.groupByType(): GroupedNotificationEvents {
val roomEvents: MutableList<NotifiableMessageEvent> = mutableListOf()
val simpleEvents: MutableList<SimpleNotifiableEvent> = mutableListOf()
val invitationEvents: MutableList<InviteNotifiableEvent> = mutableListOf()
val fallbackEvents: MutableList<FallbackNotifiableEvent> = mutableListOf()
forEach { event ->
when (event) {
is InviteNotifiableEvent -> invitationEvents.add(event.castedToEventType())
is NotifiableMessageEvent -> roomEvents.add(event.castedToEventType())
is SimpleNotifiableEvent -> simpleEvents.add(event.castedToEventType())
is FallbackNotifiableEvent -> fallbackEvents.add(event.castedToEventType())
}
}
return GroupedNotificationEvents(roomIdToEventMap, simpleEvents, invitationEvents, fallbackEvents)
return GroupedNotificationEvents(roomEvents, simpleEvents, invitationEvents, fallbackEvents)
}
@Suppress("UNCHECKED_CAST")
private fun <T : NotifiableEvent> ProcessedEvent<NotifiableEvent>.castedToEventType(): ProcessedEvent<T> = this as ProcessedEvent<T>
private fun <T : NotifiableEvent> NotifiableEvent.castedToEventType(): T = this as T
data class GroupedNotificationEvents(
val roomEvents: Map<RoomId, List<ProcessedEvent<NotifiableMessageEvent>>>,
val simpleEvents: List<ProcessedEvent<SimpleNotifiableEvent>>,
val invitationEvents: List<ProcessedEvent<InviteNotifiableEvent>>,
val fallbackEvents: List<ProcessedEvent<FallbackNotifiableEvent>>,
val roomEvents: List<NotifiableMessageEvent>,
val simpleEvents: List<SimpleNotifiableEvent>,
val invitationEvents: List<InviteNotifiableEvent>,
val fallbackEvents: List<FallbackNotifiableEvent>,
)

View file

@ -1,59 +0,0 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
class NotificationState(
/**
* The notifiable events queued for rendering or currently rendered.
*
* This is our source of truth for notifications, any changes to this list will be rendered as notifications.
* When events are removed the previously rendered notifications will be cancelled.
* When adding or updating, the notifications will be notified.
*
* Events are unique by their properties, we should be careful not to insert multiple events with the same event-id.
*/
private val queuedEvents: NotificationEventQueue,
/**
* The last known rendered notifiable events.
* We keep track of them in order to know which events have been removed from the eventList
* allowing us to cancel any notifications previous displayed by now removed events
*/
private val renderedEvents: MutableList<ProcessedEvent<NotifiableEvent>>,
) {
fun <T> updateQueuedEvents(
action: (NotificationEventQueue, List<ProcessedEvent<NotifiableEvent>>) -> T
): T {
return synchronized(queuedEvents) {
action(queuedEvents, renderedEvents)
}
}
fun clearAndAddRenderedEvents(eventsToRender: List<ProcessedEvent<NotifiableEvent>>) {
renderedEvents.clear()
renderedEvents.addAll(eventsToRender)
}
fun hasAlreadyRendered(eventsToRender: List<ProcessedEvent<NotifiableEvent>>) = renderedEvents == eventsToRender
fun queuedEvents(block: (NotificationEventQueue) -> Unit) {
synchronized(queuedEvents) {
block(queuedEvents)
}
}
}

View file

@ -1,44 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import javax.inject.Inject
class OutdatedEventDetector @Inject constructor(
// / private val activeSessionDataSource: ActiveSessionDataSource
) {
/**
* Returns true if the given event is outdated.
* Used to clean up notifications if a displayed message has been read on an
* other device.
*/
fun isMessageOutdated(@Suppress("UNUSED_PARAMETER") notifiableEvent: NotifiableEvent): Boolean {
/* TODO EAx
val session = activeSessionDataSource.currentValue?.orNull() ?: return false
if (notifiableEvent is NotifiableMessageEvent) {
val eventID = notifiableEvent.eventId
val roomID = notifiableEvent.roomId
val room = session.getRoom(roomID) ?: return false
return room.readService().isEventRead(eventID)
}
*/
return false
}
}

View file

@ -1,31 +0,0 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
data class ProcessedEvent<T>(
val type: Type,
val event: T
) {
enum class Type {
KEEP,
REMOVE
}
}
fun <T> List<ProcessedEvent<T>>.onlyKeptEvents() = mapNotNull { processedEvent ->
processedEvent.event.takeIf { processedEvent.type == ProcessedEvent.Type.KEEP }
}

View file

@ -16,48 +16,46 @@
package io.element.android.libraries.push.impl.notifications
import android.app.Notification
import android.graphics.Bitmap
import android.graphics.Typeface
import android.text.style.StyleSpan
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import coil.ImageLoader
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import io.element.android.libraries.push.impl.notifications.factories.isSmartReplyError
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
class RoomGroupMessageCreator @Inject constructor(
private val bitmapLoader: NotificationBitmapLoader,
private val stringProvider: StringProvider,
private val notificationCreator: NotificationCreator
) {
interface RoomGroupMessageCreator {
suspend fun createRoomMessage(
currentUser: MatrixUser,
events: List<NotifiableMessageEvent>,
roomId: RoomId,
imageLoader: ImageLoader,
): RoomNotification.Message {
existingNotification: Notification?,
): Notification
}
@ContributesBinding(AppScope::class)
class DefaultRoomGroupMessageCreator @Inject constructor(
private val bitmapLoader: NotificationBitmapLoader,
private val stringProvider: StringProvider,
private val notificationCreator: NotificationCreator,
) : RoomGroupMessageCreator {
override suspend fun createRoomMessage(
currentUser: MatrixUser,
events: List<NotifiableMessageEvent>,
roomId: RoomId,
imageLoader: ImageLoader,
existingNotification: Notification?,
): Notification {
val lastKnownRoomEvent = events.last()
val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderDisambiguatedDisplayName ?: "Room name (${roomId.value.take(8)}…)"
val roomIsGroup = !lastKnownRoomEvent.roomIsDirect
val style = NotificationCompat.MessagingStyle(
Person.Builder()
.setName(currentUser.displayName?.annotateForDebug(50))
.setIcon(bitmapLoader.getUserIcon(currentUser.avatarUrl, imageLoader))
.setKey(lastKnownRoomEvent.sessionId.value)
.build()
).also {
it.conversationTitle = roomName.takeIf { roomIsGroup }?.annotateForDebug(51)
it.isGroupConversation = roomIsGroup
it.addMessagesFromEvents(events, imageLoader)
}
val tickerText = if (roomIsGroup) {
stringProvider.getString(R.string.notification_ticker_text_group, roomName, events.last().senderDisambiguatedDisplayName, events.last().description)
@ -69,112 +67,28 @@ class RoomGroupMessageCreator @Inject constructor(
val lastMessageTimestamp = events.last().timestamp
val smartReplyErrors = events.filter { it.isSmartReplyError() }
val messageCount = events.size - smartReplyErrors.size
val meta = RoomNotification.Message.Meta(
summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, roomIsDirect = !roomIsGroup),
messageCount = messageCount,
latestTimestamp = lastMessageTimestamp,
roomId = roomId,
shouldBing = events.any { it.noisy }
)
return RoomNotification.Message(
notificationCreator.createMessagesListNotification(
style,
return notificationCreator.createMessagesListNotification(
RoomEventGroupInfo(
sessionId = currentUser.userId,
roomId = roomId,
roomDisplayName = roomName,
isDirect = !roomIsGroup,
hasSmartReplyError = smartReplyErrors.isNotEmpty(),
shouldBing = meta.shouldBing,
shouldBing = events.any { it.noisy },
customSound = events.last().soundName,
isUpdated = events.last().isUpdated,
),
threadId = lastKnownRoomEvent.threadId,
largeIcon = largeBitmap,
lastMessageTimestamp,
tickerText
),
meta
lastMessageTimestamp = lastMessageTimestamp,
tickerText = tickerText,
currentUser = currentUser,
existingNotification = existingNotification,
imageLoader = imageLoader,
events = events,
)
}
private suspend fun NotificationCompat.MessagingStyle.addMessagesFromEvents(
events: List<NotifiableMessageEvent>,
imageLoader: ImageLoader,
) {
events.forEach { event ->
val senderPerson = if (event.outGoingMessage) {
null
} else {
Person.Builder()
.setName(event.senderDisambiguatedDisplayName?.annotateForDebug(70))
.setIcon(bitmapLoader.getUserIcon(event.senderAvatarPath, imageLoader))
.setKey(event.senderId.value)
.build()
}
when {
event.isSmartReplyError() -> addMessage(
stringProvider.getString(R.string.notification_inline_reply_failed),
event.timestamp,
senderPerson
)
else -> {
val message = NotificationCompat.MessagingStyle.Message(
event.body?.annotateForDebug(71),
event.timestamp,
senderPerson
).also { message ->
event.imageUri?.let {
message.setData("image/", it)
}
}
addMessage(message)
}
}
}
}
private fun createRoomMessagesGroupSummaryLine(events: List<NotifiableMessageEvent>, roomName: String, roomIsDirect: Boolean): CharSequence {
return when (events.size) {
1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDirect)
else -> {
stringProvider.getQuantityString(
R.plurals.notification_compat_summary_line_for_room,
events.size,
roomName,
events.size
)
}
}
}
private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): CharSequence {
return if (roomIsDirect) {
buildSpannedString {
event.senderDisambiguatedDisplayName?.let {
inSpans(StyleSpan(Typeface.BOLD)) {
append(it)
append(": ")
}
}
append(event.description)
}
} else {
buildSpannedString {
inSpans(StyleSpan(Typeface.BOLD)) {
append(roomName)
append(": ")
event.senderDisambiguatedDisplayName?.let {
append(it)
append(" ")
}
}
append(event.description)
}
}
}
private suspend fun getRoomBitmap(
events: List<NotifiableMessageEvent>,
imageLoader: ImageLoader,
@ -184,5 +98,3 @@ class RoomGroupMessageCreator @Inject constructor(
?.let { bitmapLoader.getRoomBitmap(it, imageLoader) }
}
}
private fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed

View file

@ -18,6 +18,8 @@ package io.element.android.libraries.push.impl.notifications
import android.app.Notification
import androidx.core.app.NotificationCompat
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug
@ -25,30 +27,37 @@ import io.element.android.libraries.push.impl.notifications.factories.Notificati
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
interface SummaryGroupMessageCreator {
fun createSummaryNotification(
currentUser: MatrixUser,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
useCompleteNotificationFormat: Boolean
): Notification
}
/**
* ======== Build summary notification =========
* On Android 7.0 (API level 24) and higher, the system automatically builds a summary for
* your group using snippets of text from each notification. The user can expand this
* notification to see each separate notification.
* To support older versions, which cannot show a nested group of notifications,
* you must create an extra notification that acts as the summary.
* This appears as the only notification and the system hides all the others.
* So this summary should include a snippet from all the other notifications,
* which the user can tap to open your app.
* The behavior of the group summary may vary on some device types such as wearables.
* To ensure the best experience on all devices and versions, always include a group summary when you create a group
* https://developer.android.com/training/notify-user/group
*/
class SummaryGroupMessageCreator @Inject constructor(
@ContributesBinding(AppScope::class)
class DefaultSummaryGroupMessageCreator @Inject constructor(
private val stringProvider: StringProvider,
private val notificationCreator: NotificationCreator,
) {
fun createSummaryNotification(
) : SummaryGroupMessageCreator {
override fun createSummaryNotification(
currentUser: MatrixUser,
roomNotifications: List<RoomNotification.Message.Meta>,
invitationNotifications: List<OneShotNotification.Append.Meta>,
simpleNotifications: List<OneShotNotification.Append.Meta>,
fallbackNotifications: List<OneShotNotification.Append.Meta>,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
useCompleteNotificationFormat: Boolean
): Notification {
val summaryInboxStyle = NotificationCompat.InboxStyle().also { style ->

View file

@ -36,10 +36,9 @@ import javax.inject.Inject
@SingleIn(AppScope::class)
class NotificationChannels @Inject constructor(
@ApplicationContext private val context: Context,
private val notificationManager: NotificationManagerCompat,
private val stringProvider: StringProvider,
) {
private val notificationManager = NotificationManagerCompat.from(context)
init {
createNotificationChannels()
}

View file

@ -22,26 +22,80 @@ import android.graphics.Bitmap
import android.graphics.Canvas
import androidx.annotation.DrawableRes
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.MessagingStyle
import androidx.core.app.Person
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import coil.ImageLoader
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.NotificationConfig
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.NotificationBitmapLoader
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug
import io.element.android.libraries.push.impl.notifications.factories.action.AcceptInvitationActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.RejectInvitationActionFactory
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
class NotificationCreator @Inject constructor(
interface NotificationCreator {
/**
* Create a notification for a Room.
*/
suspend fun createMessagesListNotification(
roomInfo: RoomEventGroupInfo,
threadId: ThreadId?,
largeIcon: Bitmap?,
lastMessageTimestamp: Long,
tickerText: String,
currentUser: MatrixUser,
existingNotification: Notification?,
imageLoader: ImageLoader,
events: List<NotifiableMessageEvent>,
): Notification
fun createRoomInvitationNotification(
inviteNotifiableEvent: InviteNotifiableEvent
): Notification
fun createSimpleEventNotification(
simpleNotifiableEvent: SimpleNotifiableEvent,
): Notification
fun createFallbackNotification(
fallbackNotifiableEvent: FallbackNotifiableEvent,
): Notification
/**
* Create the summary notification.
*/
fun createSummaryListNotification(
currentUser: MatrixUser,
style: NotificationCompat.InboxStyle?,
compatSummary: String,
noisy: Boolean,
lastMessageTimestamp: Long
): Notification
fun createDiagnosticNotification(): Notification
}
@ContributesBinding(AppScope::class)
class DefaultNotificationCreator @Inject constructor(
@ApplicationContext private val context: Context,
private val notificationChannels: NotificationChannels,
private val stringProvider: StringProvider,
@ -49,17 +103,23 @@ class NotificationCreator @Inject constructor(
private val pendingIntentFactory: PendingIntentFactory,
private val markAsReadActionFactory: MarkAsReadActionFactory,
private val quickReplyActionFactory: QuickReplyActionFactory,
) {
private val bitmapLoader: NotificationBitmapLoader,
private val acceptInvitationActionFactory: AcceptInvitationActionFactory,
private val rejectInvitationActionFactory: RejectInvitationActionFactory
) : NotificationCreator {
/**
* Create a notification for a Room.
*/
fun createMessagesListNotification(
messageStyle: NotificationCompat.MessagingStyle,
override suspend fun createMessagesListNotification(
roomInfo: RoomEventGroupInfo,
threadId: ThreadId?,
largeIcon: Bitmap?,
lastMessageTimestamp: Long,
tickerText: String
tickerText: String,
currentUser: MatrixUser,
existingNotification: Notification?,
imageLoader: ImageLoader,
events: List<NotifiableMessageEvent>,
): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
// Build the pending intent for when the notification is clicked
@ -71,17 +131,39 @@ class NotificationCreator @Inject constructor(
val smallIcon = CommonDrawables.ic_notification_small
val channelId = notificationChannels.getChannelIdForMessage(roomInfo.shouldBing)
return NotificationCompat.Builder(context, channelId)
val builder = if (existingNotification != null) {
NotificationCompat.Builder(context, existingNotification)
} else {
NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(roomInfo.isUpdated)
// A category allows groups of notifications to be ranked and filtered per user or system settings.
// For example, alarm notifications should display before promo notifications, or message from known contact
// that can be displayed in not disturb mode if white listed (the later will need compat28.x)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
// ID of the corresponding shortcut, for conversation features under API 30+
.setShortcutId(roomInfo.roomId.value)
// Auto-bundling is enabled for 4 or more notifications on API 24+ (N+)
// devices and all Wear devices. But we want a custom grouping, so we specify the groupID
.setGroup(roomInfo.sessionId.value)
// In order to avoid notification making sound twice (due to the summary notification)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
// Remove notification after opening it or using an action
.setAutoCancel(true)
}
val messagingStyle = existingNotification?.let {
MessagingStyle.extractMessagingStyleFromNotification(it)
} ?: messagingStyleFromCurrentUser(roomInfo.sessionId, currentUser, imageLoader, roomInfo.roomDisplayName, !roomInfo.isDirect)
messagingStyle.addMessagesFromEvents(events, imageLoader)
return builder
.setNumber(events.size)
.setOnlyAlertOnce(roomInfo.isUpdated)
.setWhen(lastMessageTimestamp)
// MESSAGING_STYLE sets title and content for API 16 and above devices.
.setStyle(messageStyle)
// A category allows groups of notifications to be ranked and filtered per user or system settings.
// For example, alarm notifications should display before promo notifications, or message from known contact
// that can be displayed in not disturb mode if white listed (the later will need compat28.x)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
// ID of the corresponding shortcut, for conversation features under API 30+
.setShortcutId(roomInfo.roomId.value)
.setStyle(messagingStyle)
// Not needed anymore?
// Title for API < 16 devices.
.setContentTitle(roomInfo.roomDisplayName.annotateForDebug(1))
// Content for API < 16 devices.
@ -90,15 +172,10 @@ class NotificationCreator @Inject constructor(
.setSubText(
stringProvider.getQuantityString(
R.plurals.notification_new_messages_for_room,
messageStyle.messages.size,
messageStyle.messages.size
messagingStyle.messages.size,
messagingStyle.messages.size
).annotateForDebug(3)
)
// Auto-bundling is enabled for 4 or more notifications on API 24+ (N+)
// devices and all Wear devices. But we want a custom grouping, so we specify the groupID
.setGroup(roomInfo.sessionId.value)
// In order to avoid notification making sound twice (due to the summary notification)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon)
// Set primary color (important for Wear 2.0 Notifications).
.setColor(accentColor)
@ -118,7 +195,8 @@ class NotificationCreator @Inject constructor(
} else {
priority = NotificationCompat.PRIORITY_LOW
}
// Clear existing actions since we might be updating an existing notification
clearActions()
// Add actions and notification intents
// Mark room as read
addAction(markAsReadActionFactory.create(roomInfo))
@ -134,11 +212,11 @@ class NotificationCreator @Inject constructor(
}
setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId))
}
.setTicker(tickerText.annotateForDebug(4))
.setTicker(tickerText)
.build()
}
fun createRoomInvitationNotification(
override fun createRoomInvitationNotification(
inviteNotifiableEvent: InviteNotifiableEvent
): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
@ -152,10 +230,11 @@ class NotificationCreator @Inject constructor(
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon)
.setColor(accentColor)
// TODO removed for now, will be added back later
// .addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent))
// .addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent))
.apply {
if (NotificationConfig.SUPPORT_JOIN_DECLINE_INVITE) {
addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent))
addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent))
}
// Build the pending intent for when the notification is clicked
setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(inviteNotifiableEvent.sessionId, inviteNotifiableEvent.roomId))
@ -182,7 +261,7 @@ class NotificationCreator @Inject constructor(
.build()
}
fun createSimpleEventNotification(
override fun createSimpleEventNotification(
simpleNotifiableEvent: SimpleNotifiableEvent,
): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
@ -212,12 +291,11 @@ class NotificationCreator @Inject constructor(
} else {
priority = NotificationCompat.PRIORITY_LOW
}
setAutoCancel(true)
}
.build()
}
fun createFallbackNotification(
override fun createFallbackNotification(
fallbackNotifiableEvent: FallbackNotifiableEvent,
): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
@ -244,17 +322,14 @@ class NotificationCreator @Inject constructor(
fallbackNotifiableEvent.eventId
)
)
.apply {
priority = NotificationCompat.PRIORITY_LOW
setAutoCancel(true)
}
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
/**
* Create the summary notification.
*/
fun createSummaryListNotification(
override fun createSummaryListNotification(
currentUser: MatrixUser,
style: NotificationCompat.InboxStyle?,
compatSummary: String,
@ -298,7 +373,7 @@ class NotificationCreator @Inject constructor(
.build()
}
fun createDiagnosticNotification(): Notification {
override fun createDiagnosticNotification(): Notification {
val intent = pendingIntentFactory.createTestPendingIntent()
return NotificationCompat.Builder(context, notificationChannels.getChannelIdForTest())
.setContentTitle(buildMeta.applicationName)
@ -314,6 +389,61 @@ class NotificationCreator @Inject constructor(
.build()
}
private suspend fun MessagingStyle.addMessagesFromEvents(
events: List<NotifiableMessageEvent>,
imageLoader: ImageLoader,
) {
events.forEach { event ->
val senderPerson = if (event.outGoingMessage) {
null
} else {
Person.Builder()
.setName(event.senderDisambiguatedDisplayName?.annotateForDebug(70))
.setIcon(bitmapLoader.getUserIcon(event.senderAvatarPath, imageLoader))
.setKey(event.senderId.value)
.build()
}
when {
event.isSmartReplyError() -> addMessage(
stringProvider.getString(R.string.notification_inline_reply_failed),
event.timestamp,
senderPerson
)
else -> {
val message = MessagingStyle.Message(
event.body?.annotateForDebug(71),
event.timestamp,
senderPerson
).also { message ->
event.imageUri?.let {
message.setData("image/", it)
}
}
addMessage(message)
}
}
}
}
private suspend fun messagingStyleFromCurrentUser(
sessionId: SessionId,
user: MatrixUser,
imageLoader: ImageLoader,
roomName: String,
roomIsGroup: Boolean
): MessagingStyle {
return MessagingStyle(
Person.Builder()
.setName(user.displayName?.annotateForDebug(50))
.setIcon(bitmapLoader.getUserIcon(user.avatarUrl, imageLoader))
.setKey(sessionId.value)
.build()
).also {
it.conversationTitle = roomName.takeIf { roomIsGroup }
it.isGroupConversation = roomIsGroup
}
}
private fun getBitmap(@DrawableRes drawableRes: Int): Bitmap? {
val drawable = ResourcesCompat.getDrawable(context.resources, drawableRes, null) ?: return null
val canvas = Canvas()
@ -324,3 +454,5 @@ class NotificationCreator @Inject constructor(
return bitmap
}
}
fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed

View file

@ -46,21 +46,20 @@ class QuickReplyActionFactory @Inject constructor(
if (!NotificationConfig.SUPPORT_QUICK_REPLY_ACTION) return null
val sessionId = roomInfo.sessionId
val roomId = roomInfo.roomId
return buildQuickReplyIntent(sessionId, roomId, threadId)?.let { replyPendingIntent ->
val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY)
.setLabel(stringProvider.getString(R.string.notification_room_action_quick_reply))
.build()
val replyPendingIntent = buildQuickReplyIntent(sessionId, roomId, threadId)
val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY)
.setLabel(stringProvider.getString(R.string.notification_room_action_quick_reply))
.build()
NotificationCompat.Action.Builder(
R.drawable.vector_notification_quick_reply,
stringProvider.getString(R.string.notification_room_action_quick_reply),
replyPendingIntent
)
.addRemoteInput(remoteInput)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.setShowsUserInterface(false)
.build()
}
return NotificationCompat.Action.Builder(
R.drawable.vector_notification_quick_reply,
stringProvider.getString(R.string.notification_room_action_quick_reply),
replyPendingIntent
)
.addRemoteInput(remoteInput)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.setShowsUserInterface(false)
.build()
}
/*
@ -74,30 +73,26 @@ class QuickReplyActionFactory @Inject constructor(
sessionId: SessionId,
roomId: RoomId,
threadId: ThreadId?,
): PendingIntent? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.smartReply
intent.data = createIgnoredUri("quickReply/$sessionId/$roomId" + threadId?.let { "/$it" }.orEmpty())
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value)
threadId?.let {
intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it.value)
}
PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),
intent,
// PendingIntents attached to actions with remote inputs must be mutable
PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE
} else {
0
}
)
} else {
null
): PendingIntent {
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.smartReply
intent.data = createIgnoredUri("quickReply/$sessionId/$roomId" + threadId?.let { "/$it" }.orEmpty())
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value)
threadId?.let {
intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it.value)
}
return PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),
intent,
// PendingIntents attached to actions with remote inputs must be mutable
PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE
} else {
0
}
)
}
}

View file

@ -20,6 +20,8 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
interface OnNotifiableEventReceived {
@ -29,8 +31,11 @@ interface OnNotifiableEventReceived {
@ContributesBinding(AppScope::class)
class DefaultOnNotifiableEventReceived @Inject constructor(
private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager,
private val coroutineScope: CoroutineScope,
) : OnNotifiableEventReceived {
override fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) {
defaultNotificationDrawerManager.onNotifiableEventReceived(notifiableEvent)
coroutineScope.launch {
defaultNotificationDrawerManager.onNotifiableEventReceived(notifiableEvent)
}
}
}