diff options
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) { |