summaryrefslogtreecommitdiff
path: root/java/src
diff options
context:
space:
mode:
author Treehugger Robot <android-test-infra-autosubmit@system.gserviceaccount.com> 2024-05-21 20:06:01 +0000
committer Android (Google) Code Review <android-gerrit@google.com> 2024-05-21 20:06:01 +0000
commitfc99d745fb875430d06a0929b3ade7cfca55f3eb (patch)
tree7efffd4f8a70f971f6fde6d085451f97bffcba8a /java/src
parent68c1cb781b1ed97398857b7b3054764a1173ec7f (diff)
parent97492540d111a1164c74a306b4d98aea11e543f3 (diff)
Merge "Add ImageLoader with improved caching and cancelling" into main
Diffstat (limited to 'java/src')
-rw-r--r--java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt110
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt6
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt40
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt141
4 files changed, 219 insertions, 78 deletions
diff --git a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt
new file mode 100644
index 00000000..ce064cdf
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt
@@ -0,0 +1,110 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview
+
+import android.graphics.Bitmap
+import android.net.Uri
+import android.util.Log
+import androidx.core.util.lruCache
+import com.android.intentresolver.inject.Background
+import com.android.intentresolver.inject.ViewModelOwned
+import java.util.function.Consumer
+import javax.inject.Inject
+import javax.inject.Qualifier
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.async
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
+import kotlinx.coroutines.withContext
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.BINARY)
+annotation class PreviewMaxConcurrency
+
+/**
+ * Implementation of [ImageLoader].
+ *
+ * Allows for cached or uncached loading of images and limits the number of concurrent requests.
+ * Requests are automatically cancelled when they are evicted from the cache. If image loading fails
+ * or the request is cancelled (e.g. by eviction), the returned [Bitmap] will be null.
+ */
+class CachingImagePreviewImageLoader
+@Inject
+constructor(
+ @ViewModelOwned private val scope: CoroutineScope,
+ @Background private val bgDispatcher: CoroutineDispatcher,
+ private val thumbnailLoader: ThumbnailLoader,
+ @PreviewCacheSize cacheSize: Int,
+ @PreviewMaxConcurrency maxConcurrency: Int,
+) : ImageLoader {
+
+ private val semaphore = Semaphore(maxConcurrency)
+
+ private val cache =
+ lruCache(
+ maxSize = cacheSize,
+ create = { uri: Uri -> scope.async { loadUncachedImage(uri) } },
+ onEntryRemoved = { evicted: Boolean, _, oldValue: Deferred<Bitmap?>, _ ->
+ // If removed due to eviction, cancel the coroutine, otherwise it is the
+ // responsibility
+ // of the caller of [cache.remove] to cancel the removed entry when done with it.
+ if (evicted) {
+ oldValue.cancel()
+ }
+ }
+ )
+
+ override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer<Bitmap?>) {
+ callerScope.launch { callback.accept(loadCachedImage(uri)) }
+ }
+
+ override fun prePopulate(uris: List<Uri>) {
+ uris.take(cache.maxSize()).map { cache[it] }
+ }
+
+ override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? {
+ return if (caching) {
+ loadCachedImage(uri)
+ } else {
+ loadUncachedImage(uri)
+ }
+ }
+
+ private suspend fun loadUncachedImage(uri: Uri): Bitmap? =
+ withContext(bgDispatcher) {
+ runCatching { semaphore.withPermit { thumbnailLoader.invoke(uri) } }
+ .onFailure {
+ ensureActive()
+ Log.d(TAG, "Failed to load preview for $uri", it)
+ }
+ .getOrNull()
+ }
+
+ private suspend fun loadCachedImage(uri: Uri): Bitmap? =
+ // [Deferred#await] is called in a [runCatching] block to catch
+ // [CancellationExceptions]s so that they don't cancel the calling coroutine/scope.
+ runCatching { cache[uri].await() }.getOrNull()
+
+ companion object {
+ private const val TAG = "CachingImgPrevLoader"
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt
index b861a24a..7035f765 100644
--- a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt
+++ b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt
@@ -33,6 +33,10 @@ interface ImageLoaderModule {
@ActivityRetainedScoped
fun imageLoader(previewImageLoader: ImagePreviewImageLoader): ImageLoader
+ @Binds
+ @ActivityRetainedScoped
+ fun thumbnailLoader(thumbnailLoader: ThumbnailLoaderImpl): ThumbnailLoader
+
companion object {
@Provides
@ThumbnailSize
@@ -40,5 +44,7 @@ interface ImageLoaderModule {
resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen)
@Provides @PreviewCacheSize fun cacheSize() = 16
+
+ @Provides @PreviewMaxConcurrency fun maxConcurrency() = 4
}
}
diff --git a/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt b/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt
new file mode 100644
index 00000000..9f1d50da
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview
+
+import android.content.ContentResolver
+import android.graphics.Bitmap
+import android.net.Uri
+import android.util.Size
+import javax.inject.Inject
+
+/** Interface for objects that can attempt load a [Bitmap] from a [Uri]. */
+interface ThumbnailLoader : suspend (Uri) -> Bitmap?
+
+/** Default implementation of [ThumbnailLoader]. */
+class ThumbnailLoaderImpl
+@Inject
+constructor(
+ private val contentResolver: ContentResolver,
+ @ThumbnailSize thumbnailSize: Int,
+) : ThumbnailLoader {
+
+ private val size = Size(thumbnailSize, thumbnailSize)
+
+ override suspend fun invoke(uri: Uri): Bitmap =
+ contentResolver.loadThumbnail(uri, size, /* signal = */ null)
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt
index 8b2dd818..1b9c231b 100644
--- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt
@@ -15,11 +15,9 @@
*/
package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel
-import android.content.Context
-import com.android.intentresolver.R
+import com.android.intentresolver.contentpreview.CachingImagePreviewImageLoader
import com.android.intentresolver.contentpreview.HeadlineGenerator
import com.android.intentresolver.contentpreview.ImageLoader
-import com.android.intentresolver.contentpreview.ImagePreviewImageLoader
import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.PayloadToggle
import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.ChooserRequestInteractor
import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.CustomActionsInteractor
@@ -27,14 +25,12 @@ import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor
import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectionInteractor
import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel
-import com.android.intentresolver.inject.Background
import com.android.intentresolver.inject.ViewModelOwned
+import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
-import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
@@ -42,7 +38,6 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.plus
/** A dynamic carousel of selectable previews within share sheet. */
data class ShareouselViewModel(
@@ -63,80 +58,70 @@ data class ShareouselViewModel(
@Module
@InstallIn(ViewModelComponent::class)
-object ShareouselViewModelModule {
- @Provides
- fun create(
- interactor: SelectablePreviewsInteractor,
- @PayloadToggle imageLoader: ImageLoader,
- actionsInteractor: CustomActionsInteractor,
- headlineGenerator: HeadlineGenerator,
- selectionInteractor: SelectionInteractor,
- chooserRequestInteractor: ChooserRequestInteractor,
- // TODO: remove if possible
- @ViewModelOwned scope: CoroutineScope,
- ): ShareouselViewModel {
- val keySet =
- interactor.previews.stateIn(
- scope,
- SharingStarted.Eagerly,
- initialValue = null,
- )
- return ShareouselViewModel(
- headline =
- selectionInteractor.amountSelected.map { numItems ->
- val contentType = ContentType.Image // TODO: convert from metadata
- when (contentType) {
- ContentType.Other -> headlineGenerator.getFilesHeadline(numItems)
- ContentType.Image -> headlineGenerator.getImagesHeadline(numItems)
- ContentType.Video -> headlineGenerator.getVideosHeadline(numItems)
- }
- },
- metadataText = chooserRequestInteractor.metadataText,
- previews = keySet,
- actions =
- actionsInteractor.customActions.map { actions ->
- actions.mapIndexedNotNull { i, model ->
- val icon = model.icon
- val label = model.label
- if (icon == null && label.isBlank()) {
- null
- } else {
- ActionChipViewModel(
- label = label.toString(),
- icon = model.icon,
- onClicked = { model.performAction(i) },
- )
+interface ShareouselViewModelModule {
+
+ @Binds @PayloadToggle fun imageLoader(imageLoader: CachingImagePreviewImageLoader): ImageLoader
+
+ companion object {
+ @Provides
+ fun create(
+ interactor: SelectablePreviewsInteractor,
+ @PayloadToggle imageLoader: ImageLoader,
+ actionsInteractor: CustomActionsInteractor,
+ headlineGenerator: HeadlineGenerator,
+ selectionInteractor: SelectionInteractor,
+ chooserRequestInteractor: ChooserRequestInteractor,
+ // TODO: remove if possible
+ @ViewModelOwned scope: CoroutineScope,
+ ): ShareouselViewModel {
+ val keySet =
+ interactor.previews.stateIn(
+ scope,
+ SharingStarted.Eagerly,
+ initialValue = null,
+ )
+ return ShareouselViewModel(
+ headline =
+ selectionInteractor.amountSelected.map { numItems ->
+ val contentType = ContentType.Image // TODO: convert from metadata
+ when (contentType) {
+ ContentType.Other -> headlineGenerator.getFilesHeadline(numItems)
+ ContentType.Image -> headlineGenerator.getImagesHeadline(numItems)
+ ContentType.Video -> headlineGenerator.getVideosHeadline(numItems)
}
- }
+ },
+ metadataText = chooserRequestInteractor.metadataText,
+ previews = keySet,
+ actions =
+ actionsInteractor.customActions.map { actions ->
+ actions.mapIndexedNotNull { i, model ->
+ val icon = model.icon
+ val label = model.label
+ if (icon == null && label.isBlank()) {
+ null
+ } else {
+ ActionChipViewModel(
+ label = label.toString(),
+ icon = model.icon,
+ onClicked = { model.performAction(i) },
+ )
+ }
+ }
+ },
+ preview = { key ->
+ keySet.value?.maybeLoad(key)
+ val previewInteractor = interactor.preview(key)
+ ShareouselPreviewViewModel(
+ bitmap = flow { emit(key.previewUri?.let { imageLoader(it) }) },
+ contentType = flowOf(ContentType.Image), // TODO: convert from metadata
+ isSelected = previewInteractor.isSelected,
+ setSelected = previewInteractor::setSelected,
+ aspectRatio = key.aspectRatio,
+ )
},
- preview = { key ->
- keySet.value?.maybeLoad(key)
- val previewInteractor = interactor.preview(key)
- ShareouselPreviewViewModel(
- bitmap = flow { emit(key.previewUri?.let { imageLoader(it) }) },
- contentType = flowOf(ContentType.Image), // TODO: convert from metadata
- isSelected = previewInteractor.isSelected,
- setSelected = previewInteractor::setSelected,
- aspectRatio = key.aspectRatio,
- )
- },
- )
+ )
+ }
}
-
- @Provides
- @PayloadToggle
- fun imageLoader(
- @ViewModelOwned viewModelScope: CoroutineScope,
- @Background coroutineDispatcher: CoroutineDispatcher,
- @ApplicationContext context: Context,
- ): ImageLoader =
- ImagePreviewImageLoader(
- viewModelScope + coroutineDispatcher,
- thumbnailSize =
- context.resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen),
- context.contentResolver,
- cacheSize = 16,
- )
}
private fun PreviewsModel.maybeLoad(key: PreviewModel) {