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 androidx.core.util.lruCache
23 import com.android.intentresolver.inject.Background
24 import com.android.intentresolver.inject.ViewModelOwned
25 import java.util.function.Consumer
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.launch
35 import kotlinx.coroutines.sync.Semaphore
36 import kotlinx.coroutines.sync.withPermit
37 import kotlinx.coroutines.withContext
38 
39 @Qualifier
40 @MustBeDocumented
41 @Retention(AnnotationRetention.BINARY)
42 annotation class PreviewMaxConcurrency
43 
44 /**
45  * Implementation of [ImageLoader].
46  *
47  * Allows for cached or uncached loading of images and limits the number of concurrent requests.
48  * Requests are automatically cancelled when they are evicted from the cache. If image loading fails
49  * or the request is cancelled (e.g. by eviction), the returned [Bitmap] will be null.
50  */
51 class CachingImagePreviewImageLoader
52 @Inject
53 constructor(
54     @ViewModelOwned private val scope: CoroutineScope,
55     @Background private val bgDispatcher: CoroutineDispatcher,
56     private val thumbnailLoader: ThumbnailLoader,
57     @PreviewCacheSize cacheSize: Int,
58     @PreviewMaxConcurrency maxConcurrency: Int,
59 ) : ImageLoader {
60 
61     private val semaphore = Semaphore(maxConcurrency)
62 
63     private val cache =
64         lruCache(
65             maxSize = cacheSize,
66             create = { uri: Uri -> scope.async { loadUncachedImage(uri) } },
67             onEntryRemoved = { evicted: Boolean, _, oldValue: Deferred<Bitmap?>, _ ->
68                 // If removed due to eviction, cancel the coroutine, otherwise it is the
69                 // responsibility
70                 // of the caller of [cache.remove] to cancel the removed entry when done with it.
71                 if (evicted) {
72                     oldValue.cancel()
73                 }
74             }
75         )
76 
77     override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer<Bitmap?>) {
78         callerScope.launch { callback.accept(loadCachedImage(uri)) }
79     }
80 
81     override fun prePopulate(uris: List<Uri>) {
82         uris.take(cache.maxSize()).map { cache[it] }
83     }
84 
85     override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? {
86         return if (caching) {
87             loadCachedImage(uri)
88         } else {
89             loadUncachedImage(uri)
90         }
91     }
92 
93     private suspend fun loadUncachedImage(uri: Uri): Bitmap? =
94         withContext(bgDispatcher) {
95             runCatching { semaphore.withPermit { thumbnailLoader.invoke(uri) } }
96                 .onFailure {
97                     ensureActive()
98                     Log.d(TAG, "Failed to load preview for $uri", it)
99                 }
100                 .getOrNull()
101         }
102 
103     private suspend fun loadCachedImage(uri: Uri): Bitmap? =
104         // [Deferred#await] is called in a [runCatching] block to catch
105         // [CancellationExceptions]s so that they don't cancel the calling coroutine/scope.
106         runCatching { cache[uri].await() }.getOrNull()
107 
108     @OptIn(ExperimentalCoroutinesApi::class)
109     override fun getCachedBitmap(uri: Uri): Bitmap? =
110         kotlin.runCatching { cache[uri].getCompleted() }.getOrNull()
111 
112     companion object {
113         private const val TAG = "CachingImgPrevLoader"
114     }
115 }
116