diff options
9 files changed, 384 insertions, 195 deletions
@@ -51,6 +51,12 @@ android_library { "androidx.viewpager_viewpager", "androidx.lifecycle_lifecycle-common-java8", "androidx.lifecycle_lifecycle-extensions", + "androidx.lifecycle_lifecycle-runtime-ktx", + "androidx.lifecycle_lifecycle-viewmodel-ktx", + "kotlin-stdlib", + "kotlinx_coroutines", + "kotlinx-coroutines-android", + "//external/kotlinc:kotlin-annotations", "guava", ], diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml index 9d1dc208..5c324140 100644 --- a/java/res/layout/chooser_grid_preview_image.xml +++ b/java/res/layout/chooser_grid_preview_image.xml @@ -24,61 +24,14 @@ android:layout_height="wrap_content" android:orientation="vertical" android:background="?android:attr/colorBackground"> - <RelativeLayout + + <com.android.intentresolver.widget.ImagePreviewView android:id="@androidprv:id/content_preview_image_area" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:paddingBottom="@dimen/chooser_view_spacing" - android:background="?android:attr/colorBackground"> - - <com.android.intentresolver.widget.RoundedRectImageView - android:id="@androidprv:id/content_preview_image_1_large" - android:layout_width="120dp" - android:layout_height="104dp" - android:layout_alignParentTop="true" - android:adjustViewBounds="true" - android:gravity="center" - android:scaleType="centerCrop"/> - - <com.android.intentresolver.widget.RoundedRectImageView - android:id="@androidprv:id/content_preview_image_2_large" - android:visibility="gone" - android:layout_width="120dp" - android:layout_height="104dp" - android:layout_alignParentTop="true" - android:layout_toRightOf="@androidprv:id/content_preview_image_1_large" - android:layout_marginLeft="10dp" - android:adjustViewBounds="true" - android:gravity="center" - android:scaleType="centerCrop"/> - - <com.android.intentresolver.widget.RoundedRectImageView - android:id="@androidprv:id/content_preview_image_2_small" - android:visibility="gone" - android:layout_width="120dp" - android:layout_height="65dp" - android:layout_alignParentTop="true" - android:layout_toRightOf="@androidprv:id/content_preview_image_1_large" - android:layout_marginLeft="10dp" - android:adjustViewBounds="true" - android:gravity="center" - android:scaleType="centerCrop"/> - - <com.android.intentresolver.widget.RoundedRectImageView - android:id="@androidprv:id/content_preview_image_3_small" - android:visibility="gone" - android:layout_width="120dp" - android:layout_height="65dp" - android:layout_below="@androidprv:id/content_preview_image_2_small" - android:layout_toRightOf="@androidprv:id/content_preview_image_1_large" - android:layout_marginLeft="10dp" - android:layout_marginTop="10dp" - android:adjustViewBounds="true" - android:gravity="center" - android:scaleType="centerCrop"/> - - </RelativeLayout> + android:background="?android:attr/colorBackground" /> <ViewStub android:id="@+id/action_row_stub" diff --git a/java/res/layout/image_preview_view.xml b/java/res/layout/image_preview_view.xml new file mode 100644 index 00000000..d2f94690 --- /dev/null +++ b/java/res/layout/image_preview_view.xml @@ -0,0 +1,72 @@ +<!-- + ~ 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. + --> + +<merge + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:paddingBottom="@dimen/chooser_view_spacing" + android:background="?android:attr/colorBackground"> + + <com.android.intentresolver.widget.RoundedRectImageView + android:id="@androidprv:id/content_preview_image_1_large" + android:layout_width="120dp" + android:layout_height="104dp" + android:layout_alignParentTop="true" + android:adjustViewBounds="true" + android:gravity="center" + android:scaleType="centerCrop"/> + + <com.android.intentresolver.widget.RoundedRectImageView + android:id="@androidprv:id/content_preview_image_2_large" + android:visibility="gone" + android:layout_width="120dp" + android:layout_height="104dp" + android:layout_alignParentTop="true" + android:layout_toRightOf="@androidprv:id/content_preview_image_1_large" + android:layout_marginLeft="10dp" + android:adjustViewBounds="true" + android:gravity="center" + android:scaleType="centerCrop"/> + + <com.android.intentresolver.widget.RoundedRectImageView + android:id="@androidprv:id/content_preview_image_2_small" + android:visibility="gone" + android:layout_width="120dp" + android:layout_height="65dp" + android:layout_alignParentTop="true" + android:layout_toRightOf="@androidprv:id/content_preview_image_1_large" + android:layout_marginLeft="10dp" + android:adjustViewBounds="true" + android:gravity="center" + android:scaleType="centerCrop"/> + + <com.android.intentresolver.widget.RoundedRectImageView + android:id="@androidprv:id/content_preview_image_3_small" + android:visibility="gone" + android:layout_width="120dp" + android:layout_height="65dp" + android:layout_below="@androidprv:id/content_preview_image_2_small" + android:layout_toRightOf="@androidprv:id/content_preview_image_1_large" + android:layout_marginLeft="10dp" + android:layout_marginTop="10dp" + android:adjustViewBounds="true" + android:gravity="center" + android:scaleType="centerCrop"/> + +</merge> diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 83cdc9ef..df71c7ff 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -259,23 +259,17 @@ public class ChooserActivity extends ResolverActivity implements public ChooserActivity() {} - private void setupPreDrawForSharedElementTransition(View v) { - v.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { - @Override - public boolean onPreDraw() { - v.getViewTreeObserver().removeOnPreDrawListener(this); - - if (!mRemoveSharedElements && isActivityTransitionRunning()) { - // Disable the window animations as it interferes with the transition animation. - getWindow().setWindowAnimations(0); - } - mEnterTransitionAnimationDelegate.markImagePreviewReady(); - return true; - } - }); + private void onSharedElementTransitionTargetReady(boolean runTransitionAnimation) { + if (runTransitionAnimation && !mRemoveSharedElements && isActivityTransitionRunning()) { + // Disable the window animations as it interferes with the transition animation. + getWindow().setWindowAnimations(0); + mEnterTransitionAnimationDelegate.markImagePreviewReady(); + } else { + onSharedElementTransitionTargetMissing(); + } } - private void hideContentPreview() { + private void onSharedElementTransitionTargetMissing() { mRemoveSharedElements = true; mEnterTransitionAnimationDelegate.markImagePreviewReady(); } @@ -318,8 +312,7 @@ public class ChooserActivity extends ResolverActivity implements mPreviewCoordinator = new ChooserContentPreviewCoordinator( mBackgroundThreadPoolExecutor, this, - this::hideContentPreview, - this::setupPreDrawForSharedElementTransition); + this::onSharedElementTransitionTargetMissing); super.onCreate( savedInstanceState, @@ -779,6 +772,7 @@ public class ChooserActivity extends ResolverActivity implements R.layout.chooser_action_row, parent, previewCoordinator, + this::onSharedElementTransitionTargetReady, getContentResolver(), this::isImageType); diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java index fdc58170..93552e31 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java @@ -16,28 +16,20 @@ package com.android.intentresolver; -import android.animation.ObjectAnimator; -import android.animation.ValueAnimator; -import android.annotation.NonNull; import android.graphics.Bitmap; import android.net.Uri; import android.os.Handler; import android.util.Size; -import android.view.View; -import android.view.animation.DecelerateInterpolator; import androidx.annotation.MainThread; import androidx.annotation.Nullable; -import com.android.intentresolver.widget.RoundedRectImageView; - 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.Callable; import java.util.concurrent.ExecutorService; import java.util.function.Consumer; @@ -50,25 +42,23 @@ public class ChooserContentPreviewCoordinator implements public ChooserContentPreviewCoordinator( ExecutorService backgroundExecutor, ChooserActivity chooserActivity, - Runnable onFailCallback, - Consumer<View> onSingleImageSuccessCallback) { + Runnable onFailCallback) { this.mBackgroundExecutor = MoreExecutors.listeningDecorator(backgroundExecutor); this.mChooserActivity = chooserActivity; this.mOnFailCallback = onFailCallback; - this.mOnSingleImageSuccessCallback = onSingleImageSuccessCallback; this.mImageLoadTimeoutMillis = chooserActivity.getResources().getInteger(R.integer.config_shortAnimTime); } @Override - public void loadUriIntoView( - final Callable<RoundedRectImageView> deferredImageViewProvider, - final Uri imageUri, - final int extraImageCount) { + public void loadImage(final Uri imageUri, final Consumer<Bitmap> 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 + // ChooserActivity$EnterTransitionAnimationDelegate. mHandler.postDelayed(this::onWatchdogTimeout, mImageLoadTimeoutMillis); ListenableFuture<Bitmap> bitmapFuture = mBackgroundExecutor.submit( @@ -80,25 +70,22 @@ public class ChooserContentPreviewCoordinator implements @Override public void onSuccess(Bitmap loadedBitmap) { try { - onLoadCompleted( - deferredImageViewProvider.call(), - loadedBitmap, - extraImageCount); + callback.accept(loadedBitmap); + onLoadCompleted(loadedBitmap); } catch (Exception e) { /* unimportant */ } } @Override - public void onFailure(Throwable t) {} + public void onFailure(Throwable t) { + callback.accept(null); + } }, mHandler::post); } - private static final int IMAGE_FADE_IN_MILLIS = 150; - private final ChooserActivity mChooserActivity; private final ListeningExecutorService mBackgroundExecutor; private final Runnable mOnFailCallback; - private final Consumer<View> mOnSingleImageSuccessCallback; private final int mImageLoadTimeoutMillis; // TODO: this uses a `Handler` because there doesn't seem to be a straightforward way to get a @@ -121,60 +108,25 @@ public class ChooserContentPreviewCoordinator implements } @MainThread - private void onLoadCompleted( - @Nullable RoundedRectImageView imageView, - @Nullable Bitmap loadedBitmap, - int extraImageCount) { + private void onLoadCompleted(@Nullable Bitmap loadedBitmap) { if (mChooserActivity.isFinishing()) { return; } - // TODO: legacy logic didn't handle a possible null view; handle the same as other - // single-image failures for now (i.e., this is also a factor in the "race" TODO below). - boolean thisLoadSucceeded = (imageView != null) && (loadedBitmap != null); - mAtLeastOneLoaded |= thisLoadSucceeded; - - // TODO: this looks like a race condition. We may know that this specific image failed (i.e. - // it got a null Bitmap), but we'll only report that to the client (thereby failing out our - // pending loads) if we haven't yet succeeded in loading some other non-null Bitmap. But - // there could be other pending loads that would've returned non-null within the timeout - // window, except they end up (effectively) cancelled because this one single-image load - // "finished" (failed) faster. The outcome of that race may be fairly predictable (since we - // *might* imagine that the nulls would usually "load" faster?), but it's not guaranteed - // since the loads are queued in a thread pool (i.e., in parallel). One option for more - // deterministic behavior: don't signal the failure callback on a single-image load unless - // there are no other loads currently pending. + // 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 (thisLoadSucceeded) { - onImageLoadedSuccessfully(loadedBitmap, imageView, extraImageCount); - } else if (imageView != null) { - imageView.setVisibility(View.GONE); - } - if (wholeBatchFailed) { mOnFailCallback.run(); } } - - @MainThread - private void onImageLoadedSuccessfully( - @NonNull Bitmap image, - RoundedRectImageView imageView, - int extraImageCount) { - imageView.setVisibility(View.VISIBLE); - imageView.setAlpha(0.0f); - imageView.setImageBitmap(image); - - ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, 1.0f); - fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f)); - fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS); - fadeAnim.start(); - - if (extraImageCount > 0) { - imageView.setExtraImageCount(extraImageCount); - } - - mOnSingleImageSuccessCallback.accept(imageView); - } } diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java index 0cadce4b..ff88e5e1 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -18,12 +18,15 @@ package com.android.intentresolver; import static java.lang.annotation.RetentionPolicy.SOURCE; +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; import android.annotation.IntDef; import android.content.ClipData; import android.content.ContentResolver; import android.content.Intent; import android.content.res.Resources; import android.database.Cursor; +import android.graphics.Bitmap; import android.net.Uri; import android.provider.DocumentsContract; import android.provider.Downloads; @@ -35,6 +38,7 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewStub; +import android.view.animation.DecelerateInterpolator; import android.widget.ImageView; import android.widget.TextView; @@ -42,6 +46,7 @@ import androidx.annotation.LayoutRes; import androidx.annotation.Nullable; import com.android.intentresolver.widget.ActionRow; +import com.android.intentresolver.widget.ImagePreviewView; import com.android.intentresolver.widget.RoundedRectImageView; import com.android.internal.annotations.VisibleForTesting; @@ -50,7 +55,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.Callable; +import java.util.function.Consumer; /** * Collection of helpers for building the content preview UI displayed in {@link ChooserActivity}. @@ -64,6 +69,8 @@ import java.util.concurrent.Callable; * as ivars when this "class" is initialized. */ 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. */ @@ -71,19 +78,12 @@ public final class ChooserContentPreviewUi { /** * Request that an image be loaded in the background and set into a view. * - * @param viewProvider A delegate that will be called exactly once upon completion of the - * load, from the UI thread, to provide the {@link RoundedRectImageView} that should be - * populated with the result (if the load was successful) or hidden (if the load failed). If - * this returns null, the load is discarded as a failure. * @param imageUri The {@link Uri} of the image to load. - * @param extraImages The "extra image count" to set on the {@link RoundedRectImageView} - * if the image loads successfully. * * 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 loadUriIntoView( - Callable<RoundedRectImageView> viewProvider, Uri imageUri, int extraImages); + void loadImage(Uri imageUri, Consumer<Bitmap> callback); } /** @@ -182,6 +182,7 @@ public final class ChooserContentPreviewUi { @LayoutRes int actionRowLayout, ViewGroup parent, ContentPreviewCoordinator previewCoord, + Consumer<Boolean> onTransitionTargetReady, ContentResolver contentResolver, ImageMimeTypeClassifier imageClassifier) { ViewGroup layout = null; @@ -203,6 +204,7 @@ public final class ChooserContentPreviewUi { createImagePreviewActions(actionFactory), parent, previewCoord, + onTransitionTargetReady, contentResolver, imageClassifier, actionRowLayout); @@ -290,11 +292,12 @@ public final class ChooserContentPreviewUi { if (previewThumbnail == null) { previewThumbnailView.setVisibility(View.GONE); } else { - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_thumbnail), + previewCoord.loadImage( previewThumbnail, - 0); + (bitmap) -> updateViewWithImage( + contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_thumbnail), + bitmap)); } } @@ -317,12 +320,13 @@ public final class ChooserContentPreviewUi { List<ActionRow.Action> actions, ViewGroup parent, ContentPreviewCoordinator previewCoord, + Consumer<Boolean> onTransitionTargetReady, ContentResolver contentResolver, ImageMimeTypeClassifier imageClassifier, @LayoutRes int actionRowLayout) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_image, parent, false); - ViewGroup imagePreview = contentPreviewLayout.findViewById( + ImagePreviewView imagePreview = contentPreviewLayout.findViewById( com.android.internal.R.id.content_preview_image_area); final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); @@ -330,60 +334,35 @@ public final class ChooserContentPreviewUi { actionRow.setActions(actions); } + final ImagePreviewImageLoader imageLoader = new ImagePreviewImageLoader(previewCoord); + final ArrayList<Uri> imageUris = new ArrayList<>(); String action = targetIntent.getAction(); if (Intent.ACTION_SEND.equals(action)) { + // TODO: why don't we use image classifier in this case as well? Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - imagePreview.findViewById(com.android.internal.R.id.content_preview_image_1_large) - .setTransitionName(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME); - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_image_1_large), - uri, - 0); + imageUris.add(uri); } else { List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - List<Uri> imageUris = new ArrayList<>(); for (Uri uri : uris) { if (imageClassifier.isImageType(contentResolver.getType(uri))) { imageUris.add(uri); } } + } - if (imageUris.size() == 0) { - Log.i(TAG, "Attempted to display image preview area with zero" - + " available images detected in EXTRA_STREAM list"); - imagePreview.setVisibility(View.GONE); - return contentPreviewLayout; - } - - imagePreview.findViewById(com.android.internal.R.id.content_preview_image_1_large) - .setTransitionName(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME); - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_image_1_large), - imageUris.get(0), - 0); - - if (imageUris.size() == 2) { - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_image_2_large), - imageUris.get(1), - 0); - } else if (imageUris.size() > 2) { - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_image_2_small), - imageUris.get(1), - 0); - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_image_3_small), - imageUris.get(2), - imageUris.size() - 3); - } + if (imageUris.size() == 0) { + Log.i(TAG, "Attempted to display image preview area with zero" + + " available images detected in EXTRA_STREAM list"); + imagePreview.setVisibility(View.GONE); + onTransitionTargetReady.accept(false); + return contentPreviewLayout; } + imagePreview.setSharedElementTransitionTarget( + ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME, + onTransitionTargetReady); + imagePreview.setImages(imageUris, imageLoader); + return contentPreviewLayout; } @@ -503,11 +482,12 @@ public final class ChooserContentPreviewUi { fileNameView.setText(fileInfo.name); if (fileInfo.hasThumbnail) { - previewCoord.loadUriIntoView( - () -> parent.findViewById( - com.android.internal.R.id.content_preview_file_thumbnail), + previewCoord.loadImage( uri, - 0); + (bitmap) -> updateViewWithImage( + parent.findViewById( + com.android.internal.R.id.content_preview_file_thumbnail), + bitmap)); } else { View thumbnailView = parent.findViewById( com.android.internal.R.id.content_preview_file_thumbnail); @@ -520,6 +500,21 @@ public final class ChooserContentPreviewUi { } } + private static void updateViewWithImage(RoundedRectImageView imageView, Bitmap image) { + if (image == null) { + imageView.setVisibility(View.GONE); + return; + } + imageView.setVisibility(View.VISIBLE); + imageView.setAlpha(0.0f); + imageView.setImageBitmap(image); + + ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, 1.0f); + fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f)); + fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS); + fadeAnim.start(); + } + private static FileInfo extractFileInfo(Uri uri, ContentResolver resolver) { String fileName = null; boolean hasThumbnail = false; diff --git a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt new file mode 100644 index 00000000..e68eb66a --- /dev/null +++ b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt @@ -0,0 +1,38 @@ +/* + * 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 kotlinx.coroutines.suspendCancellableCoroutine + +// TODO: convert ChooserContentPreviewCoordinator to Kotlin and merge this class into it. +internal class ImagePreviewImageLoader( + private val previewCoordinator: ChooserContentPreviewUi.ContentPreviewCoordinator +) : suspend (Uri) -> Bitmap? { + + override suspend fun invoke(uri: Uri): Bitmap? = + suspendCancellableCoroutine { continuation -> + val callback = java.util.function.Consumer<Bitmap?> { bitmap -> + try { + continuation.resumeWith(Result.success(bitmap)) + } catch (ignored: Exception) { + } + } + previewCoordinator.loadImage(uri, callback) + } +} diff --git a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt new file mode 100644 index 00000000..a37ef954 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt @@ -0,0 +1,178 @@ +/* + * 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.widget + +import android.animation.ObjectAnimator +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewTreeObserver +import android.view.animation.DecelerateInterpolator +import android.widget.RelativeLayout +import androidx.core.view.isVisible +import com.android.intentresolver.R +import kotlinx.coroutines.Job +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import java.util.function.Consumer +import com.android.internal.R as IntR + +typealias ImageLoader = suspend (Uri) -> Bitmap? + +private const val IMAGE_FADE_IN_MILLIS = 150L + +class ImagePreviewView : RelativeLayout { + + constructor(context: Context) : this(context, null) + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + + constructor( + context: Context, attrs: AttributeSet?, defStyleAttr: Int + ) : this(context, attrs, defStyleAttr, 0) + + constructor( + context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int + ) : super(context, attrs, defStyleAttr, defStyleRes) + + private val coroutineScope = MainScope() + private lateinit var mainImage: RoundedRectImageView + private lateinit var secondLargeImage: RoundedRectImageView + private lateinit var secondSmallImage: RoundedRectImageView + private lateinit var thirdImage: RoundedRectImageView + + private var loadImageJob: Job? = null + private var onTransitionViewReadyCallback: Consumer<Boolean>? = null + + override fun onFinishInflate() { + LayoutInflater.from(context).inflate(R.layout.image_preview_view, this, true) + mainImage = requireViewById(IntR.id.content_preview_image_1_large) + secondLargeImage = requireViewById(IntR.id.content_preview_image_2_large) + secondSmallImage = requireViewById(IntR.id.content_preview_image_2_small) + thirdImage = requireViewById(IntR.id.content_preview_image_3_small) + } + + /** + * Specifies a transition animation target name and a readiness callback. The callback will be + * invoked once when the view preparation is done i.e. either when an image is loaded into it + * and it is laid out (and it is ready to be draw) or image loading has failed. + * Should be called before [setImages]. + * @param name, transition name + * @param onViewReady, a callback that will be invoked with `true` if the view is ready to + * receive transition animation (the image was loaded successfully) and with `false` otherwise. + */ + fun setSharedElementTransitionTarget(name: String, onViewReady: Consumer<Boolean>) { + mainImage.transitionName = name + onTransitionViewReadyCallback = onViewReady + } + + fun setImages(uris: List<Uri>, imageLoader: ImageLoader) { + loadImageJob?.cancel() + loadImageJob = coroutineScope.launch { + when (uris.size) { + 0 -> hideAllViews() + 1 -> showOneImage(uris, imageLoader) + 2 -> showTwoImages(uris, imageLoader) + else -> showThreeImages(uris, imageLoader) + } + } + } + + private fun hideAllViews() { + mainImage.isVisible = false + secondLargeImage.isVisible = false + secondSmallImage.isVisible = false + thirdImage.isVisible = false + invokeTransitionViewReadyCallback(runTransitionAnimation = false) + } + + private suspend fun showOneImage(uris: List<Uri>, imageLoader: ImageLoader) { + secondLargeImage.isVisible = false + secondSmallImage.isVisible = false + thirdImage.isVisible = false + showImages(uris, imageLoader, mainImage) + } + + private suspend fun showTwoImages(uris: List<Uri>, imageLoader: ImageLoader) { + secondSmallImage.isVisible = false + thirdImage.isVisible = false + showImages(uris, imageLoader, mainImage, secondLargeImage) + } + + private suspend fun showThreeImages(uris: List<Uri>, imageLoader: ImageLoader) { + secondLargeImage.isVisible = false + showImages(uris, imageLoader, mainImage, secondSmallImage, thirdImage) + thirdImage.setExtraImageCount(uris.size - 3) + } + + private suspend fun showImages( + uris: List<Uri>, imageLoader: ImageLoader, vararg views: RoundedRectImageView + ) = coroutineScope { + for (i in views.indices) { + launch { + loadImageIntoView(views[i], uris[i], imageLoader) + } + } + } + + private suspend fun loadImageIntoView( + view: RoundedRectImageView, uri: Uri, imageLoader: ImageLoader + ) { + val bitmap = runCatching { + imageLoader(uri) + }.getOrDefault(null) + if (bitmap == null) { + view.isVisible = false + if (view === mainImage) { + invokeTransitionViewReadyCallback(runTransitionAnimation = false) + } + } else { + view.isVisible = true + view.setImageBitmap(bitmap) + + view.alpha = 0f + ObjectAnimator.ofFloat(view, "alpha", 0.0f, 1.0f).apply { + interpolator = DecelerateInterpolator(1.0f) + duration = IMAGE_FADE_IN_MILLIS + start() + } + if (view === mainImage && onTransitionViewReadyCallback != null) { + setupPreDrawListener(mainImage) + } + } + } + + private fun setupPreDrawListener(view: View) { + view.viewTreeObserver.addOnPreDrawListener( + object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + view.viewTreeObserver.removeOnPreDrawListener(this) + invokeTransitionViewReadyCallback(runTransitionAnimation = true) + return true + } + } + ) + } + + private fun invokeTransitionViewReadyCallback(runTransitionAnimation: Boolean) { + onTransitionViewReadyCallback?.accept(runTransitionAnimation) + onTransitionViewReadyCallback = null + } +} diff --git a/java/src/com/android/intentresolver/widget/RoundedRectImageView.java b/java/src/com/android/intentresolver/widget/RoundedRectImageView.java index cf7bd543..8538041b 100644 --- a/java/src/com/android/intentresolver/widget/RoundedRectImageView.java +++ b/java/src/com/android/intentresolver/widget/RoundedRectImageView.java @@ -96,6 +96,7 @@ public class RoundedRectImageView extends ImageView { } else { this.mExtraImageCount = null; } + invalidate(); } @Override |