summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--aconfig/FeatureFlags.aconfig7
-rw-r--r--java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt2
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt15
-rw-r--r--java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt197
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt26
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt186
-rw-r--r--tests/activity/src/com/android/intentresolver/ChooserActivityTest.java5
-rw-r--r--tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt13
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/PreviewImageLoaderTest.kt497
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)