1 /*
2  * 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.Size
23 import com.google.common.truth.Truth.assertThat
24 import java.util.ArrayDeque
25 import java.util.concurrent.CountDownLatch
26 import java.util.concurrent.TimeUnit.MILLISECONDS
27 import java.util.concurrent.TimeUnit.SECONDS
28 import java.util.concurrent.atomic.AtomicInteger
29 import kotlin.coroutines.CoroutineContext
30 import kotlinx.coroutines.CancellationException
31 import kotlinx.coroutines.CompletableDeferred
32 import kotlinx.coroutines.CoroutineDispatcher
33 import kotlinx.coroutines.CoroutineName
34 import kotlinx.coroutines.CoroutineScope
35 import kotlinx.coroutines.CoroutineStart.UNDISPATCHED
36 import kotlinx.coroutines.ExperimentalCoroutinesApi
37 import kotlinx.coroutines.Runnable
38 import kotlinx.coroutines.async
39 import kotlinx.coroutines.cancel
40 import kotlinx.coroutines.coroutineScope
41 import kotlinx.coroutines.launch
42 import kotlinx.coroutines.sync.Semaphore
43 import kotlinx.coroutines.test.StandardTestDispatcher
44 import kotlinx.coroutines.test.TestCoroutineScheduler
45 import kotlinx.coroutines.test.TestScope
46 import kotlinx.coroutines.test.UnconfinedTestDispatcher
47 import kotlinx.coroutines.test.runTest
48 import kotlinx.coroutines.yield
49 import org.junit.Assert.assertTrue
50 import org.junit.Test
51 import org.mockito.kotlin.any
52 import org.mockito.kotlin.anyOrNull
53 import org.mockito.kotlin.doAnswer
54 import org.mockito.kotlin.doReturn
55 import org.mockito.kotlin.doThrow
56 import org.mockito.kotlin.mock
57 import org.mockito.kotlin.never
58 import org.mockito.kotlin.times
59 import org.mockito.kotlin.verify
60 import org.mockito.kotlin.whenever
61 
62 @OptIn(ExperimentalCoroutinesApi::class)
63 class ImagePreviewImageLoaderTest {
64     private val imageSize = Size(300, 300)
65     private val uriOne = Uri.parse("content://org.package.app/image-1.png")
66     private val uriTwo = Uri.parse("content://org.package.app/image-2.png")
67     private val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
68     private val contentResolver =
<lambda>null69         mock<ContentResolver> { on { loadThumbnail(any(), any(), anyOrNull()) } doReturn bitmap }
70     private val scheduler = TestCoroutineScheduler()
71     private val dispatcher = UnconfinedTestDispatcher(scheduler)
72     private val scope = TestScope(dispatcher)
73     private val testSubject =
74         ImagePreviewImageLoader(
75             dispatcher,
76             imageSize.width,
77             contentResolver,
78             cacheSize = 1,
79         )
80 
81     @Test
prePopulate_cachesImagesUpToTheCacheSizenull82     fun prePopulate_cachesImagesUpToTheCacheSize() =
83         scope.runTest {
84             testSubject.prePopulate(listOf(uriOne, uriTwo))
85 
86             verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
87             verify(contentResolver, never()).loadThumbnail(uriTwo, imageSize, null)
88 
89             testSubject(uriOne)
90             verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
91         }
92 
93     @Test
invoke_returnCachedImageWhenCalledTwicenull94     fun invoke_returnCachedImageWhenCalledTwice() =
95         scope.runTest {
96             testSubject(uriOne)
97             testSubject(uriOne)
98 
99             verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull())
100         }
101 
102     @Test
invoke_whenInstructed_doesNotCachenull103     fun invoke_whenInstructed_doesNotCache() =
104         scope.runTest {
105             testSubject(uriOne, false)
106             testSubject(uriOne, false)
107 
108             verify(contentResolver, times(2)).loadThumbnail(any(), any(), anyOrNull())
109         }
110 
111     @Test
invoke_overlappedRequests_Deduplicatenull112     fun invoke_overlappedRequests_Deduplicate() =
113         scope.runTest {
114             val dispatcher = StandardTestDispatcher(scheduler)
115             val testSubject =
116                 ImagePreviewImageLoader(
117                     dispatcher,
118                     imageSize.width,
119                     contentResolver,
120                     cacheSize = 1,
121                 )
122             coroutineScope {
123                 launch(start = UNDISPATCHED) { testSubject(uriOne, false) }
124                 launch(start = UNDISPATCHED) { testSubject(uriOne, false) }
125                 scheduler.advanceUntilIdle()
126             }
127 
128             verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull())
129         }
130 
131     @Test
invoke_oldRecordsEvictedFromTheCachenull132     fun invoke_oldRecordsEvictedFromTheCache() =
133         scope.runTest {
134             testSubject(uriOne)
135             testSubject(uriTwo)
136             testSubject(uriTwo)
137             testSubject(uriOne)
138 
139             verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null)
140             verify(contentResolver, times(1)).loadThumbnail(uriTwo, imageSize, null)
141         }
142 
143     @Test
invoke_doNotCacheNullsnull144     fun invoke_doNotCacheNulls() =
145         scope.runTest {
146             whenever(contentResolver.loadThumbnail(any(), any(), anyOrNull())).thenReturn(null)
147             testSubject(uriOne)
148             testSubject(uriOne)
149 
150             verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null)
151         }
152 
153     @Test(expected = CancellationException::class)
invoke_onClosedImageLoaderScope_throwsCancellationExceptionnull154     fun invoke_onClosedImageLoaderScope_throwsCancellationException() =
155         scope.runTest {
156             val imageLoaderScope = CoroutineScope(coroutineContext)
157             val testSubject =
158                 ImagePreviewImageLoader(
159                     imageLoaderScope,
160                     imageSize.width,
161                     contentResolver,
162                     cacheSize = 1,
163                 )
164             imageLoaderScope.cancel()
165             testSubject(uriOne)
166         }
167 
168     @Test(expected = CancellationException::class)
invoke_imageLoaderScopeClosedMidflight_throwsCancellationExceptionnull169     fun invoke_imageLoaderScopeClosedMidflight_throwsCancellationException() =
170         scope.runTest {
171             val dispatcher = StandardTestDispatcher(scheduler)
172             val imageLoaderScope = CoroutineScope(coroutineContext + dispatcher)
173             val testSubject =
174                 ImagePreviewImageLoader(
175                     imageLoaderScope,
176                     imageSize.width,
177                     contentResolver,
178                     cacheSize = 1,
179                 )
180             coroutineScope {
181                 val deferred = async(start = UNDISPATCHED) { testSubject(uriOne, false) }
182                 imageLoaderScope.cancel()
183                 scheduler.advanceUntilIdle()
184                 deferred.await()
185             }
186         }
187 
188     @Test
invoke_multipleCallsWithDifferentCacheInstructions_cachingPrevailsnull189     fun invoke_multipleCallsWithDifferentCacheInstructions_cachingPrevails() =
190         scope.runTest {
191             val dispatcher = StandardTestDispatcher(scheduler)
192             val imageLoaderScope = CoroutineScope(coroutineContext + dispatcher)
193             val testSubject =
194                 ImagePreviewImageLoader(
195                     imageLoaderScope,
196                     imageSize.width,
197                     contentResolver,
198                     cacheSize = 1,
199                 )
200             coroutineScope {
201                 launch(start = UNDISPATCHED) { testSubject(uriOne, false) }
202                 launch(start = UNDISPATCHED) { testSubject(uriOne, true) }
203                 scheduler.advanceUntilIdle()
204             }
205             testSubject(uriOne, true)
206 
207             verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
208         }
209 
210     @Test
invoke_semaphoreGuardsContentResolverCallsnull211     fun invoke_semaphoreGuardsContentResolverCalls() =
212         scope.runTest {
213             val contentResolver =
214                 mock<ContentResolver> {
215                     on { loadThumbnail(any(), any(), anyOrNull()) } doThrow
216                         SecurityException("test")
217                 }
218             val acquireCount = AtomicInteger()
219             val releaseCount = AtomicInteger()
220             val testSemaphore =
221                 object : Semaphore {
222                     override val availablePermits: Int
223                         get() = error("Unexpected invocation")
224 
225                     override suspend fun acquire() {
226                         acquireCount.getAndIncrement()
227                     }
228 
229                     override fun tryAcquire(): Boolean {
230                         error("Unexpected invocation")
231                     }
232 
233                     override fun release() {
234                         releaseCount.getAndIncrement()
235                     }
236                 }
237 
238             val testSubject =
239                 ImagePreviewImageLoader(
240                     CoroutineScope(coroutineContext + dispatcher),
241                     imageSize.width,
242                     contentResolver,
243                     cacheSize = 1,
244                     testSemaphore,
245                 )
246             testSubject(uriOne, false)
247 
248             verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
249             assertThat(acquireCount.get()).isEqualTo(1)
250             assertThat(releaseCount.get()).isEqualTo(1)
251         }
252 
253     @Test
invoke_semaphoreIsReleasedAfterContentResolverFailurenull254     fun invoke_semaphoreIsReleasedAfterContentResolverFailure() =
255         scope.runTest {
256             val semaphoreDeferred = CompletableDeferred<Unit>()
257             val releaseCount = AtomicInteger()
258             val testSemaphore =
259                 object : Semaphore {
260                     override val availablePermits: Int
261                         get() = error("Unexpected invocation")
262 
263                     override suspend fun acquire() {
264                         semaphoreDeferred.await()
265                     }
266 
267                     override fun tryAcquire(): Boolean {
268                         error("Unexpected invocation")
269                     }
270 
271                     override fun release() {
272                         releaseCount.getAndIncrement()
273                     }
274                 }
275 
276             val testSubject =
277                 ImagePreviewImageLoader(
278                     CoroutineScope(coroutineContext + dispatcher),
279                     imageSize.width,
280                     contentResolver,
281                     cacheSize = 1,
282                     testSemaphore,
283                 )
284             launch(start = UNDISPATCHED) { testSubject(uriOne, false) }
285 
286             verify(contentResolver, never()).loadThumbnail(any(), any(), anyOrNull())
287 
288             semaphoreDeferred.complete(Unit)
289 
290             verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
291             assertThat(releaseCount.get()).isEqualTo(1)
292         }
293 
294     @Test
invoke_multipleSimultaneousCalls_limitOnNumberOfSimultaneousOutgoingCallsIsRespectednull295     fun invoke_multipleSimultaneousCalls_limitOnNumberOfSimultaneousOutgoingCallsIsRespected() =
296         scope.runTest {
297             val requestCount = 4
298             val thumbnailCallsCdl = CountDownLatch(requestCount)
299             val pendingThumbnailCalls = ArrayDeque<CountDownLatch>()
300             val contentResolver =
301                 mock<ContentResolver> {
302                     on { loadThumbnail(any(), any(), anyOrNull()) } doAnswer
303                         {
304                             val latch = CountDownLatch(1)
305                             synchronized(pendingThumbnailCalls) {
306                                 pendingThumbnailCalls.offer(latch)
307                             }
308                             thumbnailCallsCdl.countDown()
309                             assertTrue("Timeout waiting thumbnail calls", latch.await(1, SECONDS))
310                             bitmap
311                         }
312                 }
313             val name = "LoadImage"
314             val maxSimultaneousRequests = 2
315             val threadsStartedCdl = CountDownLatch(requestCount)
316             val dispatcher = NewThreadDispatcher(name) { threadsStartedCdl.countDown() }
317             val testSubject =
318                 ImagePreviewImageLoader(
319                     CoroutineScope(coroutineContext + dispatcher + CoroutineName(name)),
320                     imageSize.width,
321                     contentResolver,
322                     cacheSize = 1,
323                     maxSimultaneousRequests,
324                 )
325             coroutineScope {
326                 repeat(requestCount) {
327                     launch { testSubject(Uri.parse("content://org.pkg.app/image-$it.png")) }
328                 }
329                 yield()
330                 // wait for all requests to be dispatched
331                 assertThat(threadsStartedCdl.await(5, SECONDS)).isTrue()
332 
333                 assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse()
334                 synchronized(pendingThumbnailCalls) {
335                     assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests)
336                 }
337 
338                 pendingThumbnailCalls.poll()?.countDown()
339                 assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse()
340                 synchronized(pendingThumbnailCalls) {
341                     assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests)
342                 }
343 
344                 pendingThumbnailCalls.poll()?.countDown()
345                 assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isTrue()
346                 synchronized(pendingThumbnailCalls) {
347                     assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests)
348                 }
349                 for (cdl in pendingThumbnailCalls) {
350                     cdl.countDown()
351                 }
352             }
353         }
354 }
355 
356 private class NewThreadDispatcher(
357     private val coroutineName: String,
358     private val launchedCallback: () -> Unit
359 ) : CoroutineDispatcher() {
isDispatchNeedednull360     override fun isDispatchNeeded(context: CoroutineContext): Boolean = true
361 
362     override fun dispatch(context: CoroutineContext, block: Runnable) {
363         Thread {
364                 if (coroutineName == context[CoroutineName.Key]?.name) {
365                     launchedCallback()
366                 }
367                 block.run()
368             }
369             .start()
370     }
371 }
372