From e83eac559b73376cdecb85b0d97404a026d557da Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 26 May 2026 15:55:18 +0300 Subject: [PATCH 1/3] MOBILE-52: Add timeout for in-app image loading --- .../cloud/mindbox/mobile_sdk/Extensions.kt | 5 + .../managers/InAppGlideImageLoaderImpl.kt | 119 +++++---- .../view/AbstractInAppViewHolder.kt | 115 +++++---- .../managers/InAppGlideImageLoaderImplTest.kt | 237 ++++++++++++++++++ 4 files changed, 376 insertions(+), 100 deletions(-) create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt index ca2530714..2ec9cb1e9 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt @@ -149,6 +149,11 @@ internal val Int.dp: Int internal val Int.px: Int get() = (this * Resources.getSystem().displayMetrics.density).roundToInt() +internal fun Context.maxScreenDimension(): Int { + val displayMetrics = resources.displayMetrics + return maxOf(displayMetrics.widthPixels, displayMetrics.heightPixels) +} + internal fun Animation.setOnAnimationEnd(runnable: Runnable) { setAnimationListener(object : AnimationListener { override fun onAnimationStart(animation: Animation?) { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt index 8efdca88a..2e4ff237b 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt @@ -7,14 +7,19 @@ import cloud.mindbox.mobile_sdk.R import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppImageLoader import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppImageSizeStorage import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppContentFetchingError -import cloud.mindbox.mobile_sdk.logger.mindboxLogD import cloud.mindbox.mobile_sdk.logger.mindboxLogE +import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.maxScreenDimension import com.bumptech.glide.Glide import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeout import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -26,53 +31,73 @@ internal class InAppGlideImageLoaderImpl( private val requests = HashMap>() override suspend fun loadImage(inAppId: String, url: String): Boolean { - mindboxLogD("loading image for inapp with id $inAppId started") - return suspendCancellableCoroutine { cancellableContinuation -> - val target = Glide.with(context).load(url) - .timeout(context.getString(R.string.mindbox_inapp_fetching_timeout).toInt()) - .listener(object : - RequestListener { - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target?, - isFirstResource: Boolean - ): Boolean { - return runCatching { - mindboxLogD("loading image with url = $url for inapp with id $inAppId failed") - cancellableContinuation.resumeWithException(InAppContentFetchingError(e)) - true - }.getOrElse { - mindboxLogE( - "Unknown error when loading image from network failed", - exception = it - ) - true - } - } + mindboxLogI("Loading image for inapp with id $inAppId started") + val timeoutMs = context.getString(R.string.mindbox_inapp_fetching_timeout).toLong() + val maxDim = context.maxScreenDimension() + return try { + withTimeout(timeoutMs) { + suspendCancellableCoroutine { continuation -> + requests[inAppId] = startPreload(inAppId, url, maxDim, timeoutMs.toInt(), continuation) + continuation.invokeOnCancellation { cancelLoading(inAppId) } + } + } + } catch (e: TimeoutCancellationException) { + mindboxLogE("Image loading timed out after ${timeoutMs}ms for inapp $inAppId", e) + throw InAppContentFetchingError(null) + } + } + + private fun startPreload( + inAppId: String, + url: String, + maxDim: Int, + timeoutMs: Int, + continuation: CancellableContinuation, + ): Target = Glide.with(context) + .load(url) + .timeout(timeoutMs) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .override(maxDim, maxDim) + .centerInside() + .listener(buildRequestListener(inAppId, url, continuation)) + .preload(maxDim, maxDim) + + private fun buildRequestListener( + inAppId: String, + url: String, + continuation: CancellableContinuation, + ): RequestListener = object : RequestListener { + + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean, + ): Boolean { + mindboxLogI("Image loading failed for inapp $inAppId, url = $url") + if (continuation.isActive) { + continuation.resumeWithException(InAppContentFetchingError(e)) + } + return true + } - override fun onResourceReady( - resource: Drawable, - model: Any?, - target: Target?, - dataSource: DataSource?, - isFirstResource: Boolean - ): Boolean { - return runCatching { - mindboxLogD("loading image with url = $url for inapp with id $inAppId succeeded") - inAppImageSizeStorage.addSize(inAppId, url, resource.toBitmap().width, resource.toBitmap().height) - cancellableContinuation.resume(true) - true - }.getOrElse { - mindboxLogE( - "Unknown error when loading image from network failed", - exception = it - ) - true - } - } - }).preload() - requests[inAppId] = target + override fun onResourceReady( + resource: Drawable, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean, + ): Boolean { + mindboxLogI("Image loading succeeded for inapp $inAppId, url = $url") + if (!continuation.isActive) return true + return runCatching { + val bitmap = resource.toBitmap() + inAppImageSizeStorage.addSize(inAppId, url, bitmap.width, bitmap.height) + continuation.resume(true) + }.onFailure { e -> + mindboxLogE("Failed to process loaded image for inapp $inAppId", e) + continuation.resumeWithException(InAppContentFetchingError(null)) + }.isSuccess } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt index b3a94c480..2c0659189 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt @@ -10,6 +10,7 @@ import android.widget.FrameLayout import android.widget.ImageView import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible import cloud.mindbox.mobile_sdk.R import cloud.mindbox.mobile_sdk.di.mindboxInject import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendPresentationFailure @@ -22,6 +23,7 @@ import cloud.mindbox.mobile_sdk.inapp.presentation.InAppMessageViewDisplayerImpl import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView import cloud.mindbox.mobile_sdk.inapp.presentation.actions.InAppActionHandler import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.maxScreenDimension import cloud.mindbox.mobile_sdk.removeChildById import cloud.mindbox.mobile_sdk.safeAs import cloud.mindbox.mobile_sdk.setSingleClickListener @@ -122,64 +124,71 @@ internal abstract class AbstractInAppViewHolder( } protected fun getImageFromCache(url: String, imageView: InAppImageView) { + val maxDim = currentDialog.context.maxScreenDimension() + val timeout = currentDialog.context.getString(R.string.mindbox_inapp_fetching_timeout).toInt() Glide .with(currentDialog.context) .load(url) - .diskCacheStrategy(DiskCacheStrategy.ALL) - .listener(object : RequestListener { - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target?, - isFirstResource: Boolean - ): Boolean { - return runCatching { - inAppFailureTracker.sendPresentationFailure( - inAppId = wrapper.inAppType.inAppId, - errorDescription = "Failed to load in-app image with url = $url", - throwable = e - ) - inAppController.close() - false - }.getOrElse { throwable -> - inAppFailureTracker.sendPresentationFailure( - inAppId = wrapper.inAppType.inAppId, - errorDescription = "Unknown error after loading image from cache succeeded", - throwable = throwable - ) - false - } - } + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .override(maxDim, maxDim) + .timeout(timeout) + .centerInside() + .listener(buildCacheRequestListener(url, imageView)) + .into(imageView) + } - override fun onResourceReady( - resource: Drawable?, - model: Any?, - target: Target?, - dataSource: DataSource?, - isFirstResource: Boolean - ): Boolean { - return runCatching { - bind() - preparedImages[imageView] = true - if (!preparedImages.values.contains(false)) { - this@AbstractInAppViewHolder.mindboxLogI("In-app shown") - wrapper.inAppActionCallbacks.onInAppShown.onShown() - for (image in preparedImages.keys) { - image.visibility = View.VISIBLE - } - } - false - }.getOrElse { throwable -> - inAppFailureTracker.sendPresentationFailure( - inAppId = wrapper.inAppType.inAppId, - errorDescription = "Unknown error in onResourceReady callback", - throwable = throwable - ) - false - } + private fun buildCacheRequestListener( + url: String, + imageView: InAppImageView, + ): RequestListener = object : RequestListener { + + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean, + ): Boolean { + runCatching { + inAppFailureTracker.sendPresentationFailure( + inAppId = wrapper.inAppType.inAppId, + errorDescription = "Failed to load in-app image with url = $url", + throwable = e + ) + inAppController.close() + }.onFailure { throwable -> + inAppFailureTracker.sendPresentationFailure( + inAppId = wrapper.inAppType.inAppId, + errorDescription = "Unknown error in onLoadFailed callback for url = $url", + throwable = throwable + ) + } + return false + } + + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean, + ): Boolean { + runCatching { + bind() + preparedImages[imageView] = true + if (!preparedImages.values.contains(false)) { + mindboxLogI("In-app ${wrapper.inAppType.inAppId} shown") + wrapper.inAppActionCallbacks.onInAppShown.onShown() + preparedImages.keys.forEach { it.isVisible = true } } - }) - .into(imageView) + }.onFailure { throwable -> + inAppFailureTracker.sendPresentationFailure( + inAppId = wrapper.inAppType.inAppId, + errorDescription = "Unknown error in onResourceReady callback for url = $url", + throwable = throwable + ) + } + return false + } } protected open fun initView(currentRoot: ViewGroup) { diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt new file mode 100644 index 000000000..5eae97bea --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt @@ -0,0 +1,237 @@ +package cloud.mindbox.mobile_sdk.inapp.data.managers + +import android.content.Context +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.util.DisplayMetrics +import androidx.core.graphics.drawable.toBitmap +import cloud.mindbox.mobile_sdk.R +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppImageSizeStorage +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppContentFetchingError +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.RequestManager +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.junit4.MockKRule +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class InAppGlideImageLoaderImplTest { + + @get:Rule + val mockkRule = MockKRule(this) + + @MockK + private lateinit var context: Context + + @MockK + private lateinit var resources: Resources + + @RelaxedMockK + private lateinit var inAppImageSizeStorage: InAppImageSizeStorage + + @RelaxedMockK + private lateinit var requestManager: RequestManager + + @RelaxedMockK + private lateinit var requestBuilder: RequestBuilder + + private val testDispatcher = StandardTestDispatcher() + private val listenerSlot = slot>() + private lateinit var loader: InAppGlideImageLoaderImpl + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + + val displayMetrics = DisplayMetrics().apply { + widthPixels = 1080 + heightPixels = 1920 + } + every { context.resources } returns resources + every { resources.displayMetrics } returns displayMetrics + every { context.getString(R.string.mindbox_inapp_fetching_timeout) } returns "3000" + + mockkStatic(Glide::class) + every { Glide.with(any()) } returns requestManager + every { requestManager.load(any()) } returns requestBuilder + every { requestBuilder.timeout(any()) } returns requestBuilder + every { requestBuilder.diskCacheStrategy(any()) } returns requestBuilder + every { requestBuilder.override(any(), any()) } returns requestBuilder + every { requestBuilder.centerInside() } returns requestBuilder + every { requestBuilder.listener(capture(listenerSlot)) } returns requestBuilder + every { requestBuilder.preload(any(), any()) } returns mockk() + + loader = InAppGlideImageLoaderImpl(context, inAppImageSizeStorage) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + // region loadImage — success + + @Test + fun `loadImage returns true when image loaded successfully`() = runTest { + val bitmap = mockk { + every { width } returns 500 + every { height } returns 300 + } + val drawable = mockk(relaxed = true) + mockkStatic("androidx.core.graphics.drawable.DrawableKt") + every { drawable.toBitmap(any(), any(), any()) } returns bitmap + + var result: Boolean? = null + val job = launch { result = loader.loadImage("id1", URL) } + + runCurrent() + listenerSlot.captured.onResourceReady(drawable, null, null, null, false) + runCurrent() + job.join() + + assertTrue(result == true) + } + + @Test + fun `loadImage stores image dimensions on success`() = runTest { + val bitmap = mockk { + every { width } returns 500 + every { height } returns 300 + } + val drawable = mockk(relaxed = true) + mockkStatic("androidx.core.graphics.drawable.DrawableKt") + every { drawable.toBitmap(any(), any(), any()) } returns bitmap + + val job = launch { runCatching { loader.loadImage("id1", URL) } } + + runCurrent() + listenerSlot.captured.onResourceReady(drawable, null, null, null, false) + runCurrent() + job.join() + + verify(exactly = 1) { inAppImageSizeStorage.addSize("id1", URL, 500, 300) } + } + + // endregion + + // region loadImage — failure + + @Test + fun `loadImage throws InAppContentFetchingError when Glide reports failure`() = runTest { + var thrownException: Throwable? = null + val job = launch { + runCatching { loader.loadImage("id1", URL) }.onFailure { thrownException = it } + } + + runCurrent() + listenerSlot.captured.onLoadFailed(mockk(), null, null, false) + runCurrent() + job.join() + + assertTrue(thrownException is InAppContentFetchingError) + } + + @Test + fun `loadImage throws InAppContentFetchingError when onResourceReady callback throws`() = runTest { + val drawable = mockk(relaxed = true) + mockkStatic("androidx.core.graphics.drawable.DrawableKt") + every { drawable.toBitmap(any(), any(), any()) } throws RuntimeException("decode error") + + var thrownException: Throwable? = null + val job = launch { + runCatching { loader.loadImage("id1", URL) }.onFailure { thrownException = it } + } + + runCurrent() + listenerSlot.captured.onResourceReady(drawable, null, null, null, false) + runCurrent() + job.join() + + assertTrue(thrownException is InAppContentFetchingError) + } + + // endregion + + // region loadImage — timeout + + @Test + fun `loadImage throws InAppContentFetchingError when timeout expires`() = runTest { + var thrownException: Throwable? = null + val job = launch { + runCatching { loader.loadImage("id1", URL) }.onFailure { thrownException = it } + } + + advanceTimeBy(3001) + job.join() + + assertTrue(thrownException is InAppContentFetchingError) + } + + @Test + fun `loadImage does not complete before timeout when no callback fires`() = runTest { + var completed = false + val job = launch { + runCatching { loader.loadImage("id1", URL) } + completed = true + } + + advanceTimeBy(2999) + assertFalse("Job must still be active before timeout", completed) + + job.cancel() + } + + // endregion + + // region cancelLoading + + @Test + fun `cancelLoading clears Glide request for given inAppId`() = runTest { + val target = mockk>(relaxed = true) + every { requestBuilder.preload(any(), any()) } returns target + + val job = launch { runCatching { loader.loadImage("id1", URL) } } + runCurrent() + + loader.cancelLoading("id1") + + verify { requestManager.clear(target) } + job.cancel() + } + + @Test + fun `cancelLoading is called when coroutine is cancelled by timeout`() = runTest { + val target = mockk>(relaxed = true) + every { requestBuilder.preload(any(), any()) } returns target + + val job = launch { runCatching { loader.loadImage("id1", URL) } } + advanceUntilIdle() + + advanceTimeBy(3001) + job.join() + + verify { requestManager.clear(target) } + } + + // endregion + + private companion object { + const val URL = "https://example.com/image.jpg" + } +} From 6629af0a93d269ca684819d5d01f5bdfcd4b4ed4 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 26 May 2026 16:34:10 +0300 Subject: [PATCH 2/3] MOBILE-52: Add requests.remove(inAppId) --- .../mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt index 2e4ff237b..4f46c94f2 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt @@ -75,6 +75,7 @@ internal class InAppGlideImageLoaderImpl( isFirstResource: Boolean, ): Boolean { mindboxLogI("Image loading failed for inapp $inAppId, url = $url") + requests.remove(inAppId) if (continuation.isActive) { continuation.resumeWithException(InAppContentFetchingError(e)) } @@ -90,6 +91,7 @@ internal class InAppGlideImageLoaderImpl( ): Boolean { mindboxLogI("Image loading succeeded for inapp $inAppId, url = $url") if (!continuation.isActive) return true + requests.remove(inAppId) return runCatching { val bitmap = resource.toBitmap() inAppImageSizeStorage.addSize(inAppId, url, bitmap.width, bitmap.height) From f19588124743c200803800aa48e0848d9815b503 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Wed, 27 May 2026 11:51:55 +0300 Subject: [PATCH 3/3] MOBILE-52: Follow code review --- .../inapp/data/managers/InAppGlideImageLoaderImpl.kt | 3 ++- .../inapp/data/managers/InAppGlideImageLoaderImplTest.kt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt index 4f46c94f2..c8c6878ce 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt @@ -16,6 +16,7 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target +import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.suspendCancellableCoroutine @@ -28,7 +29,7 @@ internal class InAppGlideImageLoaderImpl( private val inAppImageSizeStorage: InAppImageSizeStorage ) : InAppImageLoader { - private val requests = HashMap>() + private val requests = ConcurrentHashMap>() override suspend fun loadImage(inAppId: String, url: String): Boolean { mindboxLogI("Loading image for inapp with id $inAppId started") diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt index 5eae97bea..2c9d2d4ec 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt @@ -221,7 +221,7 @@ internal class InAppGlideImageLoaderImplTest { every { requestBuilder.preload(any(), any()) } returns target val job = launch { runCatching { loader.loadImage("id1", URL) } } - advanceUntilIdle() + runCurrent() advanceTimeBy(3001) job.join()