• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 androidx.lifecycle.Lifecycle
24 import androidx.lifecycle.coroutineScope
25 import androidx.lifecycle.testing.TestLifecycleOwner
26 import com.android.intentresolver.any
27 import com.android.intentresolver.anyOrNull
28 import com.android.intentresolver.mock
29 import com.android.intentresolver.whenever
30 import com.google.common.truth.Truth.assertThat
31 import java.util.ArrayDeque
32 import java.util.concurrent.CountDownLatch
33 import java.util.concurrent.TimeUnit.MILLISECONDS
34 import java.util.concurrent.TimeUnit.SECONDS
35 import java.util.concurrent.atomic.AtomicInteger
36 import kotlin.coroutines.CoroutineContext
37 import kotlinx.coroutines.CancellationException
38 import kotlinx.coroutines.CompletableDeferred
39 import kotlinx.coroutines.CoroutineDispatcher
40 import kotlinx.coroutines.CoroutineName
41 import kotlinx.coroutines.CoroutineStart.UNDISPATCHED
42 import kotlinx.coroutines.Dispatchers
43 import kotlinx.coroutines.ExperimentalCoroutinesApi
44 import kotlinx.coroutines.Runnable
45 import kotlinx.coroutines.async
46 import kotlinx.coroutines.coroutineScope
47 import kotlinx.coroutines.launch
48 import kotlinx.coroutines.plus
49 import kotlinx.coroutines.sync.Semaphore
50 import kotlinx.coroutines.test.StandardTestDispatcher
51 import kotlinx.coroutines.test.TestCoroutineScheduler
52 import kotlinx.coroutines.test.UnconfinedTestDispatcher
53 import kotlinx.coroutines.test.resetMain
54 import kotlinx.coroutines.test.runTest
55 import kotlinx.coroutines.test.setMain
56 import kotlinx.coroutines.yield
57 import org.junit.After
58 import org.junit.Before
59 import org.junit.Test
60 import org.mockito.Mockito.never
61 import org.mockito.Mockito.times
62 import org.mockito.Mockito.verify
63 
64 @OptIn(ExperimentalCoroutinesApi::class)
65 class ImagePreviewImageLoaderTest {
66     private val imageSize = Size(300, 300)
67     private val uriOne = Uri.parse("content://org.package.app/image-1.png")
68     private val uriTwo = Uri.parse("content://org.package.app/image-2.png")
69     private val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
70     private val contentResolver =
<lambda>null71         mock<ContentResolver> {
72             whenever(loadThumbnail(any(), any(), anyOrNull())).thenReturn(bitmap)
73         }
74     private val lifecycleOwner = TestLifecycleOwner()
75     private val dispatcher = UnconfinedTestDispatcher()
76     private lateinit var testSubject: ImagePreviewImageLoader
77 
78     @Before
setupnull79     fun setup() {
80         Dispatchers.setMain(dispatcher)
81         lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
82         // create test subject after we've updated the lifecycle dispatcher
83         testSubject =
84             ImagePreviewImageLoader(
85                 lifecycleOwner.lifecycle.coroutineScope + dispatcher,
86                 imageSize.width,
87                 contentResolver,
88                 cacheSize = 1,
89             )
90     }
91 
92     @After
cleanupnull93     fun cleanup() {
94         lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
95         Dispatchers.resetMain()
96     }
97 
98     @Test
<lambda>null99     fun prePopulate_cachesImagesUpToTheCacheSize() = runTest {
100         testSubject.prePopulate(listOf(uriOne, uriTwo))
101 
102         verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
103         verify(contentResolver, never()).loadThumbnail(uriTwo, imageSize, null)
104 
105         testSubject(uriOne)
106         verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
107     }
108 
109     @Test
<lambda>null110     fun invoke_returnCachedImageWhenCalledTwice() = runTest {
111         testSubject(uriOne)
112         testSubject(uriOne)
113 
114         verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull())
115     }
116 
117     @Test
<lambda>null118     fun invoke_whenInstructed_doesNotCache() = runTest {
119         testSubject(uriOne, false)
120         testSubject(uriOne, false)
121 
122         verify(contentResolver, times(2)).loadThumbnail(any(), any(), anyOrNull())
123     }
124 
125     @Test
<lambda>null126     fun invoke_overlappedRequests_Deduplicate() = runTest {
127         val scheduler = TestCoroutineScheduler()
128         val dispatcher = StandardTestDispatcher(scheduler)
129         val testSubject =
130             ImagePreviewImageLoader(
131                 lifecycleOwner.lifecycle.coroutineScope + dispatcher,
132                 imageSize.width,
133                 contentResolver,
134                 cacheSize = 1,
135             )
136         coroutineScope {
137             launch(start = UNDISPATCHED) { testSubject(uriOne, false) }
138             launch(start = UNDISPATCHED) { testSubject(uriOne, false) }
139             scheduler.advanceUntilIdle()
140         }
141 
142         verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull())
143     }
144 
145     @Test
<lambda>null146     fun invoke_oldRecordsEvictedFromTheCache() = runTest {
147         testSubject(uriOne)
148         testSubject(uriTwo)
149         testSubject(uriTwo)
150         testSubject(uriOne)
151 
152         verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null)
153         verify(contentResolver, times(1)).loadThumbnail(uriTwo, imageSize, null)
154     }
155 
156     @Test
<lambda>null157     fun invoke_doNotCacheNulls() = runTest {
158         whenever(contentResolver.loadThumbnail(any(), any(), anyOrNull())).thenReturn(null)
159         testSubject(uriOne)
160         testSubject(uriOne)
161 
162         verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null)
163     }
164 
165     @Test(expected = CancellationException::class)
<lambda>null166     fun invoke_onClosedImageLoaderScope_throwsCancellationException() = runTest {
167         lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
168         testSubject(uriOne)
169     }
170 
171     @Test(expected = CancellationException::class)
<lambda>null172     fun invoke_imageLoaderScopeClosedMidflight_throwsCancellationException() = runTest {
173         val scheduler = TestCoroutineScheduler()
174         val dispatcher = StandardTestDispatcher(scheduler)
175         val testSubject =
176             ImagePreviewImageLoader(
177                 lifecycleOwner.lifecycle.coroutineScope + dispatcher,
178                 imageSize.width,
179                 contentResolver,
180                 cacheSize = 1,
181             )
182         coroutineScope {
183             val deferred = async(start = UNDISPATCHED) { testSubject(uriOne, false) }
184             lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
185             scheduler.advanceUntilIdle()
186             deferred.await()
187         }
188     }
189 
190     @Test
<lambda>null191     fun invoke_multipleCallsWithDifferentCacheInstructions_cachingPrevails() = runTest {
192         val scheduler = TestCoroutineScheduler()
193         val dispatcher = StandardTestDispatcher(scheduler)
194         val testSubject =
195             ImagePreviewImageLoader(
196                 lifecycleOwner.lifecycle.coroutineScope + dispatcher,
197                 imageSize.width,
198                 contentResolver,
199                 cacheSize = 1,
200             )
201         coroutineScope {
202             launch(start = UNDISPATCHED) { testSubject(uriOne, false) }
203             launch(start = UNDISPATCHED) { testSubject(uriOne, true) }
204             scheduler.advanceUntilIdle()
205         }
206         testSubject(uriOne, true)
207 
208         verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
209     }
210 
211     @Test
<lambda>null212     fun invoke_semaphoreGuardsContentResolverCalls() = runTest {
213         val contentResolver =
214             mock<ContentResolver> {
215                 whenever(loadThumbnail(any(), any(), anyOrNull()))
216                     .thenThrow(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                 lifecycleOwner.lifecycle.coroutineScope + 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
<lambda>null254     fun invoke_semaphoreIsReleasedAfterContentResolverFailure() = runTest {
255         val semaphoreDeferred = CompletableDeferred<Unit>()
256         val releaseCount = AtomicInteger()
257         val testSemaphore =
258             object : Semaphore {
259                 override val availablePermits: Int
260                     get() = error("Unexpected invocation")
261 
262                 override suspend fun acquire() {
263                     semaphoreDeferred.await()
264                 }
265 
266                 override fun tryAcquire(): Boolean {
267                     error("Unexpected invocation")
268                 }
269 
270                 override fun release() {
271                     releaseCount.getAndIncrement()
272                 }
273             }
274 
275         val testSubject =
276             ImagePreviewImageLoader(
277                 lifecycleOwner.lifecycle.coroutineScope + dispatcher,
278                 imageSize.width,
279                 contentResolver,
280                 cacheSize = 1,
281                 testSemaphore,
282             )
283         launch(start = UNDISPATCHED) { testSubject(uriOne, false) }
284 
285         verify(contentResolver, never()).loadThumbnail(any(), any(), anyOrNull())
286 
287         semaphoreDeferred.complete(Unit)
288 
289         verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
290         assertThat(releaseCount.get()).isEqualTo(1)
291     }
292 
293     @Test
invoke_multipleSimultaneousCalls_limitOnNumberOfSimultaneousOutgoingCallsIsRespectednull294     fun invoke_multipleSimultaneousCalls_limitOnNumberOfSimultaneousOutgoingCallsIsRespected() {
295         val requestCount = 4
296         val thumbnailCallsCdl = CountDownLatch(requestCount)
297         val pendingThumbnailCalls = ArrayDeque<CountDownLatch>()
298         val contentResolver =
299             mock<ContentResolver> {
300                 whenever(loadThumbnail(any(), any(), anyOrNull())).thenAnswer {
301                     val latch = CountDownLatch(1)
302                     synchronized(pendingThumbnailCalls) { pendingThumbnailCalls.offer(latch) }
303                     thumbnailCallsCdl.countDown()
304                     latch.await()
305                     bitmap
306                 }
307             }
308         val name = "LoadImage"
309         val maxSimultaneousRequests = 2
310         val threadsStartedCdl = CountDownLatch(requestCount)
311         val dispatcher = NewThreadDispatcher(name) { threadsStartedCdl.countDown() }
312         val testSubject =
313             ImagePreviewImageLoader(
314                 lifecycleOwner.lifecycle.coroutineScope + dispatcher + CoroutineName(name),
315                 imageSize.width,
316                 contentResolver,
317                 cacheSize = 1,
318                 maxSimultaneousRequests,
319             )
320         runTest {
321             repeat(requestCount) {
322                 launch { testSubject(Uri.parse("content://org.pkg.app/image-$it.png")) }
323             }
324             yield()
325             // wait for all requests to be dispatched
326             assertThat(threadsStartedCdl.await(5, SECONDS)).isTrue()
327 
328             assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse()
329             synchronized(pendingThumbnailCalls) {
330                 assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests)
331             }
332 
333             pendingThumbnailCalls.poll()?.countDown()
334             assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse()
335             synchronized(pendingThumbnailCalls) {
336                 assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests)
337             }
338 
339             pendingThumbnailCalls.poll()?.countDown()
340             assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isTrue()
341             synchronized(pendingThumbnailCalls) {
342                 assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests)
343             }
344             for (cdl in pendingThumbnailCalls) {
345                 cdl.countDown()
346             }
347         }
348     }
349 }
350 
351 private class NewThreadDispatcher(
352     private val coroutineName: String,
353     private val launchedCallback: () -> Unit
354 ) : CoroutineDispatcher() {
isDispatchNeedednull355     override fun isDispatchNeeded(context: CoroutineContext): Boolean = true
356 
357     override fun dispatch(context: CoroutineContext, block: Runnable) {
358         Thread {
359                 if (coroutineName == context[CoroutineName.Key]?.name) {
360                     launchedCallback()
361                 }
362                 block.run()
363             }
364             .start()
365     }
366 }
367