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 4fa950b25e
commit 185d4fadde
42 changed files with 410 additions and 194 deletions

View File

@@ -39,6 +39,7 @@ dependencies {
implementation(projects.libraries.pushproviders.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.matrixmedia.api)
implementation(projects.libraries.uiCommon)
implementation(projects.libraries.uiStrings)
implementation(projects.features.login.api)

View File

@@ -33,7 +33,7 @@ import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices
import io.element.android.libraries.designsystem.utils.ScreenOrientation
import io.element.android.libraries.matrix.ui.media.NotLoggedInImageLoaderFactory
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
@@ -42,7 +42,7 @@ class NotLoggedInFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val loginEntryPoint: LoginEntryPoint,
private val notLoggedInImageLoaderFactory: NotLoggedInImageLoaderFactory,
private val imageLoaderHolder: ImageLoaderHolder,
) : BaseFlowNode<NotLoggedInFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
@@ -66,7 +66,7 @@ class NotLoggedInFlowNode(
super.onBuilt()
lifecycle.subscribe(
onResume = {
SingletonImageLoader.setUnsafe(notLoggedInImageLoaderFactory.newImageLoader())
SingletonImageLoader.setUnsafe(imageLoaderHolder.get())
},
)
}

View File

@@ -74,7 +74,7 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.impl)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.matrixmedia.api)
implementation(projects.libraries.network)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api)
@@ -94,7 +94,7 @@ dependencies {
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.matrixuiTest)
testImplementation(projects.libraries.matrixmedia.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.services.appnavstate.test)

View File

@@ -22,6 +22,8 @@ import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.receivers.DeclineCallBroadcastReceiver
import io.element.android.features.call.impl.ui.IncomingCallActivity
import io.element.android.features.call.impl.utils.IntentProvider
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.MatrixClientProvider
@@ -70,7 +72,15 @@ class RingingCallNotificationCreator(
): Notification? {
val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null
val imageLoader = imageLoaderHolder.get(matrixClient)
val largeIcon = notificationBitmapLoader.getUserIcon(roomAvatarUrl, imageLoader)
val largeIcon = notificationBitmapLoader.getUserIcon(
avatarData = AvatarData(
id = roomId.value,
name = roomName,
url = roomAvatarUrl,
size = AvatarSize.RoomDetailsHeader,
),
imageLoader = imageLoader,
)
val caller = Person.Builder()
.setName(senderDisplayName)

View File

@@ -13,6 +13,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import coil3.ImageLoader
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
@@ -53,7 +54,7 @@ class RingingCallNotificationCreatorTest {
@Test
fun `createNotification - tries to load the avatar URL`() = runTest {
val getUserIconLambda = lambdaRecorder<String?, ImageLoader, IconCompat?> { _, _ -> null }
val getUserIconLambda = lambdaRecorder<AvatarData, ImageLoader, IconCompat?> { _, _ -> null }
val notificationCreator = createRingingCallNotificationCreator(
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) }),
notificationBitmapLoader = FakeNotificationBitmapLoader(getUserIconResult = getUserIconLambda)

View File

@@ -39,6 +39,7 @@ dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.matrixmedia.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.textcomposer.impl)
implementation(projects.libraries.uiStrings)

View File

