summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt110
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt6
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt40
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt141
-rw-r--r--tests/activity/src/com/android/intentresolver/ChooserActivityTest.java15
-rw-r--r--tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt36
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt278
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])
+ }
+}