diff options
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() + } +} |