diff options
9 files changed, 850 insertions, 98 deletions
diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index 87ae2f1d..2c3f66fb 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -72,3 +72,10 @@ flag { purpose: PURPOSE_BUGFIX } } + +flag { + name: "preview_image_loader" + namespace: "intentresolver" + description: "Use the unified preview image loader for all preview variations; support variable preview sizes." + bug: "348665058" +} diff --git a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt index f60f550e..847fcc82 100644 --- a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt @@ -87,7 +87,7 @@ constructor( private suspend fun loadUncachedImage(uri: Uri): Bitmap? = withContext(bgDispatcher) { - runCatching { semaphore.withPermit { thumbnailLoader.invoke(uri) } } + runCatching { semaphore.withPermit { thumbnailLoader.loadThumbnail(uri) } } .onFailure { ensureActive() Log.d(TAG, "Failed to load preview for $uri", it) diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt index 17d05099..27e817db 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt @@ -17,6 +17,7 @@ package com.android.intentresolver.contentpreview import android.content.res.Resources +import com.android.intentresolver.Flags import com.android.intentresolver.R import com.android.intentresolver.inject.ApplicationOwned import dagger.Binds @@ -24,16 +25,26 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.components.ViewModelComponent +import javax.inject.Provider @Module @InstallIn(ViewModelComponent::class) interface ImageLoaderModule { - @Binds fun imageLoader(previewImageLoader: ImagePreviewImageLoader): ImageLoader - @Binds fun thumbnailLoader(thumbnailLoader: ThumbnailLoaderImpl): ThumbnailLoader companion object { @Provides + fun imageLoader( + imagePreviewImageLoader: Provider<ImagePreviewImageLoader>, + previewImageLoader: Provider<PreviewImageLoader> + ): ImageLoader = + if (Flags.previewImageLoader()) { + previewImageLoader.get() + } else { + imagePreviewImageLoader.get() + } + + @Provides @ThumbnailSize fun thumbnailSize(@ApplicationOwned resources: Resources): Int = resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen) diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt new file mode 100644 index 00000000..b10f7ef9 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt @@ -0,0 +1,197 @@ +/* + * Copyright 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 android.util.Size +import androidx.collection.lruCache +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.ViewModelOwned +import javax.annotation.concurrent.GuardedBy +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit + +private const val TAG = "PayloadSelImageLoader" + +/** + * Implements preview image loading for the payload selection UI. Cancels preview loading for items + * that has been evicted from the cache at the expense of a possible request duplication (deemed + * unlikely). + */ +class PreviewImageLoader +@Inject +constructor( + @ViewModelOwned private val scope: CoroutineScope, + @PreviewCacheSize private val cacheSize: Int, + @ThumbnailSize private val defaultPreviewSize: Int, + private val thumbnailLoader: ThumbnailLoader, + @Background private val bgDispatcher: CoroutineDispatcher, + @PreviewMaxConcurrency maxSimultaneousRequests: Int = 4, +) : ImageLoader { + + private val contentResolverSemaphore = Semaphore(maxSimultaneousRequests) + + private val lock = Any() + @GuardedBy("lock") private val runningRequests = hashMapOf<Uri, RequestRecord>() + @GuardedBy("lock") + private val cache = + lruCache<Uri, RequestRecord>( + maxSize = cacheSize, + onEntryRemoved = { _, _, oldRec, newRec -> + if (oldRec !== newRec) { + onRecordEvictedFromCache(oldRec) + } + } + ) + + override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? = + loadImageInternal(uri, size, caching) + + override fun prePopulate(uriSizePairs: List<Pair<Uri, Size>>) { + uriSizePairs.asSequence().take(cacheSize).forEach { uri -> + scope.launch { loadImageInternal(uri.first, uri.second, caching = true) } + } + } + + private suspend fun loadImageInternal(uri: Uri, size: Size, caching: Boolean): Bitmap? { + return withRequestRecord(uri, caching) { record -> + val newSize = sanitize(size) + val newMetric = newSize.metric + record + .also { + // set the requested size to the max of the new and the previous value; input + // will emit if the resulted value is greater than the old one + it.input.update { oldSize -> + if (oldSize == null || oldSize.metric < newSize.metric) newSize else oldSize + } + } + .output + // filter out bitmaps of a lower resolution than that we're requesting + .filter { it is BitmapLoadingState.Loaded && newMetric <= it.size.metric } + .firstOrNull() + ?.let { (it as BitmapLoadingState.Loaded).bitmap } + } + } + + private suspend fun withRequestRecord( + uri: Uri, + caching: Boolean, + block: suspend (RequestRecord) -> Bitmap? + ): Bitmap? { + val record = trackRecordRunning(uri, caching) + return try { + block(record) + } finally { + untrackRecordRunning(uri, record) + } + } + + private fun trackRecordRunning(uri: Uri, caching: Boolean): RequestRecord = + synchronized(lock) { + runningRequests + .getOrPut(uri) { cache[uri] ?: createRecord(uri) } + .also { record -> + record.clientCount++ + if (caching) { + cache.put(uri, record) + } + } + } + + private fun untrackRecordRunning(uri: Uri, record: RequestRecord) { + synchronized(lock) { + record.clientCount-- + if (record.clientCount <= 0) { + runningRequests.remove(uri) + val result = record.output.value + if (cache[uri] == null) { + record.loadingJob.cancel() + } else if (result is BitmapLoadingState.Loaded && result.bitmap == null) { + cache.remove(uri) + } + } + } + } + + private fun onRecordEvictedFromCache(record: RequestRecord) { + synchronized(lock) { + if (record.clientCount <= 0) { + record.loadingJob.cancel() + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun createRecord(uri: Uri): RequestRecord { + // use a StateFlow with sentinel values to avoid using SharedFlow that is deemed dangerous + val input = MutableStateFlow<Size?>(null) + val output = MutableStateFlow<BitmapLoadingState>(BitmapLoadingState.Loading) + val job = + scope.launch(bgDispatcher) { + // the image loading pipeline: input -- a desired image size, output -- a bitmap + input + .filterNotNull() + .mapLatest { size -> BitmapLoadingState.Loaded(size, loadBitmap(uri, size)) } + .collect { output.tryEmit(it) } + } + return RequestRecord(input, output, job, clientCount = 0) + } + + private suspend fun loadBitmap(uri: Uri, size: Size): Bitmap? = + contentResolverSemaphore.withPermit { + runCatching { thumbnailLoader.loadThumbnail(uri, size) } + .onFailure { Log.d(TAG, "failed to load $uri preview", it) } + .getOrNull() + } + + private class RequestRecord( + /** The image loading pipeline input: desired preview size */ + val input: MutableStateFlow<Size?>, + /** The image loading pipeline output */ + val output: MutableStateFlow<BitmapLoadingState>, + /** The image loading pipeline job */ + val loadingJob: Job, + @GuardedBy("lock") var clientCount: Int, + ) + + private sealed interface BitmapLoadingState { + data object Loading : BitmapLoadingState + + data class Loaded(val size: Size, val bitmap: Bitmap?) : BitmapLoadingState + } + + private fun sanitize(size: Size?): Size = + size?.takeIf { it.width > 0 && it.height > 0 } + ?: Size(defaultPreviewSize, defaultPreviewSize) +} + +private val Size.metric + get() = maxOf(width, height) diff --git a/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt b/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt index 9f1d50da..e8afa480 100644 --- a/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt @@ -20,10 +20,25 @@ import android.content.ContentResolver import android.graphics.Bitmap import android.net.Uri import android.util.Size +import com.android.intentresolver.util.withCancellationSignal import javax.inject.Inject /** Interface for objects that can attempt load a [Bitmap] from a [Uri]. */ -interface ThumbnailLoader : suspend (Uri) -> Bitmap? +interface ThumbnailLoader { + /** + * Loads a thumbnail for the given [uri]. + * + * The size of the thumbnail is determined by the implementation. + */ + suspend fun loadThumbnail(uri: Uri): Bitmap? + + /** + * Loads a thumbnail for the given [uri] and [size]. + * + * The [size] is the size of the thumbnail in pixels. + */ + suspend fun loadThumbnail(uri: Uri, size: Size): Bitmap? +} /** Default implementation of [ThumbnailLoader]. */ class ThumbnailLoaderImpl @@ -35,6 +50,11 @@ constructor( private val size = Size(thumbnailSize, thumbnailSize) - override suspend fun invoke(uri: Uri): Bitmap = - contentResolver.loadThumbnail(uri, size, /* signal = */ null) + override suspend fun loadThumbnail(uri: Uri): Bitmap = + contentResolver.loadThumbnail(uri, size, /* signal= */ null) + + override suspend fun loadThumbnail(uri: Uri, size: Size): Bitmap = + withCancellationSignal { signal -> + contentResolver.loadThumbnail(uri, size, signal) + } } 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 f1e65f73..6f8be1ff 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 @@ -16,10 +16,12 @@ package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel import android.util.Size +import com.android.intentresolver.Flags import com.android.intentresolver.contentpreview.CachingImagePreviewImageLoader import com.android.intentresolver.contentpreview.HeadlineGenerator import com.android.intentresolver.contentpreview.ImageLoader import com.android.intentresolver.contentpreview.MimeTypeClassifier +import com.android.intentresolver.contentpreview.PreviewImageLoader 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 @@ -30,11 +32,11 @@ import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentTyp import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel 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 javax.inject.Provider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted @@ -65,98 +67,106 @@ data class ShareouselViewModel( @Module @InstallIn(ViewModelComponent::class) -interface ShareouselViewModelModule { +object ShareouselViewModelModule { - @Binds @PayloadToggle fun imageLoader(imageLoader: CachingImagePreviewImageLoader): ImageLoader + @Provides + @PayloadToggle + fun imageLoader( + cachingImageLoader: Provider<CachingImagePreviewImageLoader>, + previewImageLoader: Provider<PreviewImageLoader> + ): ImageLoader = + if (Flags.previewImageLoader()) { + previewImageLoader.get() + } else { + cachingImageLoader.get() + } - companion object { - @Provides - fun create( - interactor: SelectablePreviewsInteractor, - @PayloadToggle imageLoader: ImageLoader, - actionsInteractor: CustomActionsInteractor, - headlineGenerator: HeadlineGenerator, - selectionInteractor: SelectionInteractor, - chooserRequestInteractor: ChooserRequestInteractor, - mimeTypeClassifier: MimeTypeClassifier, - // TODO: remove if possible - @ViewModelOwned scope: CoroutineScope, - ): ShareouselViewModel { - val keySet = - interactor.previews.stateIn( - scope, - SharingStarted.Eagerly, - initialValue = null, - ) - return ShareouselViewModel( - headline = - selectionInteractor.aggregateContentType.zip( - selectionInteractor.amountSelected - ) { contentType, numItems -> - when (contentType) { - ContentType.Other -> headlineGenerator.getFilesHeadline(numItems) - ContentType.Image -> headlineGenerator.getImagesHeadline(numItems) - ContentType.Video -> headlineGenerator.getVideosHeadline(numItems) + @Provides + fun create( + interactor: SelectablePreviewsInteractor, + @PayloadToggle imageLoader: ImageLoader, + actionsInteractor: CustomActionsInteractor, + headlineGenerator: HeadlineGenerator, + selectionInteractor: SelectionInteractor, + chooserRequestInteractor: ChooserRequestInteractor, + mimeTypeClassifier: MimeTypeClassifier, + // TODO: remove if possible + @ViewModelOwned scope: CoroutineScope, + ): ShareouselViewModel { + val keySet = + interactor.previews.stateIn( + scope, + SharingStarted.Eagerly, + initialValue = null, + ) + return ShareouselViewModel( + headline = + selectionInteractor.aggregateContentType.zip(selectionInteractor.amountSelected) { + contentType, + numItems -> + 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) }, + ) } - }, - 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, previewHeight, index, previewScope -> + keySet.value?.maybeLoad(index) + val previewInteractor = interactor.preview(key) + val contentType = + when { + mimeTypeClassifier.isImageType(key.mimeType) -> ContentType.Image + mimeTypeClassifier.isVideoType(key.mimeType) -> ContentType.Video + else -> ContentType.Other + } + val initialBitmapValue = + key.previewUri?.let { + imageLoader.getCachedBitmap(it)?.let { ValueUpdate.Value(it) } + } ?: ValueUpdate.Absent + ShareouselPreviewViewModel( + bitmapLoadState = + flow { + val previewWidth = + if (key.aspectRatio > 0) { + previewHeight.toFloat() / key.aspectRatio + } else { + previewHeight + } + .toInt() + emit( + key.previewUri?.let { + ValueUpdate.Value( + imageLoader(it, Size(previewWidth, previewHeight)) + ) + } ?: ValueUpdate.Absent ) } - } - }, - preview = { key, previewHeight, index, previewScope -> - keySet.value?.maybeLoad(index) - val previewInteractor = interactor.preview(key) - val contentType = - when { - mimeTypeClassifier.isImageType(key.mimeType) -> ContentType.Image - mimeTypeClassifier.isVideoType(key.mimeType) -> ContentType.Video - else -> ContentType.Other - } - val initialBitmapValue = - key.previewUri?.let { - imageLoader.getCachedBitmap(it)?.let { ValueUpdate.Value(it) } - } ?: ValueUpdate.Absent - ShareouselPreviewViewModel( - bitmapLoadState = - flow { - val previewWidth = - if (key.aspectRatio > 0) { - previewHeight.toFloat() / key.aspectRatio - } else { - previewHeight - } - .toInt() - emit( - key.previewUri?.let { - ValueUpdate.Value( - imageLoader(it, Size(previewWidth, previewHeight)) - ) - } ?: ValueUpdate.Absent - ) - } - .stateIn(previewScope, SharingStarted.Eagerly, initialBitmapValue), - contentType = contentType, - isSelected = previewInteractor.isSelected, - setSelected = previewInteractor::setSelected, - aspectRatio = key.aspectRatio, - ) - }, - ) - } + .stateIn(previewScope, SharingStarted.Eagerly, initialBitmapValue), + contentType = contentType, + isSelected = previewInteractor.isSelected, + setSelected = previewInteractor::setSelected, + aspectRatio = key.aspectRatio, + ) + }, + ) } } diff --git a/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java index 620ac555..e103e57b 100644 --- a/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java @@ -124,6 +124,7 @@ 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.contentpreview.ThumbnailSize; import com.android.intentresolver.data.repository.FakeUserRepository; import com.android.intentresolver.data.repository.UserRepository; import com.android.intentresolver.data.repository.UserRepositoryModule; @@ -285,6 +286,10 @@ public class ChooserActivityTest { int mPreviewMaxConcurrency = 4; @BindValue + @ThumbnailSize + int mPreviewThumbnailSize = 500; + + @BindValue ThumbnailLoader mThumbnailLoader = new FakeThumbnailLoader(); @Before diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt b/tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt index d3fdf17d..33969eb7 100644 --- a/tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt +++ b/tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt @@ -18,18 +18,23 @@ package com.android.intentresolver.contentpreview import android.graphics.Bitmap import android.net.Uri +import android.util.Size /** Fake implementation of [ThumbnailLoader] for use in testing. */ -class FakeThumbnailLoader : ThumbnailLoader { +class FakeThumbnailLoader(private val defaultSize: Size = Size(100, 100)) : ThumbnailLoader { - val fakeInvoke = mutableMapOf<Uri, suspend () -> Bitmap?>() + val fakeInvoke = mutableMapOf<Uri, suspend (Size) -> Bitmap?>() val invokeCalls = mutableListOf<Uri>() var unfinishedInvokeCount = 0 - override suspend fun invoke(uri: Uri): Bitmap? { + override suspend fun loadThumbnail(uri: Uri): Bitmap? = getBitmap(uri, defaultSize) + + override suspend fun loadThumbnail(uri: Uri, size: Size): Bitmap? = getBitmap(uri, size) + + private suspend fun getBitmap(uri: Uri, size: Size): Bitmap? { invokeCalls.add(uri) unfinishedInvokeCount++ - val result = fakeInvoke[uri]?.invoke() + val result = fakeInvoke[uri]?.invoke(size) unfinishedInvokeCount-- return result } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewImageLoaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewImageLoaderTest.kt new file mode 100644 index 00000000..8293264c --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewImageLoaderTest.kt @@ -0,0 +1,497 @@ +/* + * Copyright 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.Size +import com.google.common.truth.Truth.assertThat +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class PreviewImageLoaderTest { + private val scope = TestScope() + + @Test + fun test_cachingImageRequest_imageCached() = + scope.runTest { + val uri = createUri(0) + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uri] = { size -> createBitmap(size.width, size.height) } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + val b1 = testSubject.invoke(uri, Size(200, 100)) + val b2 = testSubject.invoke(uri, Size(200, 100), caching = false) + assertThat(b1).isEqualTo(b2) + assertThat(thumbnailLoader.invokeCalls).hasSize(1) + } + + @Test + fun test_nonCachingImageRequest_imageNotCached() = + scope.runTest { + val uri = createUri(0) + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uri] = { size -> createBitmap(size.width, size.height) } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + testSubject.invoke(uri, Size(200, 100), caching = false) + testSubject.invoke(uri, Size(200, 100), caching = false) + assertThat(thumbnailLoader.invokeCalls).hasSize(2) + } + + @Test + fun test_twoSimultaneousImageRequests_requestsDeduplicated() = + scope.runTest { + val uri = createUri(0) + val loadingStartedDeferred = CompletableDeferred<Unit>() + val bitmapDeferred = CompletableDeferred<Bitmap>() + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uri] = { + loadingStartedDeferred.complete(Unit) + bitmapDeferred.await() + } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + val b1Deferred = async { testSubject.invoke(uri, Size(200, 100), caching = false) } + loadingStartedDeferred.await() + val b2Deferred = + async(start = CoroutineStart.UNDISPATCHED) { + testSubject.invoke(uri, Size(200, 100), caching = true) + } + bitmapDeferred.complete(createBitmap(200, 200)) + + val b1 = b1Deferred.await() + val b2 = b2Deferred.await() + assertThat(b1).isEqualTo(b2) + assertThat(thumbnailLoader.invokeCalls).hasSize(1) + } + + @Test + fun test_cachingRequestCancelledAndEvoked_imageLoadingCancelled() = + scope.runTest { + val uriOne = createUri(1) + val uriTwo = createUri(2) + val loadingStartedDeferred = CompletableDeferred<Unit>() + val cancelledRequests = mutableSetOf<Uri>() + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uriOne] = { + loadingStartedDeferred.complete(Unit) + try { + awaitCancellation() + } catch (e: CancellationException) { + cancelledRequests.add(uriOne) + throw e + } + } + fakeInvoke[uriTwo] = { createBitmap(200, 200) } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + cacheSize = 1, + defaultPreviewSize = 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + val jobOne = launch { testSubject.invoke(uriOne, Size(200, 100)) } + loadingStartedDeferred.await() + jobOne.cancel() + scope.runCurrent() + + assertThat(cancelledRequests).isEmpty() + + // second URI should evict the first item from the cache + testSubject.invoke(uriTwo, Size(200, 100)) + + assertThat(thumbnailLoader.invokeCalls).hasSize(2) + assertThat(cancelledRequests).containsExactly(uriOne) + } + + @Test + fun test_nonCachingRequestClientCancels_imageLoadingCancelled() = + scope.runTest { + val uri = createUri(1) + val loadingStartedDeferred = CompletableDeferred<Unit>() + val cancelledRequests = mutableSetOf<Uri>() + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uri] = { + loadingStartedDeferred.complete(Unit) + try { + awaitCancellation() + } catch (e: CancellationException) { + cancelledRequests.add(uri) + throw e + } + } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + cacheSize = 1, + defaultPreviewSize = 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + val job = launch { testSubject.invoke(uri, Size(200, 100), caching = false) } + loadingStartedDeferred.await() + job.cancel() + scope.runCurrent() + + assertThat(cancelledRequests).containsExactly(uri) + } + + @Test + fun test_requestHigherResImage_newImageLoaded() = + scope.runTest { + val uri = createUri(0) + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uri] = { size -> createBitmap(size.width, size.height) } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + val b1 = testSubject.invoke(uri, Size(100, 100)) + val b2 = testSubject.invoke(uri, Size(200, 200)) + assertThat(b1).isNotNull() + assertThat(b1!!.width).isEqualTo(100) + assertThat(b2).isNotNull() + assertThat(b2!!.width).isEqualTo(200) + assertThat(thumbnailLoader.invokeCalls).hasSize(2) + } + + @Test + fun test_imageLoadingThrowsException_returnsNull() = + scope.runTest { + val uri = createUri(0) + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uri] = { throw SecurityException("test") } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + val bitmap = testSubject.invoke(uri, Size(100, 100)) + assertThat(bitmap).isNull() + } + + @Test + fun test_requestHigherResImage_cancelsLowerResLoading() = + scope.runTest { + val uri = createUri(0) + val cancelledRequestCount = atomic(0) + val imageLoadingStarted = CompletableDeferred<Unit>() + val bitmapDeferred = CompletableDeferred<Bitmap>() + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uri] = { + imageLoadingStarted.complete(Unit) + try { + bitmapDeferred.await() + } catch (e: CancellationException) { + cancelledRequestCount.getAndIncrement() + throw e + } + } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + val lowResSize = 100 + val highResSize = 200 + launch(start = CoroutineStart.UNDISPATCHED) { + testSubject.invoke(uri, Size(lowResSize, lowResSize)) + } + imageLoadingStarted.await() + val result = async { testSubject.invoke(uri, Size(highResSize, highResSize)) } + runCurrent() + assertThat(cancelledRequestCount.value).isEqualTo(1) + + bitmapDeferred.complete(createBitmap(highResSize, highResSize)) + val bitmap = result.await() + assertThat(bitmap).isNotNull() + assertThat(bitmap!!.width).isEqualTo(highResSize) + assertThat(thumbnailLoader.invokeCalls).hasSize(2) + } + + @Test + fun test_requestLowerResImage_cachedHigherResImageReturned() = + scope.runTest { + val uri = createUri(0) + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uri] = { size -> createBitmap(size.width, size.height) } + } + val lowResSize = 100 + val highResSize = 200 + val testSubject = + PreviewImageLoader( + backgroundScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + val b1 = testSubject.invoke(uri, Size(highResSize, highResSize)) + val b2 = testSubject.invoke(uri, Size(lowResSize, lowResSize)) + assertThat(b1).isEqualTo(b2) + assertThat(b2!!.width).isEqualTo(highResSize) + assertThat(thumbnailLoader.invokeCalls).hasSize(1) + } + + @Test + fun test_incorrectSizeRequested_defaultSizeIsUsed() = + scope.runTest { + val uri = createUri(0) + val defaultPreviewSize = 100 + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uri] = { size -> createBitmap(size.width, size.height) } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + cacheSize = 1, + defaultPreviewSize, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + val b1 = testSubject(uri, Size(0, 0)) + assertThat(b1!!.width).isEqualTo(defaultPreviewSize) + + val largerImageSize = 200 + val b2 = testSubject(uri, Size(largerImageSize, largerImageSize)) + assertThat(b2!!.width).isEqualTo(largerImageSize) + } + + @Test + fun test_prePopulateImages_cachesImagesUpToTheCacheSize() = + scope.runTest { + val previewSize = Size(100, 100) + val uris = List(2) { createUri(it) } + val loadingCount = atomic(0) + val thumbnailLoader = + FakeThumbnailLoader().apply { + for (uri in uris) { + fakeInvoke[uri] = { size -> + loadingCount.getAndIncrement() + createBitmap(size.width, size.height) + } + } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + testSubject.prePopulate(uris.map { it to previewSize }) + runCurrent() + + assertThat(loadingCount.value).isEqualTo(1) + assertThat(thumbnailLoader.invokeCalls).containsExactly(uris[0]) + + testSubject(uris[0], previewSize) + runCurrent() + + assertThat(loadingCount.value).isEqualTo(1) + } + + @Test + fun test_oldRecordEvictedFromTheCache() = + scope.runTest { + val previewSize = Size(100, 100) + val uriOne = createUri(1) + val uriTwo = createUri(2) + val requestsPerUri = HashMap<Uri, AtomicInteger>() + val thumbnailLoader = + FakeThumbnailLoader().apply { + for (uri in arrayOf(uriOne, uriTwo)) { + fakeInvoke[uri] = { size -> + requestsPerUri.getOrPut(uri) { AtomicInteger() }.incrementAndGet() + createBitmap(size.width, size.height) + } + } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + testSubject(uriOne, previewSize) + testSubject(uriTwo, previewSize) + testSubject(uriTwo, previewSize) + testSubject(uriOne, previewSize) + + assertThat(requestsPerUri[uriOne]?.get()).isEqualTo(2) + assertThat(requestsPerUri[uriTwo]?.get()).isEqualTo(1) + } + + @Test + fun test_doNotCacheNulls() = + scope.runTest { + val previewSize = Size(100, 100) + val uri = createUri(1) + val loadingCount = atomic(0) + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uri] = { + loadingCount.getAndIncrement() + null + } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + testSubject(uri, previewSize) + testSubject(uri, previewSize) + + assertThat(loadingCount.value).isEqualTo(2) + } + + @Test(expected = CancellationException::class) + fun invoke_onClosedImageLoaderScope_throwsCancellationException() = + scope.runTest { + val uri = createUri(1) + val thumbnailLoader = FakeThumbnailLoader().apply { fakeInvoke[uri] = { null } } + val imageLoaderScope = CoroutineScope(coroutineContext) + val testSubject = + PreviewImageLoader( + imageLoaderScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + imageLoaderScope.cancel() + testSubject(uri, Size(200, 200)) + } + + @Test(expected = CancellationException::class) + fun invoke_imageLoaderScopeClosedMidflight_throwsCancellationException() = + scope.runTest { + val uri = createUri(1) + val loadingStarted = CompletableDeferred<Unit>() + val bitmapDeferred = CompletableDeferred<Bitmap?>() + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uri] = { + loadingStarted.complete(Unit) + bitmapDeferred.await() + } + } + val imageLoaderScope = CoroutineScope(coroutineContext) + val testSubject = + PreviewImageLoader( + imageLoaderScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + launch { + loadingStarted.await() + imageLoaderScope.cancel() + } + testSubject(uri, Size(200, 200)) + } +} + +private fun createUri(id: Int) = Uri.parse("content://org.pkg.app/image-$id.png") + +private fun createBitmap(width: Int, height: Int) = + Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) |