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