@@ -46,7 +46,7 @@ internal fun ImageAvatar(
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
is AsyncImagePainter.State.Error -> {
SideEffect {
Timber.Forest.e(
Timber.e(
state.result.throwable,
"Error loading avatar $state\n${state.result}"
)

View File

@@ -11,11 +11,11 @@ plugins {
}
android {
namespace = "io.element.android.libraries.matrix.ui.test"
namespace = "io.element.android.libraries.matrix.ui.media.api"
}
dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(libs.coil.compose)
}

View File

@@ -8,9 +8,6 @@
package io.element.android.libraries.matrix.ui.media
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.media.MediaSource
/**
* The size in pixel of the thumbnail to generate for the avatar.
* This is not the size of the avatar displayed in the UI but the size to get from the servers.
@@ -25,10 +22,3 @@ import io.element.android.libraries.matrix.api.media.MediaSource
* Let's always use the same size so coil caching works properly.
*/
const val AVATAR_THUMBNAIL_SIZE_IN_PIXEL = 240L
internal fun AvatarData.toMediaRequestData(): MediaRequestData {
return MediaRequestData(
source = url?.let { MediaSource(it) },
kind = MediaRequestData.Kind.Thumbnail(AVATAR_THUMBNAIL_SIZE_IN_PIXEL)
)
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media
import coil3.ImageLoader
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
interface ImageLoaderHolder {
fun get(): ImageLoader
fun get(client: MatrixClient): ImageLoader
fun remove(sessionId: SessionId)
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media
import android.graphics.Bitmap
import io.element.android.libraries.designsystem.components.avatar.AvatarData
/**
* Generates a bitmap for an initials avatar based on the provided [io.element.android.libraries.designsystem.components.avatar.AvatarData].
*/
interface InitialsAvatarBitmapGenerator {
fun generateBitmap(
size: Int,
avatarData: AvatarData,
useDarkTheme: Boolean,
fontSizePercentage: Float = 0.5f,
): Bitmap?
}

View File

@@ -12,7 +12,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource
/**
* Can be use with [coil3.compose.AsyncImage] to load a [MediaSource].
* This will go internally through our [CoilMediaFetcher].
* This will go internally through our CoilMediaFetcher.
*
* Example of usage:
* AsyncImage(

View File

@@ -0,0 +1,33 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.libraries.matrix.ui.media.impl"
}
setupDependencyInjection()
dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixmedia.api)
implementation(projects.libraries.designsystem)
implementation(libs.coil.compose)
implementation(libs.coil.gif)
implementation(libs.coil.network.okhttp)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.libraries.sessionStorage.test)
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.media.MediaSource
internal fun AvatarData.toMediaRequestData(): MediaRequestData {
return MediaRequestData(
source = url?.let { MediaSource(it) },
kind = MediaRequestData.Kind.Thumbnail(AVATAR_THUMBNAIL_SIZE_IN_PIXEL)
)
}

View File

@@ -27,14 +27,15 @@ internal class CoilMediaFetcher(
private val mediaData: MediaRequestData,
) : Fetcher {
override suspend fun fetch(): FetchResult? {
if (mediaData.source == null) {
val source = mediaData.source
if (source == null) {
Timber.e("MediaData source is null")
return null
}
return when (mediaData.kind) {
is MediaRequestData.Kind.Content -> fetchContent(mediaData.source)
is MediaRequestData.Kind.Thumbnail -> fetchThumbnail(mediaData.source, mediaData.kind)
is MediaRequestData.Kind.File -> fetchFile(mediaData.source, mediaData.kind)
return when (val kind = mediaData.kind) {
is MediaRequestData.Kind.Content -> fetchContent(source)
is MediaRequestData.Kind.Thumbnail -> fetchThumbnail(source, kind)
is MediaRequestData.Kind.File -> fetchFile(source, kind)
}
}

View File

@@ -17,18 +17,16 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
interface ImageLoaderHolder {
fun get(client: MatrixClient): ImageLoader
fun remove(sessionId: SessionId)
}
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultImageLoaderHolder(
private val loggedInImageLoaderFactory: LoggedInImageLoaderFactory,
private val imageLoaderFactory: ImageLoaderFactory,
private val sessionObserver: SessionObserver,
) : ImageLoaderHolder {
private val map = mutableMapOf<SessionId, ImageLoader>()
private val notLoggedInImageLoader by lazy {
imageLoaderFactory.newImageLoader()
}
init {
observeSessions()
@@ -42,10 +40,14 @@ class DefaultImageLoaderHolder(
})
}
override fun get(): ImageLoader {
return notLoggedInImageLoader
}
override fun get(client: MatrixClient): ImageLoader {
return synchronized(map) {
map.getOrPut(client.sessionId) {
loggedInImageLoaderFactory
imageLoaderFactory
.newImageLoader(client.matrixMediaLoader)
}
}

View File

@@ -24,6 +24,8 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.dp
import androidx.core.graphics.createBitmap
import coil3.Bitmap
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.compound.theme.AvatarColors
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.SemanticColors
@@ -35,60 +37,35 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
/**
* Generates a bitmap for an initials avatar based on the provided [AvatarData].
*/
class InitialsAvatarBitmapGenerator(
useDarkTheme: Boolean = false,
private val fontSizePercentage: Float = 0.5f,
) {
private val compoundColors: SemanticColors = if (useDarkTheme) {
compoundColorsDark
} else {
compoundColorsLight
}
@ContributesBinding(AppScope::class)
class DefaultInitialsAvatarBitmapGenerator : InitialsAvatarBitmapGenerator {
// List of predefined avatar colors to use for initials avatars, in light mode
private val allAvatarColors: List<AvatarColors> = listOf(
AvatarColors(
background = compoundColors.bgDecorative1,
foreground = compoundColors.textDecorative1,
),
AvatarColors(
background = compoundColors.bgDecorative2,
foreground = compoundColors.textDecorative2,
),
AvatarColors(
background = compoundColors.bgDecorative3,
foreground = compoundColors.textDecorative3,
),
AvatarColors(
background = compoundColors.bgDecorative4,
foreground = compoundColors.textDecorative4,
),
AvatarColors(
background = compoundColors.bgDecorative5,
foreground = compoundColors.textDecorative5,
),
AvatarColors(
background = compoundColors.bgDecorative6,
foreground = compoundColors.textDecorative6,
),
)
private val lightAvatarColors: List<AvatarColors> = compoundColorsLight.buildAvatarColors()
// List of predefined avatar colors to use for initials avatars, in dark mode
private val darkAvatarColors: List<AvatarColors> = compoundColorsDark.buildAvatarColors()
/**
* Generates a bitmap for an avatar with no URL, using the initials from the [AvatarData].
* @param size The size of the bitmap to generate, in pixels.
* @param avatarData The [AvatarData] containing the initials and other information.
* @param useDarkTheme Whether the theme is dark.
* @param fontSizePercentage The percentage of the avatar size to use for the font size.
*/
fun generateBitmap(size: Int, avatarData: AvatarData): Bitmap? {
override fun generateBitmap(
size: Int,
avatarData: AvatarData,
useDarkTheme: Boolean,
fontSizePercentage: Float,
): Bitmap? {
if (avatarData.url != null) {
// This generator is only for initials avatars, not for avatars with URLs
return null
}
// Get the color pair to use for the initials avatar
val avatarColors = allAvatarColors[avatarData.id.sumOf { it.code } % allAvatarColors.size]
val colors = if (useDarkTheme) darkAvatarColors else lightAvatarColors
val avatarColors = colors[avatarData.id.sumOf { it.code } % colors.size]
val bitmap = createBitmap(size, size)
Canvas(bitmap).run {
@@ -116,20 +93,32 @@ class InitialsAvatarBitmapGenerator(
}
}
private fun SemanticColors.buildAvatarColors(): List<AvatarColors> = listOf(
AvatarColors(background = bgDecorative1, foreground = textDecorative1),
AvatarColors(background = bgDecorative2, foreground = textDecorative2),
AvatarColors(background = bgDecorative3, foreground = textDecorative3),
AvatarColors(background = bgDecorative4, foreground = textDecorative4),
AvatarColors(background = bgDecorative5, foreground = textDecorative5),
AvatarColors(background = bgDecorative6, foreground = textDecorative6),
)
@Composable
@PreviewsDayNight
internal fun InitialsAvatarBitmapGeneratorPreview() = ElementPreview {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
val generator = remember { DefaultInitialsAvatarBitmapGenerator() }
repeat(6) { index ->
val avatarData = remember { AvatarData(id = index.toString(), name = Char('0'.code + index).toString(), size = AvatarSize.IncomingCall) }
val isLightTheme = ElementTheme.isLightTheme
val bitmap = remember(isLightTheme) {
val generator = InitialsAvatarBitmapGenerator(useDarkTheme = !isLightTheme)
generator.generateBitmap(512, avatarData)?.asImageBitmap()
generator.generateBitmap(
size = 512,
avatarData = avatarData,
useDarkTheme = !isLightTheme,
)?.asImageBitmap()
}
bitmap?.let {
Image(bitmap = it, contentDescription = null, modifier = Modifier.size(48.dp))
} ?: Text("No avatar generated")

View File

@@ -16,32 +16,40 @@ import coil3.gif.GifDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.Provider
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import okhttp3.OkHttpClient
interface LoggedInImageLoaderFactory {
interface ImageLoaderFactory {
fun newImageLoader(): ImageLoader
fun newImageLoader(matrixMediaLoader: MatrixMediaLoader): ImageLoader
}
@ContributesBinding(AppScope::class)
class DefaultLoggedInImageLoaderFactory(
class DefaultImageLoaderFactory(
@ApplicationContext private val context: Context,
private val okHttpClient: Provider<OkHttpClient>,
) : LoggedInImageLoaderFactory {
) : ImageLoaderFactory {
private val okHttpNetworkFetcherFactory = OkHttpNetworkFetcherFactory(
callFactory = {
// Use newBuilder, see https://coil-kt.github.io/coil/network/#using-a-custom-okhttpclient
okHttpClient().newBuilder().build()
}
)
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(context)
.components {
add(okHttpNetworkFetcherFactory)
}
.build()
}
override fun newImageLoader(matrixMediaLoader: MatrixMediaLoader): ImageLoader {
return ImageLoader.Builder(context)
.components {
add(
OkHttpNetworkFetcherFactory(
callFactory = {
// Use newBuilder, see https://coil-kt.github.io/coil/network/#using-a-custom-okhttpclient
okHttpClient().newBuilder().build()
}
)
)
add(okHttpNetworkFetcherFactory)
// Add gif support
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
add(AnimatedImageDecoder.Factory())
@@ -56,24 +64,3 @@ class DefaultLoggedInImageLoaderFactory(
.build()
}
}
@Inject
class NotLoggedInImageLoaderFactory(
@ApplicationContext private val context: Context,
private val okHttpClient: Provider<OkHttpClient>,
) {
fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(context)
.components {
add(
OkHttpNetworkFetcherFactory(
callFactory = {
// Use newBuilder, see https://coil-kt.github.io/coil/network/#using-a-custom-okhttpclient
okHttpClient().newBuilder().build()
}
)
)
}
.build()
}
}

View File

@@ -25,6 +25,5 @@ internal class MediaRequestDataKeyer : Keyer<MediaRequestData> {
}
private fun MediaRequestData.toKey(): String? {
if (source == null) return null
return "${source.url}_$kind"
return source?.let { "${it.url}_$kind" }
}

View File

@@ -14,6 +14,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver
import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver
import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -30,9 +31,10 @@ class DefaultImageLoaderHolderTest {
val context = InstrumentationRegistry.getInstrumentation().context
val lambda = lambdaRecorder<MatrixMediaLoader, ImageLoader> { ImageLoader.Builder(context).build() }
val holder = DefaultImageLoaderHolder(
loggedInImageLoaderFactory = FakeLoggedInImageLoaderFactory(lambda),
sessionObserver = NoOpSessionObserver()
val holder = createDefaultImageLoaderHolder(
imageLoaderFactory = FakeImageLoaderFactory(
newMatrixImageLoaderLambda = lambda,
),
)
val client = FakeMatrixClient()
val imageLoader1 = holder.get(client)
@@ -50,8 +52,10 @@ class DefaultImageLoaderHolderTest {
lambdaRecorder<MatrixMediaLoader, ImageLoader> { ImageLoader.Builder(context).build() }
val sessionObserver = FakeSessionObserver()
val holder = DefaultImageLoaderHolder(
loggedInImageLoaderFactory = FakeLoggedInImageLoaderFactory(lambda),
sessionObserver = sessionObserver
imageLoaderFactory = FakeImageLoaderFactory(
newMatrixImageLoaderLambda = lambda,
),
sessionObserver = sessionObserver,
)
assertThat(sessionObserver.listeners.size).isEqualTo(1)
val client = FakeMatrixClient()
@@ -69,10 +73,20 @@ class DefaultImageLoaderHolderTest {
lambdaRecorder<MatrixMediaLoader, ImageLoader> { ImageLoader.Builder(context).build() }
val sessionObserver = FakeSessionObserver()
DefaultImageLoaderHolder(
loggedInImageLoaderFactory = FakeLoggedInImageLoaderFactory(lambda),
sessionObserver = sessionObserver
imageLoaderFactory = FakeImageLoaderFactory(
newMatrixImageLoaderLambda = lambda,
),
sessionObserver = sessionObserver,
)
assertThat(sessionObserver.listeners.size).isEqualTo(1)
sessionObserver.onSessionCreated(A_SESSION_ID.value)
}
}
private fun createDefaultImageLoaderHolder(
imageLoaderFactory: ImageLoaderFactory = FakeImageLoaderFactory(),
sessionObserver: SessionObserver = NoOpSessionObserver(),
) = DefaultImageLoaderHolder(
imageLoaderFactory = imageLoaderFactory,
sessionObserver = sessionObserver,
)

View File

@@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media
import coil3.ImageLoader
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.tests.testutils.lambda.lambdaError
class FakeImageLoaderFactory(
private val newImageLoaderLambda: () -> ImageLoader = { lambdaError() },
private val newMatrixImageLoaderLambda: (MatrixMediaLoader) -> ImageLoader = { lambdaError() },
) : ImageLoaderFactory {
override fun newImageLoader(): ImageLoader {
return newImageLoaderLambda()
}
override fun newImageLoader(matrixMediaLoader: MatrixMediaLoader): ImageLoader {
return newMatrixImageLoaderLambda(matrixMediaLoader)
}
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.matrix.ui.media.test"
}
dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixmedia.api)
implementation(projects.libraries.designsystem)
implementation(projects.tests.testutils)
implementation(libs.coil.compose)
}

View File

@@ -16,6 +16,10 @@ import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
class FakeImageLoaderHolder(
val fakeImageLoader: ImageLoader = FakeImageLoader(),
) : ImageLoaderHolder {
override fun get(): ImageLoader {
return fakeImageLoader
}
override fun get(client: MatrixClient): ImageLoader {
return fakeImageLoader
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.test.media
import coil3.Bitmap
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.ui.media.InitialsAvatarBitmapGenerator
import io.element.android.tests.testutils.lambda.lambdaError
class FakeInitialsAvatarBitmapGenerator(
private val generateBitmapResult: (Int, AvatarData, Boolean, Float) -> Bitmap? = { _, _, _, _ -> lambdaError() }
) : InitialsAvatarBitmapGenerator {
override fun generateBitmap(
size: Int,
avatarData: AvatarData,
useDarkTheme: Boolean,
fontSizePercentage: Float,
): Bitmap? {
return generateBitmapResult(size, avatarData, useDarkTheme, fontSizePercentage)
}
}

View File

@@ -30,13 +30,12 @@ dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixmedia.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.core)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags)
implementation(libs.coil.compose)
implementation(libs.coil.gif)
implementation(libs.coil.network.okhttp)
implementation(libs.jsoup)
implementation(projects.libraries.previewutils)

View File

@@ -1,20 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media
import coil3.ImageLoader
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
class FakeLoggedInImageLoaderFactory(
private val newImageLoaderLambda: (MatrixMediaLoader) -> ImageLoader
) : LoggedInImageLoaderFactory {
override fun newImageLoader(matrixMediaLoader: MatrixMediaLoader): ImageLoader {
return newImageLoaderLambda(matrixMediaLoader)
}
}

View File

@@ -44,7 +44,7 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.matrixmedia.api)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.voiceplayer.api)
implementation(projects.services.toolbox.api)
@@ -61,6 +61,7 @@ dependencies {
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.matrixui)
testImplementation(projects.libraries.mediaviewer.test)
testImplementation(projects.services.toolbox.test)
testImplementation(libs.coroutines.core)

View File

@@ -17,7 +17,8 @@ dependencies {
implementation(libs.androidx.corektx)
implementation(libs.coroutines.core)
implementation(libs.coil.compose)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.matrixmedia.api)
implementation(projects.libraries.pushproviders.api)
}

View File

@@ -11,17 +11,18 @@ package io.element.android.libraries.push.api.notifications
import android.graphics.Bitmap
import androidx.core.graphics.drawable.IconCompat
import coil3.ImageLoader
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.ui.media.AVATAR_THUMBNAIL_SIZE_IN_PIXEL
interface NotificationBitmapLoader {
/**
* Get icon of a room.
* @param path mxc url
* @param avatarData the data related to the Avatar
* @param imageLoader Coil image loader
* @param targetSize The size we want the bitmap to be resized to
*/
suspend fun getRoomBitmap(
path: String?,
avatarData: AvatarData,
imageLoader: ImageLoader,
targetSize: Long = AVATAR_THUMBNAIL_SIZE_IN_PIXEL,
): Bitmap?
@@ -29,8 +30,11 @@ interface NotificationBitmapLoader {
/**
* Get icon of a user.
* Before Android P, this does nothing because the icon won't be used
* @param path mxc url
* @param avatarData the data related to the Avatar
* @param imageLoader Coil image loader
*/
suspend fun getUserIcon(path: String?, imageLoader: ImageLoader): IconCompat?
suspend fun getUserIcon(
avatarData: AvatarData,
imageLoader: ImageLoader,
): IconCompat?
}

View File

@@ -56,6 +56,7 @@ dependencies {
implementation(projects.libraries.network)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.matrixmedia.api)
implementation(projects.features.networkmonitor.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.sessionStorage.api)
@@ -77,7 +78,7 @@ dependencies {
testCommonDependencies(libs)
testImplementation(libs.coil.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.matrixuiTest)
testImplementation(projects.libraries.matrixmedia.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.libraries.push.test)

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,

View File

@@ -17,6 +17,7 @@ android {
dependencies {
api(projects.libraries.push.api)
api(projects.libraries.pushproviders.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.push.impl)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)

View File

@@ -11,17 +11,18 @@ package io.element.android.libraries.push.test.notifications.push
import android.graphics.Bitmap
import androidx.core.graphics.drawable.IconCompat
import coil3.ImageLoader
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
class FakeNotificationBitmapLoader(
var getRoomBitmapResult: (String?, ImageLoader, Long) -> Bitmap? = { _, _, _ -> null },
var getUserIconResult: (String?, ImageLoader) -> IconCompat? = { _, _ -> null },
var getRoomBitmapResult: (AvatarData, ImageLoader, Long) -> Bitmap? = { _, _, _ -> null },
var getUserIconResult: (AvatarData, ImageLoader) -> IconCompat? = { _, _ -> null },
) : NotificationBitmapLoader {
override suspend fun getRoomBitmap(path: String?, imageLoader: ImageLoader, targetSize: Long): Bitmap? {
return getRoomBitmapResult(path, imageLoader, targetSize)
override suspend fun getRoomBitmap(avatarData: AvatarData, imageLoader: ImageLoader, targetSize: Long): Bitmap? {
return getRoomBitmapResult(avatarData, imageLoader, targetSize)
}
override suspend fun getUserIcon(path: String?, imageLoader: ImageLoader): IconCompat? {
return getUserIconResult(path, imageLoader)
override suspend fun getUserIcon(avatarData: AvatarData, imageLoader: ImageLoader): IconCompat? {
return getUserIconResult(avatarData, imageLoader)
}
}

View File

@@ -90,6 +90,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
implementation(project(":libraries:designsystem"))
implementation(project(":libraries:matrix:impl"))
implementation(project(":libraries:matrixui"))
implementation(project(":libraries:matrixmedia:impl"))
implementation(project(":libraries:network"))
implementation(project(":libraries:core"))
implementation(project(":libraries:eventformatter:impl"))