Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 7f235677 authored by Miranda Kephart's avatar Miranda Kephart
Browse files

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
parent 923deb58
Loading
Loading
Loading
Loading
+60 −0
Original line number Diff line number Diff line
/*
 * 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)) }
    }
}
+106 −16
Original line number Diff line number Diff line
@@ -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,6 +265,7 @@ public class ClipboardOverlayController implements ClipboardListener.ClipboardOv
        boolean shouldAnimate = !model.dataMatches(mClipboardModel) || wasExiting;
        mClipboardModel = model;
        mClipboardLogger.setClipSource(mClipboardModel.getSource());
        if (mFeatureFlags.isEnabled(CLIPBOARD_IMAGE_TIMEOUT)) {
            if (shouldAnimate) {
                reset();
                mClipboardLogger.setClipSource(mClipboardModel.getSource());
@@ -269,13 +275,33 @@ public class ClipboardOverlayController implements ClipboardListener.ClipboardOv
                    mView.setMinimized(true);
                } else {
                    mClipboardLogger.logUnguarded(CLIPBOARD_OVERLAY_SHOWN_EXPANDED);
                setExpandedView();
                    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()));
                }
                mView.announceForAccessibility(
                        getAccessibilityAnnouncement(mClipboardModel.getType()));
            } else if (!mIsMinimized) {
                setExpandedView();
            }
        }
        if (mClipboardModel.isRemote()) {
            mTimeoutHandler.cancelTimeout();
            mOnUiUpdate = null;
@@ -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,9 +428,13 @@ public class ClipboardOverlayController implements ClipboardListener.ClipboardOv
                    mClipboardLogger.logUnguarded(CLIPBOARD_OVERLAY_EXPANDED_FROM_MINIMIZED);
                    mIsMinimized = false;
                }
                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();
+83 −0
Original line number Diff line number Diff line
/*
 * 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)
    }
}
+68 −0
Original line number Diff line number Diff line
@@ -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"},