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