diff options
author | 2022-11-09 16:15:56 -0500 | |
---|---|---|
committer | 2022-11-10 21:54:03 +0000 | |
commit | e2463f3c11eeeb58ea3966b7872201c47a688bbb (patch) | |
tree | 46875695c6a960b1d3367d072c1bde2f8bbcad47 | |
parent | 4aae5d5a320e63b8cbecc82e904eaff238319b7f (diff) |
Clarify/simplify ContentPreviewCoordinator lifecycle
This is *not* an obviously-safe "pure" refactor; there are a few
notable logic changes(*), but they should cause no observable
behavior changes in practice.
The simplified lifecycle (deferred assignment -> pre-initialization)
shows that this component has essential responsibilities to
`ChooserActivity` in ensuring that asynchronous tasks are shut down
when the activity is destroyed. Minor refactoring in this CL shows
that the component is otherwise injectable as a delegate in our
preview-loading "factories," to be extracted in another upcoming
cleanup CL; a new (temporarily-homed) interface in this CL makes
that delegation API explicit. I extracted the implementation to
an outer class to chip away at the `ChooserActivity` monolith; to
draw attention to the coordinator's business-logic responsibilities
in defining success/failure conditions (in addition to the UI
responsibilities that ayepin@ suggests could be separated from the
rest of the coordinator component); and to provide a clearer line
to cut away if we (hopefully) eventually decide to move off of this
particular processing model altogether. For more discussion see
comments on ag/20390247, summarized below.
* [Logic changes]
1. We now guarantee at most one `ContentPreviewCoordinator` instance.
This is unlikely to differ from the earlier behavior, but we
would've had no checks before a potential re-assignment. If one
were to occur, we would've lost track of any pending tasks that
the previous instance(s) were responsible for cancelling. (By
comparison, after this CL, multiple instances would instead
queue their requests in a shared coordinator and influence each
other's "batch" timeout logic -- it's debatable whether that's
correct, but it's ultimately insignificant either way).
2. Even if we never re-assigned any extra coordinator instances,
the model prior to this CL was effectively "lazy initialization"
of the coordinator, but we now initialize a coordinator "eagerly"
to simplify the lifecycle. While the earlier model was technically
"lazy," it was still computed during the `onCreate()` flow, so
this doesn't make much difference -- except notably, we'll now
initialize a coordinator for every Sharesheet session, even if we
don't end up building a preview UI. The coordinator class is
lightweight if it's never used, so this doesn't seem like a
problem.
3. The `findViewById()` queries in `ContentPreviewCoordinator` now
have a broader root for their search so that they can work for
both kinds of previews ("sticky" and "list item"), and we can
share the one eagerly-initialized instance. We can always change
the API if we need more specificity later, but for now it seems
like we can make this change with no repercussions for our app
behavior. For more detail see ag/20390247, but essentially:
a. The IDs of the views we search for are explicitly named for
the purpose of content previews and won't plausibly be used
for anything else. Thus,
b. The broadened queries could only be ambiguous if we were to
display more than one content preview in our hierarchy. But:
c. We show at most one content preview: either the "sticky"
preview in the `ChooserActivity` root layout, or the "list
item" preview that's built into the list *when we have only
one profile to show*, and never both (gated on the result
of `shouldShowTabs()`).
Test: atest IntentResolverUnitTests
Bug: 202167050
Change-Id: I0dd6e48ee92845ce68d6dcf8e84e272b11caf496
-rw-r--r-- | Android.bp | 2 | ||||
-rw-r--r-- | java/src/com/android/intentresolver/ChooserActivity.java | 275 | ||||
-rw-r--r-- | java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java | 179 |
3 files changed, 295 insertions, 161 deletions
@@ -46,10 +46,12 @@ android_library { static_libs: [ "androidx.annotation_annotation", + "androidx.concurrent_concurrent-futures", "androidx.recyclerview_recyclerview", "androidx.viewpager_viewpager", "androidx.lifecycle_lifecycle-common-java8", "androidx.lifecycle_lifecycle-extensions", + "guava", ], plugins: ["java_api_finder"], diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 3eb30f57..d954104e 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -66,7 +66,6 @@ import android.os.AsyncTask; import android.os.Bundle; import android.os.Environment; import android.os.Handler; -import android.os.Message; import android.os.Parcelable; import android.os.PatternMatcher; import android.os.ResultReceiver; @@ -143,7 +142,9 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.function.Consumer; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.function.Supplier; /** @@ -281,7 +282,11 @@ public class ChooserActivity extends ResolverActivity implements protected static final int CONTENT_PREVIEW_TEXT = 3; protected MetricsLogger mMetricsLogger; - private ContentPreviewCoordinator mPreviewCoord; + private final ExecutorService mBackgroundThreadPoolExecutor = Executors.newFixedThreadPool(5); + + @Nullable + private ChooserContentPreviewCoordinator mPreviewCoord; + private int mScrollStatus = SCROLL_STATUS_IDLE; @VisibleForTesting @@ -297,118 +302,28 @@ public class ChooserActivity extends ResolverActivity implements new ShortcutToChooserTargetConverter(); private final SparseArray<ProfileRecord> mProfileRecords = new SparseArray<>(); - private static class ContentPreviewCoordinator { - - /* public */ ContentPreviewCoordinator( - ChooserActivity chooserActivity, - View parentView, - Runnable onFailCallback, - Consumer<View> onSingleImageSuccessCallback) { - this.mChooserActivity = chooserActivity; - this.mParentView = parentView; - this.mOnFailCallback = onFailCallback; - this.mOnSingleImageSuccessCallback = onSingleImageSuccessCallback; - - this.mImageLoadTimeoutMillis = - chooserActivity.getResources().getInteger(R.integer.config_shortAnimTime); - } - - public void cancelLoads() { - mHandler.removeMessages(IMAGE_LOAD_INTO_VIEW); - mHandler.removeMessages(IMAGE_LOAD_TIMEOUT); - } - - private static final int IMAGE_FADE_IN_MILLIS = 150; - private static final int IMAGE_LOAD_TIMEOUT = 1; - private static final int IMAGE_LOAD_INTO_VIEW = 2; - - private final ChooserActivity mChooserActivity; - private final View mParentView; - private final Runnable mOnFailCallback; - private final Consumer<View> mOnSingleImageSuccessCallback; - private final int mImageLoadTimeoutMillis; - - private boolean mAtLeastOneLoaded = false; - - private final Handler mHandler = new Handler() { - @Override - public void handleMessage(Message msg) { - if (mChooserActivity.isFinishing()) { - return; - } - - if (msg.what == IMAGE_LOAD_TIMEOUT) { - // If at least one image loads within the timeout period, allow other loads to - // continue. (I.e., only fail if no images have loaded by the timeout event.) - if (!mAtLeastOneLoaded) { - mOnFailCallback.run(); - } - return; - } - - // TODO: switch off using `Handler`. For now the following conditions implicitly - // rely on the knowledge that we only have two message types (and so after the guard - // clause above, we know this is an `IMAGE_LOAD_INTO_VIEW` message). - - RoundedRectImageView imageView = mParentView.findViewById(msg.arg1); - if (msg.obj != null) { - onImageLoaded((Bitmap) msg.obj, imageView, msg.arg2); - } else { - imageView.setVisibility(View.GONE); - if (!mAtLeastOneLoaded) { - // TODO: this looks like a race condition. We 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 - // `AsyncTask.THREAD_POOL_EXECUTOR` (i.e., in parallel). One option we might - // prefer for more deterministic behavior: don't signal the failure callback - // on a single-image load unless there are no other loads currently pending. - mOnFailCallback.run(); - } - } - } - }; - - private void onImageLoaded( - @NonNull Bitmap image, - RoundedRectImageView imageView, - int extraImageCount) { - mAtLeastOneLoaded = true; - - 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); - } - - private void loadUriIntoView( - final int imageViewResourceId, final Uri uri, final int extraImages) { - mHandler.sendEmptyMessageDelayed(IMAGE_LOAD_TIMEOUT, mImageLoadTimeoutMillis); - - AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> { - int size = mChooserActivity.getResources().getDimensionPixelSize( - R.dimen.chooser_preview_image_max_dimen); - final Bitmap bmp = mChooserActivity.loadThumbnail(uri, new Size(size, size)); - final Message msg = mHandler.obtainMessage( - IMAGE_LOAD_INTO_VIEW, imageViewResourceId, extraImages, bmp); - mHandler.sendMessage(msg); - }); - } + /** + * Delegate to handle background resource loads that are dependencies of content previews. + * + * TODO: move to an inner class of the (to-be-created) new component for content previews. + */ + public interface ContentPreviewCoordinator { + /** + * 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); } private void setupPreDrawForSharedElementTransition(View v) { @@ -565,6 +480,13 @@ public class ChooserActivity extends ResolverActivity implements this, target.getStringExtra(Intent.EXTRA_TEXT), getTargetIntentFilter(target))); + + mPreviewCoord = new ChooserContentPreviewCoordinator( + mBackgroundThreadPoolExecutor, + this, + this::hideContentPreview, + this::setupPreDrawForSharedElementTransition); + super.onCreate(savedInstanceState, target, title, defaultTitleRes, initialIntents, null, false); @@ -973,10 +895,12 @@ 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) { + protected ViewGroup createContentPreviewView( + ViewGroup parent, ContentPreviewCoordinator previewCoord) { Intent targetIntent = getTargetIntent(); int previewType = findPreferredContentPreview(targetIntent, getContentResolver()); - return displayContentPreview(previewType, targetIntent, getLayoutInflater(), parent); + return displayContentPreview( + previewType, targetIntent, getLayoutInflater(), parent, previewCoord); } @VisibleForTesting @@ -1182,19 +1106,26 @@ public class ChooserActivity extends ResolverActivity implements parent.addView(b, lp); } - private ViewGroup displayContentPreview(@ContentPreviewType int previewType, - Intent targetIntent, LayoutInflater layoutInflater, ViewGroup parent) { + private ViewGroup displayContentPreview( + @ContentPreviewType int previewType, + Intent targetIntent, + LayoutInflater layoutInflater, + ViewGroup parent, + ContentPreviewCoordinator previewCoord) { ViewGroup layout = null; switch (previewType) { case CONTENT_PREVIEW_TEXT: - layout = displayTextContentPreview(targetIntent, layoutInflater, parent); + layout = displayTextContentPreview( + targetIntent, layoutInflater, parent, previewCoord); break; case CONTENT_PREVIEW_IMAGE: - layout = displayImageContentPreview(targetIntent, layoutInflater, parent); + layout = displayImageContentPreview( + targetIntent, layoutInflater, parent, previewCoord); break; case CONTENT_PREVIEW_FILE: - layout = displayFileContentPreview(targetIntent, layoutInflater, parent); + layout = displayFileContentPreview( + targetIntent, layoutInflater, parent, previewCoord); break; default: Log.e(TAG, "Unexpected content preview type: " + previewType); @@ -1210,8 +1141,11 @@ public class ChooserActivity extends ResolverActivity implements return layout; } - private ViewGroup displayTextContentPreview(Intent targetIntent, LayoutInflater layoutInflater, - ViewGroup parent) { + private ViewGroup displayTextContentPreview( + Intent targetIntent, + LayoutInflater layoutInflater, + ViewGroup parent, + ContentPreviewCoordinator previewCoord) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_text, parent, false); @@ -1252,20 +1186,22 @@ public class ChooserActivity extends ResolverActivity implements if (previewThumbnail == null) { previewThumbnailView.setVisibility(View.GONE); } else { - mPreviewCoord = new ContentPreviewCoordinator( - this, - contentPreviewLayout, - this::hideContentPreview, - this::setupPreDrawForSharedElementTransition); - mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_thumbnail, previewThumbnail, 0); + previewCoord.loadUriIntoView( + () -> contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_thumbnail), + previewThumbnail, + 0); } } return contentPreviewLayout; } - private ViewGroup displayImageContentPreview(Intent targetIntent, LayoutInflater layoutInflater, - ViewGroup parent) { + private ViewGroup displayImageContentPreview( + Intent targetIntent, + LayoutInflater layoutInflater, + ViewGroup parent, + ContentPreviewCoordinator previewCoord) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_image, parent, false); ViewGroup imagePreview = contentPreviewLayout.findViewById(com.android.internal.R.id.content_preview_image_area); @@ -1276,18 +1212,16 @@ public class ChooserActivity extends ResolverActivity implements addActionButton(actionRow, createNearbyButton(targetIntent)); addActionButton(actionRow, createEditButton(targetIntent)); - mPreviewCoord = new ContentPreviewCoordinator( - this, - contentPreviewLayout, - this::hideContentPreview, - this::setupPreDrawForSharedElementTransition); - String action = targetIntent.getAction(); if (Intent.ACTION_SEND.equals(action)) { 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); - mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_1_large, uri, 0); + previewCoord.loadUriIntoView( + () -> contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_image_1_large), + uri, + 0); } else { ContentResolver resolver = getContentResolver(); @@ -1308,16 +1242,29 @@ public class ChooserActivity extends ResolverActivity implements imagePreview.findViewById(com.android.internal.R.id.content_preview_image_1_large) .setTransitionName(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME); - mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_1_large, imageUris.get(0), 0); + previewCoord.loadUriIntoView( + () -> contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_image_1_large), + imageUris.get(0), + 0); if (imageUris.size() == 2) { - mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_2_large, - imageUris.get(1), 0); + previewCoord.loadUriIntoView( + () -> contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_image_2_large), + imageUris.get(1), + 0); } else if (imageUris.size() > 2) { - mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_2_small, - imageUris.get(1), 0); - mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_3_small, - imageUris.get(2), imageUris.size() - 3); + 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); } } @@ -1388,9 +1335,11 @@ public class ChooserActivity extends ResolverActivity implements + "documentation"); } - private ViewGroup displayFileContentPreview(Intent targetIntent, LayoutInflater layoutInflater, - ViewGroup parent) { - + private ViewGroup displayFileContentPreview( + Intent targetIntent, + LayoutInflater layoutInflater, + ViewGroup parent, + ContentPreviewCoordinator previewCoord) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_file, parent, false); @@ -1402,7 +1351,7 @@ public class ChooserActivity extends ResolverActivity implements String action = targetIntent.getAction(); if (Intent.ACTION_SEND.equals(action)) { Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - loadFileUriIntoView(uri, contentPreviewLayout); + loadFileUriIntoView(uri, contentPreviewLayout, previewCoord); } else { List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); int uriCount = uris.size(); @@ -1414,7 +1363,7 @@ public class ChooserActivity extends ResolverActivity implements + "preview area"); return contentPreviewLayout; } else if (uriCount == 1) { - loadFileUriIntoView(uris.get(0), contentPreviewLayout); + loadFileUriIntoView(uris.get(0), contentPreviewLayout, previewCoord); } else { FileInfo fileInfo = extractFileInfo(uris.get(0), getContentResolver()); int remUriCount = uriCount - 1; @@ -1444,19 +1393,19 @@ public class ChooserActivity extends ResolverActivity implements return contentPreviewLayout; } - private void loadFileUriIntoView(final Uri uri, final View parent) { + private void loadFileUriIntoView( + final Uri uri, final View parent, final ContentPreviewCoordinator previewCoord) { FileInfo fileInfo = extractFileInfo(uri, getContentResolver()); TextView fileNameView = parent.findViewById(com.android.internal.R.id.content_preview_filename); fileNameView.setText(fileInfo.name); if (fileInfo.hasThumbnail) { - mPreviewCoord = new ContentPreviewCoordinator( - this, - parent, - this::hideContentPreview, - this::setupPreDrawForSharedElementTransition); - mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_file_thumbnail, uri, 0); + previewCoord.loadUriIntoView( + () -> parent.findViewById( + com.android.internal.R.id.content_preview_file_thumbnail), + uri, + 0); } else { View thumbnailView = parent.findViewById(com.android.internal.R.id.content_preview_file_thumbnail); thumbnailView.setVisibility(View.GONE); @@ -1544,7 +1493,7 @@ public class ChooserActivity extends ResolverActivity implements mRefinementResultReceiver = null; } - if (mPreviewCoord != null) mPreviewCoord.cancelLoads(); + mBackgroundThreadPoolExecutor.shutdownNow(); destroyProfileRecords(); } @@ -2675,7 +2624,8 @@ public class ChooserActivity extends ResolverActivity implements // then always preload it to avoid subsequent resizing of the share sheet. ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); if (contentPreviewContainer.getChildCount() == 0) { - ViewGroup contentPreviewView = createContentPreviewView(contentPreviewContainer); + ViewGroup contentPreviewView = + createContentPreviewView(contentPreviewContainer, mPreviewCoord); contentPreviewContainer.addView(contentPreviewView); } } @@ -3031,7 +2981,10 @@ public class ChooserActivity extends ResolverActivity implements public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { switch (viewType) { case VIEW_TYPE_CONTENT_PREVIEW: - return new ItemViewHolder(createContentPreviewView(parent), false, viewType); + return new ItemViewHolder( + createContentPreviewView(parent, mPreviewCoord), + false, + viewType); case VIEW_TYPE_PROFILE: return new ItemViewHolder(createProfileView(parent), false, viewType); case VIEW_TYPE_AZ_LABEL: diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java new file mode 100644 index 00000000..509f8884 --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java @@ -0,0 +1,179 @@ +/* + * 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.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; + +/** + * 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 ChooserActivity.ContentPreviewCoordinator { + public ChooserContentPreviewCoordinator( + ExecutorService backgroundExecutor, + ChooserActivity chooserActivity, + Runnable onFailCallback, + Consumer<View> onSingleImageSuccessCallback) { + 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) { + final int size = mChooserActivity.getResources().getDimensionPixelSize( + R.dimen.chooser_preview_image_max_dimen); + + mHandler.postDelayed(this::onWatchdogTimeout, mImageLoadTimeoutMillis); + + ListenableFuture<Bitmap> bitmapFuture = mBackgroundExecutor.submit( + () -> mChooserActivity.loadThumbnail(imageUri, new Size(size, size))); + + Futures.addCallback( + bitmapFuture, + new FutureCallback<Bitmap>() { + @Override + public void onSuccess(Bitmap loadedBitmap) { + try { + onLoadCompleted( + deferredImageViewProvider.call(), + loadedBitmap, + extraImageCount); + } catch (Exception e) { /* unimportant */ } + } + + @Override + public void onFailure(Throwable t) {} + }, + 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 + // `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 RoundedRectImageView imageView, + @Nullable Bitmap loadedBitmap, + int extraImageCount) { + 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. + 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); + } +} |