diff options
7 files changed, 548 insertions, 78 deletions
diff --git a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt new file mode 100644 index 00000000..ce064cdf --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.graphics.Bitmap +import android.net.Uri +import android.util.Log +import androidx.core.util.lruCache +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.ViewModelOwned +import java.util.function.Consumer +import javax.inject.Inject +import javax.inject.Qualifier +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.BINARY) +annotation class PreviewMaxConcurrency + +/** + * Implementation of [ImageLoader]. + * + * Allows for cached or uncached loading of images and limits the number of concurrent requests. + * Requests are automatically cancelled when they are evicted from the cache. If image loading fails + * or the request is cancelled (e.g. by eviction), the returned [Bitmap] will be null. + */ +class CachingImagePreviewImageLoader +@Inject +constructor( + @ViewModelOwned private val scope: CoroutineScope, + @Background private val bgDispatcher: CoroutineDispatcher, + private val thumbnailLoader: ThumbnailLoader, + @PreviewCacheSize cacheSize: Int, + @PreviewMaxConcurrency maxConcurrency: Int, +) : ImageLoader { + + private val semaphore = Semaphore(maxConcurrency) + + private val cache = + lruCache( + maxSize = cacheSize, + create = { uri: Uri -> scope.async { loadUncachedImage(uri) } }, + onEntryRemoved = { evicted: Boolean, _, oldValue: Deferred<Bitmap?>, _ -> + // If removed due to eviction, cancel the coroutine, otherwise it is the + // responsibility + // of the caller of [cache.remove] to cancel the removed entry when done with it. + if (evicted) { + oldValue.cancel() + } + } + ) + + override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer<Bitmap?>) { + callerScope.launch { callback.accept(loadCachedImage(uri)) } + } + + override fun prePopulate(uris: List<Uri>) { + uris.take(cache.maxSize()).map { cache[it] } + } + + override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? { + return if (caching) { + loadCachedImage(uri) + } else { + loadUncachedImage(uri) + } + } + + private suspend fun loadUncachedImage(uri: Uri): Bitmap? = + withContext(bgDispatcher) { + runCatching { semaphore.withPermit { thumbnailLoader.invoke(uri) } } + .onFailure { + ensureActive() + Log.d(TAG, "Failed to load preview for $uri", it) + } + .getOrNull() + } + + private suspend fun loadCachedImage(uri: Uri): Bitmap? = + // [Deferred#await] is called in a [runCatching] block to catch + // [CancellationExceptions]s so that they don't cancel the calling coroutine/scope. + runCatching { cache[uri].await() }.getOrNull() + + companion object { + private const val TAG = "CachingImgPrevLoader" + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt index b861a24a..7035f765 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt @@ -33,6 +33,10 @@ interface ImageLoaderModule { @ActivityRetainedScoped fun imageLoader(previewImageLoader: ImagePreviewImageLoader): ImageLoader + @Binds + @ActivityRetainedScoped + fun thumbnailLoader(thumbnailLoader: ThumbnailLoaderImpl): ThumbnailLoader + companion object { @Provides @ThumbnailSize @@ -40,5 +44,7 @@ interface ImageLoaderModule { resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen) @Provides @PreviewCacheSize fun cacheSize() = 16 + + @Provides @PreviewMaxConcurrency fun maxConcurrency() = 4 } } diff --git a/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt b/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt new file mode 100644 index 00000000..9f1d50da --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.content.ContentResolver +import android.graphics.Bitmap +import android.net.Uri +import android.util.Size +import javax.inject.Inject + +/** Interface for objects that can attempt load a [Bitmap] from a [Uri]. */ +interface ThumbnailLoader : suspend (Uri) -> Bitmap? + +/** Default implementation of [ThumbnailLoader]. */ +class ThumbnailLoaderImpl +@Inject +constructor( + private val contentResolver: ContentResolver, + @ThumbnailSize thumbnailSize: Int, +) : ThumbnailLoader { + + private val size = Size(thumbnailSize, thumbnailSize) + + override suspend fun invoke(uri: Uri): Bitmap = + contentResolver.loadThumbnail(uri, size, /* signal = */ null) +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt index 8b2dd818..1b9c231b 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt @@ -15,11 +15,9 @@ */ package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel -import android.content.Context -import com.android.intentresolver.R +import com.android.intentresolver.contentpreview.CachingImagePreviewImageLoader import com.android.intentresolver.contentpreview.HeadlineGenerator import com.android.intentresolver.contentpreview.ImageLoader -import com.android.intentresolver.contentpreview.ImagePreviewImageLoader import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.PayloadToggle import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.ChooserRequestInteractor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.CustomActionsInteractor @@ -27,14 +25,12 @@ import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectionInteractor import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel -import com.android.intentresolver.inject.Background import com.android.intentresolver.inject.ViewModelOwned +import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.components.ViewModelComponent -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted @@ -42,7 +38,6 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus /** A dynamic carousel of selectable previews within share sheet. */ data class ShareouselViewModel( @@ -63,80 +58,70 @@ data class ShareouselViewModel( @Module @InstallIn(ViewModelComponent::class) -object ShareouselViewModelModule { - @Provides - fun create( - interactor: SelectablePreviewsInteractor, - @PayloadToggle imageLoader: ImageLoader, - actionsInteractor: CustomActionsInteractor, - headlineGenerator: HeadlineGenerator, - selectionInteractor: SelectionInteractor, - chooserRequestInteractor: ChooserRequestInteractor, - // TODO: remove if possible - @ViewModelOwned scope: CoroutineScope, - ): ShareouselViewModel { - val keySet = - interactor.previews.stateIn( - scope, - SharingStarted.Eagerly, - initialValue = null, - ) - return ShareouselViewModel( - headline = - selectionInteractor.amountSelected.map { numItems -> - val contentType = ContentType.Image // TODO: convert from metadata - when (contentType) { - ContentType.Other -> headlineGenerator.getFilesHeadline(numItems) - ContentType.Image -> headlineGenerator.getImagesHeadline(numItems) - ContentType.Video -> headlineGenerator.getVideosHeadline(numItems) - } - }, - metadataText = chooserRequestInteractor.metadataText, - previews = keySet, - actions = - actionsInteractor.customActions.map { actions -> - actions.mapIndexedNotNull { i, model -> - val icon = model.icon - val label = model.label - if (icon == null && label.isBlank()) { - null - } else { - ActionChipViewModel( - label = label.toString(), - icon = model.icon, - onClicked = { model.performAction(i) }, - ) +interface ShareouselViewModelModule { + + @Binds @PayloadToggle fun imageLoader(imageLoader: CachingImagePreviewImageLoader): ImageLoader + + companion object { + @Provides + fun create( + interactor: SelectablePreviewsInteractor, + @PayloadToggle imageLoader: ImageLoader, + actionsInteractor: CustomActionsInteractor, + headlineGenerator: HeadlineGenerator, + selectionInteractor: SelectionInteractor, + chooserRequestInteractor: ChooserRequestInteractor, + // TODO: remove if possible + @ViewModelOwned scope: CoroutineScope, + ): ShareouselViewModel { + val keySet = + interactor.previews.stateIn( + scope, + SharingStarted.Eagerly, + initialValue = null, + ) + return ShareouselViewModel( + headline = + selectionInteractor.amountSelected.map { numItems -> + val contentType = ContentType.Image // TODO: convert from metadata + when (contentType) { + ContentType.Other -> headlineGenerator.getFilesHeadline(numItems) + ContentType.Image -> headlineGenerator.getImagesHeadline(numItems) + ContentType.Video -> headlineGenerator.getVideosHeadline(numItems) } - } + }, + metadataText = chooserRequestInteractor.metadataText, + previews = keySet, + actions = + actionsInteractor.customActions.map { actions -> + actions.mapIndexedNotNull { i, model -> + val icon = model.icon + val label = model.label + if (icon == null && label.isBlank()) { + null + } else { + ActionChipViewModel( + label = label.toString(), + icon = model.icon, + onClicked = { model.performAction(i) }, + ) + } + } + }, + preview = { key -> + keySet.value?.maybeLoad(key) + val previewInteractor = interactor.preview(key) + ShareouselPreviewViewModel( + bitmap = flow { emit(key.previewUri?.let { imageLoader(it) }) }, + contentType = flowOf(ContentType.Image), // TODO: convert from metadata + isSelected = previewInteractor.isSelected, + setSelected = previewInteractor::setSelected, + aspectRatio = key.aspectRatio, + ) }, - preview = { key -> - keySet.value?.maybeLoad(key) - val previewInteractor = interactor.preview(key) - ShareouselPreviewViewModel( - bitmap = flow { emit(key.previewUri?.let { imageLoader(it) }) }, - contentType = flowOf(ContentType.Image), // TODO: convert from metadata - isSelected = previewInteractor.isSelected, - setSelected = previewInteractor::setSelected, - aspectRatio = key.aspectRatio, - ) - }, - ) + ) + } } - - @Provides - @PayloadToggle - fun imageLoader( - @ViewModelOwned viewModelScope: CoroutineScope, - @Background coroutineDispatcher: CoroutineDispatcher, - @ApplicationContext context: Context, - ): ImageLoader = - ImagePreviewImageLoader( - viewModelScope + coroutineDispatcher, - thumbnailSize = - context.resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen), - context.contentResolver, - cacheSize = 16, - ) } private fun PreviewsModel.maybeLoad(key: PreviewModel) { diff --git a/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java index 66f7650d..a16e201b 100644 --- a/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java @@ -117,8 +117,12 @@ import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.contentpreview.FakeThumbnailLoader; import com.android.intentresolver.contentpreview.ImageLoader; import com.android.intentresolver.contentpreview.ImageLoaderModule; +import com.android.intentresolver.contentpreview.PreviewCacheSize; +import com.android.intentresolver.contentpreview.PreviewMaxConcurrency; +import com.android.intentresolver.contentpreview.ThumbnailLoader; import com.android.intentresolver.data.repository.FakeUserRepository; import com.android.intentresolver.data.repository.UserRepository; import com.android.intentresolver.data.repository.UserRepositoryModule; @@ -267,6 +271,17 @@ public class ChooserActivityTest { @BindValue final ImageLoader mImageLoader = mFakeImageLoader; + @BindValue + @PreviewCacheSize + int mPreviewCacheSize = 16; + + @BindValue + @PreviewMaxConcurrency + int mPreviewMaxConcurrency = 4; + + @BindValue + ThumbnailLoader mThumbnailLoader = new FakeThumbnailLoader(); + @Before public void setUp() { // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt b/tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt new file mode 100644 index 00000000..d3fdf17d --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.graphics.Bitmap +import android.net.Uri + +/** Fake implementation of [ThumbnailLoader] for use in testing. */ +class FakeThumbnailLoader : ThumbnailLoader { + + val fakeInvoke = mutableMapOf<Uri, suspend () -> Bitmap?>() + val invokeCalls = mutableListOf<Uri>() + var unfinishedInvokeCount = 0 + + override suspend fun invoke(uri: Uri): Bitmap? { + invokeCalls.add(uri) + unfinishedInvokeCount++ + val result = fakeInvoke[uri]?.invoke() + unfinishedInvokeCount-- + return result + } +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt new file mode 100644 index 00000000..331f9f64 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt @@ -0,0 +1,278 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.graphics.Bitmap +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import kotlin.math.ceil +import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class CachingImagePreviewImageLoaderTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + private val testJobTime = 100.milliseconds + private val testCacheSize = 4 + private val testMaxConcurrency = 2 + private val testTimeToFillCache = + testJobTime * ceil((testCacheSize).toFloat() / testMaxConcurrency.toFloat()).roundToInt() + private val testUris = + List(5) { Uri.fromParts("TestScheme$it", "TestSsp$it", "TestFragment$it") } + private val testTimeToLoadAllUris = + testJobTime * ceil((testUris.size).toFloat() / testMaxConcurrency.toFloat()).roundToInt() + private val testBitmap = Bitmap.createBitmap(10, 10, Bitmap.Config.ALPHA_8) + private val fakeThumbnailLoader = + FakeThumbnailLoader().apply { + testUris.forEach { + fakeInvoke[it] = { + delay(testJobTime) + testBitmap + } + } + } + + private val imageLoader = + CachingImagePreviewImageLoader( + scope = testScope.backgroundScope, + bgDispatcher = testDispatcher, + thumbnailLoader = fakeThumbnailLoader, + cacheSize = testCacheSize, + maxConcurrency = testMaxConcurrency, + ) + + @Test + fun loadImage_notCached_callsThumbnailLoader() = + testScope.runTest { + // Arrange + var result: Bitmap? = null + + // Act + imageLoader.loadImage(testScope, testUris[0]) { result = it } + advanceTimeBy(testJobTime) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).containsExactly(testUris[0]) + assertThat(result).isSameInstanceAs(testBitmap) + } + + @Test + fun loadImage_cached_usesCachedValue() = + testScope.runTest { + // Arrange + imageLoader.loadImage(testScope, testUris[0]) {} + advanceTimeBy(testJobTime) + runCurrent() + fakeThumbnailLoader.invokeCalls.clear() + var result: Bitmap? = null + + // Act + imageLoader.loadImage(testScope, testUris[0]) { result = it } + advanceTimeBy(testJobTime) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).isEmpty() + assertThat(result).isSameInstanceAs(testBitmap) + } + + @Test + fun loadImage_error_returnsNull() = + testScope.runTest { + // Arrange + fakeThumbnailLoader.fakeInvoke[testUris[0]] = { + delay(testJobTime) + throw RuntimeException("Test exception") + } + var result: Bitmap? = testBitmap + + // Act + imageLoader.loadImage(testScope, testUris[0]) { result = it } + advanceTimeBy(testJobTime) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).containsExactly(testUris[0]) + assertThat(result).isNull() + } + + @Test + fun loadImage_uncached_limitsConcurrency() = + testScope.runTest { + // Arrange + val results = mutableListOf<Bitmap?>() + assertThat(testUris.size).isGreaterThan(testMaxConcurrency) + + // Act + testUris.take(testMaxConcurrency + 1).forEach { uri -> + imageLoader.loadImage(testScope, uri) { results.add(it) } + } + + // Assert + assertThat(results).isEmpty() + advanceTimeBy(testJobTime) + runCurrent() + assertThat(results).hasSize(testMaxConcurrency) + advanceTimeBy(testJobTime) + runCurrent() + assertThat(results).hasSize(testMaxConcurrency + 1) + assertThat(results) + .containsExactlyElementsIn(List(testMaxConcurrency + 1) { testBitmap }) + } + + @Test + fun loadImage_cacheEvicted_cancelsLoadAndReturnsNull() = + testScope.runTest { + // Arrange + val results = MutableList<Bitmap?>(testUris.size) { null } + assertThat(testUris.size).isGreaterThan(testCacheSize) + + // Act + imageLoader.loadImage(testScope, testUris[0]) { results[0] = it } + runCurrent() + testUris.indices.drop(1).take(testCacheSize).forEach { i -> + imageLoader.loadImage(testScope, testUris[i]) { results[i] = it } + } + advanceTimeBy(testTimeToFillCache) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).containsExactlyElementsIn(testUris) + assertThat(results) + .containsExactlyElementsIn( + List(testUris.size) { index -> if (index == 0) null else testBitmap } + ) + .inOrder() + assertThat(fakeThumbnailLoader.unfinishedInvokeCount).isEqualTo(1) + } + + @Test + fun prePopulate_fillsCache() = + testScope.runTest { + // Arrange + val fullCacheUris = testUris.take(testCacheSize) + assertThat(fullCacheUris).hasSize(testCacheSize) + + // Act + imageLoader.prePopulate(fullCacheUris) + advanceTimeBy(testTimeToFillCache) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).containsExactlyElementsIn(fullCacheUris) + + // Act + fakeThumbnailLoader.invokeCalls.clear() + imageLoader.prePopulate(fullCacheUris) + advanceTimeBy(testTimeToFillCache) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).isEmpty() + } + + @Test + fun prePopulate_greaterThanCacheSize_fillsCacheThenDropsRemaining() = + testScope.runTest { + // Arrange + assertThat(testUris.size).isGreaterThan(testCacheSize) + + // Act + imageLoader.prePopulate(testUris) + advanceTimeBy(testTimeToLoadAllUris) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls) + .containsExactlyElementsIn(testUris.take(testCacheSize)) + + // Act + fakeThumbnailLoader.invokeCalls.clear() + imageLoader.prePopulate(testUris) + advanceTimeBy(testTimeToLoadAllUris) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).isEmpty() + } + + @Test + fun prePopulate_fewerThatCacheSize_loadsTheGiven() = + testScope.runTest { + // Arrange + val unfilledCacheUris = testUris.take(testMaxConcurrency) + assertThat(unfilledCacheUris.size).isLessThan(testCacheSize) + + // Act + imageLoader.prePopulate(unfilledCacheUris) + advanceTimeBy(testJobTime) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).containsExactlyElementsIn(unfilledCacheUris) + + // Act + fakeThumbnailLoader.invokeCalls.clear() + imageLoader.prePopulate(unfilledCacheUris) + advanceTimeBy(testJobTime) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).isEmpty() + } + + @Test + fun invoke_uncached_alwaysCallsTheThumbnailLoader() = + testScope.runTest { + // Arrange + + // Act + imageLoader.invoke(testUris[0], caching = false) + imageLoader.invoke(testUris[0], caching = false) + advanceTimeBy(testJobTime) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).containsExactly(testUris[0], testUris[0]) + } + + @Test + fun invoke_cached_usesTheCacheWhenPossible() = + testScope.runTest { + // Arrange + + // Act + imageLoader.invoke(testUris[0], caching = true) + imageLoader.invoke(testUris[0], caching = true) + advanceTimeBy(testJobTime) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).containsExactly(testUris[0]) + } +} |