Let notifications uses the brandColor.

This commit is contained in:
Benoit Marty 2025-10-24 17:04:48 +02:00
parent 257325022c
commit 6e9af83b6a
24 changed files with 239 additions and 92 deletions

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.push.impl.notifications
import android.app.Notification
import android.graphics.Typeface
import android.text.style.StyleSpan
import androidx.annotation.ColorInt
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import coil3.ImageLoader
@ -31,17 +32,29 @@ interface NotificationDataFactory {
messages: List<NotifiableMessageEvent>,
currentUser: MatrixUser,
imageLoader: ImageLoader,
@ColorInt color: Int,
): List<RoomNotification>
@JvmName("toNotificationInvites")
@Suppress("INAPPLICABLE_JVM_NAME")
fun toNotifications(invites: List<InviteNotifiableEvent>): List<OneShotNotification>
fun toNotifications(
invites: List<InviteNotifiableEvent>,
@ColorInt color: Int,
): List<OneShotNotification>
@JvmName("toNotificationSimpleEvents")
@Suppress("INAPPLICABLE_JVM_NAME")
fun toNotifications(simpleEvents: List<SimpleNotifiableEvent>): List<OneShotNotification>
fun toNotifications(
simpleEvents: List<SimpleNotifiableEvent>,
@ColorInt color: Int,
): List<OneShotNotification>
@JvmName("toNotificationFallbackEvents")
@Suppress("INAPPLICABLE_JVM_NAME")
fun toNotifications(fallback: List<FallbackNotifiableEvent>): List<OneShotNotification>
fun toNotifications(
fallback: List<FallbackNotifiableEvent>,
@ColorInt color: Int,
): List<OneShotNotification>
fun createSummaryNotification(
currentUser: MatrixUser,
@ -49,6 +62,7 @@ interface NotificationDataFactory {
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
@ColorInt color: Int,
): SummaryNotification
}
@ -64,6 +78,7 @@ class DefaultNotificationDataFactory(
messages: List<NotifiableMessageEvent>,
currentUser: MatrixUser,
imageLoader: ImageLoader,
@ColorInt color: Int,
): List<RoomNotification> {
val messagesToDisplay = messages.filterNot { it.canNotBeDisplayed() }
.groupBy { it.roomId }
@ -76,6 +91,7 @@ class DefaultNotificationDataFactory(
roomId = roomId,
imageLoader = imageLoader,
existingNotification = getExistingNotificationForMessages(currentUser.userId, roomId),
color = color,
)
RoomNotification(
notification = notification,
@ -96,11 +112,14 @@ class DefaultNotificationDataFactory(
@JvmName("toNotificationInvites")
@Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications(invites: List<InviteNotifiableEvent>): List<OneShotNotification> {
override fun toNotifications(
invites: List<InviteNotifiableEvent>,
@ColorInt color: Int,
): List<OneShotNotification> {
return invites.map { event ->
OneShotNotification(
key = event.roomId.value,
notification = notificationCreator.createRoomInvitationNotification(event),
notification = notificationCreator.createRoomInvitationNotification(event, color),
summaryLine = event.description,
isNoisy = event.noisy,
timestamp = event.timestamp
@ -110,11 +129,14 @@ class DefaultNotificationDataFactory(
@JvmName("toNotificationSimpleEvents")
@Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications(simpleEvents: List<SimpleNotifiableEvent>): List<OneShotNotification> {
override fun toNotifications(
simpleEvents: List<SimpleNotifiableEvent>,
@ColorInt color: Int,
): List<OneShotNotification> {
return simpleEvents.map { event ->
OneShotNotification(
key = event.eventId.value,
notification = notificationCreator.createSimpleEventNotification(event),
notification = notificationCreator.createSimpleEventNotification(event, color),
summaryLine = event.description,
isNoisy = event.noisy,
timestamp = event.timestamp
@ -124,11 +146,14 @@ class DefaultNotificationDataFactory(
@JvmName("toNotificationFallbackEvents")
@Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications(fallback: List<FallbackNotifiableEvent>): List<OneShotNotification> {
override fun toNotifications(
fallback: List<FallbackNotifiableEvent>,
@ColorInt color: Int,
): List<OneShotNotification> {
return fallback.map { event ->
OneShotNotification(
key = event.eventId.value,
notification = notificationCreator.createFallbackNotification(event),
notification = notificationCreator.createFallbackNotification(event, color),
summaryLine = event.description.orEmpty(),
isNoisy = false,
timestamp = event.timestamp
@ -142,6 +167,7 @@ class DefaultNotificationDataFactory(
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
@ColorInt color: Int,
): SummaryNotification {
return when {
roomNotifications.isEmpty() && invitationNotifications.isEmpty() && simpleNotifications.isEmpty() -> SummaryNotification.Removed
@ -152,6 +178,7 @@ class DefaultNotificationDataFactory(
invitationNotifications = invitationNotifications,
simpleNotifications = simpleNotifications,
fallbackNotifications = fallbackNotifications,
color = color,
)
)
}

View file

@ -7,8 +7,11 @@
package io.element.android.libraries.push.impl.notifications
import androidx.compose.ui.graphics.toArgb
import coil3.ImageLoader
import dev.zacsweers.metro.Inject
import io.element.android.appconfig.NotificationConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
@ -18,6 +21,7 @@ import io.element.android.libraries.push.impl.notifications.model.NotifiableEven
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 kotlinx.coroutines.flow.first
import timber.log.Timber
private val loggerTag = LoggerTag("NotificationRenderer", LoggerTag.NotificationLoggerTag)
@ -26,6 +30,7 @@ private val loggerTag = LoggerTag("NotificationRenderer", LoggerTag.Notification
class NotificationRenderer(
private val notificationDisplayer: NotificationDisplayer,
private val notificationDataFactory: NotificationDataFactory,
private val enterpriseService: EnterpriseService,
) {
suspend fun render(
currentUser: MatrixUser,
@ -33,17 +38,20 @@ class NotificationRenderer(
eventsToProcess: List<NotifiableEvent>,
imageLoader: ImageLoader,
) {
val color = enterpriseService.brandColorsFlow(currentUser.userId).first()?.toArgb()
?: NotificationConfig.NOTIFICATION_ACCENT_COLOR
val groupedEvents = eventsToProcess.groupByType()
val roomNotifications = notificationDataFactory.toNotifications(groupedEvents.roomEvents, currentUser, imageLoader)
val invitationNotifications = notificationDataFactory.toNotifications(groupedEvents.invitationEvents)
val simpleNotifications = notificationDataFactory.toNotifications(groupedEvents.simpleEvents)
val fallbackNotifications = notificationDataFactory.toNotifications(groupedEvents.fallbackEvents)
val roomNotifications = notificationDataFactory.toNotifications(groupedEvents.roomEvents, currentUser, imageLoader, color)
val invitationNotifications = notificationDataFactory.toNotifications(groupedEvents.invitationEvents, color)
val simpleNotifications = notificationDataFactory.toNotifications(groupedEvents.simpleEvents, color)
val fallbackNotifications = notificationDataFactory.toNotifications(groupedEvents.fallbackEvents, color)
val summaryNotification = notificationDataFactory.createSummaryNotification(
currentUser = currentUser,
roomNotifications = roomNotifications,
invitationNotifications = invitationNotifications,
simpleNotifications = simpleNotifications,
fallbackNotifications = fallbackNotifications,
color = color,
)
// Remove summary first to avoid briefly displaying it after dismissing the last notification

View file

@ -9,6 +9,7 @@ package io.element.android.libraries.push.impl.notifications
import android.app.Notification
import android.graphics.Bitmap
import androidx.annotation.ColorInt
import coil3.ImageLoader
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
@ -28,6 +29,7 @@ interface RoomGroupMessageCreator {
roomId: RoomId,
imageLoader: ImageLoader,
existingNotification: Notification?,
@ColorInt color: Int,
): Notification
}
@ -43,6 +45,7 @@ class DefaultRoomGroupMessageCreator(
roomId: RoomId,
imageLoader: ImageLoader,
existingNotification: Notification?,
@ColorInt color: Int,
): Notification {
val lastKnownRoomEvent = events.last()
val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderDisambiguatedDisplayName ?: "Room name (${roomId.value.take(8)}…)"
@ -60,24 +63,25 @@ class DefaultRoomGroupMessageCreator(
val smartReplyErrors = events.filter { it.isSmartReplyError() }
val roomIsDm = !roomIsGroup
return notificationCreator.createMessagesListNotification(
RoomEventGroupInfo(
sessionId = currentUser.userId,
roomId = roomId,
roomDisplayName = roomName,
isDm = roomIsDm,
hasSmartReplyError = smartReplyErrors.isNotEmpty(),
shouldBing = events.any { it.noisy },
customSound = events.last().soundName,
isUpdated = events.last().isUpdated,
),
threadId = lastKnownRoomEvent.threadId,
largeIcon = largeBitmap,
lastMessageTimestamp = lastMessageTimestamp,
tickerText = tickerText,
currentUser = currentUser,
existingNotification = existingNotification,
imageLoader = imageLoader,
events = events,
RoomEventGroupInfo(
sessionId = currentUser.userId,
roomId = roomId,
roomDisplayName = roomName,
isDm = roomIsDm,
hasSmartReplyError = smartReplyErrors.isNotEmpty(),
shouldBing = events.any { it.noisy },
customSound = events.last().soundName,
isUpdated = events.last().isUpdated,
),
threadId = lastKnownRoomEvent.threadId,
largeIcon = largeBitmap,
lastMessageTimestamp = lastMessageTimestamp,
tickerText = tickerText,
currentUser = currentUser,
existingNotification = existingNotification,
imageLoader = imageLoader,
events = events,
color = color,
)
}

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.push.impl.notifications
import android.app.Notification
import androidx.annotation.ColorInt
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.user.MatrixUser
@ -22,6 +23,7 @@ interface SummaryGroupMessageCreator {
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
@ColorInt color: Int,
): Notification
}
@ -45,6 +47,7 @@ class DefaultSummaryGroupMessageCreator(
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
@ColorInt color: Int,
): Notification {
val summaryIsNoisy = roomNotifications.any { it.shouldBing } ||
invitationNotifications.any { it.isNoisy } ||
@ -61,7 +64,8 @@ class DefaultSummaryGroupMessageCreator(
currentUser,
sumTitle,
noisy = summaryIsNoisy,
lastMessageTimestamp = lastMessageTimestamp
lastMessageTimestamp = lastMessageTimestamp,
color = color,
)
}
}

View file

@ -11,6 +11,7 @@ import android.app.Notification
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.MessagingStyle
@ -19,7 +20,6 @@ import androidx.core.content.res.ResourcesCompat
import coil3.ImageLoader
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.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.annotations.ApplicationContext
@ -57,18 +57,22 @@ interface NotificationCreator {
existingNotification: Notification?,
imageLoader: ImageLoader,
events: List<NotifiableMessageEvent>,
@ColorInt color: Int,
): Notification
fun createRoomInvitationNotification(
inviteNotifiableEvent: InviteNotifiableEvent
inviteNotifiableEvent: InviteNotifiableEvent,
@ColorInt color: Int,
): Notification
fun createSimpleEventNotification(
simpleNotifiableEvent: SimpleNotifiableEvent,
@ColorInt color: Int,
): Notification
fun createFallbackNotification(
fallbackNotifiableEvent: FallbackNotifiableEvent,
@ColorInt color: Int,
): Notification
/**
@ -78,10 +82,13 @@ interface NotificationCreator {
currentUser: MatrixUser,
compatSummary: String,
noisy: Boolean,
lastMessageTimestamp: Long
lastMessageTimestamp: Long,
@ColorInt color: Int,
): Notification
fun createDiagnosticNotification(): Notification
fun createDiagnosticNotification(
@ColorInt color: Int,
): Notification
}
@ContributesBinding(AppScope::class)
@ -97,8 +104,6 @@ class DefaultNotificationCreator(
private val acceptInvitationActionFactory: AcceptInvitationActionFactory,
private val rejectInvitationActionFactory: RejectInvitationActionFactory
) : NotificationCreator {
private val accentColor = NotificationConfig.NOTIFICATION_ACCENT_COLOR
/**
* Create a notification for a Room.
*/
@ -112,15 +117,14 @@ class DefaultNotificationCreator(
existingNotification: Notification?,
imageLoader: ImageLoader,
events: List<NotifiableMessageEvent>,
@ColorInt color: Int,
): Notification {
// Build the pending intent for when the notification is clicked
val openIntent = when {
threadId != null -> pendingIntentFactory.createOpenThreadPendingIntent(roomInfo, threadId)
else -> pendingIntentFactory.createOpenRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId)
}
val smallIcon = CommonDrawables.ic_notification
val containsMissedCall = events.any { it.type == EventType.RTC_NOTIFICATION }
val channelId = if (containsMissedCall) {
notificationChannels.getChannelForIncomingCall(false)
@ -176,7 +180,7 @@ class DefaultNotificationCreator(
)
.setSmallIcon(smallIcon)
// Set primary color (important for Wear 2.0 Notifications).
.setColor(accentColor)
.setColor(color)
// Sets priority for 25 and below. For 26 and above, 'priority' is deprecated for
// 'importance' which is set in the NotificationChannel. The integers representing
// 'priority' are different from 'importance', so make sure you don't mix them.
@ -189,7 +193,7 @@ class DefaultNotificationCreator(
setSound(it)
}
*/
setLights(accentColor, 500, 500)
setLights(color, 500, 500)
} else {
priority = NotificationCompat.PRIORITY_LOW
}
@ -221,7 +225,8 @@ class DefaultNotificationCreator(
}
override fun createRoomInvitationNotification(
inviteNotifiableEvent: InviteNotifiableEvent
inviteNotifiableEvent: InviteNotifiableEvent,
@ColorInt color: Int,
): Notification {
val smallIcon = CommonDrawables.ic_notification
val channelId = notificationChannels.getChannelIdForMessage(inviteNotifiableEvent.noisy)
@ -232,7 +237,7 @@ class DefaultNotificationCreator(
.setGroup(inviteNotifiableEvent.sessionId.value)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon)
.setColor(accentColor)
.setColor(color)
.apply {
addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent))
addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent))
@ -247,7 +252,7 @@ class DefaultNotificationCreator(
setSound(it)
}
*/
setLights(accentColor, 500, 500)
setLights(color, 500, 500)
} else {
priority = NotificationCompat.PRIORITY_LOW
}
@ -264,9 +269,9 @@ class DefaultNotificationCreator(
override fun createSimpleEventNotification(
simpleNotifiableEvent: SimpleNotifiableEvent,
@ColorInt color: Int,
): Notification {
val smallIcon = CommonDrawables.ic_notification
val channelId = notificationChannels.getChannelIdForMessage(simpleNotifiableEvent.noisy)
return NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(true)
@ -275,7 +280,7 @@ class DefaultNotificationCreator(
.setGroup(simpleNotifiableEvent.sessionId.value)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon)
.setColor(accentColor)
.setColor(color)
.setAutoCancel(true)
.setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(simpleNotifiableEvent.sessionId, simpleNotifiableEvent.roomId))
.apply {
@ -287,7 +292,7 @@ class DefaultNotificationCreator(
setSound(it)
}
*/
setLights(accentColor, 500, 500)
setLights(color, 500, 500)
} else {
priority = NotificationCompat.PRIORITY_LOW
}
@ -297,9 +302,9 @@ class DefaultNotificationCreator(
override fun createFallbackNotification(
fallbackNotifiableEvent: FallbackNotifiableEvent,
@ColorInt color: Int,
): Notification {
val smallIcon = CommonDrawables.ic_notification
val channelId = notificationChannels.getChannelIdForMessage(false)
return NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(true)
@ -308,7 +313,7 @@ class DefaultNotificationCreator(
.setGroup(fallbackNotifiableEvent.sessionId.value)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon)
.setColor(accentColor)
.setColor(color)
.setAutoCancel(true)
.setWhen(fallbackNotifiableEvent.timestamp)
// Ideally we'd use `createOpenRoomPendingIntent` here, but the broken notification might apply to an invite
@ -332,7 +337,8 @@ class DefaultNotificationCreator(
currentUser: MatrixUser,
compatSummary: String,
noisy: Boolean,
lastMessageTimestamp: Long
lastMessageTimestamp: Long,
@ColorInt color: Int,
): Notification {
val smallIcon = CommonDrawables.ic_notification
val channelId = notificationChannels.getChannelIdForMessage(noisy)
@ -345,7 +351,7 @@ class DefaultNotificationCreator(
.setGroup(currentUser.userId.value)
// set this notification as the summary for the group
.setGroupSummary(true)
.setColor(accentColor)
.setColor(color)
.apply {
if (noisy) {
// Compat
@ -355,7 +361,7 @@ class DefaultNotificationCreator(
setSound(it)
}
*/
setLights(accentColor, 500, 500)
setLights(color, 500, 500)
} else {
// compat
priority = NotificationCompat.PRIORITY_LOW
@ -366,14 +372,16 @@ class DefaultNotificationCreator(
.build()
}
override fun createDiagnosticNotification(): Notification {
override fun createDiagnosticNotification(
@ColorInt color: Int,
): Notification {
val intent = pendingIntentFactory.createTestPendingIntent()
return NotificationCompat.Builder(context, notificationChannels.getChannelIdForTest())
.setContentTitle(buildMeta.applicationName)
.setContentText(stringProvider.getString(R.string.notification_test_push_notification_content))
.setSmallIcon(CommonDrawables.ic_notification)
.setLargeIcon(getBitmap(R.drawable.element_logo_green))
.setColor(accentColor)
.setColor(color)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setAutoCancel(true)

View file

@ -7,9 +7,13 @@
package io.element.android.libraries.push.impl.troubleshoot
import dev.zacsweers.metro.AppScope
import androidx.compose.ui.graphics.toArgb
import dev.zacsweers.metro.ContributesIntoSet
import dev.zacsweers.metro.Inject
import io.element.android.appconfig.NotificationConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.di.SessionScope
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.NotificationDisplayer
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
@ -25,13 +29,15 @@ import kotlinx.coroutines.withTimeout
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
@ContributesIntoSet(AppScope::class)
@ContributesIntoSet(SessionScope::class)
@Inject
class NotificationTest(
private val sessionId: SessionId,
private val notificationCreator: NotificationCreator,
private val notificationDisplayer: NotificationDisplayer,
private val notificationClickHandler: NotificationClickHandler,
private val stringProvider: StringProvider,
private val enterpriseService: EnterpriseService,
) : NotificationTroubleshootTest {
override val order = 50
private val delegate = NotificationTroubleshootTestDelegate(
@ -43,7 +49,9 @@ class NotificationTest(
override suspend fun run(coroutineScope: CoroutineScope) {
delegate.start()
val notification = notificationCreator.createDiagnosticNotification()
val color = enterpriseService.brandColorsFlow(sessionId).first()?.toArgb()
?: NotificationConfig.NOTIFICATION_ACCENT_COLOR
val notification = notificationCreator.createDiagnosticNotification(color)
val result = notificationDisplayer.displayDiagnosticNotification(notification)
if (result) {
coroutineScope.listenToNotificationClick()