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