Merge branch 'develop' into feature/bma/notificationCustomSound

This commit is contained in:
Benoit Marty 2026-02-13 15:48:19 +01:00 committed by GitHub
commit 35e60efae2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 894 additions and 590 deletions

View file

@ -34,6 +34,7 @@ interface ActiveNotificationsProvider {
fun getMembershipNotificationForSession(sessionId: SessionId): List<StatusBarNotification>
fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification>
fun getSummaryNotification(sessionId: SessionId): StatusBarNotification?
fun getFallbackNotification(sessionId: SessionId): StatusBarNotification?
fun count(sessionId: SessionId): Int
}
@ -76,6 +77,11 @@ class DefaultActiveNotificationsProvider(
return getNotificationsForSession(sessionId).find { it.id == summaryId }
}
override fun getFallbackNotification(sessionId: SessionId): StatusBarNotification? {
val fallbackId = NotificationIdProvider.getFallbackNotificationId(sessionId)
return getNotificationsForSession(sessionId).find { it.id == fallbackId }
}
override fun count(sessionId: SessionId): Int {
return getNotificationsForSession(sessionId).size
}

View file

@ -22,11 +22,20 @@ import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
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.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.api.NavigationState
import io.element.android.services.appnavstate.api.currentRoomId
import io.element.android.services.appnavstate.api.currentSessionId
import io.element.android.services.appnavstate.api.currentThreadId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -46,46 +55,49 @@ class DefaultNotificationDrawerManager(
private val matrixClientProvider: MatrixClientProvider,
private val imageLoaderHolder: ImageLoaderHolder,
private val activeNotificationsProvider: ActiveNotificationsProvider,
sessionObserver: SessionObserver,
) : NotificationCleaner {
// TODO EAx add a setting per user for this
private var useCompleteNotificationFormat = true
private val sessionListener = object : SessionListener {
override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) {
// User signed out, clear all notifications related to the session.
clearAllEvents(SessionId(userId))
}
}
init {
// Observe application state
coroutineScope.launch {
appNavigationStateService.appNavigationState
.collect { onAppNavigationStateChange(it.navigationState) }
}
sessionObserver.addListener(sessionListener)
}
private var currentAppNavigationState: NavigationState? = null
private fun onAppNavigationStateChange(navigationState: NavigationState) {
when (navigationState) {
NavigationState.Root -> {
currentAppNavigationState?.currentSessionId()?.let { sessionId ->
// User signed out, clear all notifications related to the session.
clearAllEvents(sessionId)
}
NavigationState.Root -> {}
is NavigationState.Session -> {
// Cleanup the fallback notification
clearFallbackForSession(navigationState.sessionId)
}
is NavigationState.Session -> {}
is NavigationState.Space -> {}
is NavigationState.Room -> {
// Cleanup notification for current room
clearMessagesForRoom(
sessionId = navigationState.parentSpace.parentSession.sessionId,
sessionId = navigationState.parentSession.sessionId,
roomId = navigationState.roomId,
)
}
is NavigationState.Thread -> {
clearMessagesForThread(
sessionId = navigationState.parentRoom.parentSpace.parentSession.sessionId,
sessionId = navigationState.parentRoom.parentSession.sessionId,
roomId = navigationState.parentRoom.roomId,
threadId = navigationState.threadId,
)
}
}
currentAppNavigationState = navigationState
}
/**
@ -93,14 +105,11 @@ class DefaultNotificationDrawerManager(
* Events might be grouped and there might not be one notification per event!
*/
suspend fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) {
if (notifiableEvent.shouldIgnoreEventInRoom(appNavigationStateService.appNavigationState.value)) {
return
}
renderEvents(listOf(notifiableEvent))
onNotifiableEventsReceived(listOf(notifiableEvent))
}
suspend fun onNotifiableEventsReceived(notifiableEvents: List<NotifiableEvent>) {
val eventsToNotify = notifiableEvents.filter { !it.shouldIgnoreEventInRoom(appNavigationStateService.appNavigationState.value) }
val eventsToNotify = notifiableEvents.filter { !appNavigationStateService.appNavigationState.value.shouldIgnoreEvent(it) }
renderEvents(eventsToNotify)
}
@ -120,6 +129,17 @@ class DefaultNotificationDrawerManager(
.forEach { notificationDisplayer.cancelNotification(it.tag, it.id) }
}
/**
* Remove the fallback notification for the session.
*/
fun clearFallbackForSession(sessionId: SessionId) {
notificationDisplayer.cancelNotification(
DefaultNotificationDataFactory.FALLBACK_NOTIFICATION_TAG,
NotificationIdProvider.getFallbackNotificationId(sessionId),
)
clearSummaryNotificationIfNeeded(sessionId)
}
/**
* 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.
@ -191,3 +211,30 @@ class DefaultNotificationDrawerManager(
}
}
}
/**
* Used to check if a notifiableEvent should be ignored based on the current application navigation state.
*/
private fun AppNavigationState.shouldIgnoreEvent(event: NotifiableEvent): Boolean {
if (!isInForeground) return false
return navigationState.currentSessionId() == event.sessionId &&
when (event) {
is NotifiableRingingCallEvent -> {
// Never ignore ringing call notifications
// Note that NotifiableRingingCallEvent are not handled by DefaultNotificationDrawerManager
false
}
is FallbackNotifiableEvent -> {
// Ignore if the room list is currently displayed
navigationState is NavigationState.Session
}
is InviteNotifiableEvent,
is SimpleNotifiableEvent -> {
event.roomId == navigationState.currentRoomId()
}
is NotifiableMessageEvent -> {
event.roomId == navigationState.currentRoomId() &&
event.threadId == navigationState.currentThreadId()
}
}
}

