1 /*
<lambda>null2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.intentresolver.contentpreview
18 
19 import android.graphics.Bitmap
20 import android.net.Uri
21 import android.util.Log
22 import android.util.Size
23 import androidx.core.util.lruCache
24 import com.android.intentresolver.inject.Background
25 import com.android.intentresolver.inject.ViewModelOwned
26 import javax.inject.Inject
27 import javax.inject.Qualifier
28 import kotlinx.coroutines.CoroutineDispatcher
29 import kotlinx.coroutines.CoroutineScope
30 import kotlinx.coroutines.Deferred
31 import kotlinx.coroutines.ExperimentalCoroutinesApi
32 import kotlinx.coroutines.async
33 import kotlinx.coroutines.ensureActive
34 import kotlinx.coroutines.sync.Semaphore
35 import kotlinx.coroutines.sync.withPermit
36 import kotlinx.coroutines.withContext
37 
38 @Qualifier
39 @MustBeDocumented
40 @Retention(AnnotationRetention.BINARY)
41 annotation class PreviewMaxConcurrency
42 
43 /**
44  * Implementation of [ImageLoader].
45  *
46  * Allows for cached or uncached loading of images and limits the number of concurrent requests.
47  * Requests are automatically cancelled when they are evicted from the cache. If image loading fails
48  * or the request is cancelled (e.g. by eviction), the returned [Bitmap] will be null.
49  */
50 class CachingImagePreviewImageLoader
51 @Inject
52 constructor(
53     @ViewModelOwned private val scope: CoroutineScope,
54     @Background private val bgDispatcher: CoroutineDispatcher,
55     private val thumbnailLoader: ThumbnailLoader,
56     @PreviewCacheSize cacheSize: Int,
57     @PreviewMaxConcurrency maxConcurrency: Int,
58 ) : ImageLoader {
59 
60     private val semaphore = Semaphore(maxConcurrency)
61 
62     private val cache =
63         lruCache(
64             maxSize = cacheSize,
65             create = { uri: Uri -> scope.async { loadUncachedImage(uri) } },
66             onEntryRemoved = { evicted: Boolean, _, oldValue: Deferred<Bitmap?>, _ ->
67                 // If removed due to eviction, cancel the coroutine, otherwise it is the
68                 // responsibility
69                 // of the caller of [cache.remove] to cancel the removed entry when done with it.
70                 if (evicted) {
71                     oldValue.cancel()
72                 }
73             }
74         )
75 
76     override fun prePopulate(uriSizePairs: List<Pair<Uri, Size>>) {
77         uriSizePairs.take(cache.maxSize()).map { cache[it.first] }
78     }
79 
80     override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? {
81         return if (caching) {
82             loadCachedImage(uri)
83         } else {
84             loadUncachedImage(uri)
85         }
86     }
87 
88     private suspend fun loadUncachedImage(uri: Uri): Bitmap? =
89         withContext(bgDispatcher) {
90             runCatching { semaphore.withPermit { thumbnailLoader.loadThumbnail(uri) } }
91                 .onFailure {
92                     ensureActive()
93                     Log.d(TAG, "Failed to load preview for $uri", it)
94                 }
95                 .getOrNull()
96         }
97 
98     private suspend fun loadCachedImage(uri: Uri): Bitmap? =
99         // [Deferred#await] is called in a [runCatching] block to catch
100         // [CancellationExceptions]s so that they don't cancel the calling coroutine/scope.
101         runCatching { cache[uri].await() }.getOrNull()
102 
103     @OptIn(ExperimentalCoroutinesApi::class)
104     override fun getCachedBitmap(uri: Uri): Bitmap? =
105         kotlin.runCatching { cache[uri].getCompleted() }.getOrNull()
106 
107     companion object {
108         private const val TAG = "CachingImgPrevLoader"
109     }
110 }
111