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.Size
22 import com.google.common.truth.Truth.assertThat
23 import java.util.concurrent.atomic.AtomicInteger
24 import kotlinx.coroutines.CancellationException
25 import kotlinx.coroutines.CompletableDeferred
26 import kotlinx.coroutines.CoroutineScope
27 import kotlinx.coroutines.CoroutineStart
28 import kotlinx.coroutines.ExperimentalCoroutinesApi
29 import kotlinx.coroutines.async
30 import kotlinx.coroutines.awaitCancellation
31 import kotlinx.coroutines.cancel
32 import kotlinx.coroutines.launch
33 import kotlinx.coroutines.test.StandardTestDispatcher
34 import kotlinx.coroutines.test.TestScope
35 import kotlinx.coroutines.test.runCurrent
36 import kotlinx.coroutines.test.runTest
37 import org.junit.Test
38
39 @OptIn(ExperimentalCoroutinesApi::class)
40 class PreviewImageLoaderTest {
41 private val scope = TestScope()
42
43 @Test
44 fun test_cachingImageRequest_imageCached() =
45 scope.runTest {
46 val uri = createUri(0)
47 val thumbnailLoader =
48 FakeThumbnailLoader().apply {
49 fakeInvoke[uri] = { size -> createBitmap(size.width, size.height) }
50 }
51 val testSubject =
52 PreviewImageLoader(
53 backgroundScope,
54 1,
55 100,
56 thumbnailLoader,
57 StandardTestDispatcher(scope.testScheduler),
58 )
59
60 val b1 = testSubject.invoke(uri, Size(200, 100))
61 val b2 = testSubject.invoke(uri, Size(200, 100), caching = false)
62 assertThat(b1).isEqualTo(b2)
63 assertThat(thumbnailLoader.invokeCalls).hasSize(1)
64 }
65
66 @Test
67 fun test_nonCachingImageRequest_imageNotCached() =
68 scope.runTest {
69 val uri = createUri(0)
70 val thumbnailLoader =
71 FakeThumbnailLoader().apply {
72 fakeInvoke[uri] = { size -> createBitmap(size.width, size.height) }
73 }
74 val testSubject =
75 PreviewImageLoader(
76 backgroundScope,
77 1,
78 100,
79 thumbnailLoader,
80 StandardTestDispatcher(scope.testScheduler),
81 )
82
83 testSubject.invoke(uri, Size(200, 100), caching = false)
84 testSubject.invoke(uri, Size(200, 100), caching = false)
85 assertThat(thumbnailLoader.invokeCalls).hasSize(2)
86 }
87
88 @Test
89 fun test_twoSimultaneousImageRequests_requestsDeduplicated() =
90 scope.runTest {
91 val uri = createUri(0)
92 val loadingStartedDeferred = CompletableDeferred<Unit>()
93 val bitmapDeferred = CompletableDeferred<Bitmap>()
94 val thumbnailLoader =
95 FakeThumbnailLoader().apply {
96 fakeInvoke[uri] = {
97 loadingStartedDeferred.complete(Unit)
98 bitmapDeferred.await()
99 }
100 }
101 val testSubject =
102 PreviewImageLoader(
103 backgroundScope,
104 1,
105 100,
106 thumbnailLoader,
107 StandardTestDispatcher(scope.testScheduler),
108 )
109
110 val b1Deferred = async { testSubject.invoke(uri, Size(200, 100), caching = false) }
111 loadingStartedDeferred.await()
112 val b2Deferred =
113 async(start = CoroutineStart.UNDISPATCHED) {
114 testSubject.invoke(uri, Size(200, 100), caching = true)
115 }
116 bitmapDeferred.complete(createBitmap(200, 200))
117
118 val b1 = b1Deferred.await()
119 val b2 = b2Deferred.await()
120 assertThat(b1).isEqualTo(b2)
121 assertThat(thumbnailLoader.invokeCalls).hasSize(1)
122 }
123
124 @Test
125 fun test_cachingRequestCancelledAndEvoked_imageLoadingCancelled() =
126 scope.runTest {
127 val uriOne = createUri(1)
128 val uriTwo = createUri(2)
129 val loadingStartedDeferred = CompletableDeferred<Unit>()
130 val cancelledRequests = mutableSetOf<Uri>()
131 val thumbnailLoader =
132 FakeThumbnailLoader().apply {
133 fakeInvoke[uriOne] = {
134 loadingStartedDeferred.complete(Unit)
135 try {
136 awaitCancellation()
137 } catch (e: CancellationException) {
138 cancelledRequests.add(uriOne)
139 throw e
140 }
141 }
142 fakeInvoke[uriTwo] = { createBitmap(200, 200) }
143 }
144 val testSubject =
145 PreviewImageLoader(
146 backgroundScope,
147 cacheSize = 1,
148 defaultPreviewSize = 100,
149 thumbnailLoader,
150 StandardTestDispatcher(scope.testScheduler),
151 )
152
153 val jobOne = launch { testSubject.invoke(uriOne, Size(200, 100)) }
154 loadingStartedDeferred.await()
155 jobOne.cancel()
156 scope.runCurrent()
157
158 assertThat(cancelledRequests).isEmpty()
159
160 // second URI should evict the first item from the cache
161 testSubject.invoke(uriTwo, Size(200, 100))
162
163 assertThat(thumbnailLoader.invokeCalls).hasSize(2)
164 assertThat(cancelledRequests).containsExactly(uriOne)
165 }
166
167 @Test
168 fun test_nonCachingRequestClientCancels_imageLoadingCancelled() =
169 scope.runTest {
170 val uri = createUri(1)
171 val loadingStartedDeferred = CompletableDeferred<Unit>()
172 val cancelledRequests = mutableSetOf<Uri>()
173 val thumbnailLoader =
174 FakeThumbnailLoader().apply {
175 fakeInvoke[uri] = {
176 loadingStartedDeferred.complete(Unit)
177 try {
178 awaitCancellation()
179 } catch (e: CancellationException) {
180 cancelledRequests.add(uri)
181 throw e
182 }
183 }
184 }
185 val testSubject =
186 PreviewImageLoader(
187 backgroundScope,
188 cacheSize = 1,
189 defaultPreviewSize = 100,
190 thumbnailLoader,
191 StandardTestDispatcher(scope.testScheduler),
192 )
193
194 val job = launch { testSubject.invoke(uri, Size(200, 100), caching = false) }
195 loadingStartedDeferred.await()
196 job.cancel()
197 scope.runCurrent()
198
199 assertThat(cancelledRequests).containsExactly(uri)
200 }
201
202 @Test
203 fun test_requestHigherResImage_newImageLoaded() =
204 scope.runTest {
205 val uri = createUri(0)
206 val thumbnailLoader =
207 FakeThumbnailLoader().apply {
208 fakeInvoke[uri] = { size -> createBitmap(size.width, size.height) }
209 }
210 val testSubject =
211 PreviewImageLoader(
212 backgroundScope,
213 1,
214 100,
215 thumbnailLoader,
216 StandardTestDispatcher(scope.testScheduler),
217 )
218
219 val b1 = testSubject.invoke(uri, Size(100, 100))
220 val b2 = testSubject.invoke(uri, Size(200, 200))
221 assertThat(b1).isNotNull()
222 assertThat(b1!!.width).isEqualTo(100)
223 assertThat(b2).isNotNull()
224 assertThat(b2!!.width).isEqualTo(200)
225 assertThat(thumbnailLoader.invokeCalls).hasSize(2)
226 }
227
228 @Test
229 fun test_imageLoadingThrowsException_returnsNull() =
230 scope.runTest {
231 val uri = createUri(0)
232 val thumbnailLoader =
233 FakeThumbnailLoader().apply {
234 fakeInvoke[uri] = { throw SecurityException("test") }
235 }
236 val testSubject =
237 PreviewImageLoader(
238 backgroundScope,
239 1,
240 100,
241 thumbnailLoader,
242 StandardTestDispatcher(scope.testScheduler),
243 )
244
245 val bitmap = testSubject.invoke(uri, Size(100, 100))
246 assertThat(bitmap).isNull()
247 }
248
249 @Test
250 fun test_requestHigherResImage_cancelsLowerResLoading() =
251 scope.runTest {
252 val uri = createUri(0)
253 val cancelledRequestCount = AtomicInteger(0)
254 val imageLoadingStarted = CompletableDeferred<Unit>()
255 val bitmapDeferred = CompletableDeferred<Bitmap>()
256 val thumbnailLoader =
257 FakeThumbnailLoader().apply {
258 fakeInvoke[uri] = {
259 imageLoadingStarted.complete(Unit)
260 try {
261 bitmapDeferred.await()
262 } catch (e: CancellationException) {
263 cancelledRequestCount.getAndIncrement()
264 throw e
265 }
266 }
267 }
268 val testSubject =
269 PreviewImageLoader(
270 backgroundScope,
271 1,
272 100,
273 thumbnailLoader,
274 StandardTestDispatcher(scope.testScheduler),
275 )
276
277 val lowResSize = 100
278 val highResSize = 200
279 launch(start = CoroutineStart.UNDISPATCHED) {
280 testSubject.invoke(uri, Size(lowResSize, lowResSize))
281 }
282 imageLoadingStarted.await()
283 val result = async { testSubject.invoke(uri, Size(highResSize, highResSize)) }
284 runCurrent()
285 assertThat(cancelledRequestCount.get()).isEqualTo(1)
286
287 bitmapDeferred.complete(createBitmap(highResSize, highResSize))
288 val bitmap = result.await()
289 assertThat(bitmap).isNotNull()
290 assertThat(bitmap!!.width).isEqualTo(highResSize)
291 assertThat(thumbnailLoader.invokeCalls).hasSize(2)
292 }
293
294 @Test
295 fun test_requestLowerResImage_cachedHigherResImageReturned() =
296 scope.runTest {
297 val uri = createUri(0)
298 val thumbnailLoader =
299 FakeThumbnailLoader().apply {
300 fakeInvoke[uri] = { size -> createBitmap(size.width, size.height) }
301 }
302 val lowResSize = 100
303 val highResSize = 200
304 val testSubject =
305 PreviewImageLoader(
306 backgroundScope,
307 1,
308 100,
309 thumbnailLoader,
310 StandardTestDispatcher(scope.testScheduler),
311 )
312
313 val b1 = testSubject.invoke(uri, Size(highResSize, highResSize))
314 val b2 = testSubject.invoke(uri, Size(lowResSize, lowResSize))
315 assertThat(b1).isEqualTo(b2)
316 assertThat(b2!!.width).isEqualTo(highResSize)
317 assertThat(thumbnailLoader.invokeCalls).hasSize(1)
318 }
319
320 @Test
321 fun test_incorrectSizeRequested_defaultSizeIsUsed() =
322 scope.runTest {
323 val uri = createUri(0)
324 val defaultPreviewSize = 100
325 val thumbnailLoader =
326 FakeThumbnailLoader().apply {
327 fakeInvoke[uri] = { size -> createBitmap(size.width, size.height) }
328 }
329 val testSubject =
330 PreviewImageLoader(
331 backgroundScope,
332 cacheSize = 1,
333 defaultPreviewSize,
334 thumbnailLoader,
335 StandardTestDispatcher(scope.testScheduler),
336 )
337
338 val b1 = testSubject(uri, Size(0, 0))
339 assertThat(b1!!.width).isEqualTo(defaultPreviewSize)
340
341 val largerImageSize = 200
342 val b2 = testSubject(uri, Size(largerImageSize, largerImageSize))
343 assertThat(b2!!.width).isEqualTo(largerImageSize)
344 }
345
346 @Test
347 fun test_prePopulateImages_cachesImagesUpToTheCacheSize() =
348 scope.runTest {
349 val previewSize = Size(100, 100)
350 val uris = List(2) { createUri(it) }
351 val loadingCount = AtomicInteger(0)
352 val thumbnailLoader =
353 FakeThumbnailLoader().apply {
354 for (uri in uris) {
355 fakeInvoke[uri] = { size ->
356 loadingCount.getAndIncrement()
357 createBitmap(size.width, size.height)
358 }
359 }
360 }
361 val testSubject =
362 PreviewImageLoader(
363 backgroundScope,
364 1,
365 100,
366 thumbnailLoader,
367 StandardTestDispatcher(scope.testScheduler),
368 )
369
370 testSubject.prePopulate(uris.map { it to previewSize })
371 runCurrent()
372
373 assertThat(loadingCount.get()).isEqualTo(1)
374 assertThat(thumbnailLoader.invokeCalls).containsExactly(uris[0])
375
376 testSubject(uris[0], previewSize)
377 runCurrent()
378
379 assertThat(loadingCount.get()).isEqualTo(1)
380 }
381
382 @Test
383 fun test_oldRecordEvictedFromTheCache() =
384 scope.runTest {
385 val previewSize = Size(100, 100)
386 val uriOne = createUri(1)
387 val uriTwo = createUri(2)
388 val requestsPerUri = HashMap<Uri, AtomicInteger>()
389 val thumbnailLoader =
390 FakeThumbnailLoader().apply {
391 for (uri in arrayOf(uriOne, uriTwo)) {
392 fakeInvoke[uri] = { size ->
393 requestsPerUri.getOrPut(uri) { AtomicInteger() }.incrementAndGet()
394 createBitmap(size.width, size.height)
395 }
396 }
397 }
398 val testSubject =
399 PreviewImageLoader(
400 backgroundScope,
401 1,
402 100,
403 thumbnailLoader,
404 StandardTestDispatcher(scope.testScheduler),
405 )
406
407 testSubject(uriOne, previewSize)
408 testSubject(uriTwo, previewSize)
409 testSubject(uriTwo, previewSize)
410 testSubject(uriOne, previewSize)
411
412 assertThat(requestsPerUri[uriOne]?.get()).isEqualTo(2)
413 assertThat(requestsPerUri[uriTwo]?.get()).isEqualTo(1)
414 }
415
416 @Test
417 fun test_doNotCacheNulls() =
418 scope.runTest {
419 val previewSize = Size(100, 100)
420 val uri = createUri(1)
421 val loadingCount = AtomicInteger(0)
422 val thumbnailLoader =
423 FakeThumbnailLoader().apply {
424 fakeInvoke[uri] = {
425 loadingCount.getAndIncrement()
426 null
427 }
428 }
429 val testSubject =
430 PreviewImageLoader(
431 backgroundScope,
432 1,
433 100,
434 thumbnailLoader,
435 StandardTestDispatcher(scope.testScheduler),
436 )
437
438 testSubject(uri, previewSize)
439 testSubject(uri, previewSize)
440
441 assertThat(loadingCount.get()).isEqualTo(2)
442 }
443
444 @Test(expected = CancellationException::class)
445 fun invoke_onClosedImageLoaderScope_throwsCancellationException() =
446 scope.runTest {
447 val uri = createUri(1)
448 val thumbnailLoader = FakeThumbnailLoader().apply { fakeInvoke[uri] = { null } }
449 val imageLoaderScope = CoroutineScope(coroutineContext)
450 val testSubject =
451 PreviewImageLoader(
452 imageLoaderScope,
453 1,
454 100,
455 thumbnailLoader,
456 StandardTestDispatcher(scope.testScheduler),
457 )
458 imageLoaderScope.cancel()
459 testSubject(uri, Size(200, 200))
460 }
461
462 @Test(expected = CancellationException::class)
463 fun invoke_imageLoaderScopeClosedMidflight_throwsCancellationException() =
464 scope.runTest {
465 val uri = createUri(1)
466 val loadingStarted = CompletableDeferred<Unit>()
467 val bitmapDeferred = CompletableDeferred<Bitmap?>()
468 val thumbnailLoader =
469 FakeThumbnailLoader().apply {
470 fakeInvoke[uri] = {
471 loadingStarted.complete(Unit)
472 bitmapDeferred.await()
473 }
474 }
475 val imageLoaderScope = CoroutineScope(coroutineContext)
476 val testSubject =
477 PreviewImageLoader(
478 imageLoaderScope,
479 1,
480 100,
481 thumbnailLoader,
482 StandardTestDispatcher(scope.testScheduler),
483 )
484
485 launch {
486 loadingStarted.await()
487 imageLoaderScope.cancel()
488 }
489 testSubject(uri, Size(200, 200))
490 }
491 }
492
createUrinull493 private fun createUri(id: Int) = Uri.parse("content://org.pkg.app/image-$id.png")
494
495 private fun createBitmap(width: Int, height: Int) =
496 Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
497