1 /* <lambda>null2 * Copyright (C) 2023 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.content.ContentResolver 20 import android.graphics.Bitmap 21 import android.net.Uri 22 import android.util.Log 23 import android.util.Size 24 import androidx.annotation.GuardedBy 25 import androidx.annotation.VisibleForTesting 26 import androidx.collection.LruCache 27 import com.android.intentresolver.inject.Background 28 import javax.inject.Inject 29 import javax.inject.Qualifier 30 import kotlinx.coroutines.CancellationException 31 import kotlinx.coroutines.CompletableDeferred 32 import kotlinx.coroutines.CoroutineDispatcher 33 import kotlinx.coroutines.CoroutineExceptionHandler 34 import kotlinx.coroutines.CoroutineName 35 import kotlinx.coroutines.CoroutineScope 36 import kotlinx.coroutines.Deferred 37 import kotlinx.coroutines.SupervisorJob 38 import kotlinx.coroutines.launch 39 import kotlinx.coroutines.sync.Semaphore 40 41 private const val TAG = "ImagePreviewImageLoader" 42 43 @Qualifier @MustBeDocumented @Retention(AnnotationRetention.BINARY) annotation class ThumbnailSize 44 45 @Qualifier 46 @MustBeDocumented 47 @Retention(AnnotationRetention.BINARY) 48 annotation class PreviewCacheSize 49 50 /** 51 * Implements preview image loading for the content preview UI. Provides requests deduplication, 52 * image caching, and a limit on the number of parallel loadings. 53 */ 54 @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) 55 class ImagePreviewImageLoader 56 @VisibleForTesting 57 constructor( 58 private val scope: CoroutineScope, 59 thumbnailSize: Int, 60 private val contentResolver: ContentResolver, 61 cacheSize: Int, 62 // TODO: consider providing a scope with the dispatcher configured with 63 // [CoroutineDispatcher#limitedParallelism] instead 64 private val contentResolverSemaphore: Semaphore, 65 ) : ImageLoader { 66 67 @Inject 68 constructor( 69 @Background dispatcher: CoroutineDispatcher, 70 @ThumbnailSize thumbnailSize: Int, 71 contentResolver: ContentResolver, 72 @PreviewCacheSize cacheSize: Int, 73 ) : this( 74 CoroutineScope( 75 SupervisorJob() + 76 dispatcher + 77 CoroutineExceptionHandler { _, exception -> 78 Log.w(TAG, "Uncaught exception in ImageLoader", exception) 79 } + 80 CoroutineName("ImageLoader") 81 ), 82 thumbnailSize, 83 contentResolver, 84 cacheSize, 85 ) 86 87 constructor( 88 scope: CoroutineScope, 89 thumbnailSize: Int, 90 contentResolver: ContentResolver, 91 cacheSize: Int, 92 maxSimultaneousRequests: Int = 4 93 ) : this(scope, thumbnailSize, contentResolver, cacheSize, Semaphore(maxSimultaneousRequests)) 94 95 private val thumbnailSize: Size = Size(thumbnailSize, thumbnailSize) 96 97 private val lock = Any() 98 @GuardedBy("lock") private val cache = LruCache<Uri, RequestRecord>(cacheSize) 99 @GuardedBy("lock") private val runningRequests = HashMap<Uri, RequestRecord>() 100 101 override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? = 102 loadImageAsync(uri, caching) 103 104 override fun prePopulate(uriSizePairs: List<Pair<Uri, Size>>) { 105 uriSizePairs.asSequence().take(cache.maxSize()).forEach { (uri, _) -> 106 scope.launch { loadImageAsync(uri, caching = true) } 107 } 108 } 109 110 private suspend fun loadImageAsync(uri: Uri, caching: Boolean): Bitmap? { 111 return getRequestDeferred(uri, caching).await() 112 } 113 114 private fun getRequestDeferred(uri: Uri, caching: Boolean): Deferred<Bitmap?> { 115 var shouldLaunchImageLoading = false 116 val request = 117 synchronized(lock) { 118 cache[uri] 119 ?: runningRequests 120 .getOrPut(uri) { 121 shouldLaunchImageLoading = true 122 RequestRecord(uri, CompletableDeferred(), caching) 123 } 124 .apply { this.caching = this.caching || caching } 125 } 126 if (shouldLaunchImageLoading) { 127 request.loadBitmapAsync() 128 } 129 return request.deferred 130 } 131 132 private fun RequestRecord.loadBitmapAsync() { 133 scope 134 .launch { loadBitmap() } 135 .invokeOnCompletion { cause -> 136 if (cause is CancellationException) { 137 cancel() 138 } 139 } 140 } 141 142 private suspend fun RequestRecord.loadBitmap() { 143 contentResolverSemaphore.acquire() 144 val bitmap = 145 try { 146 contentResolver.loadThumbnail(uri, thumbnailSize, null) 147 } catch (t: Throwable) { 148 Log.d(TAG, "failed to load $uri preview", t) 149 null 150 } finally { 151 contentResolverSemaphore.release() 152 } 153 complete(bitmap) 154 } 155 156 private fun RequestRecord.cancel() { 157 synchronized(lock) { 158 runningRequests.remove(uri) 159 deferred.cancel() 160 } 161 } 162 163 private fun RequestRecord.complete(bitmap: Bitmap?) { 164 deferred.complete(bitmap) 165 synchronized(lock) { 166 runningRequests.remove(uri) 167 if (bitmap != null && caching) { 168 cache.put(uri, this) 169 } 170 } 171 } 172 173 private class RequestRecord( 174 val uri: Uri, 175 val deferred: CompletableDeferred<Bitmap?>, 176 @GuardedBy("lock") var caching: Boolean 177 ) 178 } 179