From a9a6a1c4494529fe2a8e1672d7f6c56aca978743 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 29 Dec 2022 20:47:57 -0800 Subject: EnterTransitionAnimationDelegate to track animation hold timeout Make EnterTransitionAnimationDelegate to track transition animation hold timeout instead of relying on image lading timeout in ChooserContentPreviewCoordinator. As after removing timeout tracking from the coordinator, not much functionality left in it, the following collateral changes were made: - ChooserContentPreviewUi$ContentPreviewCoordinator interface is replaced with a new ImageLoader interface that defines both suspend and callback methods for image loading by an URI; - ImagePreviewImageLoader implements the new ImageLoader interface; all image loading logic is moved from ChooserActivity and ChooserContentPreviewCoordinator is moved into it, ChooserContentPreviewCoordinator is removed. Flag: IntentResolver package entirely behind the CHOOSER_UNBUNDLED which is in teamfood Bug: 262280076 Test: manual basic functionality test for all preview types with and without work profile Test: atest IntentResolverUnitTests Change-Id: I310927e664e8de83d63472cc826665d36d994ed9 --- .../android/intentresolver/ChooserActivity.java | 33 ++---- .../ChooserContentPreviewCoordinator.java | 132 --------------------- .../intentresolver/ChooserContentPreviewUi.java | 127 ++++++++++---------- .../EnterTransitionAnimationDelegate.kt | 35 ++++-- java/src/com/android/intentresolver/ImageLoader.kt | 25 ++++ .../intentresolver/ImagePreviewImageLoader.kt | 45 +++++-- .../intentresolver/widget/ImagePreviewView.kt | 2 +- java/tests/Android.bp | 4 + .../intentresolver/ChooserWrapperActivity.java | 11 +- .../EnterTransitionAnimationDelegateTest.kt | 111 +++++++++++++++++ .../android/intentresolver/TestLifecycleOwner.kt | 33 ++++++ .../intentresolver/TestPreviewImageLoader.kt | 37 ++++++ 12 files changed, 346 insertions(+), 249 deletions(-) delete mode 100644 java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java create mode 100644 java/src/com/android/intentresolver/ImageLoader.kt create mode 100644 java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt create mode 100644 java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt create mode 100644 java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt (limited to 'java') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index ceab62b2..32cd45da 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -54,7 +54,6 @@ import android.content.pm.ShortcutInfo; import android.content.res.Configuration; import android.content.res.Resources; import android.database.Cursor; -import android.graphics.Bitmap; import android.graphics.Insets; import android.graphics.drawable.Drawable; import android.net.Uri; @@ -73,7 +72,6 @@ import android.provider.Settings; import android.service.chooser.ChooserTarget; import android.text.TextUtils; import android.util.Log; -import android.util.Size; import android.util.Slog; import android.util.SparseArray; import android.view.View; @@ -113,7 +111,6 @@ import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.android.internal.util.FrameworkStatsLog; import java.io.File; -import java.io.IOException; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.text.Collator; @@ -241,7 +238,7 @@ public class ChooserActivity extends ResolverActivity implements private final ExecutorService mBackgroundThreadPoolExecutor = Executors.newFixedThreadPool(5); @Nullable - private ChooserContentPreviewCoordinator mPreviewCoordinator; + private ImageLoader mPreviewImageLoader; private int mScrollStatus = SCROLL_STATUS_IDLE; @@ -291,10 +288,7 @@ public class ChooserActivity extends ResolverActivity implements mChooserRequest.getTargetIntentFilter()), mChooserRequest.getTargetIntentFilter()); - mPreviewCoordinator = new ChooserContentPreviewCoordinator( - mBackgroundThreadPoolExecutor, - this, - () -> mEnterTransitionAnimationDelegate.markImagePreviewReady(false)); + mPreviewImageLoader = createPreviewImageLoader(); super.onCreate( savedInstanceState, @@ -707,9 +701,7 @@ public class ChooserActivity extends ResolverActivity implements * @param parent reference to the parent container where the view should be attached to * @return content preview view */ - protected ViewGroup createContentPreviewView( - ViewGroup parent, - ChooserContentPreviewUi.ContentPreviewCoordinator previewCoordinator) { + protected ViewGroup createContentPreviewView(ViewGroup parent, ImageLoader imageLoader) { Intent targetIntent = getTargetIntent(); int previewType = ChooserContentPreviewUi.findPreferredContentPreview( targetIntent, getContentResolver(), this::isImageType); @@ -742,7 +734,7 @@ public class ChooserActivity extends ResolverActivity implements actionFactory, R.layout.chooser_action_row, parent, - previewCoordinator, + imageLoader, mEnterTransitionAnimationDelegate::markImagePreviewReady, getContentResolver(), this::isImageType); @@ -1485,7 +1477,7 @@ public class ChooserActivity extends ResolverActivity implements @Override public View buildContentPreview(ViewGroup parent) { - return createContentPreviewView(parent, mPreviewCoordinator); + return createContentPreviewView(parent, mPreviewImageLoader); } @Override @@ -1600,17 +1592,8 @@ public class ChooserActivity extends ResolverActivity implements } @VisibleForTesting - protected Bitmap loadThumbnail(Uri uri, Size size) { - if (uri == null || size == null) { - return null; - } - - try { - return getContentResolver().loadThumbnail(uri, size, null); - } catch (IOException | NullPointerException | SecurityException ex) { - getChooserActivityLogger().logContentPreviewWarning(uri); - } - return null; + protected ImageLoader createPreviewImageLoader() { + return new ImagePreviewImageLoader(this, getLifecycle()); } private void handleScroll(View view, int x, int y, int oldx, int oldy) { @@ -1967,7 +1950,7 @@ public class ChooserActivity extends ResolverActivity implements ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); if (contentPreviewContainer.getChildCount() == 0) { ViewGroup contentPreviewView = - createContentPreviewView(contentPreviewContainer, mPreviewCoordinator); + createContentPreviewView(contentPreviewContainer, mPreviewImageLoader); contentPreviewContainer.addView(contentPreviewView); } } diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java deleted file mode 100644 index 0b8dbe35..00000000 --- a/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright (C) 2008 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; - -import android.graphics.Bitmap; -import android.net.Uri; -import android.os.Handler; -import android.util.Size; - -import androidx.annotation.MainThread; -import androidx.annotation.Nullable; - -import com.google.common.util.concurrent.FutureCallback; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.ListeningExecutorService; -import com.google.common.util.concurrent.MoreExecutors; - -import java.util.concurrent.ExecutorService; -import java.util.function.Consumer; - -/** - * Delegate to manage deferred resource loads for content preview assets, while - * implementing Chooser's application logic for determining timeout/success/failure conditions. - */ -public class ChooserContentPreviewCoordinator implements - ChooserContentPreviewUi.ContentPreviewCoordinator { - public ChooserContentPreviewCoordinator( - ExecutorService backgroundExecutor, - ChooserActivity chooserActivity, - Runnable onFailCallback) { - this.mBackgroundExecutor = MoreExecutors.listeningDecorator(backgroundExecutor); - this.mChooserActivity = chooserActivity; - this.mOnFailCallback = onFailCallback; - - this.mImageLoadTimeoutMillis = - chooserActivity.getResources().getInteger(R.integer.config_shortAnimTime); - } - - @Override - public void loadImage(final Uri imageUri, final Consumer callback) { - final int size = mChooserActivity.getResources().getDimensionPixelSize( - R.dimen.chooser_preview_image_max_dimen); - - // TODO: apparently this timeout is only used for not holding shared element transition - // animation for too long. If so, we already have a better place for it - // EnterTransitionAnimationDelegate. - mHandler.postDelayed(this::onWatchdogTimeout, mImageLoadTimeoutMillis); - - ListenableFuture bitmapFuture = mBackgroundExecutor.submit( - () -> mChooserActivity.loadThumbnail(imageUri, new Size(size, size))); - - Futures.addCallback( - bitmapFuture, - new FutureCallback() { - @Override - public void onSuccess(Bitmap loadedBitmap) { - try { - callback.accept(loadedBitmap); - onLoadCompleted(loadedBitmap); - } catch (Exception e) { /* unimportant */ } - } - - @Override - public void onFailure(Throwable t) { - callback.accept(null); - } - }, - mHandler::post); - } - - private final ChooserActivity mChooserActivity; - private final ListeningExecutorService mBackgroundExecutor; - private final Runnable mOnFailCallback; - private final int mImageLoadTimeoutMillis; - - // TODO: this uses a `Handler` because there doesn't seem to be a straightforward way to get a - // `ScheduledExecutorService` that posts to the UI thread unless we use Dagger. Eventually we'll - // use Dagger and can inject this as a `@UiThread ScheduledExecutorService`. - private final Handler mHandler = new Handler(); - - private boolean mAtLeastOneLoaded = false; - - @MainThread - private void onWatchdogTimeout() { - if (mChooserActivity.isFinishing()) { - return; - } - - // If at least one image loads within the timeout period, allow other loads to continue. - if (!mAtLeastOneLoaded) { - mOnFailCallback.run(); - } - } - - @MainThread - private void onLoadCompleted(@Nullable Bitmap loadedBitmap) { - if (mChooserActivity.isFinishing()) { - return; - } - - // TODO: the following logic can be described as "invoke the fail callback when the first - // image loading has failed". Historically, before we had switched from a single-threaded - // pool to a multi-threaded pool, we first loaded the transition element's image (the image - // preview is the only case when those callbacks matter) and aborting the animation on it's - // failure was reasonable. With the multi-thread pool, the first result may belong to any - // image and thus we can falsely abort the animation. - // Now, when we track the transition view state directly and after the timeout logic will - // be moved into ChooserActivity$EnterTransitionAnimationDelegate, we can just get rid of - // the fail callback and the following logic altogether. - mAtLeastOneLoaded |= loadedBitmap != null; - boolean wholeBatchFailed = !mAtLeastOneLoaded; - - if (wholeBatchFailed) { - mOnFailCallback.run(); - } - } -} diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java index ff88e5e1..7dd771cb 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -71,21 +71,6 @@ import java.util.function.Consumer; public final class ChooserContentPreviewUi { private static final int IMAGE_FADE_IN_MILLIS = 150; - /** - * Delegate to handle background resource loads that are dependencies of content previews. - */ - public interface ContentPreviewCoordinator { - /** - * Request that an image be loaded in the background and set into a view. - * - * @param imageUri The {@link Uri} of the image to load. - * - * TODO: it looks like clients are probably capable of passing the view directly, but the - * deferred computation here is a closer match to the legacy model for now. - */ - void loadImage(Uri imageUri, Consumer callback); - } - /** * Delegate to build the default system action buttons to display in the preview layout, if/when * they're determined to be appropriate for the particular preview we display. @@ -181,7 +166,7 @@ public final class ChooserContentPreviewUi { ActionFactory actionFactory, @LayoutRes int actionRowLayout, ViewGroup parent, - ContentPreviewCoordinator previewCoord, + ImageLoader previewImageLoader, Consumer onTransitionTargetReady, ContentResolver contentResolver, ImageMimeTypeClassifier imageClassifier) { @@ -194,7 +179,7 @@ public final class ChooserContentPreviewUi { layoutInflater, createTextPreviewActions(actionFactory), parent, - previewCoord, + previewImageLoader, actionRowLayout); break; case CONTENT_PREVIEW_IMAGE: @@ -203,7 +188,7 @@ public final class ChooserContentPreviewUi { layoutInflater, createImagePreviewActions(actionFactory), parent, - previewCoord, + previewImageLoader, onTransitionTargetReady, contentResolver, imageClassifier, @@ -216,7 +201,7 @@ public final class ChooserContentPreviewUi { layoutInflater, createFilePreviewActions(actionFactory), parent, - previewCoord, + previewImageLoader, contentResolver, actionRowLayout); break; @@ -247,7 +232,7 @@ public final class ChooserContentPreviewUi { LayoutInflater layoutInflater, List actions, ViewGroup parent, - ContentPreviewCoordinator previewCoord, + ImageLoader previewImageLoader, @LayoutRes int actionRowLayout) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_text, parent, false); @@ -292,7 +277,7 @@ public final class ChooserContentPreviewUi { if (previewThumbnail == null) { previewThumbnailView.setVisibility(View.GONE); } else { - previewCoord.loadImage( + previewImageLoader.loadImage( previewThumbnail, (bitmap) -> updateViewWithImage( contentPreviewLayout.findViewById( @@ -319,7 +304,7 @@ public final class ChooserContentPreviewUi { LayoutInflater layoutInflater, List actions, ViewGroup parent, - ContentPreviewCoordinator previewCoord, + ImageLoader imageLoader, Consumer onTransitionTargetReady, ContentResolver contentResolver, ImageMimeTypeClassifier imageClassifier, @@ -334,7 +319,6 @@ public final class ChooserContentPreviewUi { actionRow.setActions(actions); } - final ImagePreviewImageLoader imageLoader = new ImagePreviewImageLoader(previewCoord); final ArrayList imageUris = new ArrayList<>(); String action = targetIntent.getAction(); if (Intent.ACTION_SEND.equals(action)) { @@ -387,59 +371,74 @@ public final class ChooserContentPreviewUi { LayoutInflater layoutInflater, List actions, ViewGroup parent, - ContentPreviewCoordinator previewCoord, + ImageLoader imageLoader, ContentResolver contentResolver, @LayoutRes int actionRowLayout) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_file, parent, false); + List uris = extractFileUris(targetIntent); + final int uriCount = uris.size(); + + if (uriCount == 0) { + contentPreviewLayout.setVisibility(View.GONE); + Log.i(TAG, + "Appears to be no uris available in EXTRA_STREAM, removing " + + "preview area"); + return contentPreviewLayout; + } + + if (uriCount == 1) { + loadFileUriIntoView(uris.get(0), contentPreviewLayout, imageLoader, contentResolver); + } else { + FileInfo fileInfo = extractFileInfo(uris.get(0), contentResolver); + int remUriCount = uriCount - 1; + Map arguments = new HashMap<>(); + arguments.put(PLURALS_COUNT, remUriCount); + arguments.put(PLURALS_FILE_NAME, fileInfo.name); + String fileName = + PluralsMessageFormatter.format(resources, arguments, R.string.file_count); + + TextView fileNameView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_filename); + fileNameView.setText(fileName); + + View thumbnailView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_file_thumbnail); + thumbnailView.setVisibility(View.GONE); + + ImageView fileIconView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_file_icon); + fileIconView.setVisibility(View.VISIBLE); + fileIconView.setImageResource(R.drawable.ic_file_copy); + } + final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); if (actionRow != null) { actionRow.setActions(actions); } - String action = targetIntent.getAction(); - if (Intent.ACTION_SEND.equals(action)) { + return contentPreviewLayout; + } + + private static List extractFileUris(Intent targetIntent) { + List uris = new ArrayList<>(); + if (Intent.ACTION_SEND.equals(targetIntent.getAction())) { Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - loadFileUriIntoView(uri, contentPreviewLayout, previewCoord, contentResolver); + if (uri != null) { + uris.add(uri); + } } else { - List uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - int uriCount = uris.size(); - - if (uriCount == 0) { - contentPreviewLayout.setVisibility(View.GONE); - Log.i(TAG, - "Appears to be no uris available in EXTRA_STREAM, removing " - + "preview area"); - return contentPreviewLayout; - } else if (uriCount == 1) { - loadFileUriIntoView( - uris.get(0), contentPreviewLayout, previewCoord, contentResolver); - } else { - FileInfo fileInfo = extractFileInfo(uris.get(0), contentResolver); - int remUriCount = uriCount - 1; - Map arguments = new HashMap<>(); - arguments.put(PLURALS_COUNT, remUriCount); - arguments.put(PLURALS_FILE_NAME, fileInfo.name); - String fileName = - PluralsMessageFormatter.format(resources, arguments, R.string.file_count); - - TextView fileNameView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_filename); - fileNameView.setText(fileName); - - View thumbnailView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_file_thumbnail); - thumbnailView.setVisibility(View.GONE); - - ImageView fileIconView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_file_icon); - fileIconView.setVisibility(View.VISIBLE); - fileIconView.setImageResource(R.drawable.ic_file_copy); + List receivedUris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + if (receivedUris != null) { + for (Uri uri : receivedUris) { + if (uri != null) { + uris.add(uri); + } + } } } - - return contentPreviewLayout; + return uris; } private static List createFilePreviewActions(ActionFactory actionFactory) { @@ -473,7 +472,7 @@ public final class ChooserContentPreviewUi { private static void loadFileUriIntoView( final Uri uri, final View parent, - final ContentPreviewCoordinator previewCoord, + final ImageLoader imageLoader, final ContentResolver contentResolver) { FileInfo fileInfo = extractFileInfo(uri, contentResolver); @@ -482,7 +481,7 @@ public final class ChooserContentPreviewUi { fileNameView.setText(fileInfo.name); if (fileInfo.hasThumbnail) { - previewCoord.loadImage( + imageLoader.loadImage( uri, (bitmap) -> updateViewWithImage( parent.findViewById( diff --git a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt index a0bf61b6..31aeea44 100644 --- a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt +++ b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt @@ -15,23 +15,30 @@ */ package com.android.intentresolver -import android.app.Activity import android.app.SharedElementCallback import android.view.View -import com.android.intentresolver.widget.ResolverDrawerLayout +import androidx.activity.ComponentActivity +import androidx.lifecycle.lifecycleScope +import com.android.internal.annotations.VisibleForTesting +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import java.util.function.Supplier /** * A helper class to track app's readiness for the scene transition animation. * The app is ready when both the image is laid out and the drawer offset is calculated. */ -internal class EnterTransitionAnimationDelegate( - private val activity: Activity, - private val resolverDrawerLayoutSupplier: Supplier +@VisibleForTesting +class EnterTransitionAnimationDelegate( + private val activity: ComponentActivity, + private val transitionTargetSupplier: Supplier, ) : View.OnLayoutChangeListener { + private var removeSharedElements = false private var previewReady = false private var offsetCalculated = false + private var timeoutJob: Job? = null init { activity.setEnterSharedElementCallback( @@ -46,9 +53,23 @@ internal class EnterTransitionAnimationDelegate( }) } - fun postponeTransition() = activity.postponeEnterTransition() + fun postponeTransition() { + activity.postponeEnterTransition() + timeoutJob = activity.lifecycleScope.launch { + delay(activity.resources.getInteger(R.integer.config_shortAnimTime).toLong()) + onTimeout() + } + } + + private fun onTimeout() { + // We only mark the preview readiness and not the offset readiness + // (see [#markOffsetCalculated()]) as this is what legacy logic, effectively, did. We might + // want to review that aspect separately. + markImagePreviewReady(runTransitionAnimation = false) + } fun markImagePreviewReady(runTransitionAnimation: Boolean) { + timeoutJob?.cancel() if (!runTransitionAnimation) { removeSharedElements = true } @@ -77,7 +98,7 @@ internal class EnterTransitionAnimationDelegate( } private fun maybeStartListenForLayout() { - val drawer = resolverDrawerLayoutSupplier.get() + val drawer = transitionTargetSupplier.get() if (previewReady && offsetCalculated && drawer != null) { if (drawer.isInLayout) { startPostponedEnterTransition() diff --git a/java/src/com/android/intentresolver/ImageLoader.kt b/java/src/com/android/intentresolver/ImageLoader.kt new file mode 100644 index 00000000..13b1dd9c --- /dev/null +++ b/java/src/com/android/intentresolver/ImageLoader.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2022 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 + +import android.graphics.Bitmap +import android.net.Uri +import java.util.function.Consumer + +interface ImageLoader : suspend (Uri) -> Bitmap? { + fun loadImage(uri: Uri, callback: Consumer) +} diff --git a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt index e68eb66a..40081c87 100644 --- a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt @@ -16,23 +16,42 @@ package com.android.intentresolver +import android.content.Context import android.graphics.Bitmap import android.net.Uri -import kotlinx.coroutines.suspendCancellableCoroutine +import android.util.Size +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.function.Consumer -// TODO: convert ChooserContentPreviewCoordinator to Kotlin and merge this class into it. -internal class ImagePreviewImageLoader( - private val previewCoordinator: ChooserContentPreviewUi.ContentPreviewCoordinator -) : suspend (Uri) -> Bitmap? { +internal class ImagePreviewImageLoader @JvmOverloads constructor( + private val context: Context, + private val lifecycle: Lifecycle, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +) : ImageLoader { - override suspend fun invoke(uri: Uri): Bitmap? = - suspendCancellableCoroutine { continuation -> - val callback = java.util.function.Consumer { bitmap -> - try { - continuation.resumeWith(Result.success(bitmap)) - } catch (ignored: Exception) { - } + override suspend fun invoke(uri: Uri): Bitmap? = loadImageAsync(uri) + + override fun loadImage(uri: Uri, callback: Consumer) { + lifecycle.coroutineScope.launch { + val image = loadImageAsync(uri) + if (isActive) { + callback.accept(image) } - previewCoordinator.loadImage(uri, callback) } + } + + private suspend fun loadImageAsync(uri: Uri): Bitmap? { + val size = context.resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen) + return withContext(dispatcher) { + runCatching { + context.contentResolver.loadThumbnail(uri, Size(size, size), null) + }.getOrNull() + } + } } diff --git a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt index a37ef954..c61c7c72 100644 --- a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt @@ -35,7 +35,7 @@ import kotlinx.coroutines.launch import java.util.function.Consumer import com.android.internal.R as IntR -typealias ImageLoader = suspend (Uri) -> Bitmap? +private typealias ImageLoader = suspend (Uri) -> Bitmap? private const val IMAGE_FADE_IN_MILLIS = 150L diff --git a/java/tests/Android.bp b/java/tests/Android.bp index 2913d128..a62c52e6 100644 --- a/java/tests/Android.bp +++ b/java/tests/Android.bp @@ -23,9 +23,13 @@ android_test { "androidx.test.ext.junit", "mockito-target-minus-junit4", "androidx.test.espresso.core", + "androidx.lifecycle_lifecycle-common-java8", + "androidx.lifecycle_lifecycle-extensions", + "androidx.lifecycle_lifecycle-runtime-ktx", "truth-prebuilt", "testables", "testng", + "kotlinx_coroutines_test", ], test_suites: ["general-tests"], sdk_version: "core_platform", diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 97de97f5..6bc5e12a 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -30,10 +30,8 @@ import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.res.Resources; import android.database.Cursor; -import android.graphics.Bitmap; import android.net.Uri; import android.os.UserHandle; -import android.util.Size; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; @@ -208,11 +206,10 @@ public class ChooserWrapperActivity } @Override - protected Bitmap loadThumbnail(Uri uri, Size size) { - if (sOverrides.previewThumbnail != null) { - return sOverrides.previewThumbnail; - } - return super.loadThumbnail(uri, size); + protected ImageLoader createPreviewImageLoader() { + return new TestPreviewImageLoader( + super.createPreviewImageLoader(), + () -> sOverrides.previewThumbnail); } @Override diff --git a/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt b/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt new file mode 100644 index 00000000..ffe89400 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt @@ -0,0 +1,111 @@ +/* + * 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 + +import android.content.res.Resources +import android.view.View +import android.view.Window +import androidx.activity.ComponentActivity +import androidx.lifecycle.Lifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify + +private const val TIMEOUT_MS = 200 + +@OptIn(ExperimentalCoroutinesApi::class) +class EnterTransitionAnimationDelegateTest { + private val scheduler = TestCoroutineScheduler() + private val dispatcher = StandardTestDispatcher(scheduler) + private val lifecycleOwner = TestLifecycleOwner() + + private val transitionTargetView = mock { + // avoid the request-layout path in the delegate + whenever(isInLayout).thenReturn(true) + } + + private val windowMock = mock() + private val resourcesMock = mock { + whenever(getInteger(anyInt())).thenReturn(TIMEOUT_MS) + } + private val activity = mock { + whenever(lifecycle).thenReturn(lifecycleOwner.lifecycle) + whenever(resources).thenReturn(resourcesMock) + whenever(isActivityTransitionRunning).thenReturn(true) + whenever(window).thenReturn(windowMock) + } + + private val testSubject = EnterTransitionAnimationDelegate(activity) { + transitionTargetView + } + + @Before + fun setup() { + Dispatchers.setMain(dispatcher) + lifecycleOwner.state = Lifecycle.State.CREATED + } + + @After + fun cleanup() { + lifecycleOwner.state = Lifecycle.State.DESTROYED + Dispatchers.resetMain() + } + + @Test + fun test_postponeTransition_timeout() { + testSubject.postponeTransition() + testSubject.markOffsetCalculated() + + scheduler.advanceTimeBy(TIMEOUT_MS + 1L) + verify(activity, times(1)).startPostponedEnterTransition() + verify(windowMock, never()).setWindowAnimations(anyInt()) + } + + @Test + fun test_postponeTransition_animation_resumes_only_once() { + testSubject.postponeTransition() + testSubject.markOffsetCalculated() + testSubject.markImagePreviewReady(true) + testSubject.markOffsetCalculated() + testSubject.markImagePreviewReady(true) + + scheduler.advanceTimeBy(TIMEOUT_MS + 1L) + verify(activity, times(1)).startPostponedEnterTransition() + } + + @Test + fun test_postponeTransition_resume_animation_conditions() { + testSubject.postponeTransition() + verify(activity, never()).startPostponedEnterTransition() + + testSubject.markOffsetCalculated() + verify(activity, never()).startPostponedEnterTransition() + + testSubject.markImagePreviewReady(true) + verify(activity, times(1)).startPostponedEnterTransition() + } +} diff --git a/java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt b/java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt new file mode 100644 index 00000000..f47e343f --- /dev/null +++ b/java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt @@ -0,0 +1,33 @@ +/* + * 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 + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry + +internal class TestLifecycleOwner : LifecycleOwner { + private val lifecycleRegistry = LifecycleRegistry.createUnsafe(this) + + override fun getLifecycle(): Lifecycle = lifecycleRegistry + + var state: Lifecycle.State + get() = lifecycle.currentState + set(value) { + lifecycleRegistry.currentState = value + } +} \ No newline at end of file diff --git a/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt b/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt new file mode 100644 index 00000000..fd617fdd --- /dev/null +++ b/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2022 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 + +import android.graphics.Bitmap +import android.net.Uri +import java.util.function.Consumer + +internal class TestPreviewImageLoader( + private val imageLoader: ImageLoader, + private val imageOverride: () -> Bitmap? +) : ImageLoader { + override fun loadImage(uri: Uri, callback: Consumer) { + val override = imageOverride() + if (override != null) { + callback.accept(override) + } else { + imageLoader.loadImage(uri, callback) + } + } + + override suspend fun invoke(uri: Uri): Bitmap? = imageOverride() ?: imageLoader(uri) +} -- cgit v1.2.3-59-g8ed1b