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