Let notifications use avatar fallback.

Extract code which handles Matrix image to its own api / impl / test modules.
This commit is contained in:
Benoit Marty 2025-11-12 11:15:02 +01:00
parent 8603d54778
commit 573767aca1
42 changed files with 410 additions and 194 deletions

View file

@ -9,6 +9,7 @@
package io.element.android.libraries.push.impl.notifications
import android.content.Context
import android.content.res.Configuration
import android.graphics.Bitmap
import android.os.Build
import androidx.core.graphics.drawable.IconCompat
@ -19,9 +20,11 @@ import coil3.toBitmap
import coil3.transform.CircleCropTransformation
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.AVATAR_THUMBNAIL_SIZE_IN_PIXEL
import io.element.android.libraries.matrix.ui.media.InitialsAvatarBitmapGenerator
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
@ -31,48 +34,71 @@ import timber.log.Timber
class DefaultNotificationBitmapLoader(
@ApplicationContext private val context: Context,
private val sdkIntProvider: BuildVersionSdkIntProvider,
private val initialsAvatarBitmapGenerator: InitialsAvatarBitmapGenerator,
) : NotificationBitmapLoader {
override suspend fun getRoomBitmap(path: String?, imageLoader: ImageLoader, targetSize: Long): Bitmap? {
if (path == null) {
return null
}
return loadRoomBitmap(path, imageLoader, targetSize)
}
private suspend fun loadRoomBitmap(path: String, imageLoader: ImageLoader, targetSize: Long): Bitmap? {
override suspend fun getRoomBitmap(
avatarData: AvatarData,
imageLoader: ImageLoader,
targetSize: Long,
): Bitmap? {
return try {
val imageRequest = ImageRequest.Builder(context)
.data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(targetSize)))
.transformations(CircleCropTransformation())
.build()
val result = imageLoader.execute(imageRequest)
result.image?.toBitmap()
loadBitmap(
avatarData = avatarData,
imageLoader = imageLoader,
targetSize = targetSize,
)
} catch (e: Throwable) {
Timber.e(e, "Unable to load room bitmap")
null
}
}
override suspend fun getUserIcon(path: String?, imageLoader: ImageLoader): IconCompat? {
if (path == null || sdkIntProvider.get() < Build.VERSION_CODES.P) {
override suspend fun getUserIcon(
avatarData: AvatarData,
imageLoader: ImageLoader,
): IconCompat? {
if (sdkIntProvider.get() < Build.VERSION_CODES.P) {
return null
}
return loadUserIcon(path, imageLoader)
}
private suspend fun loadUserIcon(path: String, imageLoader: ImageLoader): IconCompat? {
return try {
val imageRequest = ImageRequest.Builder(context)
.data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(AVATAR_THUMBNAIL_SIZE_IN_PIXEL)))
.transformations(CircleCropTransformation())
.build()
val result = imageLoader.execute(imageRequest)
val bitmap = result.image?.toBitmap()
return bitmap?.let { IconCompat.createWithBitmap(it) }
loadBitmap(
avatarData = avatarData,
imageLoader = imageLoader,
targetSize = AVATAR_THUMBNAIL_SIZE_IN_PIXEL,
)
?.let { IconCompat.createWithBitmap(it) }
} catch (e: Throwable) {
Timber.e(e, "Unable to load user bitmap")
null
}
}
private fun isDarkTheme(): Boolean {
return context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
}
private suspend fun loadBitmap(
avatarData: AvatarData,
imageLoader: ImageLoader,
targetSize: Long
): Bitmap? {
val path = avatarData.url
val data = if (path != null) {
MediaRequestData(
source = MediaSource(path),
kind = MediaRequestData.Kind.Thumbnail(targetSize),
)
} else {
initialsAvatarBitmapGenerator.generateBitmap(
size = targetSize.toInt(),
avatarData = avatarData,
useDarkTheme = isDarkTheme(),
)
}
val imageRequest = ImageRequest.Builder(context)
.data(data)
.transformations(CircleCropTransformation())
.build()
return imageLoader.execute(imageRequest).image?.toBitmap()
}
}

View file

@ -13,6 +13,8 @@ import android.graphics.Bitmap
import coil3.ImageLoader
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
@ -90,7 +92,18 @@ class DefaultRoomGroupMessageCreator(
imageLoader: ImageLoader,
): Bitmap? {
// Use the last event (most recent?)
return events.reversed().firstNotNullOfOrNull { it.roomAvatarPath }
?.let { bitmapLoader.getRoomBitmap(it, imageLoader) }
val event = events.reversed().firstOrNull { it.roomAvatarPath != null }
?: events.reversed().firstOrNull()
return event?.let { event ->
bitmapLoader.getRoomBitmap(
avatarData = AvatarData(
id = event.roomId.value,
name = event.roomName,
url = event.roomAvatarPath,
size = AvatarSize.RoomDetailsHeader,
),
imageLoader = imageLoader,
)
}
}
}