View file

@ -12,15 +12,12 @@ import dev.zacsweers.metro.Inject
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.push.impl.R
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock
@Inject
class FallbackNotificationFactory(
private val clock: SystemClock,
private val stringProvider: StringProvider,
) {
fun create(
sessionId: SessionId,
@ -36,7 +33,7 @@ class FallbackNotificationFactory(
isRedacted = false,
isUpdated = false,
timestamp = clock.epochMillis(),
description = stringProvider.getString(R.string.notification_fallback_content),
description = "",
cause = cause,
)
}

View file

@ -9,24 +9,18 @@
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 coil3.ImageLoader
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
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.R
import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams
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
interface NotificationDataFactory {
suspend fun toNotifications(
@ -51,16 +45,15 @@ interface NotificationDataFactory {
@JvmName("toNotificationFallbackEvents")
@Suppress("INAPPLICABLE_JVM_NAME")
fun toNotifications(
fun toNotification(
fallback: List<FallbackNotifiableEvent>,
notificationAccountParams: NotificationAccountParams,
): List<OneShotNotification>
): OneShotNotification?
fun createSummaryNotification(
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
notificationAccountParams: NotificationAccountParams,
): SummaryNotification
}
@ -71,7 +64,6 @@ class DefaultNotificationDataFactory(
private val roomGroupMessageCreator: RoomGroupMessageCreator,
private val summaryGroupMessageCreator: SummaryGroupMessageCreator,
private val activeNotificationsProvider: ActiveNotificationsProvider,
private val stringProvider: StringProvider,
) : NotificationDataFactory {
override suspend fun toNotifications(
messages: List<NotifiableMessageEvent>,
@ -81,10 +73,7 @@ class DefaultNotificationDataFactory(
val messagesToDisplay = messages.filterNot { it.canNotBeDisplayed() }
.groupBy { it.roomId }
return messagesToDisplay.flatMap { (roomId, events) ->
val roomName = events.lastOrNull()?.roomName ?: roomId.value
val isDm = events.lastOrNull()?.roomIsDm ?: false
val eventsByThreadId = events.groupBy { it.threadId }
eventsByThreadId.map { (threadId, events) ->
val notification = roomGroupMessageCreator.createRoomMessage(
events = events,
@ -98,7 +87,6 @@ class DefaultNotificationDataFactory(
notification = notification,
roomId = roomId,
threadId = threadId,
summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, isDm),
messageCount = events.size,
latestTimestamp = events.maxOf { it.timestamp },
shouldBing = events.any { it.noisy }
@ -123,7 +111,6 @@ class DefaultNotificationDataFactory(
OneShotNotification(
tag = event.roomId.value,
notification = notificationCreator.createRoomInvitationNotification(notificationAccountParams, event),
summaryLine = event.description,
isNoisy = event.noisy,
timestamp = event.timestamp
)
@ -140,7 +127,6 @@ class DefaultNotificationDataFactory(
OneShotNotification(
tag = event.eventId.value,
notification = notificationCreator.createSimpleEventNotification(notificationAccountParams, event),
summaryLine = event.description,
isNoisy = event.noisy,
timestamp = event.timestamp
)
@ -149,26 +135,31 @@ class DefaultNotificationDataFactory(
@JvmName("toNotificationFallbackEvents")
@Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications(
override fun toNotification(
fallback: List<FallbackNotifiableEvent>,
notificationAccountParams: NotificationAccountParams,
): List<OneShotNotification> {
return fallback.map { event ->
OneShotNotification(
tag = event.eventId.value,
notification = notificationCreator.createFallbackNotification(notificationAccountParams, event),
summaryLine = event.description.orEmpty(),
isNoisy = false,
timestamp = event.timestamp
)
}
): OneShotNotification? {
if (fallback.isEmpty()) return null
val existingNotification = activeNotificationsProvider
.getFallbackNotification(notificationAccountParams.user.userId)
?.notification
val notification = notificationCreator.createFallbackNotification(
existingNotification = existingNotification,
notificationAccountParams = notificationAccountParams,
fallbackNotifiableEvents = fallback,
)
return OneShotNotification(
tag = FALLBACK_NOTIFICATION_TAG,
notification = notification,
isNoisy = false,
timestamp = fallback.first().timestamp
)
}
override fun createSummaryNotification(
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
notificationAccountParams: NotificationAccountParams,
): SummaryNotification {
return when {
@ -178,51 +169,14 @@ class DefaultNotificationDataFactory(
roomNotifications = roomNotifications,
invitationNotifications = invitationNotifications,
simpleNotifications = simpleNotifications,
fallbackNotifications = fallbackNotifications,
notificationAccountParams = notificationAccountParams,
)
)
}
}
private fun createRoomMessagesGroupSummaryLine(events: List<NotifiableMessageEvent>, roomName: String, roomIsDm: Boolean): CharSequence {
return when (events.size) {
1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDm)
else -> {
stringProvider.getQuantityString(
R.plurals.notification_compat_summary_line_for_room,
events.size,
roomName,
events.size
)
}
}
}
private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDm: Boolean): CharSequence {
return if (roomIsDm) {
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)
}
}
companion object {
const val FALLBACK_NOTIFICATION_TAG = "FALLBACK"
}
}
@ -230,7 +184,6 @@ data class RoomNotification(
val notification: Notification,
val roomId: RoomId,
val threadId: ThreadId?,
val summaryLine: CharSequence,
val messageCount: Int,
val latestTimestamp: Long,
val shouldBing: Boolean,
@ -239,7 +192,6 @@ data class RoomNotification(
return notification == other.notification &&
roomId == other.roomId &&
threadId == other.threadId &&
summaryLine.toString() == other.summaryLine.toString() &&
messageCount == other.messageCount &&
latestTimestamp == other.latestTimestamp &&
shouldBing == other.shouldBing
@ -249,7 +201,6 @@ data class RoomNotification(
data class OneShotNotification(
val notification: Notification,
val tag: String,
val summaryLine: CharSequence,
val isNoisy: Boolean,
val timestamp: Long,
)

View file

@ -55,12 +55,11 @@ class NotificationRenderer(
val roomNotifications = notificationDataFactory.toNotifications(groupedEvents.roomEvents, imageLoader, notificationAccountParams)
val invitationNotifications = notificationDataFactory.toNotifications(groupedEvents.invitationEvents, notificationAccountParams)
val simpleNotifications = notificationDataFactory.toNotifications(groupedEvents.simpleEvents, notificationAccountParams)
val fallbackNotifications = notificationDataFactory.toNotifications(groupedEvents.fallbackEvents, notificationAccountParams)
val fallbackNotification = notificationDataFactory.toNotification(groupedEvents.fallbackEvents, notificationAccountParams)
val summaryNotification = notificationDataFactory.createSummaryNotification(
roomNotifications = roomNotifications,
invitationNotifications = invitationNotifications,
simpleNotifications = simpleNotifications,
fallbackNotifications = fallbackNotifications,
notificationAccountParams = notificationAccountParams,
)
@ -107,13 +106,12 @@ class NotificationRenderer(
}
}
// Show only the first fallback notification
if (fallbackNotifications.isNotEmpty()) {
Timber.tag(loggerTag.value).d("Showing fallback notification")
if (fallbackNotification != null) {
Timber.tag(loggerTag.value).d("Showing or updating fallback notification")
notificationDisplayer.showNotification(
tag = "FALLBACK",
tag = fallbackNotification.tag,
id = NotificationIdProvider.getFallbackNotificationId(currentUser.userId),
notification = fallbackNotifications.first().notification
notification = fallbackNotification.notification,
)
}

View file

@ -22,7 +22,6 @@ interface SummaryGroupMessageCreator {
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
): Notification
}
@ -45,7 +44,6 @@ class DefaultSummaryGroupMessageCreator(
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
): Notification {
val summaryIsNoisy = roomNotifications.any { it.shouldBing } ||
invitationNotifications.any { it.isNoisy } ||

View file

@ -75,8 +75,9 @@ interface NotificationCreator {
): Notification
fun createFallbackNotification(
existingNotification: Notification?,
notificationAccountParams: NotificationAccountParams,
fallbackNotifiableEvent: FallbackNotifiableEvent,
fallbackNotifiableEvents: List<FallbackNotifiableEvent>,
): Notification
/**
@ -308,31 +309,38 @@ class DefaultNotificationCreator(
}
override fun createFallbackNotification(
existingNotification: Notification?,
notificationAccountParams: NotificationAccountParams,
fallbackNotifiableEvent: FallbackNotifiableEvent,
fallbackNotifiableEvents: List<FallbackNotifiableEvent>,
): Notification {
val channelId = notificationChannels.getChannelIdForMessage(
sessionId = fallbackNotifiableEvent.sessionId,
noisy = false,
)
val existingCounter = existingNotification
?.extras
?.getInt(FALLBACK_COUNTER_EXTRA)
?: 0
val counter = existingCounter + fallbackNotifiableEvents.size
val fallbackNotifiableEvent = fallbackNotifiableEvents.first()
return NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(true)
.setContentTitle(buildMeta.applicationName.annotateForDebug(7))
.setContentText(fallbackNotifiableEvent.description.orEmpty().annotateForDebug(8))
.setContentText(
stringProvider.getQuantityString(R.plurals.notification_fallback_n_content, counter, counter)
.annotateForDebug(8)
)
.setExtras(
bundleOf(
FALLBACK_COUNTER_EXTRA to counter
)
)
.setNumber(counter)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.configureWith(notificationAccountParams)
.setAutoCancel(true)
.setWhen(fallbackNotifiableEvent.timestamp)
// Ideally we'd use `createOpenRoomPendingIntent` here, but the broken notification might apply to an invite
// and the user won't have access to the room yet, resulting in an error screen.
.setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(fallbackNotifiableEvent.sessionId))
.setDeleteIntent(
pendingIntentFactory.createDismissEventPendingIntent(
fallbackNotifiableEvent.sessionId,
fallbackNotifiableEvent.roomId,
fallbackNotifiableEvent.eventId
)
)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
@ -522,6 +530,7 @@ class DefaultNotificationCreator(
companion object {
const val MESSAGE_EVENT_ID = "message_event_id"
private const val FALLBACK_COUNTER_EXTRA = "COUNTER"
}
}

View file

@ -15,10 +15,6 @@ 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.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.currentRoomId
import io.element.android.services.appnavstate.api.currentSessionId
import io.element.android.services.appnavstate.api.currentThreadId
data class NotifiableMessageEvent(
override val sessionId: SessionId,
@ -56,24 +52,3 @@ data class NotifiableMessageEvent(
val imageUri: Uri?
get() = imageUriString?.toUri()
}
/**
* Used to check if a notification should be ignored based on the current app and navigation state.
*/
fun NotifiableEvent.shouldIgnoreEventInRoom(appNavigationState: AppNavigationState): Boolean {
val currentSessionId = appNavigationState.navigationState.currentSessionId() ?: return false
return when (val currentRoomId = appNavigationState.navigationState.currentRoomId()) {
null -> false
else -> {
// Never ignore ringing call notifications
if (this is NotifiableRingingCallEvent) {
false
} else {
appNavigationState.isInForeground &&
sessionId == currentSessionId &&
roomId == currentRoomId &&
(this as? NotifiableMessageEvent)?.threadId == appNavigationState.navigationState.currentThreadId()
}
}
}
}

View file

@ -15,6 +15,10 @@
</plurals>
<string name="notification_error_unified_push_unregistered_android">"The UnifiedPush notification distributor couldn\'t be registered, so you will not receive notifications anymore. Please check the notifications settings of the app and the status of the push distributor."</string>
<string name="notification_fallback_content">"You have new messages."</string>
<plurals name="notification_fallback_n_content">
<item quantity="one">"You have %d new message."</item>
<item quantity="other">"You have %d new messages."</item>
</plurals>
<string name="notification_incoming_call">"📹 Incoming call"</string>
<string name="notification_inline_reply_failed">"** Failed to send - please open room"</string>
<string name="notification_invitation_action_join">"Join"</string>