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 ca253071..2ec9cb1e 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 8efdca88..c8c6878c 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,20 @@ 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 java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeout import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -23,56 +29,78 @@ 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 { - 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") + requests.remove(inAppId) + 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 + requests.remove(inAppId) + 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 b3a94c48..2c065918 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 00000000..2c9d2d4e --- /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) } } + runCurrent() + + advanceTimeBy(3001) + job.join() + + verify { requestManager.clear(target) } + } + + // endregion + + private companion object { + const val URL = "https://example.com/image.jpg" + } +}