View file

@ -10,7 +10,6 @@ package io.element.android.libraries.push.impl.notifications.conversations
import android.content.Context
import android.content.pm.ShortcutInfo
import android.content.res.Configuration
import android.os.Build
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
@ -29,7 +28,6 @@ import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.matrix.ui.media.InitialsAvatarBitmapGenerator
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
import io.element.android.libraries.push.impl.intent.IntentProvider
@ -95,15 +93,16 @@ class DefaultNotificationConversationService(
val imageLoader = imageLoaderHolder.get(client)
val defaultShortcutIconSize = ShortcutManagerCompat.getIconMaxWidth(context)
val useDarkTheme = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
val icon = bitmapLoader.getRoomBitmap(
path = roomAvatarUrl,
avatarData = AvatarData(
id = roomId.value,
name = roomName,
url = roomAvatarUrl,
size = AvatarSize.RoomDetailsHeader,
),
imageLoader = imageLoader,
targetSize = defaultShortcutIconSize.toLong()
)?.let(IconCompat::createWithBitmap)
?: InitialsAvatarBitmapGenerator(useDarkTheme = useDarkTheme)
.generateBitmap(defaultShortcutIconSize, AvatarData(id = roomId.value, name = roomName, size = AvatarSize.RoomDetailsHeader))
?.let(IconCompat::createWithAdaptiveBitmap)
val shortcutInfo = ShortcutInfoCompat.Builder(context, createShortcutId(sessionId, roomId))
.setShortLabel(roomName)

View file

@ -20,12 +20,15 @@ import coil3.ImageLoader
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import io.element.android.libraries.push.impl.R
@ -401,7 +404,17 @@ class DefaultNotificationCreator(
}
Person.Builder()
.setName(displayName.annotateForDebug(70))
.setIcon(bitmapLoader.getUserIcon(event.senderAvatarPath, imageLoader))
.setIcon(
bitmapLoader.getUserIcon(
avatarData = AvatarData(
id = event.senderId.value,
name = senderName,
url = event.senderAvatarPath,
size = AvatarSize.UserHeader,
),
imageLoader = imageLoader,
)
)
.setKey(key)
.build()
}
@ -460,7 +473,12 @@ class DefaultNotificationCreator(
Person.Builder()
// Note: name cannot be empty else NotificationCompat.MessagingStyle() will crash
.setName(user.getBestName().annotateForDebug(50))
.setIcon(bitmapLoader.getUserIcon(user.avatarUrl, imageLoader))
.setIcon(
bitmapLoader.getUserIcon(
avatarData = user.getAvatarData(AvatarSize.UserHeader),
imageLoader = imageLoader,
)
)
.setKey(user.userId.value)
.build()
).also {

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.media.AVATAR_THUMBNAIL_SIZE_IN_PIXEL
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.matrix.ui.test.media.FakeImageLoader
import io.element.android.libraries.matrix.ui.test.media.FakeInitialsAvatarBitmapGenerator
import io.element.android.libraries.push.impl.notifications.factories.MARK_AS_READ_ACTION_TITLE
import io.element.android.libraries.push.impl.notifications.factories.QUICK_REPLY_ACTION_TITLE
import io.element.android.libraries.push.impl.notifications.factories.aNotificationAccountParams
@ -232,6 +233,7 @@ fun createRoomGroupMessageCreator(
val bitmapLoader = DefaultNotificationBitmapLoader(
context = RuntimeEnvironment.getApplication(),
sdkIntProvider = sdkIntProvider,
initialsAvatarBitmapGenerator = FakeInitialsAvatarBitmapGenerator(),
)
return DefaultRoomGroupMessageCreator(
notificationCreator = createNotificationCreator(bitmapLoader = bitmapLoader),

View file

@ -24,6 +24,7 @@ import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.test.media.FakeImageLoader
import io.element.android.libraries.matrix.ui.test.media.FakeInitialsAvatarBitmapGenerator
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import io.element.android.libraries.push.impl.notifications.DefaultNotificationBitmapLoader
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
@ -297,7 +298,11 @@ fun createNotificationCreator(
context: Context = RuntimeEnvironment.getApplication(),
buildMeta: BuildMeta = aBuildMeta(),
notificationChannels: NotificationChannels = createNotificationChannels(),
bitmapLoader: NotificationBitmapLoader = DefaultNotificationBitmapLoader(context, FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.R)),
bitmapLoader: NotificationBitmapLoader = DefaultNotificationBitmapLoader(
context = context,
sdkIntProvider = FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.R),
initialsAvatarBitmapGenerator = FakeInitialsAvatarBitmapGenerator(),
),
): NotificationCreator {
return DefaultNotificationCreator(
context = context,