summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Andrey Yepin <ayepin@google.com> 2024-03-04 22:28:23 +0000
committer Android (Google) Code Review <android-gerrit@google.com> 2024-03-04 22:28:23 +0000
commit2bf5c23334dd691c3677cf6a3d9a5869dd9f301e (patch)
tree8701dc88c690601c68b3efa3b95b220a6bec25f5
parentbe63632a01b639a7a169da21f5996796c588fa5d (diff)
parentd4d7e961cb7a21e02a40168e3911d69279d4ce1b (diff)
Merge "Make ImageLoader injectable" into main
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt44
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt34
-rw-r--r--tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java8
-rw-r--r--tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java76
-rw-r--r--tests/shared/src/com/android/intentresolver/FakeImageLoader.kt (renamed from tests/shared/src/com/android/intentresolver/TestPreviewImageLoader.kt)8
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt4
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt476
7 files changed, 359 insertions, 291 deletions
diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt
new file mode 100644
index 00000000..b861a24a
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview
+
+import android.content.res.Resources
+import com.android.intentresolver.R
+import com.android.intentresolver.inject.ApplicationOwned
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ActivityRetainedComponent
+import dagger.hilt.android.scopes.ActivityRetainedScoped
+
+@Module
+@InstallIn(ActivityRetainedComponent::class)
+interface ImageLoaderModule {
+ @Binds
+ @ActivityRetainedScoped
+ fun imageLoader(previewImageLoader: ImagePreviewImageLoader): ImageLoader
+
+ companion object {
+ @Provides
+ @ThumbnailSize
+ fun thumbnailSize(@ApplicationOwned resources: Resources): Int =
+ resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen)
+
+ @Provides @PreviewCacheSize fun cacheSize() = 16
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt
index 572ccf0b..fab7203e 100644
--- a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt
+++ b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt
@@ -24,17 +24,31 @@ import android.util.Size
import androidx.annotation.GuardedBy
import androidx.annotation.VisibleForTesting
import androidx.collection.LruCache
+import com.android.intentresolver.inject.Background
import java.util.function.Consumer
+import javax.inject.Inject
+import javax.inject.Qualifier
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
private const val TAG = "ImagePreviewImageLoader"
+@Qualifier @MustBeDocumented @Retention(AnnotationRetention.BINARY) annotation class ThumbnailSize
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.BINARY)
+annotation class PreviewCacheSize
+
/**
* Implements preview image loading for the content preview UI. Provides requests deduplication,
* image caching, and a limit on the number of parallel loadings.
@@ -52,6 +66,26 @@ constructor(
private val contentResolverSemaphore: Semaphore,
) : ImageLoader {
+ @Inject
+ constructor(
+ @Background dispatcher: CoroutineDispatcher,
+ @ThumbnailSize thumbnailSize: Int,
+ contentResolver: ContentResolver,
+ @PreviewCacheSize cacheSize: Int,
+ ) : this(
+ CoroutineScope(
+ SupervisorJob() +
+ dispatcher +
+ CoroutineExceptionHandler { _, exception ->
+ Log.w(TAG, "Uncaught exception in ImageLoader", exception)
+ } +
+ CoroutineName("ImageLoader")
+ ),
+ thumbnailSize,
+ contentResolver,
+ cacheSize,
+ )
+
constructor(
scope: CoroutineScope,
thumbnailSize: Int,
diff --git a/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java
index f597d7f2..c7b41ce0 100644
--- a/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java
+++ b/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java
@@ -800,7 +800,7 @@ public class UnbundledChooserActivityTest {
Uri uri = createTestContentProviderUri("image/png", null);
Intent sendIntent = createSendImageIntent(uri);
ChooserActivityOverrideData.getInstance().imageLoader =
- new TestPreviewImageLoader(Collections.emptyMap());
+ new FakeImageLoader(Collections.emptyMap());
sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google");
List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList(
@@ -958,7 +958,7 @@ public class UnbundledChooserActivityTest {
Intent sendIntent = createSendUriIntentWithPreview(uris);
ChooserActivityOverrideData.getInstance().imageLoader =
- new TestPreviewImageLoader(Collections.emptyMap());
+ new FakeImageLoader(Collections.emptyMap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -1076,7 +1076,7 @@ public class UnbundledChooserActivityTest {
bitmaps.put(imgTwoUri, createWideBitmap(Color.GREEN));
bitmaps.put(docUri, createWideBitmap(Color.BLUE));
ChooserActivityOverrideData.getInstance().imageLoader =
- new TestPreviewImageLoader(bitmaps);
+ new FakeImageLoader(bitmaps);
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
setupResolverControllers(resolvedComponentInfos);
@@ -3122,6 +3122,6 @@ public class UnbundledChooserActivityTest {
}
private static ImageLoader createImageLoader(Uri uri, Bitmap bitmap) {
- return new TestPreviewImageLoader(Collections.singletonMap(uri, bitmap));
+ return new FakeImageLoader(Collections.singletonMap(uri, bitmap));
}
}
diff --git a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java
index b8113422..a7221c10 100644
--- a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java
+++ b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java
@@ -119,15 +119,16 @@ import androidx.test.rule.ActivityTestRule;
import com.android.intentresolver.AnnotatedUserHandles;
import com.android.intentresolver.ChooserListAdapter;
+import com.android.intentresolver.FakeImageLoader;
import com.android.intentresolver.Flags;
import com.android.intentresolver.IChooserWrapper;
import com.android.intentresolver.R;
import com.android.intentresolver.ResolvedComponentInfo;
import com.android.intentresolver.ResolverDataProvider;
import com.android.intentresolver.TestContentProvider;
-import com.android.intentresolver.TestPreviewImageLoader;
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.contentpreview.ImageLoader;
+import com.android.intentresolver.contentpreview.ImageLoaderModule;
import com.android.intentresolver.inject.PackageManagerModule;
import com.android.intentresolver.logging.EventLog;
import com.android.intentresolver.logging.FakeEventLog;
@@ -160,7 +161,6 @@ import org.mockito.Mockito;
import java.util.ArrayList;
import java.util.Arrays;
-import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -183,7 +183,8 @@ import javax.inject.Inject;
@UninstallModules({
AppPredictionModule.class,
ImageEditorModule.class,
- PackageManagerModule.class
+ PackageManagerModule.class,
+ ImageLoaderModule.class,
})
public class UnbundledChooserActivityTest {
@@ -239,6 +240,11 @@ public class UnbundledChooserActivityTest {
@BindValue
PackageManager mPackageManager;
+ private final FakeImageLoader mFakeImageLoader = new FakeImageLoader();
+
+ @BindValue
+ final ImageLoader mImageLoader = mFakeImageLoader;
+
@Before
public void setUp() {
// TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the
@@ -257,6 +263,9 @@ public class UnbundledChooserActivityTest {
// values to the dependency graph at activity launch time. This allows replacing
// arbitrary bindings per-test case if needed.
mPackageManager = mContext.getPackageManager();
+
+ // TODO: inject image loader in the prod code and remove this override
+ ChooserActivityOverrideData.getInstance().imageLoader = mFakeImageLoader;
}
public UnbundledChooserActivityTest(boolean appPredictionAvailable) {
@@ -434,14 +443,13 @@ public class UnbundledChooserActivityTest {
}
@Test
- public void visiblePreviewTitleAndThumbnail() throws InterruptedException {
+ public void visiblePreviewTitleAndThumbnail() {
String previewTitle = "My Content Preview Title";
Uri uri = Uri.parse(
"android.resource://com.android.frameworks.coretests/"
+ com.android.intentresolver.tests.R.drawable.test320x240);
Intent sendIntent = createSendTextIntentWithPreview(previewTitle, uri);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
setupResolverControllers(resolvedComponentInfos);
@@ -707,8 +715,7 @@ public class UnbundledChooserActivityTest {
public void testFilePlusTextSharing_ExcludeText() {
Uri uri = createTestContentProviderUri(null, "image/png");
Intent sendIntent = createSendImageIntent(uri);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google");
List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList(
@@ -749,8 +756,7 @@ public class UnbundledChooserActivityTest {
public void testFilePlusTextSharing_RemoveAndAddBackText() {
Uri uri = createTestContentProviderUri("application/pdf", "image/png");
Intent sendIntent = createSendImageIntent(uri);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
final String text = "https://google.com/search?q=google";
sendIntent.putExtra(Intent.EXTRA_TEXT, text);
@@ -797,8 +803,7 @@ public class UnbundledChooserActivityTest {
public void testFilePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() {
Uri uri = createTestContentProviderUri("image/png", null);
Intent sendIntent = createSendImageIntent(uri);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google");
Intent alternativeIntent = createSendTextIntent();
@@ -841,8 +846,6 @@ public class UnbundledChooserActivityTest {
public void testImagePlusTextSharing_failedThumbnailAndExcludedText_textChanges() {
Uri uri = createTestContentProviderUri("image/png", null);
Intent sendIntent = createSendImageIntent(uri);
- ChooserActivityOverrideData.getInstance().imageLoader =
- new TestPreviewImageLoader(Collections.emptyMap());
sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google");
List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList(
@@ -937,8 +940,7 @@ public class UnbundledChooserActivityTest {
Uri uri = createTestContentProviderUri("image/png", null);
Intent sendIntent = createSendImageIntent(uri);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -962,8 +964,7 @@ public class UnbundledChooserActivityTest {
uris.add(uri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createWideBitmap());
+ mFakeImageLoader.setBitmap(uri, createWideBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -1000,8 +1001,6 @@ public class UnbundledChooserActivityTest {
uris.add(uri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- new TestPreviewImageLoader(Collections.emptyMap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -1019,8 +1018,7 @@ public class UnbundledChooserActivityTest {
ArrayList<Uri> uris = new ArrayList<>(1);
uris.add(uri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -1046,8 +1044,7 @@ public class UnbundledChooserActivityTest {
}
uris.add(imageUri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(imageUri, createBitmap());
+ mFakeImageLoader.setBitmap(imageUri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
setupResolverControllers(resolvedComponentInfos);
@@ -1079,8 +1076,7 @@ public class UnbundledChooserActivityTest {
uris.add(uri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -1114,12 +1110,9 @@ public class UnbundledChooserActivityTest {
uris.add(docUri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- Map<Uri, Bitmap> bitmaps = new HashMap<>();
- bitmaps.put(imgOneUri, createWideBitmap(Color.RED));
- bitmaps.put(imgTwoUri, createWideBitmap(Color.GREEN));
- bitmaps.put(docUri, createWideBitmap(Color.BLUE));
- ChooserActivityOverrideData.getInstance().imageLoader =
- new TestPreviewImageLoader(bitmaps);
+ mFakeImageLoader.setBitmap(imgOneUri, createWideBitmap(Color.RED));
+ mFakeImageLoader.setBitmap(imgTwoUri, createWideBitmap(Color.GREEN));
+ mFakeImageLoader.setBitmap(docUri, createWideBitmap(Color.BLUE));
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
setupResolverControllers(resolvedComponentInfos);
@@ -1167,8 +1160,7 @@ public class UnbundledChooserActivityTest {
Intent sendIntent = createSendUriIntentWithPreview(uris);
sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -1197,8 +1189,7 @@ public class UnbundledChooserActivityTest {
Intent sendIntent = createSendUriIntentWithPreview(uris);
sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -1234,8 +1225,7 @@ public class UnbundledChooserActivityTest {
Intent sendIntent = createSendUriIntentWithPreview(uris);
sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -1331,8 +1321,7 @@ public class UnbundledChooserActivityTest {
uris.add(uri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createBitmap());
+ mFakeImageLoader.setBitmap(uri, createBitmap());
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -2228,8 +2217,7 @@ public class UnbundledChooserActivityTest {
uris.add(uri);
Intent sendIntent = createSendUriIntentWithPreview(uris);
- ChooserActivityOverrideData.getInstance().imageLoader =
- createImageLoader(uri, createWideBitmap());
+ mFakeImageLoader.setBitmap(uri, createWideBitmap());
mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Scrollable preview test"));
waitForIdle();
@@ -3134,8 +3122,4 @@ public class UnbundledChooserActivityTest {
};
return shortcutLoaders;
}
-
- private static ImageLoader createImageLoader(Uri uri, Bitmap bitmap) {
- return new TestPreviewImageLoader(Collections.singletonMap(uri, bitmap));
- }
}
diff --git a/tests/shared/src/com/android/intentresolver/TestPreviewImageLoader.kt b/tests/shared/src/com/android/intentresolver/FakeImageLoader.kt
index f0203bb6..c57ea78b 100644
--- a/tests/shared/src/com/android/intentresolver/TestPreviewImageLoader.kt
+++ b/tests/shared/src/com/android/intentresolver/FakeImageLoader.kt
@@ -22,7 +22,9 @@ import com.android.intentresolver.contentpreview.ImageLoader
import java.util.function.Consumer
import kotlinx.coroutines.CoroutineScope
-class TestPreviewImageLoader(private val bitmaps: Map<Uri, Bitmap>) : ImageLoader {
+class FakeImageLoader(initialBitmaps: Map<Uri, Bitmap> = emptyMap()) : ImageLoader {
+ private val bitmaps = HashMap<Uri, Bitmap>().apply { putAll(initialBitmaps) }
+
override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer<Bitmap?>) {
callback.accept(bitmaps[uri])
}
@@ -30,4 +32,8 @@ class TestPreviewImageLoader(private val bitmaps: Map<Uri, Bitmap>) : ImageLoade
override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = bitmaps[uri]
override fun prePopulate(uris: List<Uri>) = Unit
+
+ fun setBitmap(uri: Uri, bitmap: Bitmap) {
+ bitmaps[uri] = bitmap
+ }
}
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt
index c7c3c516..68b277e7 100644
--- a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt
@@ -21,7 +21,7 @@ import android.net.Uri
import android.platform.test.flag.junit.CheckFlagsRule
import android.platform.test.flag.junit.DeviceFlagsValueProvider
import com.android.intentresolver.ContentTypeHint
-import com.android.intentresolver.TestPreviewImageLoader
+import com.android.intentresolver.FakeImageLoader
import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory
import com.android.intentresolver.mock
import com.android.intentresolver.whenever
@@ -43,7 +43,7 @@ class ChooserContentPreviewUiTest {
private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher())
private val previewData = mock<PreviewDataProvider>()
private val headlineGenerator = mock<HeadlineGenerator>()
- private val imageLoader = TestPreviewImageLoader(emptyMap())
+ private val imageLoader = FakeImageLoader(emptyMap())
private val testMetadataText: CharSequence = "Test metadata text"
private val actionFactory =
object : ActionFactory {
diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt
index 89978707..41989bda 100644
--- a/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt
+++ b/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt
@@ -20,9 +20,6 @@ import android.content.ContentResolver
import android.graphics.Bitmap
import android.net.Uri
import android.util.Size
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.coroutineScope
-import androidx.lifecycle.testing.TestLifecycleOwner
import com.android.intentresolver.any
import com.android.intentresolver.anyOrNull
import com.android.intentresolver.mock
@@ -38,25 +35,22 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineName
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart.UNDISPATCHED
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Runnable
import kotlinx.coroutines.async
+import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
-import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestCoroutineScheduler
+import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
-import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
-import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.yield
-import org.junit.After
import org.junit.Assert.assertTrue
-import org.junit.Before
import org.junit.Test
import org.mockito.Mockito.never
import org.mockito.Mockito.times
@@ -72,281 +66,287 @@ class ImagePreviewImageLoaderTest {
mock<ContentResolver> {
whenever(loadThumbnail(any(), any(), anyOrNull())).thenReturn(bitmap)
}
- private val lifecycleOwner = TestLifecycleOwner()
- private val dispatcher = UnconfinedTestDispatcher()
- private lateinit var testSubject: ImagePreviewImageLoader
-
- @Before
- fun setup() {
- Dispatchers.setMain(dispatcher)
- lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
- // create test subject after we've updated the lifecycle dispatcher
- testSubject =
- ImagePreviewImageLoader(
- lifecycleOwner.lifecycle.coroutineScope + dispatcher,
- imageSize.width,
- contentResolver,
- cacheSize = 1,
- )
- }
-
- @After
- fun cleanup() {
- lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
- Dispatchers.resetMain()
- }
+ private val scheduler = TestCoroutineScheduler()
+ private val dispatcher = UnconfinedTestDispatcher(scheduler)
+ private val scope = TestScope(dispatcher)
+ private val testSubject =
+ ImagePreviewImageLoader(
+ dispatcher,
+ imageSize.width,
+ contentResolver,
+ cacheSize = 1,
+ )
@Test
- fun prePopulate_cachesImagesUpToTheCacheSize() = runTest {
- testSubject.prePopulate(listOf(uriOne, uriTwo))
+ fun prePopulate_cachesImagesUpToTheCacheSize() =
+ scope.runTest {
+ testSubject.prePopulate(listOf(uriOne, uriTwo))
- verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
- verify(contentResolver, never()).loadThumbnail(uriTwo, imageSize, null)
+ verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
+ verify(contentResolver, never()).loadThumbnail(uriTwo, imageSize, null)
- testSubject(uriOne)
- verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
- }
+ testSubject(uriOne)
+ verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
+ }
@Test
- fun invoke_returnCachedImageWhenCalledTwice() = runTest {
- testSubject(uriOne)
- testSubject(uriOne)
+ fun invoke_returnCachedImageWhenCalledTwice() =
+ scope.runTest {
+ testSubject(uriOne)
+ testSubject(uriOne)
- verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull())
- }
+ verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull())
+ }
@Test
- fun invoke_whenInstructed_doesNotCache() = runTest {
- testSubject(uriOne, false)
- testSubject(uriOne, false)
+ fun invoke_whenInstructed_doesNotCache() =
+ scope.runTest {
+ testSubject(uriOne, false)
+ testSubject(uriOne, false)
- verify(contentResolver, times(2)).loadThumbnail(any(), any(), anyOrNull())
- }
+ verify(contentResolver, times(2)).loadThumbnail(any(), any(), anyOrNull())
+ }
@Test
- fun invoke_overlappedRequests_Deduplicate() = runTest {
- val scheduler = TestCoroutineScheduler()
- val dispatcher = StandardTestDispatcher(scheduler)
- val testSubject =
- ImagePreviewImageLoader(
- lifecycleOwner.lifecycle.coroutineScope + dispatcher,
- imageSize.width,
- contentResolver,
- cacheSize = 1,
- )
- coroutineScope {
- launch(start = UNDISPATCHED) { testSubject(uriOne, false) }
- launch(start = UNDISPATCHED) { testSubject(uriOne, false) }
- scheduler.advanceUntilIdle()
- }
+ fun invoke_overlappedRequests_Deduplicate() =
+ scope.runTest {
+ val dispatcher = StandardTestDispatcher(scheduler)
+ val testSubject =
+ ImagePreviewImageLoader(
+ dispatcher,
+ imageSize.width,
+ contentResolver,
+ cacheSize = 1,
+ )
+ coroutineScope {
+ launch(start = UNDISPATCHED) { testSubject(uriOne, false) }
+ launch(start = UNDISPATCHED) { testSubject(uriOne, false) }
+ scheduler.advanceUntilIdle()
+ }
- verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull())
- }
+ verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull())
+ }
@Test
- fun invoke_oldRecordsEvictedFromTheCache() = runTest {
- testSubject(uriOne)
- testSubject(uriTwo)
- testSubject(uriTwo)
- testSubject(uriOne)
-
- verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null)
- verify(contentResolver, times(1)).loadThumbnail(uriTwo, imageSize, null)
- }
+ fun invoke_oldRecordsEvictedFromTheCache() =
+ scope.runTest {
+ testSubject(uriOne)
+ testSubject(uriTwo)
+ testSubject(uriTwo)
+ testSubject(uriOne)
+
+ verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null)
+ verify(contentResolver, times(1)).loadThumbnail(uriTwo, imageSize, null)
+ }
@Test
- fun invoke_doNotCacheNulls() = runTest {
- whenever(contentResolver.loadThumbnail(any(), any(), anyOrNull())).thenReturn(null)
- testSubject(uriOne)
- testSubject(uriOne)
+ fun invoke_doNotCacheNulls() =
+ scope.runTest {
+ whenever(contentResolver.loadThumbnail(any(), any(), anyOrNull())).thenReturn(null)
+ testSubject(uriOne)
+ testSubject(uriOne)
- verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null)
- }
+ verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null)
+ }
@Test(expected = CancellationException::class)
- fun invoke_onClosedImageLoaderScope_throwsCancellationException() = runTest {
- lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
- testSubject(uriOne)
- }
+ fun invoke_onClosedImageLoaderScope_throwsCancellationException() =
+ scope.runTest {
+ val imageLoaderScope = CoroutineScope(coroutineContext)
+ val testSubject =
+ ImagePreviewImageLoader(
+ imageLoaderScope,
+ imageSize.width,
+ contentResolver,
+ cacheSize = 1,
+ )
+ imageLoaderScope.cancel()
+ testSubject(uriOne)
+ }
@Test(expected = CancellationException::class)
- fun invoke_imageLoaderScopeClosedMidflight_throwsCancellationException() = runTest {
- val scheduler = TestCoroutineScheduler()
- val dispatcher = StandardTestDispatcher(scheduler)
- val testSubject =
- ImagePreviewImageLoader(
- lifecycleOwner.lifecycle.coroutineScope + dispatcher,
- imageSize.width,
- contentResolver,
- cacheSize = 1,
- )
- coroutineScope {
- val deferred = async(start = UNDISPATCHED) { testSubject(uriOne, false) }
- lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
- scheduler.advanceUntilIdle()
- deferred.await()
+ fun invoke_imageLoaderScopeClosedMidflight_throwsCancellationException() =
+ scope.runTest {
+ val dispatcher = StandardTestDispatcher(scheduler)
+ val imageLoaderScope = CoroutineScope(coroutineContext + dispatcher)
+ val testSubject =
+ ImagePreviewImageLoader(
+ imageLoaderScope,
+ imageSize.width,
+ contentResolver,
+ cacheSize = 1,
+ )
+ coroutineScope {
+ val deferred = async(start = UNDISPATCHED) { testSubject(uriOne, false) }
+ imageLoaderScope.cancel()
+ scheduler.advanceUntilIdle()
+ deferred.await()
+ }
}
- }
@Test
- fun invoke_multipleCallsWithDifferentCacheInstructions_cachingPrevails() = runTest {
- val scheduler = TestCoroutineScheduler()
- val dispatcher = StandardTestDispatcher(scheduler)
- val testSubject =
- ImagePreviewImageLoader(
- lifecycleOwner.lifecycle.coroutineScope + dispatcher,
- imageSize.width,
- contentResolver,
- cacheSize = 1,
- )
- coroutineScope {
- launch(start = UNDISPATCHED) { testSubject(uriOne, false) }
- launch(start = UNDISPATCHED) { testSubject(uriOne, true) }
- scheduler.advanceUntilIdle()
- }
- testSubject(uriOne, true)
+ fun invoke_multipleCallsWithDifferentCacheInstructions_cachingPrevails() =
+ scope.runTest {
+ val dispatcher = StandardTestDispatcher(scheduler)
+ val imageLoaderScope = CoroutineScope(coroutineContext + dispatcher)
+ val testSubject =
+ ImagePreviewImageLoader(
+ imageLoaderScope,
+ imageSize.width,
+ contentResolver,
+ cacheSize = 1,
+ )
+ coroutineScope {
+ launch(start = UNDISPATCHED) { testSubject(uriOne, false) }
+ launch(start = UNDISPATCHED) { testSubject(uriOne, true) }
+ scheduler.advanceUntilIdle()
+ }
+ testSubject(uriOne, true)
- verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
- }
+ verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
+ }
@Test
- fun invoke_semaphoreGuardsContentResolverCalls() = runTest {
- val contentResolver =
- mock<ContentResolver> {
- whenever(loadThumbnail(any(), any(), anyOrNull()))
- .thenThrow(SecurityException("test"))
- }
- val acquireCount = AtomicInteger()
- val releaseCount = AtomicInteger()
- val testSemaphore =
- object : Semaphore {
- override val availablePermits: Int
- get() = error("Unexpected invocation")
-
- override suspend fun acquire() {
- acquireCount.getAndIncrement()
+ fun invoke_semaphoreGuardsContentResolverCalls() =
+ scope.runTest {
+ val contentResolver =
+ mock<ContentResolver> {
+ whenever(loadThumbnail(any(), any(), anyOrNull()))
+ .thenThrow(SecurityException("test"))
}
-
- override fun tryAcquire(): Boolean {
- error("Unexpected invocation")
+ val acquireCount = AtomicInteger()
+ val releaseCount = AtomicInteger()
+ val testSemaphore =
+ object : Semaphore {
+ override val availablePermits: Int
+ get() = error("Unexpected invocation")
+
+ override suspend fun acquire() {
+ acquireCount.getAndIncrement()
+ }
+
+ override fun tryAcquire(): Boolean {
+ error("Unexpected invocation")
+ }
+
+ override fun release() {
+ releaseCount.getAndIncrement()
+ }
}
- override fun release() {
- releaseCount.getAndIncrement()
- }
- }
-
- val testSubject =
- ImagePreviewImageLoader(
- lifecycleOwner.lifecycle.coroutineScope + dispatcher,
- imageSize.width,
- contentResolver,
- cacheSize = 1,
- testSemaphore,
- )
- testSubject(uriOne, false)
-
- verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
- assertThat(acquireCount.get()).isEqualTo(1)
- assertThat(releaseCount.get()).isEqualTo(1)
- }
+ val testSubject =
+ ImagePreviewImageLoader(
+ CoroutineScope(coroutineContext + dispatcher),
+ imageSize.width,
+ contentResolver,
+ cacheSize = 1,
+ testSemaphore,
+ )
+ testSubject(uriOne, false)
+
+ verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
+ assertThat(acquireCount.get()).isEqualTo(1)
+ assertThat(releaseCount.get()).isEqualTo(1)
+ }
@Test
- fun invoke_semaphoreIsReleasedAfterContentResolverFailure() = runTest {
- val semaphoreDeferred = CompletableDeferred<Unit>()
- val releaseCount = AtomicInteger()
- val testSemaphore =
- object : Semaphore {
- override val availablePermits: Int
- get() = error("Unexpected invocation")
-
- override suspend fun acquire() {
- semaphoreDeferred.await()
- }
-
- override fun tryAcquire(): Boolean {
- error("Unexpected invocation")
+ fun invoke_semaphoreIsReleasedAfterContentResolverFailure() =
+ scope.runTest {
+ val semaphoreDeferred = CompletableDeferred<Unit>()
+ val releaseCount = AtomicInteger()
+ val testSemaphore =
+ object : Semaphore {
+ override val availablePermits: Int
+ get() = error("Unexpected invocation")
+
+ override suspend fun acquire() {
+ semaphoreDeferred.await()
+ }
+
+ override fun tryAcquire(): Boolean {
+ error("Unexpected invocation")
+ }
+
+ override fun release() {
+ releaseCount.getAndIncrement()
+ }
}
- override fun release() {
- releaseCount.getAndIncrement()
- }
- }
-
- val testSubject =
- ImagePreviewImageLoader(
- lifecycleOwner.lifecycle.coroutineScope + dispatcher,
- imageSize.width,
- contentResolver,
- cacheSize = 1,
- testSemaphore,
- )
- launch(start = UNDISPATCHED) { testSubject(uriOne, false) }
+ val testSubject =
+ ImagePreviewImageLoader(
+ CoroutineScope(coroutineContext + dispatcher),
+ imageSize.width,
+ contentResolver,
+ cacheSize = 1,
+ testSemaphore,
+ )
+ launch(start = UNDISPATCHED) { testSubject(uriOne, false) }
- verify(contentResolver, never()).loadThumbnail(any(), any(), anyOrNull())
+ verify(contentResolver, never()).loadThumbnail(any(), any(), anyOrNull())
- semaphoreDeferred.complete(Unit)
+ semaphoreDeferred.complete(Unit)
- verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
- assertThat(releaseCount.get()).isEqualTo(1)
- }
+ verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null)
+ assertThat(releaseCount.get()).isEqualTo(1)
+ }
@Test
- fun invoke_multipleSimultaneousCalls_limitOnNumberOfSimultaneousOutgoingCallsIsRespected() {
- val requestCount = 4
- val thumbnailCallsCdl = CountDownLatch(requestCount)
- val pendingThumbnailCalls = ArrayDeque<CountDownLatch>()
- val contentResolver =
- mock<ContentResolver> {
- whenever(loadThumbnail(any(), any(), anyOrNull())).thenAnswer {
- val latch = CountDownLatch(1)
- synchronized(pendingThumbnailCalls) { pendingThumbnailCalls.offer(latch) }
- thumbnailCallsCdl.countDown()
- assertTrue("Timeout waiting thumbnail calls", latch.await(1, SECONDS))
- bitmap
+ fun invoke_multipleSimultaneousCalls_limitOnNumberOfSimultaneousOutgoingCallsIsRespected() =
+ scope.runTest {
+ val requestCount = 4
+ val thumbnailCallsCdl = CountDownLatch(requestCount)
+ val pendingThumbnailCalls = ArrayDeque<CountDownLatch>()
+ val contentResolver =
+ mock<ContentResolver> {
+ whenever(loadThumbnail(any(), any(), anyOrNull())).thenAnswer {
+ val latch = CountDownLatch(1)
+ synchronized(pendingThumbnailCalls) { pendingThumbnailCalls.offer(latch) }
+ thumbnailCallsCdl.countDown()
+ assertTrue("Timeout waiting thumbnail calls", latch.await(1, SECONDS))
+ bitmap
+ }
}
- }
- val name = "LoadImage"
- val maxSimultaneousRequests = 2
- val threadsStartedCdl = CountDownLatch(requestCount)
- val dispatcher = NewThreadDispatcher(name) { threadsStartedCdl.countDown() }
- val testSubject =
- ImagePreviewImageLoader(
- lifecycleOwner.lifecycle.coroutineScope + dispatcher + CoroutineName(name),
- imageSize.width,
- contentResolver,
- cacheSize = 1,
- maxSimultaneousRequests,
- )
- runTest {
- repeat(requestCount) {
- launch { testSubject(Uri.parse("content://org.pkg.app/image-$it.png")) }
- }
- yield()
- // wait for all requests to be dispatched
- assertThat(threadsStartedCdl.await(5, SECONDS)).isTrue()
+ val name = "LoadImage"
+ val maxSimultaneousRequests = 2
+ val threadsStartedCdl = CountDownLatch(requestCount)
+ val dispatcher = NewThreadDispatcher(name) { threadsStartedCdl.countDown() }
+ val testSubject =
+ ImagePreviewImageLoader(
+ CoroutineScope(coroutineContext + dispatcher + CoroutineName(name)),
+ imageSize.width,
+ contentResolver,
+ cacheSize = 1,
+ maxSimultaneousRequests,
+ )
+ coroutineScope {
+ repeat(requestCount) {
+ launch { testSubject(Uri.parse("content://org.pkg.app/image-$it.png")) }
+ }
+ yield()
+ // wait for all requests to be dispatched
+ assertThat(threadsStartedCdl.await(5, SECONDS)).isTrue()
- assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse()
- synchronized(pendingThumbnailCalls) {
- assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests)
- }
+ assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse()
+ synchronized(pendingThumbnailCalls) {
+ assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests)
+ }
- pendingThumbnailCalls.poll()?.countDown()
- assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse()
- synchronized(pendingThumbnailCalls) {
- assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests)
- }
+ pendingThumbnailCalls.poll()?.countDown()
+ assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse()
+ synchronized(pendingThumbnailCalls) {
+ assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests)
+ }
- pendingThumbnailCalls.poll()?.countDown()
- assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isTrue()
- synchronized(pendingThumbnailCalls) {
- assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests)
- }
- for (cdl in pendingThumbnailCalls) {
- cdl.countDown()
+ pendingThumbnailCalls.poll()?.countDown()
+ assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isTrue()
+ synchronized(pendingThumbnailCalls) {
+ assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests)
+ }
+ for (cdl in pendingThumbnailCalls) {
+ cdl.countDown()
+ }
}
}
- }
}
private class NewThreadDispatcher(