[PM-45006] chore: Migrate from glide to coil for async image loading (#7465)

This commit is contained in:
David Perez
2026-10-19 14:04:38 +00:00
committed by GitHub
parent 1470154cf3
commit cf75707839
17 changed files with 99 additions and 401 deletions
+5 -5
View File
@@ -168,16 +168,16 @@ The following is a list of all third-party dependencies included as part of the
- Purpose: The WorkManager is used to schedule deferrable, asynchronous tasks that must be run reliably.
- License: Apache 2.0
- **Coil**
- https://github.com/coil-kt/coil
- Purpose: Image loading and caching.
- License: Apache 2.0
- **Dagger Hilt**
- https://github.com/google/dagger
- Purpose: Dependency injection framework.
- License: Apache 2.0
- **Glide**
- https://github.com/bumptech/glide
- Purpose: Image loading and caching.
- License: BSD, part MIT and Apache 2.0
- **kotlinx.collections.immutable**
- https://github.com/Kotlin/kotlinx.collections.immutable
- Purpose: Immutable collection interfaces and implementation prototypes for Kotlin.
+2 -3
View File
@@ -275,9 +275,8 @@ dependencies {
implementation(libs.androidx.splashscreen)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.bitwarden.sdk)
implementation(libs.bumptech.glide)
implementation(libs.bumptech.glide.okhttp)
ksp(libs.bumptech.glide.compiler)
implementation(libs.coil.coil)
implementation(libs.coil.coil.network.okhttp)
implementation(libs.google.hilt.android)
ksp(libs.google.hilt.compiler)
implementation(libs.kotlinx.collections.immutable)
-7
View File
@@ -55,13 +55,6 @@
kotlinx.serialization.KSerializer serializer(...);
}
################################################################################
# Glide
################################################################################
-keep public class * implements com.bumptech.glide.module.GlideModule
-keep public class * extends com.bumptech.glide.module.AppGlideModule
################################################################################
# JNA
################################################################################
@@ -1,6 +1,9 @@
package com.x8bit.bitwarden
import android.app.Application
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.data.manager.flightrecorder.FlightRecorderManager
import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager
@@ -11,6 +14,7 @@ import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConfigManager
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import dagger.Lazy
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
import javax.inject.Inject
@@ -20,7 +24,7 @@ import javax.inject.Inject
*/
@OmitFromCoverage
@HiltAndroidApp
class BitwardenApplication : Application() {
class BitwardenApplication : Application(), SingletonImageLoader.Factory {
// Inject classes here that must be triggered on startup but are not otherwise consumed by
// other callers.
@Inject
@@ -50,6 +54,9 @@ class BitwardenApplication : Application() {
@Inject
lateinit var environmentRepository: EnvironmentRepository
@Inject
lateinit var imageLoader: Lazy<ImageLoader>
override fun onCreate() {
super.onCreate()
// These must be initialized in order to ensure that the restrictionManager does not
@@ -62,4 +69,6 @@ class BitwardenApplication : Application() {
super.onLowMemory()
Timber.w("onLowMemory")
}
override fun newImageLoader(context: PlatformContext): ImageLoader = imageLoader.get()
}
@@ -1,63 +0,0 @@
package com.x8bit.bitwarden.ui.credentials.util
import android.content.Context
import androidx.annotation.DrawableRes
import androidx.core.graphics.drawable.IconCompat
import com.bitwarden.ui.platform.components.icon.model.IconData
import com.bumptech.glide.Glide
import timber.log.Timber
import java.util.concurrent.CancellationException
import java.util.concurrent.ExecutionException
/**
* Creates an IconCompat from an IconData, or falls back to a default resource if IconData is null
* or is not of type Network.
*
* @param iconData The IconData to create the IconCompat from.
* @param defaultResourceId The resource ID of the default icon to use if IconData is null or not of
* type Network.
* @return An IconCompat created from the IconData or the default resource.
*/
suspend fun Context.createFido2IconCompatFromIconDataOrDefault(
iconData: IconData?,
@DrawableRes defaultResourceId: Int,
): IconCompat = if (iconData != null && iconData is IconData.Network) {
createFido2IconCompatFromRemoteUriOrDefaultResource(
uri = iconData.uri,
defaultResourceId = defaultResourceId,
)
} else {
createFido2IconCompatFromResource(defaultResourceId)
}
/**
* Creates an IconCompat from a drawable resource ID.
*/
fun Context.createFido2IconCompatFromResource(@DrawableRes resourceId: Int) =
IconCompat.createWithResource(this, resourceId)
// futureTargetBitmap.get() is a blocking call so this function must be called from a coroutine.
@Suppress("RedundantSuspendModifier")
private suspend fun Context.createFido2IconCompatFromRemoteUriOrDefaultResource(
uri: String,
@DrawableRes defaultResourceId: Int,
): IconCompat {
val futureTargetBitmap = Glide
.with(this)
.asBitmap()
.load(uri)
.placeholder(defaultResourceId)
.submit()
return try {
IconCompat.createWithBitmap(futureTargetBitmap.get())
} catch (e: CancellationException) {
Timber.e(e, "Cancellation exception while loading icon.")
IconCompat.createWithResource(this, defaultResourceId)
} catch (e: ExecutionException) {
Timber.e(e, "Execution exception while loading icon.")
IconCompat.createWithResource(this, defaultResourceId)
} catch (e: InterruptedException) {
Timber.e(e, "Interrupted while loading icon.")
IconCompat.createWithResource(this, defaultResourceId)
}
}
@@ -1,75 +0,0 @@
package com.x8bit.bitwarden.ui.platform.glide
import android.content.Context
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.network.ssl.createMtlsOkHttpClient
import com.bumptech.glide.Glide
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.module.AppGlideModule
import com.x8bit.bitwarden.data.platform.manager.CertificateManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkCookieManager
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import java.io.InputStream
/**
* Custom Glide module for the Bitwarden app that configures Glide to use an OkHttpClient
* with mTLS (mutual TLS) support.
*
* This ensures that all icon/image loading requests through Glide present the client certificate
* for mutual TLS authentication, allowing them to pass through Cloudflare's mTLS checks.
*
* The configuration mirrors the SSL setup used in RetrofitsImpl for API calls.
*/
@OmitFromCoverage
@GlideModule
class BitwardenAppGlideModule : AppGlideModule() {
/**
* Entry point to access Hilt-provided dependencies from non-Hilt managed classes.
*/
@EntryPoint
@InstallIn(SingletonComponent::class)
interface BitwardenGlideEntryPoint {
/**
* Provides access to the [CertificateManager] for mTLS certificate management.
*/
fun certificateManager(): CertificateManager
/**
* Provides access to the [NetworkCookieManager] for cookie-based authentication.
*/
fun networkCookieManager(): NetworkCookieManager
}
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
// Get CertificateManager from Hilt
val entryPoint = EntryPointAccessors.fromApplication(
context = context.applicationContext,
entryPoint = BitwardenGlideEntryPoint::class.java,
)
val certificateManager = entryPoint.certificateManager()
val networkCookieManager = entryPoint.networkCookieManager()
// Build OkHttpClient with mTLS and cookie support
val client = certificateManager
.createMtlsOkHttpClient()
.newBuilder()
.addNetworkInterceptor(GlideCookieInterceptor(networkCookieManager))
.build()
// Register OkHttpUrlLoader that uses our mTLS + cookie OkHttpClient
registry.replace(
GlideUrl::class.java,
InputStream::class.java,
OkHttpUrlLoader.Factory(client),
)
}
override fun isManifestParsingEnabled(): Boolean = false
}
@@ -1,4 +1,4 @@
package com.x8bit.bitwarden.ui.platform.glide
package com.x8bit.bitwarden.ui.platform.image
import com.bitwarden.network.exception.CookieRedirectException
import com.bitwarden.network.provider.CookieProvider
@@ -9,16 +9,16 @@ private const val HEADER_COOKIE = "Cookie"
private const val HTTP_302 = 302
/**
* Interceptor that attaches cookies to Glide image requests for enterprise environments
* requiring cookie-based authentication.
* Interceptor that attaches cookies to image requests for enterprise environments requiring
* cookie-based authentication.
*
* Unlike [com.bitwarden.network.interceptor.CookieInterceptor], this interceptor does not
* trigger cookie acquisition. It throws [CookieRedirectException] on HTTP 302 responses
* to prevent Glide from following redirects and caching invalid content.
* to prevent following redirects and caching invalid content.
*
* @property cookieProvider Provider for retrieving cookies by hostname.
*/
class GlideCookieInterceptor(
class ImageCookieInterceptor(
private val cookieProvider: CookieProvider,
) : Interceptor {
@@ -0,0 +1,55 @@
package com.x8bit.bitwarden.ui.platform.image.di
import android.content.Context
import coil3.ImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.bitwarden.network.ssl.createMtlsOkHttpClient
import com.x8bit.bitwarden.data.platform.manager.CertificateManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkCookieManager
import com.x8bit.bitwarden.ui.platform.image.ImageCookieInterceptor
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Provides dependencies for loading images.
*/
@Module
@InstallIn(SingletonComponent::class)
object ImageModule {
/**
* Provides the [ImageLoader] used by Coil, configured to use an OkHttpClient with (mutual TLS)
* and cookie support.
*
* This ensures that all icon/image loading requests through Coil present the client
* certificate for mutual TLS authentication, allowing them to pass through Cloudflare's mTLS
* checks.
*
* The configuration mirrors the SSL setup used for non-image API calls.
*/
@Provides
@Singleton
fun provideImageLoader(
@ApplicationContext context: Context,
certificateManager: CertificateManager,
networkCookieManager: NetworkCookieManager,
): ImageLoader = ImageLoader
.Builder(context = context)
.components {
this.add(
OkHttpNetworkFetcherFactory(
callFactory = {
certificateManager
.createMtlsOkHttpClient()
.newBuilder()
.addNetworkInterceptor(ImageCookieInterceptor(networkCookieManager))
.build()
},
),
)
}
.build()
}
@@ -1,144 +0,0 @@
package com.x8bit.bitwarden.ui.credentials.util
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.Icon
import android.net.Uri
import androidx.core.graphics.drawable.IconCompat
import com.bitwarden.ui.platform.components.icon.model.IconData
import com.bumptech.glide.Glide
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.util.concurrent.CancellationException
import java.util.concurrent.ExecutionException
class ContextExtensionsTest {
val mockContext = mockk<Context>()
@BeforeEach
fun setUp() {
mockkStatic(
Glide::class,
Icon::class,
IconCompat::class,
)
}
@AfterEach
fun tearDown() {
unmockkStatic(
Glide::class,
Icon::class,
IconCompat::class,
Uri::class,
)
}
@Suppress("MaxLineLength")
@Test
fun `createFido2IconCompatFromIconDataOrDefault should return default icon when IconData is null`() =
runTest {
every { IconCompat.createWithResource(mockContext, 0) } returns IconCompat()
mockContext.createFido2IconCompatFromIconDataOrDefault(
iconData = null,
defaultResourceId = 0,
)
verify { IconCompat.createWithResource(mockContext, 0) }
}
@Suppress("MaxLineLength")
@Test
fun `createFido2IconCompatFromIconDataOrDefault should return default icon when IconData is not Network type`() =
runTest {
every { IconCompat.createWithResource(mockContext, 0) } returns IconCompat()
mockContext.createFido2IconCompatFromIconDataOrDefault(
iconData = IconData.Local(0),
defaultResourceId = 0,
)
verify { IconCompat.createWithResource(mockContext, 0) }
}
@Suppress("MaxLineLength")
@Test
fun `createFido2IconCompatFromIconDataOrDefault should load icon from URI when IconData is Network type`() =
runTest {
val mockBitmap = mockk<Bitmap>()
setupMockGlide(mockBitmap)
every { IconCompat.createWithBitmap(mockBitmap) } returns mockk()
mockContext.createFido2IconCompatFromIconDataOrDefault(
iconData = IconData.Network("https://www.mockuri.com", 0),
defaultResourceId = 0,
)
verify { IconCompat.createWithBitmap(mockBitmap) }
}
@Suppress("MaxLineLength")
@Test
fun `createFido2IconCompatFromIconDataOrDefault should return default icon when loading from URI throws CancellationException`() =
runTest {
val mockBitmap = mockk<Bitmap>()
setupMockGlide(mockBitmap)
every { IconCompat.createWithResource(mockContext, 0) } returns mockk()
every { IconCompat.createWithBitmap(mockBitmap) } throws CancellationException()
mockContext.createFido2IconCompatFromIconDataOrDefault(
iconData = IconData.Network("https://www.mockuri.com", 0),
defaultResourceId = 0,
)
verify { IconCompat.createWithResource(mockContext, 0) }
}
@Suppress("MaxLineLength")
@Test
fun `createFido2IconCompatFromIconDataOrDefault should return default icon when loading from URI throws ExecutionException`() =
runTest {
val mockBitmap = mockk<Bitmap>()
setupMockGlide(mockBitmap)
every { IconCompat.createWithResource(mockContext, 0) } returns mockk()
every {
IconCompat.createWithBitmap(mockBitmap)
} throws ExecutionException("message", Throwable())
mockContext.createFido2IconCompatFromIconDataOrDefault(
iconData = IconData.Network("https://www.mockuri.com", 0),
defaultResourceId = 0,
)
verify { IconCompat.createWithResource(mockContext, 0) }
}
@Suppress("MaxLineLength")
@Test
fun `createFido2IconCompatFromIconDataOrDefault should return default icon when loading from URI throws InterruptedException`() =
runTest {
val mockBitmap = mockk<Bitmap>()
setupMockGlide(mockBitmap)
every { IconCompat.createWithResource(mockContext, 0) } returns mockk()
every { IconCompat.createWithBitmap(mockBitmap) } throws InterruptedException()
mockContext.createFido2IconCompatFromIconDataOrDefault(
iconData = IconData.Network("https://www.mockuri.com", 0),
defaultResourceId = 0,
)
verify { IconCompat.createWithResource(mockContext, 0) }
}
private fun setupMockGlide(mockBitmap: Bitmap) {
every { Glide.with(mockContext) } returns mockk {
every { asBitmap() } returns mockk {
every { load(any<String>()) } returns mockk {
every { placeholder(0) } returns mockk {
every { submit() } returns mockk {
every { get() } returns mockBitmap
}
}
}
}
}
}
}
@@ -1,60 +0,0 @@
package com.x8bit.bitwarden.ui.platform.glide
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* Test class for [BitwardenAppGlideModule] to verify mTLS configuration is properly applied
* to Glide without requiring a real mTLS server.
*
* These tests verify the module's structure and that it can be instantiated.
* Full integration testing requires running the app and checking logcat for
* "BitwardenGlide" logs when images are loaded.
*/
class BitwardenAppGlideModuleTest {
@Test
fun `BitwardenAppGlideModule should be instantiable`() {
// Verify the module can be created
val module = BitwardenAppGlideModule()
assertNotNull(module)
}
@Test
fun `BitwardenAppGlideModule should have EntryPoint interface for Hilt dependency injection`() {
// Verify the Hilt EntryPoint interface exists for accessing CertificateManager
val entryPointInterface = BitwardenAppGlideModule::class.java
.declaredClasses
.firstOrNull { it.simpleName == "BitwardenGlideEntryPoint" }
assertNotNull(entryPointInterface)
}
@Test
fun `BitwardenGlideEntryPoint should declare certificateManager method`() {
// Verify the EntryPoint has the required method to access CertificateManager
val entryPointInterface = BitwardenAppGlideModule::class.java
.declaredClasses
.firstOrNull { it.simpleName == "BitwardenGlideEntryPoint" }
val methods = requireNotNull(entryPointInterface).declaredMethods
val hasCertificateManagerMethod = methods.any { it.name == "certificateManager" }
assertTrue(hasCertificateManagerMethod)
}
@Test
fun `BitwardenGlideEntryPoint should declare networkCookieManager method`() {
val entryPointInterface = BitwardenAppGlideModule::class.java
.declaredClasses
.firstOrNull { it.simpleName == "BitwardenGlideEntryPoint" }
val methods = requireNotNull(entryPointInterface).declaredMethods
val hasNetworkCookieManagerMethod =
methods.any { it.name == "networkCookieManager" }
assertTrue(hasNetworkCookieManagerMethod)
}
}
@@ -1,4 +1,4 @@
package com.x8bit.bitwarden.ui.platform.glide
package com.x8bit.bitwarden.ui.platform.image
import com.bitwarden.network.exception.CookieRedirectException
import com.bitwarden.network.interceptor.FakeInterceptorChain
@@ -15,11 +15,11 @@ import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
class GlideCookieInterceptorTest {
class ImageCookieInterceptorTest {
private val mockCookieProvider: CookieProvider = mockk()
private val interceptor = GlideCookieInterceptor(
private val interceptor = ImageCookieInterceptor(
cookieProvider = mockCookieProvider,
)
-1
View File
@@ -227,7 +227,6 @@ dependencies {
implementation(libs.androidx.splashscreen)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.bitwarden.sdk)
implementation(libs.bumptech.glide)
implementation(platform(libs.google.firebase.bom))
implementation(libs.google.firebase.cloud.messaging)
implementation(libs.google.firebase.crashlytics)
-7
View File
@@ -46,13 +46,6 @@
kotlinx.serialization.KSerializer serializer(...);
}
################################################################################
# Glide
################################################################################
-keep public class * implements com.bumptech.glide.module.GlideModule
-keep public class * extends com.bumptech.glide.module.AppGlideModule
################################################################################
# Google Protobuf generated files
################################################################################
+5 -5
View File
@@ -30,11 +30,10 @@ androidxSecurityCrypto = "1.1.0"
androidxSplash = "1.2.0"
androidxWork = "2.12.0"
bitwardenSdk = "4.0.0-9318-3b2c8fc3"
coil = "3.6.3"
crashlytics = "3.0.8"
detekt = "1.23.8"
firebaseBom = "35.0.0"
glide = "5.0.9"
glideCompose = "1.0.0-beta01"
googleBilling = "9.1.0"
googleMlkitTextRecognition = "16.0.1"
googleGuava = "33.7.2-jre"
@@ -99,9 +98,10 @@ androidx-security-crypto = { module = "androidx.security:security-crypto", versi
androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidxSplash" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidxWork" }
bitwarden-sdk = { module = "com.bitwarden:sdk-android", version.ref = "bitwardenSdk" }
bumptech-glide = { module = "com.github.bumptech.glide:compose", version.ref = "glideCompose" }
bumptech-glide-okhttp = { module = "com.github.bumptech.glide:okhttp3-integration", version.ref = "glide" }
bumptech-glide-compiler = { module = "com.github.bumptech.glide:ksp", version.ref = "glide" }
coil-coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
coil-coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
coil-coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
detekt-detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" }
detekt-detekt-rules = { module = "io.gitlab.arturbosch.detekt:detekt-rules-libraries", version.ref = "detekt" }
google-billing = { module = "com.android.billingclient:billing", version.ref = "googleBilling" }
+2 -1
View File
@@ -75,7 +75,8 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.credentials)
implementation(libs.androidx.navigation.compose)
implementation(libs.bumptech.glide)
implementation(libs.coil.coil.compose)
implementation(libs.coil.coil.gif)
implementation(libs.kotlinx.serialization)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.collections.immutable)
@@ -4,21 +4,19 @@ import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import coil3.compose.AsyncImage
import com.bitwarden.ui.platform.base.util.nullableTestTag
import com.bitwarden.ui.platform.components.icon.model.IconData
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage
import com.bumptech.glide.integration.compose.placeholder
/**
* Represents a Bitwarden icon that is either locally loaded or loaded using glide.
* Represents a Bitwarden icon that is either locally loaded or loaded using Coil.
*
* @param iconData Label for the text field.
* @param modifier A [Modifier] for the composable.
* @param tint the color to be applied as the tint for the icon.
*/
@OptIn(ExperimentalGlideComposeApi::class)
@Composable
fun BitwardenIcon(
iconData: IconData,
@@ -27,14 +25,14 @@ fun BitwardenIcon(
) {
when (iconData) {
is IconData.Network -> {
GlideImage(
val fallbackPainter = painterResource(id = iconData.fallbackIconRes)
AsyncImage(
model = iconData.uri,
failure = placeholder(iconData.fallbackIconRes),
contentDescription = iconData.contentDescription?.invoke(),
placeholder = fallbackPainter,
error = fallbackPainter,
modifier = modifier.nullableTestTag(tag = iconData.testTag),
) {
it.placeholder(iconData.fallbackIconRes)
}
)
}
is IconData.Local -> {
@@ -1,32 +1,25 @@
package com.bitwarden.ui.platform.components.image
import androidx.annotation.DrawableRes
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage
import coil3.compose.AsyncImage
/**
* A composable that displays a gif image.
*
* The content will also be scaled to fit the image to the container.
*/
@OptIn(ExperimentalGlideComposeApi::class)
@Composable
fun BitwardenGifImage(
@DrawableRes resId: Int,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
// The need for this workaround of loading into a drawable first is related to issue reported
// [here](https://github.com/bumptech/glide/issues/3751)
val context = LocalContext.current
val drawable = AppCompatResources.getDrawable(context, resId)
GlideImage(
model = drawable,
// Coil automatically registers the `coil-gif` decoder, which handles the animation.
AsyncImage(
model = resId,
contentDescription = contentDescription,
contentScale = ContentScale.Fit,
modifier = modifier,