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