summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--aconfig/FeatureFlags.aconfig7
-rw-r--r--java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt110
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt15
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt178
-rw-r--r--java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt17
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt19
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt280
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt375
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()
- }
-}