diff --git a/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/RoadShieldContentManagerContainer.kt b/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/RoadShieldContentManagerContainer.kt index 534c8dd1f12..856562dfe7f 100644 --- a/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/RoadShieldContentManagerContainer.kt +++ b/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/RoadShieldContentManagerContainer.kt @@ -1,5 +1,9 @@ package com.mapbox.navigation.ui.shield +import com.mapbox.navigation.ui.shield.internal.RoadShieldDownloader +import com.mapbox.navigation.ui.shield.internal.loader.CachedLoader +import com.mapbox.navigation.ui.shield.internal.loader.RoadShieldLoader +import com.mapbox.navigation.ui.shield.internal.loader.ShieldSpritesDownloader import com.mapbox.navigation.ui.shield.internal.model.RouteShieldToDownload /** @@ -7,8 +11,24 @@ import com.mapbox.navigation.ui.shield.internal.model.RouteShieldToDownload * so that the same cache can be reused through the app processes life. */ internal object RoadShieldContentManagerContainer : RoadShieldContentManager { + private const val SPRITES_CACHE_SIZE = 8 // entries + private const val IMAGES_CACHE_SIZE = 40 // entries + private val contentManager: RoadShieldContentManager by lazy { - RoadShieldContentManagerImpl() + RoadShieldContentManagerImpl( + shieldLoader = CachedLoader( + IMAGES_CACHE_SIZE, + RoadShieldLoader( + spritesLoader = CachedLoader( + SPRITES_CACHE_SIZE, + ShieldSpritesDownloader() + ), + imageLoader = { url -> + RoadShieldDownloader.download(url) + } + ) + ) + ) } override suspend fun getShields(shieldsToDownload: List) = diff --git a/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/RoadShieldContentManagerImpl.kt b/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/RoadShieldContentManagerImpl.kt index d83af2bc652..5d23c226700 100644 --- a/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/RoadShieldContentManagerImpl.kt +++ b/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/RoadShieldContentManagerImpl.kt @@ -2,8 +2,11 @@ package com.mapbox.navigation.ui.shield import com.mapbox.api.directions.v5.models.BannerComponents import com.mapbox.bindgen.Expected -import com.mapbox.bindgen.ExpectedFactory +import com.mapbox.bindgen.ExpectedFactory.createError +import com.mapbox.bindgen.ExpectedFactory.createValue +import com.mapbox.navigation.ui.shield.internal.loader.Loader import com.mapbox.navigation.ui.shield.internal.model.RouteShieldToDownload +import com.mapbox.navigation.ui.shield.model.RouteShield import com.mapbox.navigation.ui.shield.model.RouteShieldError import com.mapbox.navigation.ui.shield.model.RouteShieldOrigin import com.mapbox.navigation.ui.shield.model.RouteShieldResult @@ -11,7 +14,7 @@ import com.mapbox.navigation.utils.internal.InternalJobControlFactory import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine -import java.util.UUID +import java.util.* import kotlin.coroutines.resume /** @@ -67,7 +70,7 @@ import kotlin.coroutines.resume * - If request fails: repeat step 1. */ internal class RoadShieldContentManagerImpl( - private val shieldResultCache: ShieldResultCache = ShieldResultCache() + private val shieldLoader: Loader ) : RoadShieldContentManager { internal companion object { internal const val CANCELED_MESSAGE = "canceled" @@ -95,7 +98,7 @@ internal class RoadShieldContentManagerImpl( } returnList.addAll( missingResults.map { - ExpectedFactory.createError( + createError( RouteShieldError(it.toDownload.url, CANCELED_MESSAGE) ) } @@ -116,90 +119,103 @@ internal class RoadShieldContentManagerImpl( mainJob.scope.launch { when (toDownload) { is RouteShieldToDownload.MapboxDesign -> { - val mapboxDesignShieldResult = shieldResultCache.getOrRequest(toDownload) - resultMap[request] = if (mapboxDesignShieldResult.isError) { - val legacyFallback = toDownload.legacyFallback - if (legacyFallback != null) { - shieldResultCache.getOrRequest(legacyFallback).fold( - { error -> - ExpectedFactory.createError( - RouteShieldError( - url = toDownload.url, - errorMessage = """ - |original request failed with: - |url: ${toDownload.url} - |error: ${mapboxDesignShieldResult.error} - | - |fallback request failed with: - |url: ${legacyFallback.url} - |error: $error - """.trimMargin() - ) - ) - }, - { legacyShield -> - ExpectedFactory.createValue( - RouteShieldResult( - legacyShield, - RouteShieldOrigin( - isFallback = true, - originalUrl = toDownload.url, - mapboxDesignShieldResult.error!! - ) - ) - ) - } - ) - } else { - ExpectedFactory.createError( - RouteShieldError( - url = toDownload.url, - errorMessage = mapboxDesignShieldResult.error!! - ) + resultMap[request] = loadDesignShield(toDownload) + } + is RouteShieldToDownload.MapboxLegacy -> { + resultMap[request] = loadLegacyShield(toDownload) + } + } + invalidate() + } + request + }.toSet() + } + + private suspend fun loadDesignShield( + toDownload: RouteShieldToDownload.MapboxDesign + ): Expected { + val mapboxDesignShieldResult = shieldLoader.load(toDownload) + return if (mapboxDesignShieldResult.isError) { + val legacyFallback = toDownload.legacyFallback + if (legacyFallback != null) { + shieldLoader.load(legacyFallback) + .fold( + { error -> + createError( + RouteShieldError( + toDownload.url, + """ + |original request failed with: + |url: ${toDownload.url} + |error: ${mapboxDesignShieldResult.error?.localizedMessage} + | + |fallback request failed with: + |url: ${legacyFallback.url} + |error: ${error.localizedMessage} + """.trimMargin() ) - } - } else { - ExpectedFactory.createValue( + ) + }, + { legacyShield -> + createValue( RouteShieldResult( - mapboxDesignShieldResult.value!!, + legacyShield, RouteShieldOrigin( - isFallback = false, - mapboxDesignShieldResult.value!!.url, - "" + true, + toDownload.url, + mapboxDesignShieldResult.error?.message ?: "" ) ) ) } - } - is RouteShieldToDownload.MapboxLegacy -> { - resultMap[request] = shieldResultCache.getOrRequest(toDownload).fold( - { error -> - ExpectedFactory.createError( - RouteShieldError( - url = toDownload.url, - errorMessage = error - ) - ) - }, - { legacyShield -> - ExpectedFactory.createValue( - RouteShieldResult( - legacyShield, - RouteShieldOrigin( - isFallback = false, - originalUrl = toDownload.url, - originalErrorMessage = "" - ) - ) - ) - } + ) + } else { + createError( + RouteShieldError( + toDownload.url, + mapboxDesignShieldResult.error?.message ?: "" + ) + ) + } + } else { + createValue( + RouteShieldResult( + mapboxDesignShieldResult.value!!, + RouteShieldOrigin( + false, + mapboxDesignShieldResult.value!!.url, + "" + ) + ) + ) + } + } + + private suspend fun loadLegacyShield( + toDownload: RouteShieldToDownload.MapboxLegacy + ): Expected { + return shieldLoader.load(toDownload).fold( + { error -> + createError( + RouteShieldError( + toDownload.url, + error.message ?: "" + ) + ) + }, + { legacyShield -> + createValue( + RouteShieldResult( + legacyShield, + RouteShieldOrigin( + false, + toDownload.url, + "" ) - } - } - invalidate() + ) + ) } - request - }.toSet() + ) } private fun invalidate() { diff --git a/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/ShieldsCache.kt b/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/ShieldsCache.kt deleted file mode 100644 index 9e7ca416bbe..00000000000 --- a/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/ShieldsCache.kt +++ /dev/null @@ -1,227 +0,0 @@ -package com.mapbox.navigation.ui.shield - -import android.util.LruCache -import com.google.gson.JsonSyntaxException -import com.mapbox.api.directions.v5.models.ShieldSprites -import com.mapbox.api.directions.v5.models.ShieldSvg -import com.mapbox.bindgen.Expected -import com.mapbox.bindgen.ExpectedFactory -import com.mapbox.navigation.ui.shield.internal.model.RouteShieldToDownload -import com.mapbox.navigation.ui.shield.internal.model.generateSpriteSheetUrl -import com.mapbox.navigation.ui.shield.internal.model.getSpriteFrom -import com.mapbox.navigation.ui.shield.model.RouteShield -import com.mapbox.navigation.utils.internal.ifNonNull -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume - -internal abstract class ResourceCache(cacheSize: Int) { - - internal companion object { - internal const val CANCELED_MESSAGE = "canceled" - } - - private val cache = LruCache>(cacheSize) - private val ongoingRequest = mutableSetOf() - private val awaitingCallbacks = mutableListOf<() -> Boolean>() - - /** - * Returns an [Expected] with value or error depending on whether the resource was generated - * successfully or not. - * - * The provided **[argument] is considered as a unique key** for the cache entry. - * Whenever there's a successful result generated for a given [argument], - * that result is saved to the cache and returned immediately upon calls to [getOrRequest] with the same [argument] again. - * - * If a successful result for given [argument] is not available in the cache, the result will be attempted to be generated. - * If the generation is asynchronous, any [getOrRequest] calls with the same [argument] will suspend - * until the already ongoing request returns to avoid duplicating work. - * - * If the generation fails, the erroneous result is returned to original and all awaiting callers. - * Once [getOrRequest] with the same [argument] is called again, the generation will be re-attempted. - * - * Calls to this function should be executed from a single thread, it's not thread safe. - */ - suspend fun getOrRequest(argument: Argument): Expected { - return cache.get(argument)?.value?.let { ExpectedFactory.createValue(it) } ?: run { - if (ongoingRequest.contains(argument)) { - suspendCancellableCoroutine { continuation -> - /** - * The callback checks if the result for this argument is available. - * Returns if true or keeps waiting if false. - */ - val callback = { - ifNonNull(cache.get(argument)) { expected -> - if (!continuation.isCancelled) { - continuation.resume(expected) - } - true - } ?: false - } - - // immediately verify if the result is available in case it was generated - // while we were initializing the coroutine - if (callback()) { - return@suspendCancellableCoroutine - } - - // if the result is not available, wait - awaitingCallbacks.add(callback) - continuation.invokeOnCancellation { - awaitingCallbacks.remove(callback) - } - } - } else { - try { - ongoingRequest.add(argument) - val result = obtainResource(argument) - cache.put(argument, result) - ongoingRequest.remove(argument) - invalidate() - result - } catch (ex: CancellationException) { - ongoingRequest.remove(argument) - val result = ExpectedFactory.createError(CANCELED_MESSAGE) - cache.put(argument, result) - invalidate() - result - } - } - } - } - - /** - * Notifies all awaiting callbacks that the result might be available. - */ - private fun invalidate() { - val iterator = awaitingCallbacks.iterator() - while (iterator.hasNext()) { - val remove = iterator.next().invoke() - if (remove) { - iterator.remove() - } - } - } - - /** - * Produces a result for a given [argument] if it was not found in the cache. - */ - protected abstract suspend fun obtainResource(argument: Argument): Expected -} - -internal class ShieldResultCache( - private val shieldSpritesCache: ShieldSpritesCache = ShieldSpritesCache(), - private val shieldByteArrayCache: ShieldByteArrayCache = ShieldByteArrayCache(), -) : ResourceCache(40) { - override suspend fun obtainResource( - argument: RouteShieldToDownload - ): Expected { - return when (argument) { - is RouteShieldToDownload.MapboxDesign -> prepareMapboxDesignShield(argument) - is RouteShieldToDownload.MapboxLegacy -> prepareMapboxLegacyShield(argument) - } - } - - private suspend fun prepareMapboxDesignShield( - toDownload: RouteShieldToDownload.MapboxDesign - ): Expected { - val spriteUrl = toDownload.generateSpriteSheetUrl() - val shieldSpritesResult = shieldSpritesCache.getOrRequest(spriteUrl) - val shieldSprites = if (shieldSpritesResult.isValue) { - shieldSpritesResult.value!! - } else { - return ExpectedFactory.createError( - """ - Error when downloading image sprite. - url: $spriteUrl - result: ${shieldSpritesResult.error!!} - """.trimIndent() - ) - } - val sprite = toDownload.getSpriteFrom(shieldSprites) - ?: return ExpectedFactory.createError( - "Sprite not found for ${toDownload.mapboxShield.name()} in $shieldSprites." - ) - val placeholder = sprite.spriteAttributes().placeholder() - if (placeholder.isNullOrEmpty()) { - return ExpectedFactory.createError( - """ - Mapbox shield sprite placeholder was null or empty in: $sprite - """.trimIndent() - ) - } - - val mapboxShieldUrl = toDownload.url - return shieldByteArrayCache.getOrRequest(mapboxShieldUrl).mapValue { shieldByteArray -> - val svgJson = String(shieldByteArray) - val svg = appendTextToShield( - text = toDownload.mapboxShield.displayRef(), - shieldSvg = ShieldSvg.fromJson(svgJson).svg(), - textColor = toDownload.mapboxShield.textColor(), - placeholder = placeholder - ).toByteArray() - RouteShield.MapboxDesignedShield( - mapboxShieldUrl, - svg, - toDownload.mapboxShield, - sprite - ) - } - } - - private fun appendTextToShield( - text: String, - shieldSvg: String, - textColor: String, - placeholder: List - ): String { - val textTagX = placeholder[0] + placeholder[2] / 2 - val textTagY = placeholder[3] - val textSize = placeholder[3] - placeholder[1] + 3 - val shieldText = "\t$text" - return shieldSvg.replace("", shieldText.plus("")) - } - - private suspend fun prepareMapboxLegacyShield( - toDownload: RouteShieldToDownload.MapboxLegacy - ): Expected { - val shieldUrl = toDownload.url - return shieldByteArrayCache.getOrRequest(shieldUrl).mapValue { byteArray -> - RouteShield.MapboxLegacyShield( - toDownload.url, - byteArray, - toDownload.initialUrl - ) - } - } -} - -internal class ShieldSpritesCache : ResourceCache(8) { - override suspend fun obtainResource(argument: String): Expected { - val result = RoadShieldDownloader.download(argument) - return try { - result.mapValue { data -> - val spriteJson = String(data) - val shieldSprites = ShieldSprites.fromJson(spriteJson) - shieldSprites - } - } catch (exception: JsonSyntaxException) { - val json = result.value?.let { String(it) } ?: "null" - ExpectedFactory.createError( - """ - |Error parsing shield sprites: - |exception: $exception - |json: $json - """.trimMargin() - ) - } - } -} - -internal class ShieldByteArrayCache : ResourceCache(15) { - override suspend fun obtainResource(argument: String): Expected { - return RoadShieldDownloader.download(argument) - } -} diff --git a/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/RoadShieldDownloader.kt b/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/internal/RoadShieldDownloader.kt similarity index 71% rename from libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/RoadShieldDownloader.kt rename to libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/internal/RoadShieldDownloader.kt index 6f54dd45c5d..64290d163d2 100644 --- a/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/RoadShieldDownloader.kt +++ b/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/internal/RoadShieldDownloader.kt @@ -1,4 +1,4 @@ -package com.mapbox.navigation.ui.shield +package com.mapbox.navigation.ui.shield.internal import com.mapbox.bindgen.Expected import com.mapbox.bindgen.ExpectedFactory.createError @@ -13,7 +13,7 @@ internal object RoadShieldDownloader { private val resourceLoader get() = ResourceLoaderFactory.getInstance() - suspend fun download(url: String): Expected { + suspend fun download(url: String): Expected { val response = resourceLoader.load(url) return response.value?.let { responseData -> @@ -23,17 +23,17 @@ internal object RoadShieldDownloader { if (blob.isNotEmpty()) { createValue(blob) } else { - createError("No data available.") + createError(Error("No data available.")) } } ResourceLoadStatus.UNAUTHORIZED -> - createError("Your token cannot access this resource.") + createError(Error("Your token cannot access this resource.")) ResourceLoadStatus.NOT_FOUND -> - createError("Resource is missing.") + createError(Error("Resource is missing.")) else -> - createError("Unknown error (status: ${responseData.status}).") + createError(Error("Unknown error (status: ${responseData.status}).")) } - } ?: createError(response.error?.message ?: "No data available.") + } ?: createError(Error(response.error?.message ?: "No data available.")) } private suspend fun ResourceLoader.load(url: String) = load(ResourceLoadRequest(url)) diff --git a/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/internal/loader/CachedLoader.kt b/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/internal/loader/CachedLoader.kt new file mode 100644 index 00000000000..e2b311af5ed --- /dev/null +++ b/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/internal/loader/CachedLoader.kt @@ -0,0 +1,26 @@ +package com.mapbox.navigation.ui.shield.internal.loader + +import android.util.LruCache +import com.mapbox.bindgen.Expected + +/** + * Loader backed by LruCache + */ +internal class CachedLoader( + cacheSize: Int, + private val loader: Loader +) : Loader { + + private val cache = LruCache>(cacheSize) + + override suspend fun load(input: Input): Expected { + var value = cache.get(input) + if (value != null) { + return value + } + + value = loader.load(input) + cache.put(input, value) + return value + } +} diff --git a/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/internal/loader/Downloader.kt b/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/internal/loader/Downloader.kt new file mode 100644 index 00000000000..2b84792b44e --- /dev/null +++ b/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/internal/loader/Downloader.kt @@ -0,0 +1,43 @@ +package com.mapbox.navigation.ui.shield.internal.loader + +import com.mapbox.bindgen.Expected +import com.mapbox.bindgen.ExpectedFactory +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +private typealias Callback = (Expected) -> Unit + +internal abstract class Downloader : Loader { + internal companion object { + internal const val CANCELED_MESSAGE = "canceled" + } + + private val ongoingRequest = mutableMapOf>>() + + override suspend fun load(input: I): Expected { + return if (ongoingRequest.contains(input)) { + suspendCancellableCoroutine { continuation -> + val callback: Callback = { result -> + continuation.resume(result) + } + + ongoingRequest[input]?.add(callback) + continuation.invokeOnCancellation { + ongoingRequest[input]?.remove(callback) + } + } + } else { + ongoingRequest[input] = mutableListOf() + val result = try { + download(input) + } catch (ex: CancellationException) { + ExpectedFactory.createError(Error(CANCELED_MESSAGE, ex)) + } + ongoingRequest.remove(input)?.onEach { it(result) } + result + } + } + + protected abstract suspend fun download(input: I): Expected +} diff --git a/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/internal/loader/Loader.kt b/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/internal/loader/Loader.kt new file mode 100644 index 00000000000..1dbd9c0281f --- /dev/null +++ b/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/internal/loader/Loader.kt @@ -0,0 +1,7 @@ +package com.mapbox.navigation.ui.shield.internal.loader + +import com.mapbox.bindgen.Expected + +internal fun interface Loader { + suspend fun load(input: Input): Expected +} diff --git a/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/internal/loader/RoadShieldLoader.kt b/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/internal/loader/RoadShieldLoader.kt new file mode 100644 index 00000000000..7924b04478a --- /dev/null +++ b/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/internal/loader/RoadShieldLoader.kt @@ -0,0 +1,101 @@ +package com.mapbox.navigation.ui.shield.internal.loader + +import com.mapbox.api.directions.v5.models.ShieldSprites +import com.mapbox.api.directions.v5.models.ShieldSvg +import com.mapbox.bindgen.Expected +import com.mapbox.bindgen.ExpectedFactory +import com.mapbox.navigation.ui.shield.internal.model.RouteShieldToDownload +import com.mapbox.navigation.ui.shield.internal.model.generateSpriteSheetUrl +import com.mapbox.navigation.ui.shield.internal.model.getSpriteFrom +import com.mapbox.navigation.ui.shield.model.RouteShield + +internal class RoadShieldLoader( + private val spritesLoader: Loader, + private val imageLoader: Loader +) : Loader { + + override suspend fun load(input: RouteShieldToDownload): Expected { + return when (input) { + is RouteShieldToDownload.MapboxDesign -> loadMapboxDesignShield(input) + is RouteShieldToDownload.MapboxLegacy -> loadMapboxLegacyShield(input) + } + } + + private suspend fun loadMapboxDesignShield( + toDownload: RouteShieldToDownload.MapboxDesign + ): Expected { + val spriteUrl = toDownload.generateSpriteSheetUrl() + val shieldSpritesResult = spritesLoader.load(spriteUrl) + val shieldSprites = if (shieldSpritesResult.isValue) { + shieldSpritesResult.value!! + } else { + return createError( + """ + Error when downloading image sprite. + url: $spriteUrl + result: ${shieldSpritesResult.error?.message} + """.trimIndent() + ) + } + val sprite = toDownload.getSpriteFrom(shieldSprites) + ?: return createError( + "Sprite not found for ${toDownload.mapboxShield.name()} in $shieldSprites." + ) + val placeholder = sprite.spriteAttributes().placeholder() + if (placeholder.isNullOrEmpty()) { + return createError( + """ + Mapbox shield sprite placeholder was null or empty in: $sprite + """.trimIndent() + ) + } + + val mapboxShieldUrl = toDownload.url + return imageLoader.load(mapboxShieldUrl).mapValue { shieldByteArray -> + val svgJson = String(shieldByteArray) + val svg = appendTextToShield( + text = toDownload.mapboxShield.displayRef(), + shieldSvg = ShieldSvg.fromJson(svgJson).svg(), + textColor = toDownload.mapboxShield.textColor(), + placeholder = placeholder + ).toByteArray() + RouteShield.MapboxDesignedShield( + mapboxShieldUrl, + svg, + toDownload.mapboxShield, + sprite + ) + } + } + + private fun appendTextToShield( + text: String, + shieldSvg: String, + textColor: String, + placeholder: List + ): String { + val textTagX = placeholder[0] + placeholder[2] / 2 + val textTagY = placeholder[3] + val textSize = placeholder[3] - placeholder[1] + 3 + val shieldText = "\t$text" + return shieldSvg.replace("", shieldText.plus("")) + } + + private suspend fun loadMapboxLegacyShield( + toDownload: RouteShieldToDownload.MapboxLegacy + ): Expected { + val shieldUrl = toDownload.url + return imageLoader.load(shieldUrl).mapValue { byteArray -> + RouteShield.MapboxLegacyShield( + toDownload.url, + byteArray, + toDownload.initialUrl + ) + } + } + + private fun createError(errorMessage: String): Expected = + ExpectedFactory.createError(Error(errorMessage)) +} diff --git a/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/internal/loader/ShieldSpritesDownloader.kt b/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/internal/loader/ShieldSpritesDownloader.kt new file mode 100644 index 00000000000..ec13aa1b5df --- /dev/null +++ b/libnavui-shield/src/main/java/com/mapbox/navigation/ui/shield/internal/loader/ShieldSpritesDownloader.kt @@ -0,0 +1,32 @@ +package com.mapbox.navigation.ui.shield.internal.loader + +import com.google.gson.JsonSyntaxException +import com.mapbox.api.directions.v5.models.ShieldSprites +import com.mapbox.bindgen.Expected +import com.mapbox.bindgen.ExpectedFactory.createError +import com.mapbox.navigation.ui.shield.internal.RoadShieldDownloader + +internal class ShieldSpritesDownloader : Downloader() { + override suspend fun download(input: String): Expected { + val result = RoadShieldDownloader.download(input) + return try { + result.mapValue { data -> + val spriteJson = String(data) + val shieldSprites = ShieldSprites.fromJson(spriteJson) + shieldSprites + } + } catch (exception: JsonSyntaxException) { + val json = result.value?.let { String(it) } ?: "null" + createError( + Error( + """ + |Error parsing shield sprites: + |exception: $exception + |json: $json + """.trimMargin(), + exception + ) + ) + } + } +} diff --git a/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/RoadShieldContentManagerImplTest.kt b/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/RoadShieldContentManagerImplTest.kt index 25aa61d4c55..83bf2cd15c8 100644 --- a/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/RoadShieldContentManagerImplTest.kt +++ b/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/RoadShieldContentManagerImplTest.kt @@ -5,6 +5,7 @@ import com.mapbox.api.directions.v5.models.ShieldSprite import com.mapbox.bindgen.Expected import com.mapbox.bindgen.ExpectedFactory import com.mapbox.navigation.testing.MainCoroutineRule +import com.mapbox.navigation.ui.shield.internal.loader.Loader import com.mapbox.navigation.ui.shield.internal.model.RouteShieldToDownload import com.mapbox.navigation.ui.shield.model.RouteShield import com.mapbox.navigation.ui.shield.model.RouteShieldError @@ -18,6 +19,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.junit.Assert.assertEquals +import org.junit.Before import org.junit.Rule import org.junit.Test @@ -27,12 +29,18 @@ class RoadShieldContentManagerImplTest { @get:Rule var coroutineRule = MainCoroutineRule() + private lateinit var sut: RoadShieldContentManagerImpl + private lateinit var loader: Loader + + @Before + fun setUp() { + loader = mockk() + sut = RoadShieldContentManagerImpl(loader) + } + @Test fun `request waits for all results to be available (success and manual cancellation), async`() = coroutineRule.runBlockingTest { - val cache = mockk() - val contentManager = createContentManager(cache) - val legacyUrl = "url_legacy" val downloadUrl = legacyUrl.plus(".svg") val toDownloadLegacy = mockk { @@ -53,7 +61,7 @@ class RoadShieldContentManagerImplTest { ) ) coEvery { - cache.getOrRequest(toDownloadLegacy) + loader.load(toDownloadLegacy) } coAnswers { delay(500L) ExpectedFactory.createValue(expectedLegacyShield) @@ -69,26 +77,28 @@ class RoadShieldContentManagerImplTest { RoadShieldContentManagerImpl.CANCELED_MESSAGE ) coEvery { - cache.getOrRequest(toDownloadDesign) + loader.load(toDownloadDesign) } coAnswers { try { delay(1000L) - ExpectedFactory.createError("error") + ExpectedFactory.createError(Error("error")) } catch (ex: CancellationException) { - ExpectedFactory.createError(RoadShieldContentManagerImpl.CANCELED_MESSAGE) + ExpectedFactory.createError( + Error(RoadShieldContentManagerImpl.CANCELED_MESSAGE) + ) } } var result: List>? = null pauseDispatcher { launch { - result = contentManager.getShields(listOf(toDownloadLegacy, toDownloadDesign)) + result = sut.getShields(listOf(toDownloadLegacy, toDownloadDesign)) } // run coroutines until they hit the download delays runCurrent() // advance by enough to only download one shield advanceTimeBy(600L) - contentManager.cancelAll() + sut.cancelAll() } assertEquals(expectedLegacyResult, result!![0].value) assertEquals(expectedDesignResult, result!![1].error) @@ -97,9 +107,6 @@ class RoadShieldContentManagerImplTest { @Test fun `request waits for all results to be available (success and job cancellation), async`() = coroutineRule.runBlockingTest { - val cache = mockk() - val contentManager = createContentManager(cache) - val legacyUrl = "url_legacy" val downloadUrl = legacyUrl.plus(".svg") val toDownloadLegacy = mockk { @@ -120,7 +127,7 @@ class RoadShieldContentManagerImplTest { ) ) coEvery { - cache.getOrRequest(toDownloadLegacy) + loader.load(toDownloadLegacy) } coAnswers { delay(500L) ExpectedFactory.createValue(expectedLegacyShield) @@ -136,16 +143,16 @@ class RoadShieldContentManagerImplTest { RoadShieldContentManagerImpl.CANCELED_MESSAGE ) coEvery { - cache.getOrRequest(toDownloadDesign) + loader.load(toDownloadDesign) } coAnswers { delay(1000L) - ExpectedFactory.createError("error") + ExpectedFactory.createError(Error("error")) } var result: List>? = null pauseDispatcher { val job = launch { - result = contentManager.getShields(listOf(toDownloadLegacy, toDownloadDesign)) + result = sut.getShields(listOf(toDownloadLegacy, toDownloadDesign)) } // run coroutines until they hit the download delays runCurrent() @@ -161,9 +168,6 @@ class RoadShieldContentManagerImplTest { @Test fun `request waits for all results to be available (success and failure), async`() = coroutineRule.runBlockingTest { - val cache = mockk() - val contentManager = createContentManager(cache) - val legacyUrl = "url_legacy" val downloadUrl = legacyUrl.plus(".svg") val toDownloadLegacy = mockk { @@ -184,7 +188,7 @@ class RoadShieldContentManagerImplTest { ) ) coEvery { - cache.getOrRequest(toDownloadLegacy) + loader.load(toDownloadLegacy) } coAnswers { delay(1000L) ExpectedFactory.createValue(expectedLegacyShield) @@ -200,15 +204,15 @@ class RoadShieldContentManagerImplTest { "error" ) coEvery { - cache.getOrRequest(toDownloadDesign) + loader.load(toDownloadDesign) } coAnswers { delay(500L) - ExpectedFactory.createError("error") + ExpectedFactory.createError(Error("error")) } var result: List>? = null pauseDispatcher { - result = contentManager.getShields(listOf(toDownloadLegacy, toDownloadDesign)) + result = sut.getShields(listOf(toDownloadLegacy, toDownloadDesign)) } assertEquals(expectedLegacyResult, result!![0].value) assertEquals(expectedDesignResult, result!![1].error) @@ -217,9 +221,6 @@ class RoadShieldContentManagerImplTest { @Test fun `request waits for all results to be available (success and failure), sync`() = coroutineRule.runBlockingTest { - val cache = mockk() - val contentManager = createContentManager(cache) - val legacyUrl = "url_legacy" val downloadUrl = legacyUrl.plus(".svg") val toDownloadLegacy = mockk { @@ -240,7 +241,7 @@ class RoadShieldContentManagerImplTest { ) ) coEvery { - cache.getOrRequest(toDownloadLegacy) + loader.load(toDownloadLegacy) } returns ExpectedFactory.createValue(expectedLegacyShield) val designShieldUrl = "url" @@ -253,10 +254,10 @@ class RoadShieldContentManagerImplTest { "error" ) coEvery { - cache.getOrRequest(toDownloadDesign) - } returns ExpectedFactory.createError("error") + loader.load(toDownloadDesign) + } returns ExpectedFactory.createError(Error("error")) - val result = contentManager.getShields(listOf(toDownloadLegacy, toDownloadDesign)) + val result = sut.getShields(listOf(toDownloadLegacy, toDownloadDesign)) assertEquals(expectedLegacyResult, result[0].value) assertEquals(expectedDesignResult, result[1].error) @@ -265,9 +266,6 @@ class RoadShieldContentManagerImplTest { @Test fun `unsuccessful design shield results with successful fallback`() = coroutineRule.runBlockingTest { - val cache = mockk() - val contentManager = createContentManager(cache) - val initialUrl = "url_legacy" val downloadUrl = initialUrl.plus(".svg") val expectedLegacyShield = RouteShield.MapboxLegacyShield( @@ -291,13 +289,13 @@ class RoadShieldContentManagerImplTest { ) ) coEvery { - cache.getOrRequest(toDownload) - } returns ExpectedFactory.createError("error") + loader.load(toDownload) + } returns ExpectedFactory.createError(Error("error")) coEvery { - cache.getOrRequest(legacyToDownload) + loader.load(legacyToDownload) } returns ExpectedFactory.createValue(expectedLegacyShield) - val result = contentManager.getShields(listOf(toDownload)) + val result = sut.getShields(listOf(toDownload)) assertEquals(expectedResult, result.first().value) } @@ -305,9 +303,6 @@ class RoadShieldContentManagerImplTest { @Test fun `unsuccessful design shield results with unsuccessful fallback`() = coroutineRule.runBlockingTest { - val cache = mockk() - val contentManager = createContentManager(cache) - val legacyUrl = "url_legacy" val legacyToDownload = RouteShieldToDownload.MapboxLegacy(legacyUrl) val shieldUrl = "url_design" @@ -318,32 +313,29 @@ class RoadShieldContentManagerImplTest { val expectedResult = RouteShieldError( shieldUrl, """ - |original request failed with: - |url: url_design - |error: error - | - |fallback request failed with: - |url: url_legacy.svg - |error: error_legacy + |original request failed with: + |url: url_design + |error: error + | + |fallback request failed with: + |url: url_legacy.svg + |error: error_legacy """.trimMargin() ) coEvery { - cache.getOrRequest(toDownload) - } returns ExpectedFactory.createError("error") + loader.load(toDownload) + } returns ExpectedFactory.createError(Error("error")) coEvery { - cache.getOrRequest(legacyToDownload) - } returns ExpectedFactory.createError("error_legacy") + loader.load(legacyToDownload) + } returns ExpectedFactory.createError(Error("error_legacy")) - val result = contentManager.getShields(listOf(toDownload)) + val result = sut.getShields(listOf(toDownload)) assertEquals(expectedResult, result.first().error) } @Test fun `unsuccessful design shield results without fallback`() = coroutineRule.runBlockingTest { - val cache = mockk() - val contentManager = createContentManager(cache) - val shieldUrl = "url_design" val toDownload = mockk { every { url } returns shieldUrl @@ -355,19 +347,16 @@ class RoadShieldContentManagerImplTest { ) coEvery { - cache.getOrRequest(toDownload) - } returns ExpectedFactory.createError("error") + loader.load(toDownload) + } returns ExpectedFactory.createError(Error("error")) - val result = contentManager.getShields(listOf(toDownload)) + val result = sut.getShields(listOf(toDownload)) assertEquals(expectedResult, result.first().error) } @Test fun `successful design shield results`() = coroutineRule.runBlockingTest { - val cache = mockk() - val contentManager = createContentManager(cache) - val shieldUrl = "url_design" val mapboxShield = mockk() val sprite = mockk() @@ -390,19 +379,16 @@ class RoadShieldContentManagerImplTest { ) coEvery { - cache.getOrRequest(toDownload) + loader.load(toDownload) } returns ExpectedFactory.createValue(expectedShield) - val result = contentManager.getShields(listOf(toDownload)) + val result = sut.getShields(listOf(toDownload)) assertEquals(expectedResult, result.first().value) } @Test fun `successful legacy shield results`() = coroutineRule.runBlockingTest { - val cache = mockk() - val contentManager = createContentManager(cache) - val initialUrl = "url_legacy" val downloadUrl = initialUrl.plus(".svg") val toDownload = RouteShieldToDownload.MapboxLegacy(initialUrl) @@ -421,19 +407,16 @@ class RoadShieldContentManagerImplTest { ) coEvery { - cache.getOrRequest(toDownload) + loader.load(toDownload) } returns ExpectedFactory.createValue(expectedShield) - val result = contentManager.getShields(listOf(toDownload)) + val result = sut.getShields(listOf(toDownload)) assertEquals(expectedResult, result.first().value) } @Test fun `unsuccessful legacy shield results`() = coroutineRule.runBlockingTest { - val cache = mockk() - val contentManager = createContentManager(cache) - val initialUrl = "url_legacy" val toDownload = RouteShieldToDownload.MapboxLegacy(initialUrl) val expectedResult = RouteShieldError( @@ -442,15 +425,11 @@ class RoadShieldContentManagerImplTest { ) coEvery { - cache.getOrRequest(toDownload) - } returns ExpectedFactory.createError("error") + loader.load(toDownload) + } returns ExpectedFactory.createError(Error("error")) - val result = contentManager.getShields(listOf(toDownload)) + val result = sut.getShields(listOf(toDownload)) assertEquals(expectedResult, result.first().error) } - - private fun createContentManager( - shieldResultCache: ShieldResultCache = mockk() - ): RoadShieldContentManagerImpl = RoadShieldContentManagerImpl(shieldResultCache) } diff --git a/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/RoadShieldDownloaderTest.kt b/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/RoadShieldDownloaderTest.kt index 3d2cee19da3..f3d427cf18f 100644 --- a/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/RoadShieldDownloaderTest.kt +++ b/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/RoadShieldDownloaderTest.kt @@ -6,6 +6,7 @@ import com.mapbox.common.ResourceData import com.mapbox.common.ResourceLoadError import com.mapbox.common.ResourceLoadResult import com.mapbox.common.ResourceLoadStatus +import com.mapbox.navigation.ui.shield.internal.RoadShieldDownloader import com.mapbox.navigation.ui.utils.internal.resource.ResourceLoadCallback import com.mapbox.navigation.ui.utils.internal.resource.ResourceLoadRequest import com.mapbox.navigation.ui.utils.internal.resource.ResourceLoader @@ -100,7 +101,7 @@ internal class RoadShieldDownloaderTest { val result = sut.download(url) - assertEquals(expectedError, result.error) + assertEquals(expectedError, result.error?.message) } @Test @@ -119,7 +120,7 @@ internal class RoadShieldDownloaderTest { val result = sut.download(url) - assertEquals(expectedError, result.error) + assertEquals(expectedError, result.error?.message) } @Test @@ -137,7 +138,7 @@ internal class RoadShieldDownloaderTest { val result = sut.download(url) - assertEquals(expectedError, result.error) + assertEquals(expectedError, result.error?.message) } private fun givenResourceLoaderResponse( diff --git a/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/ShieldByteArrayCacheTest.kt b/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/ShieldByteArrayCacheTest.kt deleted file mode 100644 index 92d24166e6e..00000000000 --- a/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/ShieldByteArrayCacheTest.kt +++ /dev/null @@ -1,315 +0,0 @@ -package com.mapbox.navigation.ui.shield - -import com.mapbox.bindgen.Expected -import com.mapbox.bindgen.ExpectedFactory -import com.mapbox.navigation.testing.MainCoroutineRule -import io.mockk.Call -import io.mockk.MockKAnswerScope -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.coVerifyOrder -import io.mockk.mockkObject -import io.mockk.unmockkObject -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -private typealias Result = Expected - -private const val RequestDelay = 500L - -/** - * This tests the entire logic of [ResourceCache] since [ShieldByteArrayCache] is the simplest implementation. - */ -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -class ShieldByteArrayCacheTest { - - @get:Rule - var coroutineRule = MainCoroutineRule() - - private val cache = ShieldByteArrayCache() - private val downloadValueResult = ExpectedFactory.createValue(byteArrayOf()) - private val downloadErrorResult = ExpectedFactory.createError("error") - private val downloadValueAsyncAnswer: - suspend MockKAnswerScope.(Call) -> Result = { - delay(RequestDelay) - downloadValueResult - } - private val downloadErrorAsyncAnswer: - suspend MockKAnswerScope.(Call) -> Result = { - delay(RequestDelay) - downloadErrorResult - } - - @Before - fun setup() { - mockkObject(RoadShieldDownloader) - } - - @Test - fun `single request returns success`() = coroutineRule.runBlockingTest { - val argument = "url" - coEvery { RoadShieldDownloader.download(argument) } returns downloadValueResult - - val result = cache.getOrRequest(argument) - - assertEquals(downloadValueResult.value, result.value) - } - - @Test - fun `single request returns failure`() = coroutineRule.runBlockingTest { - val argument = "url" - coEvery { RoadShieldDownloader.download(argument) } returns downloadErrorResult - - val result = cache.getOrRequest(argument) - - assertEquals(downloadErrorResult.error, result.error) - } - - @Test - fun `cache hit for previous success on synchronous attempt`() = - coroutineRule.runBlockingTest { - val argument = "url" - coEvery { RoadShieldDownloader.download(argument) } returns downloadValueResult - - val firstResult = cache.getOrRequest(argument) - val secondResult = cache.getOrRequest(argument) - - coVerify(exactly = 1) { - RoadShieldDownloader.download(argument) - } - assertEquals(downloadValueResult.value, firstResult.value) - assertEquals(downloadValueResult.value, secondResult.value) - } - - @Test - fun `retry for previous failure on synchronous attempt, still fails`() = - coroutineRule.runBlockingTest { - val argument = "url" - coEvery { RoadShieldDownloader.download(argument) } returns downloadErrorResult - - val firstResult = cache.getOrRequest(argument) - val secondResult = cache.getOrRequest(argument) - - // all coroutines are run synchronously, so the second one is not suspending - coVerify(exactly = 2) { - RoadShieldDownloader.download(argument) - } - assertEquals(downloadErrorResult.error, firstResult.error) - assertEquals(downloadErrorResult.error, secondResult.error) - } - - @Test - fun `retry for previous failure on synchronous attempt, succeeds`() = - coroutineRule.runBlockingTest { - val argument = "url" - - coEvery { RoadShieldDownloader.download(argument) } returns downloadErrorResult - val firstResult = cache.getOrRequest(argument) - coEvery { RoadShieldDownloader.download(argument) } returns downloadValueResult - val secondResult = cache.getOrRequest(argument) - - // all coroutines are run synchronously, so the second one is not suspending - coVerify(exactly = 2) { - RoadShieldDownloader.download(argument) - } - assertEquals(downloadErrorResult.error, firstResult.error) - assertEquals(downloadValueResult.value, secondResult.value) - } - - @Test - fun `duplicate async request awaits results for success`() = - coroutineRule.runBlockingTest { - val argument = "url" - coEvery { RoadShieldDownloader.download(argument) } coAnswers downloadValueAsyncAnswer - - var firstResult: Result? = null - var secondResult: Result? = null - pauseDispatcher { - launch { - firstResult = cache.getOrRequest(argument) - } - launch { - secondResult = cache.getOrRequest(argument) - } - } - - coVerify(exactly = 1) { - RoadShieldDownloader.download(argument) - } - assertEquals(downloadValueResult.value, firstResult!!.value) - assertEquals(downloadValueResult.value, secondResult!!.value) - } - - @Test - fun `duplicate async request awaits results for failure`() = - coroutineRule.runBlockingTest { - val argument = "url" - coEvery { RoadShieldDownloader.download(argument) } coAnswers downloadErrorAsyncAnswer - - var firstResult: Result? = null - var secondResult: Result? = null - pauseDispatcher { - launch { - firstResult = cache.getOrRequest(argument) - } - launch { - secondResult = cache.getOrRequest(argument) - } - } - - coVerify(exactly = 1) { - RoadShieldDownloader.download(argument) - } - assertEquals(downloadErrorResult.error, firstResult!!.error) - assertEquals(downloadErrorResult.error, secondResult!!.error) - } - - @Test - fun `requests for different resources are executed in parallel`() = - coroutineRule.runBlockingTest { - val argument1 = "url1" - val argument2 = "url2" - val expectedResult1 = ExpectedFactory.createValue(byteArrayOf(0, 1)) - val expectedResult2 = ExpectedFactory.createValue(byteArrayOf(2, 3)) - coEvery { RoadShieldDownloader.download(argument1) } coAnswers { - delay(1000L) - expectedResult1 - } - coEvery { RoadShieldDownloader.download(argument2) } coAnswers { - delay(500L) - expectedResult2 - } - - var firstResult: Result? = null - var secondResult: Result? = null - pauseDispatcher { - launch { - firstResult = cache.getOrRequest(argument1) - } - launch { - secondResult = cache.getOrRequest(argument2) - } - - // advance to let second get downloaded but not the first - advanceTimeBy(750L) - - // verify that indeed first was scheduled to be downloaded earlier than second - coVerifyOrder { - RoadShieldDownloader.download(argument1) - RoadShieldDownloader.download(argument2) - } - - // second (shorter) request should already finish while first (longer) is ongoing - assertEquals(expectedResult2.value, secondResult!!.value) - assertNull(firstResult) - } - - // both requests eventually finish - assertEquals(expectedResult1.value, firstResult!!.value) - assertEquals(expectedResult2.value, secondResult!!.value) - } - - @Test - fun `non-overlapping duplicate async request hit cache for success`() = - coroutineRule.runBlockingTest { - val argument = "url" - coEvery { RoadShieldDownloader.download(argument) } coAnswers downloadValueAsyncAnswer - - var firstResult: Result? = null - var secondResult: Result? = null - pauseDispatcher { - launch { - firstResult = cache.getOrRequest(argument) - } - // let first request finish - advanceTimeBy(RequestDelay + 100) - launch { - secondResult = cache.getOrRequest(argument) - } - } - - coVerify(exactly = 1) { - RoadShieldDownloader.download(argument) - } - assertEquals(downloadValueResult.value, firstResult!!.value) - assertEquals(downloadValueResult.value, secondResult!!.value) - } - - @Test - fun `non-overlapping duplicate async request retry for failure`() = - coroutineRule.runBlockingTest { - val argument = "url" - coEvery { RoadShieldDownloader.download(argument) } coAnswers downloadErrorAsyncAnswer - - var firstResult: Result? = null - var secondResult: Result? = null - pauseDispatcher { - launch { - firstResult = cache.getOrRequest(argument) - } - // let first request finish - advanceTimeBy(RequestDelay + 100) - coEvery { - RoadShieldDownloader.download(argument) - } coAnswers downloadValueAsyncAnswer - launch { - secondResult = cache.getOrRequest(argument) - } - } - - coVerify(exactly = 2) { - RoadShieldDownloader.download(argument) - } - assertEquals(downloadErrorResult.error, firstResult!!.error) - assertEquals(downloadValueResult.value, secondResult!!.value) - } - - /** - * This should be fixed so that instead of awaiting requests being canceled, - * they attempt to get the resource themselves if the ongoing request is canceled. - */ - @Test - fun `awaiting duplicate async request gets canceled if original is canceled`() = - coroutineRule.runBlockingTest { - val argument = "url" - coEvery { RoadShieldDownloader.download(argument) } coAnswers downloadValueAsyncAnswer - - var firstResult: Result? = null - var secondResult: Result? = null - pauseDispatcher { - val original = launch { - firstResult = cache.getOrRequest(argument) - } - // let first run by not finish yet - advanceTimeBy(RequestDelay - 100) - launch { - secondResult = cache.getOrRequest(argument) - } - // cancel the first one - original.cancel() - } - - coVerify(exactly = 1) { - RoadShieldDownloader.download(argument) - } - - // all request get canceled - assertEquals(ResourceCache.CANCELED_MESSAGE, firstResult!!.error) - assertEquals(ResourceCache.CANCELED_MESSAGE, secondResult!!.error) - } - - @After - fun tearDown() { - unmockkObject(RoadShieldDownloader) - } -} diff --git a/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/internal/loader/CachedLoaderTest.kt b/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/internal/loader/CachedLoaderTest.kt new file mode 100644 index 00000000000..96a3233f5e5 --- /dev/null +++ b/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/internal/loader/CachedLoaderTest.kt @@ -0,0 +1,50 @@ +package com.mapbox.navigation.ui.shield.internal.loader + +import com.mapbox.bindgen.ExpectedFactory.createValue +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class CachedLoaderTest { + + private lateinit var loader: Loader + private lateinit var sut: CachedLoader + + @Before + fun setUp() { + loader = mockk { + coEvery { load(any()) } coAnswers { + createValue(firstArg().toString()) + } + } + sut = CachedLoader(2, loader) + } + + @Test + fun `should load non-cached value`() = runBlockingTest { + val r = sut.load(1) + + coVerify(exactly = 1) { loader.load(1) } + assertEquals("1", r.value) + } + + @Test + fun `should return cached value`() = runBlockingTest { + val results = listOf( + sut.load(1), + sut.load(1) + ) + + coVerify(exactly = 1) { loader.load(1) } + assertEquals(listOf("1", "1"), results.map { it.value }) + } +} diff --git a/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/internal/loader/ResourceDownloaderTest.kt b/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/internal/loader/ResourceDownloaderTest.kt new file mode 100644 index 00000000000..c90af569377 --- /dev/null +++ b/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/internal/loader/ResourceDownloaderTest.kt @@ -0,0 +1,83 @@ +package com.mapbox.navigation.ui.shield.internal.loader + +import com.mapbox.bindgen.Expected +import com.mapbox.bindgen.ExpectedFactory.createValue +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ResourceDownloaderTest { + + @Test + fun `load should call download method and return its value`() = runBlockingTest { + val sut = object : Downloader() { + override suspend fun download(input: Int): Expected = + createValue("$input") + } + val r = sut.load(1234) + + assertEquals("1234", r.value) + } + + @Test + fun `load should call download only once`() = + runBlockingTest { + val stateFlow = MutableStateFlow(null) + val downloadCalls = mutableListOf() + val sut = object : Downloader() { + override suspend fun download(input: Int): Expected { + downloadCalls.add(input) + val v = stateFlow.filter { it == "$input" }.filterNotNull().first() + return createValue(v) + } + } + + val loaded = mutableListOf() + val j = launch { + launch { loaded.add(sut.load(1).value) } + launch { loaded.add(sut.load(1).value) } + } + stateFlow.value = "1" + j.join() + + assertEquals(1, downloadCalls.size) + } + + @Test + fun `load should notify all callbacks when the value has been downloaded`() = + runBlockingTest { + // Simulate scenario where resource (1) has been requested multiple times and takes longer + // to load than resource (2). + // + // |-------------(1)------(1)-----(2)-----------------[2]-[1]-------> + // resource 1: {-------------- loading 1 -----------} + // resource 2: {-- loading 2 --} + // + val stateFlow = MutableStateFlow(null) + val sut = object : Downloader() { + override suspend fun download(input: Int): Expected { + val v = stateFlow.filter { it == "$input" }.filterNotNull().first() + return createValue(v) + } + } + + val loaded = mutableListOf() + val j = launch { + launch { loaded.add(sut.load(1).value) } + launch { loaded.add(sut.load(1).value) } + launch { loaded.add(sut.load(2).value) } + } + stateFlow.value = "2" // simulate faster load time of the resource (2) + stateFlow.value = "1" + j.join() + + assertEquals(listOf("2", "1", "1"), loaded) + } +} diff --git a/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/ShieldResultCacheTest.kt b/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/internal/loader/RoadShieldLoaderTest.kt similarity index 75% rename from libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/ShieldResultCacheTest.kt rename to libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/internal/loader/RoadShieldLoaderTest.kt index b6a4012ba61..7798d5a1dc5 100644 --- a/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/ShieldResultCacheTest.kt +++ b/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/internal/loader/RoadShieldLoaderTest.kt @@ -1,9 +1,8 @@ -package com.mapbox.navigation.ui.shield +package com.mapbox.navigation.ui.shield.internal.loader import com.mapbox.api.directions.v5.models.ShieldSprite import com.mapbox.api.directions.v5.models.ShieldSprites import com.mapbox.bindgen.ExpectedFactory -import com.mapbox.navigation.testing.MainCoroutineRule import com.mapbox.navigation.ui.shield.internal.model.RouteShieldToDownload import com.mapbox.navigation.ui.shield.internal.model.generateSpriteSheetUrl import com.mapbox.navigation.ui.shield.internal.model.getSpriteFrom @@ -12,37 +11,43 @@ import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic -import io.mockk.unmockkStatic +import io.mockk.unmockkAll import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runBlockingTest import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before -import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) -class ShieldResultCacheTest { +class RoadShieldLoaderTest { - @get:Rule - var coroutineRule = MainCoroutineRule() + private lateinit var sut: RoadShieldLoader - private val shieldSpritesCache: ShieldSpritesCache = mockk() - private val shieldByteArrayCache: ShieldByteArrayCache = mockk() - - private val cache = ShieldResultCache(shieldSpritesCache, shieldByteArrayCache) + private lateinit var spritesLoader: Loader + private lateinit var imageLoader: Loader @Before fun setup() { + spritesLoader = mockk() + imageLoader = mockk() + sut = RoadShieldLoader(spritesLoader, imageLoader) + mockkStatic(RouteShieldToDownload.MapboxDesign::generateSpriteSheetUrl) mockkStatic(RouteShieldToDownload.MapboxDesign::getSpriteFrom) } + @After + fun tearDown() { + unmockkAll() + } + @Test @Suppress("MaxLineLength") - fun `design shield - success`() = coroutineRule.runBlockingTest { + fun `design shield - success`() = runBlockingTest { val rawShieldJson = """ {"svg":""} @@ -66,10 +71,10 @@ class ShieldResultCacheTest { } } coEvery { - shieldSpritesCache.getOrRequest(spriteUrl) + spritesLoader.load(spriteUrl) } returns ExpectedFactory.createValue(shieldSprites) coEvery { - shieldByteArrayCache.getOrRequest(shieldUrl) + imageLoader.load(shieldUrl) } returns ExpectedFactory.createValue(rawShieldJson.toByteArray()) val expectedShieldString = @@ -82,13 +87,13 @@ class ShieldResultCacheTest { mapboxShield = toDownload.mapboxShield, shieldSprite = shieldSprite ) - val result = cache.getOrRequest(toDownload) + val result = sut.load(toDownload) assertEquals(expected, result.value) } @Test - fun `design shield - failure - shield download failure`() = coroutineRule.runBlockingTest { + fun `design shield - failure - shield download failure`() = runBlockingTest { val shieldUrl = "shield-url" val spriteUrl = "sprite-url" val shieldSprites = mockk() @@ -107,40 +112,40 @@ class ShieldResultCacheTest { } } coEvery { - shieldSpritesCache.getOrRequest(spriteUrl) + spritesLoader.load(spriteUrl) } returns ExpectedFactory.createValue(shieldSprites) coEvery { - shieldByteArrayCache.getOrRequest(shieldUrl) - } returns ExpectedFactory.createError("error") + imageLoader.load(shieldUrl) + } returns ExpectedFactory.createError(Error("error")) val expected = "error" - val result = cache.getOrRequest(toDownload) + val result = sut.load(toDownload) - assertEquals(expected, result.error) + assertEquals(expected, result.error?.message) } @Test - fun `design shield - failure - missing sprites`() = coroutineRule.runBlockingTest { + fun `design shield - failure - missing sprites`() = runBlockingTest { val spriteUrl = "sprite-url" val toDownload = mockk { every { generateSpriteSheetUrl() } returns spriteUrl } coEvery { - shieldSpritesCache.getOrRequest(spriteUrl) - } returns ExpectedFactory.createError("error") + spritesLoader.load(spriteUrl) + } returns ExpectedFactory.createError(Error("error")) val expected = """ Error when downloading image sprite. url: $spriteUrl result: error """.trimIndent() - val result = cache.getOrRequest(toDownload) + val result = sut.load(toDownload) - assertEquals(expected, result.error) + assertEquals(expected, result.error?.message) } @Test - fun `design shield - failure - missing sprite`() = coroutineRule.runBlockingTest { + fun `design shield - failure - missing sprite`() = runBlockingTest { val spriteUrl = "sprite-url" val shieldSprites = mockk() val toDownload = mockk { @@ -151,17 +156,17 @@ class ShieldResultCacheTest { } } coEvery { - shieldSpritesCache.getOrRequest(spriteUrl) + spritesLoader.load(spriteUrl) } returns ExpectedFactory.createValue(shieldSprites) val expected = "Sprite not found for ${toDownload.mapboxShield.name()} in $shieldSprites." - val result = cache.getOrRequest(toDownload) + val result = sut.load(toDownload) - assertEquals(expected, result.error) + assertEquals(expected, result.error?.message) } @Test - fun `design shield - failure - missing placeholder`() = coroutineRule.runBlockingTest { + fun `design shield - failure - missing placeholder`() = runBlockingTest { val spriteUrl = "sprite-url" val shieldSprites = mockk() val shieldSprite = mockk { @@ -174,17 +179,17 @@ class ShieldResultCacheTest { every { getSpriteFrom(shieldSprites) } returns shieldSprite } coEvery { - shieldSpritesCache.getOrRequest(spriteUrl) + spritesLoader.load(spriteUrl) } returns ExpectedFactory.createValue(shieldSprites) val expected = "Mapbox shield sprite placeholder was null or empty in: $shieldSprite" - val result = cache.getOrRequest(toDownload) + val result = sut.load(toDownload) - assertEquals(expected, result.error) + assertEquals(expected, result.error?.message) } @Test - fun `legacy shield - success`() = coroutineRule.runBlockingTest { + fun `legacy shield - success`() = runBlockingTest { val shieldByteArray = byteArrayOf() val shieldUrl = "shield-url" val toDownload = mockk { @@ -192,7 +197,7 @@ class ShieldResultCacheTest { every { url } returns shieldUrl.plus(".svg") } coEvery { - shieldByteArrayCache.getOrRequest(shieldUrl.plus(".svg")) + imageLoader.load(shieldUrl.plus(".svg")) } returns ExpectedFactory.createValue(shieldByteArray) val expected = RouteShield.MapboxLegacyShield( @@ -200,30 +205,24 @@ class ShieldResultCacheTest { byteArray = shieldByteArray, initialUrl = shieldUrl ) - val result = cache.getOrRequest(toDownload) + val result = sut.load(toDownload) assertEquals(expected, result.value) } @Test - fun `legacy shield - failure`() = coroutineRule.runBlockingTest { + fun `legacy shield - failure`() = runBlockingTest { val shieldUrl = "shield-url" val toDownload = mockk { every { url } returns shieldUrl } coEvery { - shieldByteArrayCache.getOrRequest(shieldUrl) - } returns ExpectedFactory.createError("error") + imageLoader.load(shieldUrl) + } returns ExpectedFactory.createError(Error("error")) val expected = "error" - val result = cache.getOrRequest(toDownload) - - assertEquals(expected, result.error) - } + val result = sut.load(toDownload) - @After - fun tearDown() { - unmockkStatic(RouteShieldToDownload.MapboxDesign::generateSpriteSheetUrl) - unmockkStatic(RouteShieldToDownload.MapboxDesign::getSpriteFrom) + assertEquals(expected, result.error?.message) } } diff --git a/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/ShieldSpritesCacheTest.kt b/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/internal/loader/ShieldSpritesDownloaderTest.kt similarity index 66% rename from libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/ShieldSpritesCacheTest.kt rename to libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/internal/loader/ShieldSpritesDownloaderTest.kt index 34714066e43..104631d00e6 100644 --- a/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/ShieldSpritesCacheTest.kt +++ b/libnavui-shield/src/test/java/com/mapbox/navigation/ui/shield/internal/loader/ShieldSpritesDownloaderTest.kt @@ -1,63 +1,66 @@ -package com.mapbox.navigation.ui.shield +package com.mapbox.navigation.ui.shield.internal.loader import com.mapbox.api.directions.v5.models.ShieldSprites import com.mapbox.bindgen.ExpectedFactory -import com.mapbox.navigation.testing.MainCoroutineRule +import com.mapbox.navigation.ui.shield.internal.RoadShieldDownloader import io.mockk.coEvery import io.mockk.mockkObject -import io.mockk.unmockkObject +import io.mockk.unmockkAll import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runBlockingTest import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before -import org.junit.Rule import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) -class ShieldSpritesCacheTest { +class ShieldSpritesDownloaderTest { - @get:Rule - var coroutineRule = MainCoroutineRule() - - private val cache = ShieldSpritesCache() + private lateinit var sut: ShieldSpritesDownloader @Before fun setup() { mockkObject(RoadShieldDownloader) + sut = ShieldSpritesDownloader() + } + + @After + fun tearDown() { + unmockkAll() } @Test - fun `download error`() = coroutineRule.runBlockingTest { + fun `download error`() = runBlockingTest { val argument = "url" coEvery { RoadShieldDownloader.download(argument) - } returns ExpectedFactory.createError("error") + } returns ExpectedFactory.createError(Error("error")) - val result = cache.getOrRequest(argument) + val result = sut.load(argument) - assertEquals("error", result.error) + assertEquals("error", result.error?.message) } @Test - fun `parsing error`() = coroutineRule.runBlockingTest { + fun `parsing error`() = runBlockingTest { val argument = "url" coEvery { RoadShieldDownloader.download(argument) } returns ExpectedFactory.createValue("wrong json".toByteArray()) - val result = cache.getOrRequest(argument) + val result = sut.load(argument) assertTrue( - result.error!!.startsWith("Error parsing shield sprites:") + result.error!!.message!!.startsWith("Error parsing shield sprites:") ) } @Test - fun `download success`() = coroutineRule.runBlockingTest { + fun `download success`() = runBlockingTest { val argument = "url" - val expectedResult = ExpectedFactory.createValue( + val expectedResult = ExpectedFactory.createValue( """ { "turning-circle-outline": { @@ -86,13 +89,8 @@ class ShieldSpritesCacheTest { expectedResult } - val result = cache.getOrRequest(argument) + val result = sut.load(argument) assertEquals(ShieldSprites.fromJson(String(expectedResult.value!!)), result.value) } - - @After - fun tearDown() { - unmockkObject(RoadShieldDownloader) - } }