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
This commit is contained in:
@@ -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<Bitmap?>) {
|
||||
mainScope.launch { callback.accept(load(uri)) }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user