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