• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright 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.collection.lruCache
24 import com.android.intentresolver.inject.Background
25 import com.android.intentresolver.inject.ViewModelOwned
26 import javax.annotation.concurrent.GuardedBy
27 import javax.inject.Inject
28 import javax.inject.Qualifier
29 import kotlinx.coroutines.CoroutineDispatcher
30 import kotlinx.coroutines.CoroutineScope
31 import kotlinx.coroutines.ExperimentalCoroutinesApi
32 import kotlinx.coroutines.Job
33 import kotlinx.coroutines.flow.MutableStateFlow
34 import kotlinx.coroutines.flow.filter
35 import kotlinx.coroutines.flow.filterNotNull
36 import kotlinx.coroutines.flow.firstOrNull
37 import kotlinx.coroutines.flow.mapLatest
38 import kotlinx.coroutines.flow.update
39 import kotlinx.coroutines.launch
40 import kotlinx.coroutines.sync.Semaphore
41 import kotlinx.coroutines.sync.withPermit
42 
43 private const val TAG = "ImageLoader"
44 
45 @Qualifier @MustBeDocumented @Retention(AnnotationRetention.BINARY) annotation class ThumbnailSize
46 
47 @Qualifier
48 @MustBeDocumented
49 @Retention(AnnotationRetention.BINARY)
50 annotation class PreviewCacheSize
51 
52 @Qualifier
53 @MustBeDocumented
54 @Retention(AnnotationRetention.BINARY)
55 annotation class PreviewMaxConcurrency
56 
57 /**
58  * Implements preview image loading for the payload selection UI. Cancels preview loading for items
59  * that has been evicted from the cache at the expense of a possible request duplication (deemed
60  * unlikely).
61  */
62 class PreviewImageLoader
63 @Inject
64 constructor(
65     @ViewModelOwned private val scope: CoroutineScope,
66     @PreviewCacheSize private val cacheSize: Int,
67     @ThumbnailSize private val defaultPreviewSize: Int,
68     private val thumbnailLoader: ThumbnailLoader,
69     @Background private val bgDispatcher: CoroutineDispatcher,
70     @PreviewMaxConcurrency maxSimultaneousRequests: Int = 4,
71 ) : ImageLoader {
72 
73     private val contentResolverSemaphore = Semaphore(maxSimultaneousRequests)
74 
75     private val lock = Any()
76     @GuardedBy("lock") private val runningRequests = hashMapOf<Uri, RequestRecord>()
77     @GuardedBy("lock")
78     private val cache =
79         lruCache<Uri, RequestRecord>(
80             maxSize = cacheSize,
81             onEntryRemoved = { _, _, oldRec, newRec ->
82                 if (oldRec !== newRec) {
83                     onRecordEvictedFromCache(oldRec)
84                 }
85             },
86         )
87 
88     override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? =
89         loadImageInternal(uri, size, caching)
90 
91     override fun prePopulate(uriSizePairs: List<Pair<Uri, Size>>) {
92         uriSizePairs.asSequence().take(cacheSize).forEach { uri ->
93             scope.launch { loadImageInternal(uri.first, uri.second, caching = true) }
94         }
95     }
96 
97     private suspend fun loadImageInternal(uri: Uri, size: Size, caching: Boolean): Bitmap? {
98         return withRequestRecord(uri, caching) { record ->
99             val newSize = sanitize(size)
100             val newMetric = newSize.metric
101             record
102                 .also {
103                     // set the requested size to the max of the new and the previous value; input
104                     // will emit if the resulted value is greater than the old one
105                     it.input.update { oldSize ->
106                         if (oldSize == null || oldSize.metric < newSize.metric) newSize else oldSize
107                     }
108                 }
109                 .output
110                 // filter out bitmaps of a lower resolution than that we're requesting
111                 .filter { it is BitmapLoadingState.Loaded && newMetric <= it.size.metric }
112                 .firstOrNull()
113                 ?.let { (it as BitmapLoadingState.Loaded).bitmap }
114         }
115     }
116 
117     private suspend fun withRequestRecord(
118         uri: Uri,
119         caching: Boolean,
120         block: suspend (RequestRecord) -> Bitmap?,
121     ): Bitmap? {
122         val record = trackRecordRunning(uri, caching)
123         return try {
124             block(record)
125         } finally {
126             untrackRecordRunning(uri, record)
127         }
128     }
129 
130     private fun trackRecordRunning(uri: Uri, caching: Boolean): RequestRecord =
131         synchronized(lock) {
132             runningRequests
133                 .getOrPut(uri) { cache[uri] ?: createRecord(uri) }
134                 .also { record ->
135                     record.clientCount++
136                     if (caching) {
137                         cache.put(uri, record)
138                     }
139                 }
140         }
141 
142     private fun untrackRecordRunning(uri: Uri, record: RequestRecord) {
143         synchronized(lock) {
144             record.clientCount--
145             if (record.clientCount <= 0) {
146                 runningRequests.remove(uri)
147                 val result = record.output.value
148                 if (cache[uri] == null) {
149                     record.loadingJob.cancel()
150                 } else if (result is BitmapLoadingState.Loaded && result.bitmap == null) {
151                     cache.remove(uri)
152                 }
153             }
154         }
155     }
156 
157     private fun onRecordEvictedFromCache(record: RequestRecord) {
158         synchronized(lock) {
159             if (record.clientCount <= 0) {
160                 record.loadingJob.cancel()
161             }
162         }
163     }
164 
165     @OptIn(ExperimentalCoroutinesApi::class)
166     private fun createRecord(uri: Uri): RequestRecord {
167         // use a StateFlow with sentinel values to avoid using SharedFlow that is deemed dangerous
168         val input = MutableStateFlow<Size?>(null)
169         val output = MutableStateFlow<BitmapLoadingState>(BitmapLoadingState.Loading)
170         val job =
171             scope.launch(bgDispatcher) {
172                 // the image loading pipeline: input -- a desired image size, output -- a bitmap
173                 input
174                     .filterNotNull()
175                     .mapLatest { size -> BitmapLoadingState.Loaded(size, loadBitmap(uri, size)) }
176                     .collect { output.tryEmit(it) }
177             }
178         return RequestRecord(input, output, job, clientCount = 0)
179     }
180 
181     private suspend fun loadBitmap(uri: Uri, size: Size): Bitmap? =
182         contentResolverSemaphore.withPermit {
183             runCatching { thumbnailLoader.loadThumbnail(uri, size) }
184                 .onFailure { Log.d(TAG, "failed to load $uri preview", it) }
185                 .getOrNull()
186         }
187 
188     private class RequestRecord(
189         /** The image loading pipeline input: desired preview size */
190         val input: MutableStateFlow<Size?>,
191         /** The image loading pipeline output */
192         val output: MutableStateFlow<BitmapLoadingState>,
193         /** The image loading pipeline job */
194         val loadingJob: Job,
195         @GuardedBy("lock") var clientCount: Int,
196     )
197 
198     private sealed interface BitmapLoadingState {
199         data object Loading : BitmapLoadingState
200 
201         data class Loaded(val size: Size, val bitmap: Bitmap?) : BitmapLoadingState
202     }
203 
204     private fun sanitize(size: Size?): Size =
205         size?.takeIf { it.width > 0 && it.height > 0 }
206             ?: Size(defaultPreviewSize, defaultPreviewSize)
207 }
208 
209 private val Size.metric
210     get() = maxOf(width, height)
211