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