diff options
| author | 2024-05-21 20:06:01 +0000 | |
|---|---|---|
| committer | 2024-05-21 20:06:01 +0000 | |
| commit | fc99d745fb875430d06a0929b3ade7cfca55f3eb (patch) | |
| tree | 7efffd4f8a70f971f6fde6d085451f97bffcba8a /java/src | |
| parent | 68c1cb781b1ed97398857b7b3054764a1173ec7f (diff) | |
| parent | 97492540d111a1164c74a306b4d98aea11e543f3 (diff) | |
Merge "Add ImageLoader with improved caching and cancelling" into main
Diffstat (limited to 'java/src')
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) { |