From 7f235677796d44d1d795404379a314cc48b3eb28 Mon Sep 17 00:00:00 2001 From: Miranda Kephart Date: Mon, 17 Apr 2023 16:35:35 -0400 Subject: [PATCH] Delay clipboard entrance animation until image loaded When an image is copied, we load the image in the background but start the entrance animation immediately. If it takes non-negligible time for the image to load, this effectively means we animate in a "blank" preview, then instantly replace it with the real image once it's loaded, which looks jarring. Instead, hold the animation until the image is loaded, with a timeout (currently 300ms). If we don't load the image within the timeout period, fall back to showing the "copied" preview (same as we do if we get an exception while loading the image). It's now possible for us to be "up" while not actually showing any UI (during that timeout period), so we also turn off tap-outside-to-dismiss until the animation starts. Bug: 277586289 Fix: 277586289 Test: atest Change-Id: Ie6015f712c8993d234bc2bc657c9a81bf549e6e6 --- .../clipboardoverlay/ClipboardImageLoader.kt | 60 +++++++++ .../ClipboardOverlayController.java | 122 +++++++++++++++--- .../ClipboardImageLoaderTest.kt | 83 ++++++++++++ .../ClipboardOverlayControllerTest.java | 68 ++++++++++ 4 files changed, 317 insertions(+), 16 deletions(-) create mode 100644 packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardImageLoader.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardImageLoaderTest.kt diff --git a/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardImageLoader.kt b/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardImageLoader.kt new file mode 100644 index 0000000000000..0542e13ba6da4 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardImageLoader.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.systemui.clipboardoverlay + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.util.Log +import android.util.Size +import com.android.systemui.R +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.dagger.qualifiers.Background +import java.io.IOException +import java.util.function.Consumer +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull + +class ClipboardImageLoader +@Inject +constructor( + private val context: Context, + @Background private val bgDispatcher: CoroutineDispatcher, + @Application private val mainScope: CoroutineScope +) { + private val TAG: String = "ClipboardImageLoader" + + suspend fun load(uri: Uri, timeoutMs: Long = 300) = + withTimeoutOrNull(timeoutMs) { + withContext(bgDispatcher) { + try { + val size = context.resources.getDimensionPixelSize(R.dimen.overlay_x_scale) + context.contentResolver.loadThumbnail(uri, Size(size, size * 4), null) + } catch (e: IOException) { + Log.e(TAG, "Thumbnail loading failed!", e) + null + } + } + } + + fun loadAsync(uri: Uri, callback: Consumer) { + mainScope.launch { callback.accept(load(uri)) } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayController.java b/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayController.java index 0aeab10101f68..757ebf45e9ade 100644 --- a/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayController.java +++ b/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayController.java @@ -32,6 +32,7 @@ import static com.android.systemui.clipboardoverlay.ClipboardOverlayEvent.CLIPBO import static com.android.systemui.clipboardoverlay.ClipboardOverlayEvent.CLIPBOARD_OVERLAY_SWIPE_DISMISSED; import static com.android.systemui.clipboardoverlay.ClipboardOverlayEvent.CLIPBOARD_OVERLAY_TAP_OUTSIDE; import static com.android.systemui.clipboardoverlay.ClipboardOverlayEvent.CLIPBOARD_OVERLAY_TIMED_OUT; +import static com.android.systemui.flags.Flags.CLIPBOARD_IMAGE_TIMEOUT; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; @@ -90,6 +91,7 @@ public class ClipboardOverlayController implements ClipboardListener.ClipboardOv private final ClipboardOverlayUtils mClipboardUtils; private final FeatureFlags mFeatureFlags; private final Executor mBgExecutor; + private final ClipboardImageLoader mClipboardImageLoader; private final ClipboardOverlayView mView; @@ -109,6 +111,7 @@ public class ClipboardOverlayController implements ClipboardListener.ClipboardOv private Runnable mOnUiUpdate; + private boolean mShowingUi; private boolean mIsMinimized; private ClipboardModel mClipboardModel; @@ -175,9 +178,11 @@ public class ClipboardOverlayController implements ClipboardListener.ClipboardOv FeatureFlags featureFlags, ClipboardOverlayUtils clipboardUtils, @Background Executor bgExecutor, + ClipboardImageLoader clipboardImageLoader, UiEventLogger uiEventLogger) { mContext = context; mBroadcastDispatcher = broadcastDispatcher; + mClipboardImageLoader = clipboardImageLoader; mClipboardLogger = new ClipboardLogger(uiEventLogger); @@ -260,21 +265,42 @@ public class ClipboardOverlayController implements ClipboardListener.ClipboardOv boolean shouldAnimate = !model.dataMatches(mClipboardModel) || wasExiting; mClipboardModel = model; mClipboardLogger.setClipSource(mClipboardModel.getSource()); - if (shouldAnimate) { - reset(); - mClipboardLogger.setClipSource(mClipboardModel.getSource()); - if (shouldShowMinimized(mWindow.getWindowInsets())) { - mClipboardLogger.logUnguarded(CLIPBOARD_OVERLAY_SHOWN_MINIMIZED); - mIsMinimized = true; - mView.setMinimized(true); - } else { - mClipboardLogger.logUnguarded(CLIPBOARD_OVERLAY_SHOWN_EXPANDED); + if (mFeatureFlags.isEnabled(CLIPBOARD_IMAGE_TIMEOUT)) { + if (shouldAnimate) { + reset(); + mClipboardLogger.setClipSource(mClipboardModel.getSource()); + if (shouldShowMinimized(mWindow.getWindowInsets())) { + mClipboardLogger.logUnguarded(CLIPBOARD_OVERLAY_SHOWN_MINIMIZED); + mIsMinimized = true; + mView.setMinimized(true); + } else { + mClipboardLogger.logUnguarded(CLIPBOARD_OVERLAY_SHOWN_EXPANDED); + setExpandedView(this::animateIn); + } + mView.announceForAccessibility( + getAccessibilityAnnouncement(mClipboardModel.getType())); + } else if (!mIsMinimized) { + setExpandedView(() -> { + }); + } + } else { + if (shouldAnimate) { + reset(); + mClipboardLogger.setClipSource(mClipboardModel.getSource()); + if (shouldShowMinimized(mWindow.getWindowInsets())) { + mClipboardLogger.logUnguarded(CLIPBOARD_OVERLAY_SHOWN_MINIMIZED); + mIsMinimized = true; + mView.setMinimized(true); + } else { + mClipboardLogger.logUnguarded(CLIPBOARD_OVERLAY_SHOWN_EXPANDED); + setExpandedView(); + animateIn(); + } + mView.announceForAccessibility( + getAccessibilityAnnouncement(mClipboardModel.getType())); + } else if (!mIsMinimized) { setExpandedView(); } - animateIn(); - mView.announceForAccessibility(getAccessibilityAnnouncement(mClipboardModel.getType())); - } else if (!mIsMinimized) { - setExpandedView(); } if (mClipboardModel.isRemote()) { mTimeoutHandler.cancelTimeout(); @@ -285,6 +311,58 @@ public class ClipboardOverlayController implements ClipboardListener.ClipboardOv } } + private void setExpandedView(Runnable onViewReady) { + final ClipboardModel model = mClipboardModel; + mView.setMinimized(false); + switch (model.getType()) { + case TEXT: + if (model.isRemote() || DeviceConfig.getBoolean( + DeviceConfig.NAMESPACE_SYSTEMUI, CLIPBOARD_OVERLAY_SHOW_ACTIONS, false)) { + if (model.getTextLinks() != null) { + classifyText(model); + } + } + if (model.isSensitive()) { + mView.showTextPreview(mContext.getString(R.string.clipboard_asterisks), true); + } else { + mView.showTextPreview(model.getText().toString(), false); + } + mView.setEditAccessibilityAction(true); + mOnPreviewTapped = this::editText; + onViewReady.run(); + break; + case IMAGE: + mView.setEditAccessibilityAction(true); + mOnPreviewTapped = () -> editImage(model.getUri()); + if (model.isSensitive()) { + mView.showImagePreview(null); + onViewReady.run(); + } else { + mClipboardImageLoader.loadAsync(model.getUri(), (bitmap) -> mView.post(() -> { + if (bitmap == null) { + mView.showDefaultTextPreview(); + } else { + mView.showImagePreview(bitmap); + } + onViewReady.run(); + })); + } + break; + case URI: + case OTHER: + mView.showDefaultTextPreview(); + onViewReady.run(); + break; + } + if (!model.isRemote()) { + maybeShowRemoteCopy(model.getClipData()); + } + if (model.getType() != ClipboardModel.Type.OTHER) { + mOnShareTapped = () -> shareContent(model.getClipData()); + mView.showShareChip(); + } + } + private void setExpandedView() { final ClipboardModel model = mClipboardModel; mView.setMinimized(false); @@ -350,8 +428,12 @@ public class ClipboardOverlayController implements ClipboardListener.ClipboardOv mClipboardLogger.logUnguarded(CLIPBOARD_OVERLAY_EXPANDED_FROM_MINIMIZED); mIsMinimized = false; } - setExpandedView(); - animateIn(); + if (mFeatureFlags.isEnabled(CLIPBOARD_IMAGE_TIMEOUT)) { + setExpandedView(() -> animateIn()); + } else { + setExpandedView(); + animateIn(); + } } }); mEnterAnimator.start(); @@ -412,7 +494,8 @@ public class ClipboardOverlayController implements ClipboardListener.ClipboardOv mInputMonitor.getInputChannel(), Looper.getMainLooper()) { @Override public void onInputEvent(InputEvent event) { - if (event instanceof MotionEvent) { + if ((!mFeatureFlags.isEnabled(CLIPBOARD_IMAGE_TIMEOUT) || mShowingUi) + && event instanceof MotionEvent) { MotionEvent motionEvent = (MotionEvent) event; if (motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { if (!mView.isInTouchRegion( @@ -451,6 +534,12 @@ public class ClipboardOverlayController implements ClipboardListener.ClipboardOv } mEnterAnimator = mView.getEnterAnimation(); mEnterAnimator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + super.onAnimationStart(animation); + mShowingUi = true; + } + @Override public void onAnimationEnd(Animator animation) { super.onAnimationEnd(animation); @@ -518,6 +607,7 @@ public class ClipboardOverlayController implements ClipboardListener.ClipboardOv mOnRemoteCopyTapped = null; mOnShareTapped = null; mOnPreviewTapped = null; + mShowingUi = false; mView.reset(); mTimeoutHandler.cancelTimeout(); mClipboardLogger.reset(); diff --git a/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardImageLoaderTest.kt b/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardImageLoaderTest.kt new file mode 100644 index 0000000000000..21516d4917b59 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardImageLoaderTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.systemui.clipboardoverlay + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.util.mockito.whenever +import java.io.IOException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations + +@SmallTest +@OptIn(ExperimentalCoroutinesApi::class) +class ClipboardImageLoaderTest : SysuiTestCase() { + @Mock private lateinit var mockContext: Context + + @Mock private lateinit var mockContentResolver: ContentResolver + + private lateinit var clipboardImageLoader: ClipboardImageLoader + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + } + + @Test + @Throws(IOException::class) + fun test_imageLoadSuccess() = runTest { + val testDispatcher = StandardTestDispatcher(this.testScheduler) + clipboardImageLoader = + ClipboardImageLoader(mockContext, testDispatcher, CoroutineScope(testDispatcher)) + val testUri = Uri.parse("testUri") + whenever(mockContext.contentResolver).thenReturn(mockContentResolver) + whenever(mockContext.resources).thenReturn(context.resources) + + clipboardImageLoader.load(testUri) + + verify(mockContentResolver).loadThumbnail(eq(testUri), any(), any()) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + @Throws(IOException::class) + fun test_imageLoadFailure() = runTest { + val testDispatcher = StandardTestDispatcher(this.testScheduler) + clipboardImageLoader = + ClipboardImageLoader(mockContext, testDispatcher, CoroutineScope(testDispatcher)) + val testUri = Uri.parse("testUri") + whenever(mockContext.contentResolver).thenReturn(mockContentResolver) + whenever(mockContext.resources).thenReturn(context.resources) + + val res = clipboardImageLoader.load(testUri) + + verify(mockContentResolver).loadThumbnail(eq(testUri), any(), any()) + assertNull(res) + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardOverlayControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardOverlayControllerTest.java index fe5fa1fdd39f2..39fb7b4cda2c4 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardOverlayControllerTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardOverlayControllerTest.java @@ -25,6 +25,7 @@ import static com.android.systemui.clipboardoverlay.ClipboardOverlayEvent.CLIPBO import static com.android.systemui.clipboardoverlay.ClipboardOverlayEvent.CLIPBOARD_OVERLAY_SHOWN_EXPANDED; import static com.android.systemui.clipboardoverlay.ClipboardOverlayEvent.CLIPBOARD_OVERLAY_SHOWN_MINIMIZED; import static com.android.systemui.clipboardoverlay.ClipboardOverlayEvent.CLIPBOARD_OVERLAY_SWIPE_DISMISSED; +import static com.android.systemui.flags.Flags.CLIPBOARD_IMAGE_TIMEOUT; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; @@ -90,6 +91,8 @@ public class ClipboardOverlayControllerTest extends SysuiTestCase { @Mock private ClipboardOverlayUtils mClipboardUtils; @Mock + private ClipboardImageLoader mClipboardImageLoader; + @Mock private UiEventLogger mUiEventLogger; private FakeDisplayTracker mDisplayTracker = new FakeDisplayTracker(mContext); private FakeFeatureFlags mFeatureFlags = new FakeFeatureFlags(); @@ -120,6 +123,7 @@ public class ClipboardOverlayControllerTest extends SysuiTestCase { mSampleClipData = new ClipData("Test", new String[]{"text/plain"}, new ClipData.Item("Test Item")); + mFeatureFlags.set(CLIPBOARD_IMAGE_TIMEOUT, true); // turned off for legacy tests mOverlayController = new ClipboardOverlayController( mContext, @@ -131,6 +135,7 @@ public class ClipboardOverlayControllerTest extends SysuiTestCase { mFeatureFlags, mClipboardUtils, mExecutor, + mClipboardImageLoader, mUiEventLogger); verify(mClipboardOverlayView).setCallbacks(mOverlayCallbacksCaptor.capture()); mCallbacks = mOverlayCallbacksCaptor.getValue(); @@ -141,6 +146,69 @@ public class ClipboardOverlayControllerTest extends SysuiTestCase { mOverlayController.hideImmediate(); } + @Test + public void test_setClipData_invalidImageData_legacy() { + ClipData clipData = new ClipData("", new String[]{"image/png"}, + new ClipData.Item(Uri.parse(""))); + mFeatureFlags.set(CLIPBOARD_IMAGE_TIMEOUT, false); + + mOverlayController.setClipData(clipData, ""); + + verify(mClipboardOverlayView, times(1)).showDefaultTextPreview(); + verify(mClipboardOverlayView, times(1)).showShareChip(); + verify(mClipboardOverlayView, times(1)).getEnterAnimation(); + } + + @Test + public void test_setClipData_nonImageUri_legacy() { + ClipData clipData = new ClipData("", new String[]{"resource/png"}, + new ClipData.Item(Uri.parse(""))); + mFeatureFlags.set(CLIPBOARD_IMAGE_TIMEOUT, false); + + mOverlayController.setClipData(clipData, ""); + + verify(mClipboardOverlayView, times(1)).showDefaultTextPreview(); + verify(mClipboardOverlayView, times(1)).showShareChip(); + verify(mClipboardOverlayView, times(1)).getEnterAnimation(); + } + + @Test + public void test_setClipData_textData_legacy() { + mFeatureFlags.set(CLIPBOARD_IMAGE_TIMEOUT, false); + mOverlayController.setClipData(mSampleClipData, "abc"); + + verify(mClipboardOverlayView, times(1)).showTextPreview("Test Item", false); + verify(mUiEventLogger, times(1)).log(CLIPBOARD_OVERLAY_SHOWN_EXPANDED, 0, "abc"); + verify(mClipboardOverlayView, times(1)).showShareChip(); + verify(mClipboardOverlayView, times(1)).getEnterAnimation(); + } + + @Test + public void test_setClipData_sensitiveTextData_legacy() { + mFeatureFlags.set(CLIPBOARD_IMAGE_TIMEOUT, false); + ClipDescription description = mSampleClipData.getDescription(); + PersistableBundle b = new PersistableBundle(); + b.putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true); + description.setExtras(b); + ClipData data = new ClipData(description, mSampleClipData.getItemAt(0)); + mOverlayController.setClipData(data, ""); + + verify(mClipboardOverlayView, times(1)).showTextPreview("••••••", true); + verify(mClipboardOverlayView, times(1)).showShareChip(); + verify(mClipboardOverlayView, times(1)).getEnterAnimation(); + } + + @Test + public void test_setClipData_repeatedCalls_legacy() { + when(mAnimator.isRunning()).thenReturn(true); + mFeatureFlags.set(CLIPBOARD_IMAGE_TIMEOUT, false); + + mOverlayController.setClipData(mSampleClipData, ""); + mOverlayController.setClipData(mSampleClipData, ""); + + verify(mClipboardOverlayView, times(1)).getEnterAnimation(); + } + @Test public void test_setClipData_invalidImageData() { ClipData clipData = new ClipData("", new String[]{"image/png"},