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