diff options
8 files changed, 18 insertions, 983 deletions
diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index 4e72be17..bf6109a8 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -70,13 +70,6 @@ flag { } flag { - name: "preview_image_loader" - namespace: "intentresolver" - description: "Use the unified preview image loader for all preview variations; support variable preview sizes." - bug: "348665058" -} - -flag { name: "save_shareousel_state" namespace: "intentresolver" description: "Preserve Shareousel state over a system-initiated process death." diff --git a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt deleted file mode 100644 index 847fcc82..00000000 --- a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt +++ /dev/null @@ -1,110 +0,0 @@ -/* - * 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 android.util.Size -import androidx.core.util.lruCache -import com.android.intentresolver.inject.Background -import com.android.intentresolver.inject.ViewModelOwned -import javax.inject.Inject -import javax.inject.Qualifier -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.ensureActive -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 prePopulate(uriSizePairs: List<Pair<Uri, Size>>) { - uriSizePairs.take(cache.maxSize()).map { cache[it.first] } - } - - override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? { - return if (caching) { - loadCachedImage(uri) - } else { - loadUncachedImage(uri) - } - } - - private suspend fun loadUncachedImage(uri: Uri): Bitmap? = - withContext(bgDispatcher) { - runCatching { semaphore.withPermit { thumbnailLoader.loadThumbnail(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() - - @OptIn(ExperimentalCoroutinesApi::class) - override fun getCachedBitmap(uri: Uri): Bitmap? = - kotlin.runCatching { cache[uri].getCompleted() }.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 7df98cd2..7cc4458f 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt @@ -17,7 +17,6 @@ package com.android.intentresolver.contentpreview import android.content.res.Resources -import com.android.intentresolver.Flags.previewImageLoader import com.android.intentresolver.R import com.android.intentresolver.inject.ApplicationOwned import dagger.Binds @@ -25,25 +24,15 @@ 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 thumbnailLoader(thumbnailLoader: ThumbnailLoaderImpl): ThumbnailLoader - companion object { - @Provides - fun imageLoader( - imagePreviewImageLoader: Provider<ImagePreviewImageLoader>, - previewImageLoader: Provider<PreviewImageLoader>, - ): ImageLoader = - if (previewImageLoader()) { - previewImageLoader.get() - } else { - imagePreviewImageLoader.get() - } + @Binds fun imageLoader(previewImageLoader: PreviewImageLoader): ImageLoader + companion object { @Provides @ThumbnailSize fun thumbnailSize(@ApplicationOwned resources: Resources): Int = diff --git a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt deleted file mode 100644 index 379bdb37..00000000 --- a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright (C) 2023 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.Log -import android.util.Size -import androidx.annotation.GuardedBy -import androidx.annotation.VisibleForTesting -import androidx.collection.LruCache -import com.android.intentresolver.inject.Background -import javax.inject.Inject -import javax.inject.Qualifier -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore - -private const val TAG = "ImagePreviewImageLoader" - -@Qualifier @MustBeDocumented @Retention(AnnotationRetention.BINARY) annotation class ThumbnailSize - -@Qualifier -@MustBeDocumented -@Retention(AnnotationRetention.BINARY) -annotation class PreviewCacheSize - -/** - * Implements preview image loading for the content preview UI. Provides requests deduplication, - * image caching, and a limit on the number of parallel loadings. - */ -@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) -class ImagePreviewImageLoader -@VisibleForTesting -constructor( - private val scope: CoroutineScope, - thumbnailSize: Int, - private val contentResolver: ContentResolver, - cacheSize: Int, - // TODO: consider providing a scope with the dispatcher configured with - // [CoroutineDispatcher#limitedParallelism] instead - private val contentResolverSemaphore: Semaphore, -) : ImageLoader { - - @Inject - constructor( - @Background dispatcher: CoroutineDispatcher, - @ThumbnailSize thumbnailSize: Int, - contentResolver: ContentResolver, - @PreviewCacheSize cacheSize: Int, - ) : this( - CoroutineScope( - SupervisorJob() + - dispatcher + - CoroutineExceptionHandler { _, exception -> - Log.w(TAG, "Uncaught exception in ImageLoader", exception) - } + - CoroutineName("ImageLoader") - ), - thumbnailSize, - contentResolver, - cacheSize, - ) - - constructor( - scope: CoroutineScope, - thumbnailSize: Int, - contentResolver: ContentResolver, - cacheSize: Int, - maxSimultaneousRequests: Int = 4 - ) : this(scope, thumbnailSize, contentResolver, cacheSize, Semaphore(maxSimultaneousRequests)) - - private val thumbnailSize: Size = Size(thumbnailSize, thumbnailSize) - - private val lock = Any() - @GuardedBy("lock") private val cache = LruCache<Uri, RequestRecord>(cacheSize) - @GuardedBy("lock") private val runningRequests = HashMap<Uri, RequestRecord>() - - override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? = - loadImageAsync(uri, caching) - - override fun prePopulate(uriSizePairs: List<Pair<Uri, Size>>) { - uriSizePairs.asSequence().take(cache.maxSize()).forEach { (uri, _) -> - scope.launch { loadImageAsync(uri, caching = true) } - } - } - - private suspend fun loadImageAsync(uri: Uri, caching: Boolean): Bitmap? { - return getRequestDeferred(uri, caching).await() - } - - private fun getRequestDeferred(uri: Uri, caching: Boolean): Deferred<Bitmap?> { - var shouldLaunchImageLoading = false - val request = - synchronized(lock) { - cache[uri] - ?: runningRequests - .getOrPut(uri) { - shouldLaunchImageLoading = true - RequestRecord(uri, CompletableDeferred(), caching) - } - .apply { this.caching = this.caching || caching } - } - if (shouldLaunchImageLoading) { - request.loadBitmapAsync() - } - return request.deferred - } - - private fun RequestRecord.loadBitmapAsync() { - scope - .launch { loadBitmap() } - .invokeOnCompletion { cause -> - if (cause is CancellationException) { - cancel() - } - } - } - - private suspend fun RequestRecord.loadBitmap() { - contentResolverSemaphore.acquire() - val bitmap = - try { - contentResolver.loadThumbnail(uri, thumbnailSize, null) - } catch (t: Throwable) { - Log.d(TAG, "failed to load $uri preview", t) - null - } finally { - contentResolverSemaphore.release() - } - complete(bitmap) - } - - private fun RequestRecord.cancel() { - synchronized(lock) { - runningRequests.remove(uri) - deferred.cancel() - } - } - - private fun RequestRecord.complete(bitmap: Bitmap?) { - deferred.complete(bitmap) - synchronized(lock) { - runningRequests.remove(uri) - if (bitmap != null && caching) { - cache.put(uri, this) - } - } - } - - private class RequestRecord( - val uri: Uri, - val deferred: CompletableDeferred<Bitmap?>, - @GuardedBy("lock") var caching: Boolean - ) -} diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt index b10f7ef9..1dc497b3 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt @@ -25,6 +25,7 @@ import com.android.intentresolver.inject.Background import com.android.intentresolver.inject.ViewModelOwned import javax.annotation.concurrent.GuardedBy import javax.inject.Inject +import javax.inject.Qualifier import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -41,6 +42,18 @@ import kotlinx.coroutines.sync.withPermit private const val TAG = "PayloadSelImageLoader" +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.BINARY) annotation class ThumbnailSize + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.BINARY) +annotation class PreviewCacheSize + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.BINARY) +annotation class PreviewMaxConcurrency + /** * 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 @@ -69,7 +82,7 @@ constructor( if (oldRec !== newRec) { onRecordEvictedFromCache(oldRec) } - } + }, ) override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? = @@ -104,7 +117,7 @@ constructor( private suspend fun withRequestRecord( uri: Uri, caching: Boolean, - block: suspend (RequestRecord) -> Bitmap? + block: suspend (RequestRecord) -> Bitmap?, ): Bitmap? { val record = trackRecordRunning(uri, caching) return try { 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 7f363949..6baf5935 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,14 +16,10 @@ package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel import android.util.Size -import com.android.intentresolver.Flags.previewImageLoader import com.android.intentresolver.Flags.unselectFinalItem -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 import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectablePreviewsInteractor @@ -37,7 +33,6 @@ 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 @@ -74,21 +69,9 @@ data class ShareouselViewModel( object ShareouselViewModelModule { @Provides - @PayloadToggle - fun imageLoader( - cachingImageLoader: Provider<CachingImagePreviewImageLoader>, - previewImageLoader: Provider<PreviewImageLoader>, - ): ImageLoader = - if (previewImageLoader()) { - previewImageLoader.get() - } else { - cachingImageLoader.get() - } - - @Provides fun create( interactor: SelectablePreviewsInteractor, - @PayloadToggle imageLoader: ImageLoader, + imageLoader: ImageLoader, actionsInteractor: CustomActionsInteractor, headlineGenerator: HeadlineGenerator, selectionInteractor: SelectionInteractor, diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt deleted file mode 100644 index d5a569aa..00000000 --- a/tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt +++ /dev/null @@ -1,280 +0,0 @@ -/* - * 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.Size -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 previewSize = Size(500, 500) - 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], previewSize) { 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], previewSize) {} - advanceTimeBy(testJobTime) - runCurrent() - fakeThumbnailLoader.invokeCalls.clear() - var result: Bitmap? = null - - // Act - imageLoader.loadImage(testScope, testUris[0], previewSize) { 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], previewSize) { 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, previewSize) { 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], previewSize) { results[0] = it } - runCurrent() - testUris.indices.drop(1).take(testCacheSize).forEach { i -> - imageLoader.loadImage(testScope, testUris[i], previewSize) { 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.map { it to previewSize }) - advanceTimeBy(testTimeToFillCache) - runCurrent() - - // Assert - assertThat(fakeThumbnailLoader.invokeCalls).containsExactlyElementsIn(fullCacheUris) - - // Act - fakeThumbnailLoader.invokeCalls.clear() - imageLoader.prePopulate(fullCacheUris.map { it to previewSize }) - 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.map { it to previewSize }) - advanceTimeBy(testTimeToLoadAllUris) - runCurrent() - - // Assert - assertThat(fakeThumbnailLoader.invokeCalls) - .containsExactlyElementsIn(testUris.take(testCacheSize)) - - // Act - fakeThumbnailLoader.invokeCalls.clear() - imageLoader.prePopulate(testUris.map { it to previewSize }) - 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.map { it to previewSize }) - advanceTimeBy(testJobTime) - runCurrent() - - // Assert - assertThat(fakeThumbnailLoader.invokeCalls).containsExactlyElementsIn(unfilledCacheUris) - - // Act - fakeThumbnailLoader.invokeCalls.clear() - imageLoader.prePopulate(unfilledCacheUris.map { it to previewSize }) - advanceTimeBy(testJobTime) - runCurrent() - - // Assert - assertThat(fakeThumbnailLoader.invokeCalls).isEmpty() - } - - @Test - fun invoke_uncached_alwaysCallsTheThumbnailLoader() = - testScope.runTest { - // Arrange - - // Act - imageLoader.invoke(testUris[0], previewSize, caching = false) - imageLoader.invoke(testUris[0], previewSize, 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], previewSize, caching = true) - imageLoader.invoke(testUris[0], previewSize, caching = true) - advanceTimeBy(testJobTime) - runCurrent() - - // Assert - assertThat(fakeThumbnailLoader.invokeCalls).containsExactly(testUris[0]) - } -} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt deleted file mode 100644 index d78e6665..00000000 --- a/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt +++ /dev/null @@ -1,375 +0,0 @@ -/* - * Copyright (C) 2023 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 com.google.common.truth.Truth.assertThat -import java.util.ArrayDeque -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit.MILLISECONDS -import java.util.concurrent.TimeUnit.SECONDS -import java.util.concurrent.atomic.AtomicInteger -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart.UNDISPATCHED -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Runnable -import kotlinx.coroutines.async -import kotlinx.coroutines.cancel -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestCoroutineScheduler -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.yield -import org.junit.Assert.assertTrue -import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.doAnswer -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.doThrow -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.times -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever - -@OptIn(ExperimentalCoroutinesApi::class) -class ImagePreviewImageLoaderTest { - private val imageSize = Size(300, 300) - private val uriOne = Uri.parse("content://org.package.app/image-1.png") - private val uriTwo = Uri.parse("content://org.package.app/image-2.png") - private val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) - private val contentResolver = - mock<ContentResolver> { on { loadThumbnail(any(), any(), anyOrNull()) } doReturn bitmap } - private val scheduler = TestCoroutineScheduler() - private val dispatcher = UnconfinedTestDispatcher(scheduler) - private val scope = TestScope(dispatcher) - private val testSubject = - ImagePreviewImageLoader( - dispatcher, - imageSize.width, - contentResolver, - cacheSize = 1, - ) - private val previewSize = Size(500, 500) - - @Test - fun prePopulate_cachesImagesUpToTheCacheSize() = - scope.runTest { - testSubject.prePopulate(listOf(uriOne to previewSize, uriTwo to previewSize)) - - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - verify(contentResolver, never()).loadThumbnail(uriTwo, imageSize, null) - - testSubject(uriOne, previewSize) - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - } - - @Test - fun invoke_returnCachedImageWhenCalledTwice() = - scope.runTest { - testSubject(uriOne, previewSize) - testSubject(uriOne, previewSize) - - verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull()) - } - - @Test - fun invoke_whenInstructed_doesNotCache() = - scope.runTest { - testSubject(uriOne, previewSize, false) - testSubject(uriOne, previewSize, false) - - verify(contentResolver, times(2)).loadThumbnail(any(), any(), anyOrNull()) - } - - @Test - fun invoke_overlappedRequests_Deduplicate() = - scope.runTest { - val dispatcher = StandardTestDispatcher(scheduler) - val testSubject = - ImagePreviewImageLoader( - dispatcher, - imageSize.width, - contentResolver, - cacheSize = 1, - ) - coroutineScope { - launch(start = UNDISPATCHED) { testSubject(uriOne, previewSize, false) } - launch(start = UNDISPATCHED) { testSubject(uriOne, previewSize, false) } - scheduler.advanceUntilIdle() - } - - verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull()) - } - - @Test - fun invoke_oldRecordsEvictedFromTheCache() = - scope.runTest { - testSubject(uriOne, previewSize) - testSubject(uriTwo, previewSize) - testSubject(uriTwo, previewSize) - testSubject(uriOne, previewSize) - - verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) - verify(contentResolver, times(1)).loadThumbnail(uriTwo, imageSize, null) - } - - @Test - fun invoke_doNotCacheNulls() = - scope.runTest { - whenever(contentResolver.loadThumbnail(any(), any(), anyOrNull())).thenReturn(null) - testSubject(uriOne, previewSize) - testSubject(uriOne, previewSize) - - verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) - } - - @Test(expected = CancellationException::class) - fun invoke_onClosedImageLoaderScope_throwsCancellationException() = - scope.runTest { - val imageLoaderScope = CoroutineScope(coroutineContext) - val testSubject = - ImagePreviewImageLoader( - imageLoaderScope, - imageSize.width, - contentResolver, - cacheSize = 1, - ) - imageLoaderScope.cancel() - testSubject(uriOne, previewSize) - } - - @Test(expected = CancellationException::class) - fun invoke_imageLoaderScopeClosedMidflight_throwsCancellationException() = - scope.runTest { - val dispatcher = StandardTestDispatcher(scheduler) - val imageLoaderScope = CoroutineScope(coroutineContext + dispatcher) - val testSubject = - ImagePreviewImageLoader( - imageLoaderScope, - imageSize.width, - contentResolver, - cacheSize = 1, - ) - coroutineScope { - val deferred = - async(start = UNDISPATCHED) { testSubject(uriOne, previewSize, false) } - imageLoaderScope.cancel() - scheduler.advanceUntilIdle() - deferred.await() - } - } - - @Test - fun invoke_multipleCallsWithDifferentCacheInstructions_cachingPrevails() = - scope.runTest { - val dispatcher = StandardTestDispatcher(scheduler) - val imageLoaderScope = CoroutineScope(coroutineContext + dispatcher) - val testSubject = - ImagePreviewImageLoader( - imageLoaderScope, - imageSize.width, - contentResolver, - cacheSize = 1, - ) - coroutineScope { - launch(start = UNDISPATCHED) { testSubject(uriOne, previewSize, false) } - launch(start = UNDISPATCHED) { testSubject(uriOne, previewSize, true) } - scheduler.advanceUntilIdle() - } - testSubject(uriOne, previewSize, true) - - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - } - - @Test - fun invoke_semaphoreGuardsContentResolverCalls() = - scope.runTest { - val contentResolver = - mock<ContentResolver> { - on { loadThumbnail(any(), any(), anyOrNull()) } doThrow - SecurityException("test") - } - val acquireCount = AtomicInteger() - val releaseCount = AtomicInteger() - val testSemaphore = - object : Semaphore { - override val availablePermits: Int - get() = error("Unexpected invocation") - - override suspend fun acquire() { - acquireCount.getAndIncrement() - } - - override fun tryAcquire(): Boolean { - error("Unexpected invocation") - } - - override fun release() { - releaseCount.getAndIncrement() - } - } - - val testSubject = - ImagePreviewImageLoader( - CoroutineScope(coroutineContext + dispatcher), - imageSize.width, - contentResolver, - cacheSize = 1, - testSemaphore, - ) - testSubject(uriOne, previewSize, false) - - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - assertThat(acquireCount.get()).isEqualTo(1) - assertThat(releaseCount.get()).isEqualTo(1) - } - - @Test - fun invoke_semaphoreIsReleasedAfterContentResolverFailure() = - scope.runTest { - val semaphoreDeferred = CompletableDeferred<Unit>() - val releaseCount = AtomicInteger() - val testSemaphore = - object : Semaphore { - override val availablePermits: Int - get() = error("Unexpected invocation") - - override suspend fun acquire() { - semaphoreDeferred.await() - } - - override fun tryAcquire(): Boolean { - error("Unexpected invocation") - } - - override fun release() { - releaseCount.getAndIncrement() - } - } - - val testSubject = - ImagePreviewImageLoader( - CoroutineScope(coroutineContext + dispatcher), - imageSize.width, - contentResolver, - cacheSize = 1, - testSemaphore, - ) - launch(start = UNDISPATCHED) { testSubject(uriOne, previewSize, false) } - - verify(contentResolver, never()).loadThumbnail(any(), any(), anyOrNull()) - - semaphoreDeferred.complete(Unit) - - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - assertThat(releaseCount.get()).isEqualTo(1) - } - - @Test - fun invoke_multipleSimultaneousCalls_limitOnNumberOfSimultaneousOutgoingCallsIsRespected() = - scope.runTest { - val requestCount = 4 - val thumbnailCallsCdl = CountDownLatch(requestCount) - val pendingThumbnailCalls = ArrayDeque<CountDownLatch>() - val contentResolver = - mock<ContentResolver> { - on { loadThumbnail(any(), any(), anyOrNull()) } doAnswer - { - val latch = CountDownLatch(1) - synchronized(pendingThumbnailCalls) { - pendingThumbnailCalls.offer(latch) - } - thumbnailCallsCdl.countDown() - assertTrue("Timeout waiting thumbnail calls", latch.await(1, SECONDS)) - bitmap - } - } - val name = "LoadImage" - val maxSimultaneousRequests = 2 - val threadsStartedCdl = CountDownLatch(requestCount) - val dispatcher = NewThreadDispatcher(name) { threadsStartedCdl.countDown() } - val testSubject = - ImagePreviewImageLoader( - CoroutineScope(coroutineContext + dispatcher + CoroutineName(name)), - imageSize.width, - contentResolver, - cacheSize = 1, - maxSimultaneousRequests, - ) - coroutineScope { - repeat(requestCount) { - launch { - testSubject(Uri.parse("content://org.pkg.app/image-$it.png"), previewSize) - } - } - yield() - // wait for all requests to be dispatched - assertThat(threadsStartedCdl.await(5, SECONDS)).isTrue() - - assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse() - synchronized(pendingThumbnailCalls) { - assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) - } - - pendingThumbnailCalls.poll()?.countDown() - assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse() - synchronized(pendingThumbnailCalls) { - assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) - } - - pendingThumbnailCalls.poll()?.countDown() - assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isTrue() - synchronized(pendingThumbnailCalls) { - assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) - } - for (cdl in pendingThumbnailCalls) { - cdl.countDown() - } - } - } -} - -private class NewThreadDispatcher( - private val coroutineName: String, - private val launchedCallback: () -> Unit -) : CoroutineDispatcher() { - override fun isDispatchNeeded(context: CoroutineContext): Boolean = true - - override fun dispatch(context: CoroutineContext, block: Runnable) { - Thread { - if (coroutineName == context[CoroutineName.Key]?.name) { - launchedCallback() - } - block.run() - } - .start() - } -} |