diff options
5 files changed, 319 insertions, 16 deletions
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 000000000000..0542e13ba6da --- /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<Bitmap?>) { + 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 0aeab10101f6..757ebf45e9ad 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( @@ -452,6 +535,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); if (mOnUiUpdate != null) { @@ -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/src/com/android/systemui/flags/Flags.kt b/packages/SystemUI/src/com/android/systemui/flags/Flags.kt index 7cc3f79fb365..6ca409f07f6f 100644 --- a/packages/SystemUI/src/com/android/systemui/flags/Flags.kt +++ b/packages/SystemUI/src/com/android/systemui/flags/Flags.kt @@ -600,6 +600,8 @@ object Flags { // 1700 - clipboard @JvmField val CLIPBOARD_REMOTE_BEHAVIOR = releasedFlag(1701, "clipboard_remote_behavior") + // TODO(b/278714186) Tracking Bug + @JvmField val CLIPBOARD_IMAGE_TIMEOUT = unreleasedFlag(1702, "clipboard_image_timeout") // 1800 - shade container // TODO(b/265944639): Tracking Bug 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 000000000000..21516d4917b5 --- /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 fe5fa1fdd39f..39fb7b4cda2c 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(); @@ -142,6 +147,69 @@ public class ClipboardOverlayControllerTest extends SysuiTestCase { } @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"}, new ClipData.Item(Uri.parse(""))); |