summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--java/src/com/android/intentresolver/ChooserActivity.java46
-rw-r--r--java/src/com/android/intentresolver/ChooserRefinementManager.java117
-rw-r--r--java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt98
-rw-r--r--java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt225
-rw-r--r--java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt34
-rw-r--r--java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt10
-rw-r--r--java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt239
7 files changed, 631 insertions, 138 deletions
diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java
index ecfaf0e2..af0089d0 100644
--- a/java/src/com/android/intentresolver/ChooserActivity.java
+++ b/java/src/com/android/intentresolver/ChooserActivity.java
@@ -72,6 +72,7 @@ import android.view.animation.LinearInterpolator;
import android.widget.TextView;
import androidx.annotation.MainThread;
+import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager.widget.ViewPager;
@@ -250,20 +251,25 @@ public class ChooserActivity extends ResolverActivity implements
return;
}
- mRefinementManager = new ChooserRefinementManager(
- this,
- mChooserRequest.getRefinementIntentSender(),
- (validatedRefinedTarget) -> {
- maybeRemoveSharedText(validatedRefinedTarget);
+ mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class);
+
+ mRefinementManager.getRefinementCompletion().observe(this, completion -> {
+ if (completion.consume()) {
+ TargetInfo targetInfo = completion.getTargetInfo();
+ // targetInfo is non-null if the refinement process was successful.
+ if (targetInfo != null) {
+ maybeRemoveSharedText(targetInfo);
// We already block suspended targets from going to refinement, and we probably
// can't recover a Chooser session if that's the reason the refined target fails
// to launch now. Fire-and-forget the refined launch; ignore the return value
// and just make sure the Sharesheet session gets cleaned up regardless.
- super.onTargetSelected(validatedRefinedTarget, false);
- finish();
- },
- this::finish);
+ ChooserActivity.super.onTargetSelected(targetInfo, false);
+ }
+
+ finish();
+ }
+ });
mChooserContentPreviewUi = new ChooserContentPreviewUi(
mChooserRequest.getTargetIntent(),
@@ -611,14 +617,7 @@ public class ChooserActivity extends ResolverActivity implements
Log.d(TAG, "onResume: " + getComponentName().flattenToShortString());
maybeCancelFinishAnimation();
- if (mRefinementManager.isAwaitingRefinementResult()) {
- // This can happen if the refinement activity terminates without ever sending a response
- // to our `ResultReceiver`. We're probably not prepared to return the user into a valid
- // Chooser session, so we'll treat it as a cancellation instead.
- Log.w(TAG, "Chooser resumed while awaiting refinement result; aborting");
- mRefinementManager.destroy();
- finish();
- }
+ mRefinementManager.onActivityResume();
}
@Override
@@ -729,6 +728,8 @@ public class ChooserActivity extends ResolverActivity implements
@Override
protected void onStop() {
super.onStop();
+ mRefinementManager.onActivityStop(isChangingConfigurations());
+
if (maybeCancelFinishAnimation()) {
finish();
}
@@ -742,11 +743,6 @@ public class ChooserActivity extends ResolverActivity implements
mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET);
}
- if (mRefinementManager != null) { // TODO: null-checked in case of early-destroy, or skip?
- mRefinementManager.destroy();
- mRefinementManager = null;
- }
-
mBackgroundThreadPoolExecutor.shutdownNow();
destroyProfileRecords();
@@ -872,7 +868,11 @@ public class ChooserActivity extends ResolverActivity implements
@Override
protected boolean onTargetSelected(TargetInfo target, boolean alwaysCheck) {
- if (mRefinementManager.maybeHandleSelection(target)) {
+ if (mRefinementManager.maybeHandleSelection(
+ target,
+ mChooserRequest.getRefinementIntentSender(),
+ getApplication(),
+ getMainThreadHandler())) {
return false;
}
updateModelAndChooserCounts(target);
diff --git a/java/src/com/android/intentresolver/ChooserRefinementManager.java b/java/src/com/android/intentresolver/ChooserRefinementManager.java
index 8d7b1aac..2ebe48a6 100644
--- a/java/src/com/android/intentresolver/ChooserRefinementManager.java
+++ b/java/src/com/android/intentresolver/ChooserRefinementManager.java
@@ -19,16 +19,19 @@ package com.android.intentresolver;
import android.annotation.Nullable;
import android.annotation.UiThread;
import android.app.Activity;
-import android.content.Context;
+import android.app.Application;
import android.content.Intent;
import android.content.IntentSender;
-import android.content.IntentSender.SendIntentException;
import android.os.Bundle;
import android.os.Handler;
import android.os.Parcel;
import android.os.ResultReceiver;
import android.util.Log;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+import androidx.lifecycle.ViewModel;
+
import com.android.intentresolver.chooser.TargetInfo;
import java.util.List;
@@ -42,38 +45,51 @@ import java.util.function.Consumer;
* call).
*/
@UiThread
-public final class ChooserRefinementManager {
+public final class ChooserRefinementManager extends ViewModel {
private static final String TAG = "ChooserRefinement";
- @Nullable
- private final IntentSender mRefinementIntentSender;
-
- private final Context mContext;
- private final Consumer<TargetInfo> mOnSelectionRefined;
- private final Runnable mOnRefinementCancelled;
-
@Nullable // Non-null only during an active refinement session.
private RefinementResultReceiver mRefinementResultReceiver;
- public ChooserRefinementManager(
- Context context,
- @Nullable IntentSender refinementIntentSender,
- Consumer<TargetInfo> onSelectionRefined,
- Runnable onRefinementCancelled) {
- mContext = context;
- mRefinementIntentSender = refinementIntentSender;
- mOnSelectionRefined = onSelectionRefined;
- mOnRefinementCancelled = onRefinementCancelled;
- }
+ private boolean mConfigurationChangeInProgress = false;
/**
- * @return whether a refinement session has been initiated (i.e., an earlier call to
- * {@link #maybeHandleSelection(TargetInfo)} returned true), and isn't yet complete. The session
- * is complete if the refinement activity calls {@link ResultReceiver#onResultReceived()} (with
- * any result), or if it's cancelled on our side by {@link ChooserRefinementManager#destroy()}.
+ * A token for the completion of a refinement process that can be consumed exactly once.
*/
- public boolean isAwaitingRefinementResult() {
- return (mRefinementResultReceiver != null);
+ public static class RefinementCompletion {
+ private TargetInfo mTargetInfo;
+ private boolean mConsumed;
+
+ RefinementCompletion(TargetInfo targetInfo) {
+ mTargetInfo = targetInfo;
+ }
+
+ /**
+ * @return The output of the completed refinement process. Null if the process was aborted
+ * or failed.
+ */
+ public TargetInfo getTargetInfo() {
+ return mTargetInfo;
+ }
+
+ /**
+ * Mark this event as consumed if it wasn't already.
+ *
+ * @return true if this had not already been consumed.
+ */
+ public boolean consume() {
+ if (!mConsumed) {
+ mConsumed = true;
+ return true;
+ }
+ return false;
+ }
+ }
+
+ private MutableLiveData<RefinementCompletion> mRefinementCompletion = new MutableLiveData<>();
+
+ public LiveData<RefinementCompletion> getRefinementCompletion() {
+ return mRefinementCompletion;
}
/**
@@ -81,8 +97,9 @@ public final class ChooserRefinementManager {
* @return true if the selection should wait for a now-started refinement flow, or false if it
* can proceed by the default (non-refinement) logic.
*/
- public boolean maybeHandleSelection(TargetInfo selectedTarget) {
- if (mRefinementIntentSender == null) {
+ public boolean maybeHandleSelection(TargetInfo selectedTarget,
+ IntentSender refinementIntentSender, Application application, Handler mainHandler) {
+ if (refinementIntentSender == null) {
return false;
}
if (selectedTarget.getAllSourceIntents().isEmpty()) {
@@ -100,33 +117,61 @@ public final class ChooserRefinementManager {
mRefinementResultReceiver = new RefinementResultReceiver(
refinedIntent -> {
destroy();
+
TargetInfo refinedTarget =
selectedTarget.tryToCloneWithAppliedRefinement(refinedIntent);
if (refinedTarget != null) {
- mOnSelectionRefined.accept(refinedTarget);
+ mRefinementCompletion.setValue(new RefinementCompletion(refinedTarget));
} else {
Log.e(TAG, "Failed to apply refinement to any matching source intent");
- mOnRefinementCancelled.run();
+ mRefinementCompletion.setValue(new RefinementCompletion(null));
}
},
() -> {
destroy();
- mOnRefinementCancelled.run();
+ mRefinementCompletion.setValue(new RefinementCompletion(null));
},
- mContext.getMainThreadHandler());
+ mainHandler);
Intent refinementRequest = makeRefinementRequest(mRefinementResultReceiver, selectedTarget);
try {
- mRefinementIntentSender.sendIntent(mContext, 0, refinementRequest, null, null);
+ refinementIntentSender.sendIntent(application, 0, refinementRequest, null, null);
return true;
- } catch (SendIntentException e) {
+ } catch (IntentSender.SendIntentException e) {
Log.e(TAG, "Refinement IntentSender failed to send", e);
}
- return false;
+ return true;
+ }
+
+ /** ChooserActivity has stopped */
+ public void onActivityStop(boolean configurationChanging) {
+ mConfigurationChangeInProgress = configurationChanging;
+ }
+
+ /** ChooserActivity has resumed */
+ public void onActivityResume() {
+ if (mConfigurationChangeInProgress) {
+ mConfigurationChangeInProgress = false;
+ } else {
+ if (mRefinementResultReceiver != null) {
+ // This can happen if the refinement activity terminates without ever sending a
+ // response to our `ResultReceiver`. We're probably not prepared to return the user
+ // into a valid Chooser session, so we'll treat it as a cancellation instead.
+ Log.w(TAG, "Chooser resumed while awaiting refinement result; aborting");
+ destroy();
+ mRefinementCompletion.setValue(new RefinementCompletion(null));
+ }
+ }
+ }
+
+ @Override
+ protected void onCleared() {
+ // App lifecycle over, time to clean up.
+ destroy();
}
/** Clean up any ongoing refinement session. */
- public void destroy() {
+ private void destroy() {
if (mRefinementResultReceiver != null) {
mRefinementResultReceiver.destroyReceiver();
mRefinementResultReceiver = null;
diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt
index e760e6d0..1f5be601 100644
--- a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt
+++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt
@@ -28,6 +28,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
+import androidx.annotation.VisibleForTesting
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@@ -45,8 +46,6 @@ import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
-import java.util.ArrayDeque
-import kotlin.math.roundToInt
private const val TRANSITION_NAME = "screenshot_preview_image"
private const val PLURALS_COUNT = "count"
@@ -149,14 +148,17 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
previewAdapter.reset(0, imageLoader)
batchLoader?.cancel()
batchLoader = BatchPreviewLoader(
- previewAdapter,
imageLoader,
previews,
otherItemCount,
- ) {
- onNoPreviewCallback?.run()
- }
- .apply {
+ onReset = { totalItemCount -> previewAdapter.reset(totalItemCount, imageLoader) },
+ onUpdate = previewAdapter::addPreviews,
+ onCompletion = {
+ if (!previewAdapter.hasPreviews) {
+ onNoPreviewCallback?.run()
+ }
+ }
+ ).apply {
if (isMeasured) {
loadAspectRatios(getMaxWidth(), this@ScrollableImagePreviewView::updatePreviewSize)
}
@@ -409,14 +411,17 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
}
}
- private class BatchPreviewLoader(
- private val adapter: Adapter,
+ @VisibleForTesting
+ class BatchPreviewLoader(
private val imageLoader: CachingImageLoader,
previews: List<Preview>,
otherItemCount: Int,
- private val onNoPreviewCallback: (() -> Unit)
+ private val onReset: (Int) -> Unit,
+ private val onUpdate: (List<Preview>) -> Unit,
+ private val onCompletion: () -> Unit,
) {
- private val pendingPreviews = ArrayDeque<Preview>(previews)
+ private val previews: List<Preview> =
+ if (previews is RandomAccess) previews else ArrayList(previews)
private val totalItemCount = previews.size + otherItemCount
private var scope: CoroutineScope? = MainScope() + Dispatchers.Main.immediate
@@ -427,52 +432,75 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
fun loadAspectRatios(maxWidth: Int, previewSizeUpdater: (Preview, Int, Int) -> Int) {
val scope = this.scope ?: return
- val updates = ArrayDeque<Preview>(pendingPreviews.size)
+ // -1 encodes that the preview has not been processed,
+ // 0 means failed, > 0 is a preview width
+ val previewWidths = IntArray(previews.size) { -1 }
+ var blockStart = 0 // inclusive
+ var blockEnd = 0 // exclusive
+
// replay 2 items to guarantee that we'd get at least one update
val reportFlow = MutableSharedFlow<Any>(replay = 2)
- var isFirstUpdate = true
val updateEvent = Any()
val completedEvent = Any()
- // throttle adapter updates by waiting on the channel, the channel first notified
- // when enough preview elements is loaded and then periodically with a delay
+
+ // throttle adapter updates using flow; the flow first emits when enough preview
+ // elements is loaded to fill the viewport and then each time a subsequent block of
+ // previews is loaded
scope.launch(Dispatchers.Main) {
reportFlow
.takeWhile { it !== completedEvent }
.throttle(ADAPTER_UPDATE_INTERVAL_MS)
.onCompletion { cause ->
- if (cause == null && !adapter.hasPreviews) {
- onNoPreviewCallback()
+ if (cause == null) {
+ onCompletion()
}
}
.collect {
- if (isFirstUpdate) {
- isFirstUpdate = false
- adapter.reset(totalItemCount, imageLoader)
+ if (blockStart == 0) {
+ onReset(totalItemCount)
+ }
+ val updates = ArrayList<Preview>(blockEnd - blockStart)
+ while (blockStart < blockEnd) {
+ if (previewWidths[blockStart] > 0) {
+ updates.add(previews[blockStart])
+ }
+ blockStart++
}
if (updates.isNotEmpty()) {
- adapter.addPreviews(updates)
- updates.clear()
+ onUpdate(updates)
}
}
}
scope.launch {
- var loadedPreviewWidth = 0
+ var blockWidth = 0
+ var isFirstBlock = true
+ var nextIdx = 0
List<Job>(4) {
launch {
- while (pendingPreviews.isNotEmpty()) {
- val preview = pendingPreviews.poll() ?: continue
- val isVisible = loadedPreviewWidth < maxWidth
- val bitmap = runCatching {
+ while (true) {
+ val i = nextIdx++
+ if (i >= previews.size) break
+ val preview = previews[i]
+
+ previewWidths[i] = runCatching {
// TODO: decide on adding a timeout
- imageLoader(preview.uri, isVisible)
- }.getOrNull() ?: continue
- val previewWidth =
- previewSizeUpdater(preview, bitmap.width, bitmap.height)
- updates.add(preview)
- if (isVisible) {
- loadedPreviewWidth += previewWidth
- if (loadedPreviewWidth >= maxWidth) {
+ imageLoader(preview.uri, isFirstBlock)?.let { bitmap ->
+ previewSizeUpdater(preview, bitmap.width, bitmap.height)
+ } ?: 0
+ }.getOrDefault(0)
+
+ if (blockEnd != i) continue
+ while (
+ blockEnd < previewWidths.size
+ && previewWidths[blockEnd] >= 0
+ ) {
+ blockWidth += previewWidths[blockEnd]
+ blockEnd++
+ }
+ if (isFirstBlock) {
+ if (blockWidth >= maxWidth) {
+ isFirstBlock = false
// notify that the preview now can be displayed
reportFlow.emit(updateEvent)
}
diff --git a/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt b/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt
index 50c37c7f..bd355c86 100644
--- a/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt
+++ b/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt
@@ -16,46 +16,227 @@
package com.android.intentresolver
-import android.content.Context
+import android.app.Activity
+import android.app.Application
import android.content.Intent
import android.content.IntentSender
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.os.Message
+import android.os.ResultReceiver
+import androidx.lifecycle.Observer
+import androidx.test.annotation.UiThreadTest
import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.intentresolver.ChooserRefinementManager.RefinementCompletion
+import com.android.intentresolver.chooser.ImmutableTargetInfo
import com.android.intentresolver.chooser.TargetInfo
+import com.google.common.truth.Truth.assertThat
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
-import java.util.function.Consumer
-import org.junit.Assert.assertEquals
@RunWith(AndroidJUnit4::class)
+@UiThreadTest
class ChooserRefinementManagerTest {
- @Test
- fun testMaybeHandleSelection() {
- val intentSender = mock<IntentSender>()
- val refinementManager = ChooserRefinementManager(
- mock<Context>(),
- intentSender,
- Consumer<TargetInfo>{},
- Runnable{})
-
- val intents = listOf(Intent(Intent.ACTION_VIEW), Intent(Intent.ACTION_EDIT))
- val targetInfo = mock<TargetInfo>{
- whenever(allSourceIntents).thenReturn(intents)
+ private val refinementManager = ChooserRefinementManager()
+ private val intentSender = mock<IntentSender>()
+ private val application = mock<Application>()
+ private val exampleSourceIntents =
+ listOf(Intent(Intent.ACTION_VIEW), Intent(Intent.ACTION_EDIT))
+ private val exampleTargetInfo =
+ ImmutableTargetInfo.newBuilder().setAllSourceIntents(exampleSourceIntents).build()
+
+ private val completionObserver =
+ object : Observer<RefinementCompletion> {
+ val failureCountDown = CountDownLatch(1)
+ val successCountDown = CountDownLatch(1)
+ var latestTargetInfo: TargetInfo? = null
+
+ override fun onChanged(completion: RefinementCompletion) {
+ if (completion.consume()) {
+ val targetInfo = completion.targetInfo
+ if (targetInfo == null) {
+ failureCountDown.countDown()
+ } else {
+ latestTargetInfo = targetInfo
+ successCountDown.countDown()
+ }
+ }
+ }
}
- refinementManager.maybeHandleSelection(targetInfo)
+ /** Synchronously executes post() calls. */
+ private class FakeHandler(looper: Looper) : Handler(looper) {
+ override fun sendMessageAtTime(msg: Message, uptimeMillis: Long): Boolean {
+ dispatchMessage(msg)
+ return true
+ }
+ }
+
+ @Before
+ fun setup() {
+ refinementManager.refinementCompletion.observeForever(completionObserver)
+ }
+
+ @Test
+ fun testTypicalRefinementFlow() {
+ assertThat(
+ refinementManager.maybeHandleSelection(
+ exampleTargetInfo,
+ intentSender,
+ application,
+ FakeHandler(Looper.myLooper())
+ )
+ )
+ .isTrue()
val intentCaptor = ArgumentCaptor.forClass(Intent::class.java)
- Mockito.verify(intentSender).sendIntent(
- any(), eq(0), intentCaptor.capture(), eq(null), eq(null))
+ Mockito.verify(intentSender)
+ .sendIntent(any(), eq(0), intentCaptor.capture(), eq(null), eq(null))
val intent = intentCaptor.value
- assertEquals(intents[0], intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java))
+ assertThat(intent?.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java))
+ .isEqualTo(exampleSourceIntents[0])
val alternates =
- intent.getParcelableArrayExtra(Intent.EXTRA_ALTERNATE_INTENTS, Intent::class.java)
- assertEquals(1, alternates?.size)
- assertEquals(intents[1], alternates?.get(0))
+ intent?.getParcelableArrayExtra(Intent.EXTRA_ALTERNATE_INTENTS, Intent::class.java)
+ assertThat(alternates?.size).isEqualTo(1)
+ assertThat(alternates?.get(0)).isEqualTo(exampleSourceIntents[1])
+
+ // Complete the refinement
+ val receiver =
+ intent?.getParcelableExtra(Intent.EXTRA_RESULT_RECEIVER, ResultReceiver::class.java)
+ val bundle = Bundle().apply { putParcelable(Intent.EXTRA_INTENT, exampleSourceIntents[0]) }
+ receiver?.send(Activity.RESULT_OK, bundle)
+
+ assertThat(completionObserver.successCountDown.await(1000, TimeUnit.MILLISECONDS)).isTrue()
+ assertThat(completionObserver.latestTargetInfo?.resolvedIntent?.action)
+ .isEqualTo(Intent.ACTION_VIEW)
+ }
+
+ @Test
+ fun testRefinementCancelled() {
+ assertThat(
+ refinementManager.maybeHandleSelection(
+ exampleTargetInfo,
+ intentSender,
+ application,
+ FakeHandler(Looper.myLooper())
+ )
+ )
+ .isTrue()
+
+ val intentCaptor = ArgumentCaptor.forClass(Intent::class.java)
+ Mockito.verify(intentSender)
+ .sendIntent(any(), eq(0), intentCaptor.capture(), eq(null), eq(null))
+
+ val intent = intentCaptor.value
+
+ // Complete the refinement
+ val receiver =
+ intent?.getParcelableExtra(Intent.EXTRA_RESULT_RECEIVER, ResultReceiver::class.java)
+ val bundle = Bundle().apply { putParcelable(Intent.EXTRA_INTENT, exampleSourceIntents[0]) }
+ receiver?.send(Activity.RESULT_CANCELED, bundle)
+
+ assertThat(completionObserver.failureCountDown.await(1000, TimeUnit.MILLISECONDS)).isTrue()
+ }
+
+ @Test
+ fun testMaybeHandleSelection_noSourceIntents() {
+ assertThat(
+ refinementManager.maybeHandleSelection(
+ ImmutableTargetInfo.newBuilder().build(),
+ intentSender,
+ application,
+ FakeHandler(Looper.myLooper())
+ )
+ )
+ .isFalse()
+ }
+
+ @Test
+ fun testMaybeHandleSelection_suspended() {
+ val targetInfo =
+ ImmutableTargetInfo.newBuilder()
+ .setAllSourceIntents(exampleSourceIntents)
+ .setIsSuspended(true)
+ .build()
+
+ assertThat(
+ refinementManager.maybeHandleSelection(
+ targetInfo,
+ intentSender,
+ application,
+ FakeHandler(Looper.myLooper())
+ )
+ )
+ .isFalse()
+ }
+
+ @Test
+ fun testMaybeHandleSelection_noIntentSender() {
+ assertThat(
+ refinementManager.maybeHandleSelection(
+ exampleTargetInfo,
+ /* IntentSender */ null,
+ application,
+ FakeHandler(Looper.myLooper())
+ )
+ )
+ .isFalse()
+ }
+
+ @Test
+ fun testConfigurationChangeDuringRefinement() {
+ assertThat(
+ refinementManager.maybeHandleSelection(
+ exampleTargetInfo,
+ intentSender,
+ application,
+ FakeHandler(Looper.myLooper())
+ )
+ )
+ .isTrue()
+
+ refinementManager.onActivityStop(/* config changing = */ true)
+ refinementManager.onActivityResume()
+
+ assertThat(completionObserver.failureCountDown.count).isEqualTo(1)
+ }
+
+ @Test
+ fun testResumeDuringRefinement() {
+ assertThat(
+ refinementManager.maybeHandleSelection(
+ exampleTargetInfo,
+ intentSender,
+ application,
+ FakeHandler(Looper.myLooper()!!)
+ )
+ )
+ .isTrue()
+
+ refinementManager.onActivityStop(/* config changing = */ false)
+ // Resume during refinement but not during a config change, so finish the activity.
+ refinementManager.onActivityResume()
+
+ // Call should be synchronous, don't need to await for this one.
+ assertThat(completionObserver.failureCountDown.count).isEqualTo(0)
+ }
+
+ @Test
+ fun testRefinementCompletion() {
+ val refinementCompletion = RefinementCompletion(exampleTargetInfo)
+ assertThat(refinementCompletion.targetInfo).isEqualTo(exampleTargetInfo)
+ assertThat(refinementCompletion.consume()).isTrue()
+ assertThat(refinementCompletion.targetInfo).isEqualTo(exampleTargetInfo)
+
+ // can only consume once.
+ assertThat(refinementCompletion.consume()).isFalse()
}
}
diff --git a/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt
index cebccaae..504cfd97 100644
--- a/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt
+++ b/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt
@@ -222,8 +222,8 @@ class ImmutableTargetInfoTest {
.build()
val info = originalInfo.tryToCloneWithAppliedRefinement(refinementIntent)
- assertThat(info.baseIntentToSend.getBooleanExtra("ORIGINAL", false)).isTrue()
- assertThat(info.baseIntentToSend.getBooleanExtra("REFINEMENT", false)).isTrue()
+ assertThat(info?.baseIntentToSend?.getBooleanExtra("ORIGINAL", false)).isTrue()
+ assertThat(info?.baseIntentToSend?.getBooleanExtra("REFINEMENT", false)).isTrue()
}
@Test
@@ -245,9 +245,9 @@ class ImmutableTargetInfoTest {
val info = infoWithReferrerFillIn.tryToCloneWithAppliedRefinement(refinementIntent)
- assertThat(info.baseIntentToSend.getPackage()).isEqualTo("original") // Set all along.
- assertThat(info.baseIntentToSend.action).isEqualTo("REFINE_ME") // Refinement wins.
- assertThat(info.baseIntentToSend.type).isEqualTo("test/referrer") // Left for referrer.
+ assertThat(info?.baseIntentToSend?.getPackage()).isEqualTo("original") // Set all along.
+ assertThat(info?.baseIntentToSend?.action).isEqualTo("REFINE_ME") // Refinement wins.
+ assertThat(info?.baseIntentToSend?.type).isEqualTo("test/referrer") // Left for referrer.
}
@Test
@@ -266,18 +266,18 @@ class ImmutableTargetInfoTest {
.build()
val refined1 = originalInfo.tryToCloneWithAppliedRefinement(refinementIntent1)
- val refined2 = refined1.tryToCloneWithAppliedRefinement(refinementIntent2) // Cloned clone.
+ val refined2 = refined1?.tryToCloneWithAppliedRefinement(refinementIntent2) // Cloned clone.
// Both clones get the same values filled in from the referrer intent.
- assertThat(refined1.baseIntentToSend.getStringExtra("TEST")).isEqualTo("REFERRER")
- assertThat(refined2.baseIntentToSend.getStringExtra("TEST")).isEqualTo("REFERRER")
+ assertThat(refined1?.baseIntentToSend?.getStringExtra("TEST")).isEqualTo("REFERRER")
+ assertThat(refined2?.baseIntentToSend?.getStringExtra("TEST")).isEqualTo("REFERRER")
// Each clone has the respective value that was set in their own refinement request.
- assertThat(refined1.baseIntentToSend.getStringExtra("TEST1")).isEqualTo("1")
- assertThat(refined2.baseIntentToSend.getStringExtra("TEST2")).isEqualTo("2")
+ assertThat(refined1?.baseIntentToSend?.getStringExtra("TEST1")).isEqualTo("1")
+ assertThat(refined2?.baseIntentToSend?.getStringExtra("TEST2")).isEqualTo("2")
// The clones don't have the data from each other's refinements, even though the intent
// field is empty (thus able to be populated by filling-in).
- assertThat(refined1.baseIntentToSend.getStringExtra("TEST2")).isNull()
- assertThat(refined2.baseIntentToSend.getStringExtra("TEST1")).isNull()
+ assertThat(refined1?.baseIntentToSend?.getStringExtra("TEST2")).isNull()
+ assertThat(refined2?.baseIntentToSend?.getStringExtra("TEST1")).isNull()
}
@Test
@@ -301,15 +301,15 @@ class ImmutableTargetInfoTest {
refinement.putExtra("refinement", true)
val refinedResult = originalInfo.tryToCloneWithAppliedRefinement(refinement)
- assertThat(refinedResult.baseIntentToSend.getBooleanExtra("refinement", false)).isTrue()
- assertThat(refinedResult.baseIntentToSend.getBooleanExtra("targetAlternate", false))
+ assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("refinement", false)).isTrue()
+ assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("targetAlternate", false))
.isTrue()
// None of the other source intents got merged in (not even the later one that matched):
- assertThat(refinedResult.baseIntentToSend.getBooleanExtra("originalIntent", false))
+ assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("originalIntent", false))
.isFalse()
- assertThat(refinedResult.baseIntentToSend.getBooleanExtra("mismatchedAlternate", false))
+ assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("mismatchedAlternate", false))
.isFalse()
- assertThat(refinedResult.baseIntentToSend.getBooleanExtra("extraMatch", false)).isFalse()
+ assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("extraMatch", false)).isFalse()
}
@Test
diff --git a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt
index 886e32df..f9d3dd96 100644
--- a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt
+++ b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt
@@ -234,15 +234,15 @@ class TargetInfoTest {
val refinedResult = originalInfo.tryToCloneWithAppliedRefinement(refinement)
// Note `DisplayResolveInfo` targets merge refinements directly into their `resolvedIntent`.
- assertThat(refinedResult.resolvedIntent.getBooleanExtra("refinement", false)).isTrue()
- assertThat(refinedResult.resolvedIntent.getBooleanExtra("targetAlternate", false))
+ assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("refinement", false)).isTrue()
+ assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("targetAlternate", false))
.isTrue()
// None of the other source intents got merged in (not even the later one that matched):
- assertThat(refinedResult.resolvedIntent.getBooleanExtra("originalIntent", false))
+ assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("originalIntent", false))
.isFalse()
- assertThat(refinedResult.resolvedIntent.getBooleanExtra("mismatchedAlternate", false))
+ assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("mismatchedAlternate", false))
.isFalse()
- assertThat(refinedResult.resolvedIntent.getBooleanExtra("extraMatch", false)).isFalse()
+ assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("extraMatch", false)).isFalse()
}
@Test
diff --git a/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt b/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt
new file mode 100644
index 00000000..c1d7451f
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt
@@ -0,0 +1,239 @@
+/*
+ * Copyright (C) 2023 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.widget
+
+import android.graphics.Bitmap
+import android.net.Uri
+import com.android.intentresolver.widget.ScrollableImagePreviewView.BatchPreviewLoader
+import com.android.intentresolver.widget.ScrollableImagePreviewView.Preview
+import com.android.intentresolver.widget.ScrollableImagePreviewView.PreviewType
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import org.junit.Test
+import com.android.intentresolver.mock
+import com.android.intentresolver.captureMany
+import com.android.intentresolver.withArgCaptor
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.mockito.Mockito.atLeast
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import com.google.common.truth.Truth.assertThat
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class BatchPreviewLoaderTest {
+ private val dispatcher = UnconfinedTestDispatcher()
+ private val testScope = CoroutineScope(dispatcher)
+ private val onCompletion = mock<() -> Unit>()
+ private val onReset = mock<(Int) -> Unit>()
+ private val onUpdate = mock<(List<Preview>) -> Unit>()
+
+ @Before
+ fun setup() {
+ Dispatchers.setMain(dispatcher)
+ }
+
+ @After
+ fun cleanup() {
+ testScope.cancel()
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun test_allImagesWithinViewPort_oneUpdate() {
+ val imageLoader = TestImageLoader(testScope)
+ val uriOne = createUri(1)
+ val uriTwo = createUri(2)
+ imageLoader.setUriLoadingOrder(succeed(uriTwo), succeed(uriOne))
+ val testSubject = BatchPreviewLoader(
+ imageLoader,
+ previews(uriOne, uriTwo),
+ 0,
+ onReset,
+ onUpdate,
+ onCompletion
+ )
+ testSubject.loadAspectRatios(200) { _, _, _ -> 100 }
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(onCompletion, times(1)).invoke()
+ verify(onReset, times(1)).invoke(2)
+ val list = withArgCaptor {
+ verify(onUpdate, times(1)).invoke(capture())
+ }.map { it.uri }
+ assertThat(list).containsExactly(uriOne, uriTwo).inOrder()
+ }
+
+ @Test
+ fun test_allImagesWithinViewPortOneFailed_failedPreviewIsNotUpdated() {
+ val imageLoader = TestImageLoader(testScope)
+ val uriOne = createUri(1)
+ val uriTwo = createUri(2)
+ val uriThree = createUri(3)
+ imageLoader.setUriLoadingOrder(succeed(uriThree), fail(uriTwo), succeed(uriOne))
+ val testSubject = BatchPreviewLoader(
+ imageLoader,
+ previews(uriOne, uriTwo, uriThree),
+ 0,
+ onReset,
+ onUpdate,
+ onCompletion
+ )
+ testSubject.loadAspectRatios(200) { _, _, _ -> 100 }
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(onCompletion, times(1)).invoke()
+ verify(onReset, times(1)).invoke(3)
+ val list = withArgCaptor {
+ verify(onUpdate, times(1)).invoke(capture())
+ }.map { it.uri }
+ assertThat(list).containsExactly(uriOne, uriThree).inOrder()
+ }
+
+ @Test
+ fun test_imagesLoadedNotInOrder_updatedInOrder() {
+ val imageLoader = TestImageLoader(testScope)
+ val uris = Array(10) { createUri(it) }
+ val loadingOrder = Array(uris.size) { i ->
+ val uriIdx = when {
+ i % 2 == 1 -> i - 1
+ i % 2 == 0 && i < uris.size - 1 -> i + 1
+ else -> i
+ }
+ succeed(uris[uriIdx])
+ }
+ imageLoader.setUriLoadingOrder(*loadingOrder)
+ val testSubject = BatchPreviewLoader(
+ imageLoader,
+ previews(*uris),
+ 0,
+ onReset,
+ onUpdate,
+ onCompletion
+ )
+ testSubject.loadAspectRatios(200) { _, _, _ -> 100 }
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(onCompletion, times(1)).invoke()
+ verify(onReset, times(1)).invoke(uris.size)
+ val list = captureMany {
+ verify(onUpdate, atLeast(1)).invoke(capture())
+ }.fold(ArrayList<Preview>()) { acc, update ->
+ acc.apply {
+ addAll(update)
+ }
+ }.map { it.uri }
+ assertThat(list).containsExactly(*uris).inOrder()
+ }
+
+ @Test
+ fun test_imagesLoadedNotInOrderSomeFailed_updatedInOrder() {
+ val imageLoader = TestImageLoader(testScope)
+ val uris = Array(10) { createUri(it) }
+ val loadingOrder = Array(uris.size) { i ->
+ val uriIdx = when {
+ i % 2 == 1 -> i - 1
+ i % 2 == 0 && i < uris.size - 1 -> i + 1
+ else -> i
+ }
+ if (uriIdx % 2 == 0) fail(uris[uriIdx]) else succeed(uris[uriIdx])
+ }
+ val expectedUris = Array(uris.size / 2) { createUri(it * 2 + 1) }
+ imageLoader.setUriLoadingOrder(*loadingOrder)
+ val testSubject = BatchPreviewLoader(
+ imageLoader,
+ previews(*uris),
+ 0,
+ onReset,
+ onUpdate,
+ onCompletion
+ )
+ testSubject.loadAspectRatios(200) { _, _, _ -> 100 }
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(onCompletion, times(1)).invoke()
+ verify(onReset, times(1)).invoke(uris.size)
+ val list = captureMany {
+ verify(onUpdate, atLeast(1)).invoke(capture())
+ }.fold(ArrayList<Preview>()) { acc, update ->
+ acc.apply {
+ addAll(update)
+ }
+ }.map { it.uri }
+ assertThat(list).containsExactly(*expectedUris).inOrder()
+ }
+
+ private fun createUri(idx: Int): Uri = Uri.parse("content://org.pkg.app/image-$idx.png")
+
+ private fun fail(uri: Uri) = uri to false
+ private fun succeed(uri: Uri) = uri to true
+ private fun previews(vararg uris: Uri) =
+ uris.fold(ArrayList<Preview>(uris.size)) { acc, uri ->
+ acc.apply {
+ add(Preview(PreviewType.Image, uri))
+ }
+ }
+}
+
+private class TestImageLoader(
+ scope: CoroutineScope
+) : suspend (Uri, Boolean) -> Bitmap? {
+ private val loadingOrder = ArrayDeque<Pair<Uri, Boolean>>()
+ private val pendingRequests = LinkedHashMap<Uri, CompletableDeferred<Bitmap?>>()
+ private val flow = MutableSharedFlow<Unit>(replay = 1)
+ private val bitmap by lazy {
+ Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)
+ }
+
+ init {
+ scope.launch {
+ flow.collect {
+ while (true) {
+ val (nextUri, isLoaded) = loadingOrder.firstOrNull() ?: break
+ val deferred = pendingRequests.remove(nextUri) ?: break
+ loadingOrder.removeFirst()
+ deferred.complete(if (isLoaded) bitmap else null)
+ }
+ if (loadingOrder.isEmpty()) {
+ pendingRequests.forEach { (uri, deferred) ->
+ deferred.complete(bitmap)
+ }
+ pendingRequests.clear()
+ }
+ }
+ }
+ }
+
+ fun setUriLoadingOrder(vararg uris: Pair<Uri, Boolean>) {
+ loadingOrder.clear()
+ loadingOrder.addAll(uris)
+ }
+
+ override suspend fun invoke(uri: Uri, cache: Boolean): Bitmap? {
+ val deferred = pendingRequests.getOrPut(uri) { CompletableDeferred() }
+ flow.tryEmit(Unit)
+ return deferred.await()
+ }
+}