summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author TreeHugger Robot <treehugger-gerrit@google.com> 2023-01-04 01:43:43 +0000
committer Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com> 2023-01-04 01:43:43 +0000
commitc06129bb98ae8657ff68173dfdbe3a87532e93c7 (patch)
tree3be2dc0510b6a016aabcf366c009c879fbd30d73
parentd6b6abd031b8ef3a0fc76c3584128139232d5f93 (diff)
parent50e07cf4d4589560d38a83c93b6d4ff64181c6ee (diff)
Merge "Introduce an image preview view" into tm-qpr-dev am: 50e07cf4d4
Original change: https://googleplex-android-review.googlesource.com/c/platform/packages/modules/IntentResolver/+/20822634 Change-Id: I9c10ffd7c9983a0352b6a730ec82789e049b6f27 Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
-rw-r--r--Android.bp6
-rw-r--r--java/res/layout/chooser_grid_preview_image.xml53
-rw-r--r--java/res/layout/image_preview_view.xml72
-rw-r--r--java/src/com/android/intentresolver/ChooserActivity.java28
-rw-r--r--java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java90
-rw-r--r--java/src/com/android/intentresolver/ChooserContentPreviewUi.java113
-rw-r--r--java/src/com/android/intentresolver/ImagePreviewImageLoader.kt38
-rw-r--r--java/src/com/android/intentresolver/widget/ImagePreviewView.kt178
-rw-r--r--java/src/com/android/intentresolver/widget/RoundedRectImageView.java1
9 files changed, 384 insertions, 195 deletions
diff --git a/Android.bp b/Android.bp
index 047820e9..124a4b87 100644
--- a/Android.bp
+++ b/Android.bp
@@ -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