Merge "Add clipboard overlay prototype"

This commit is contained in:
Miranda Kephart
2022-01-13 17:02:25 +00:00
committed by Android (Google) Code Review
22 changed files with 1465 additions and 248 deletions

View File

@@ -523,6 +523,12 @@ public final class SystemUiDeviceConfigFlags {
*/
public static final String TASK_MANAGER_ENABLED = "task_manager_enabled";
/**
* (boolean) Whether the clipboard overlay is enabled.
*/
public static final String CLIPBOARD_OVERLAY_ENABLED = "clipboard_overlay_enabled";
private SystemUiDeviceConfigFlags() {
}
}

View File

@@ -299,6 +299,9 @@
<uses-permission android:name="android.permission.BIND_APPWIDGET" />
<!-- For clipboard overlay -->
<uses-permission android:name="android.permission.READ_CLIPBOARD_IN_BACKGROUND" />
<protected-broadcast android:name="com.android.settingslib.action.REGISTER_SLICE_RECEIVER" />
<protected-broadcast android:name="com.android.settingslib.action.UNREGISTER_SLICE_RECEIVER" />
<protected-broadcast android:name="com.android.settings.flashlight.action.FLASHLIGHT_CHANGED" />
@@ -348,6 +351,7 @@
<!-- started from PhoneWindowManager
TODO: Should have an android:permission attribute -->
<service android:name=".screenshot.TakeScreenshotService"
android:permission="com.android.systemui.permission.SELF"
android:process=":screenshot"
android:exported="false" />
@@ -760,6 +764,12 @@
</intent-filter>
</activity>
<activity android:name=".clipboardoverlay.EditTextActivity"
android:theme="@style/EditTextActivity"
android:exported="false"
android:excludeFromRecents="true"
/>
<activity android:name=".controls.management.ControlsProviderSelectorActivity"
android:label="@string/controls_providers_title"
android:theme="@style/Theme.ControlsManagement"

View File

@@ -0,0 +1,27 @@
<!--
Copyright (C) 2020 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
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M4,6h18L22,4L4,4c-1.1,0 -2,0.9 -2,2v11L0,17v3h14v-3L4,17L4,6zM23,8h-6c-0.55,0 -1,0.45 -1,1v10c0,0.55 0.45,1 1,1h6c0.55,0 1,-0.45 1,-1L24,9c0,-0.55 -0.45,-1 -1,-1zM22,17h-4v-7h4v7z"/>
</vector>

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2021 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.
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/preview_border"
android:elevation="9dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/screenshot_offset_x"
android:layout_marginBottom="@dimen/screenshot_offset_y"
android:layout_gravity="bottom|start"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:clipToPadding="false"
android:clipChildren="false"
android:padding="4dp"
android:background="@drawable/screenshot_border"
>
<FrameLayout
android:elevation="0dp"
android:background="@drawable/screenshot_preview_background"
android:clipChildren="true"
android:clipToOutline="true"
android:clipToPadding="true"
android:layout_width="@dimen/screenshot_x_scale"
android:layout_height="wrap_content">
<TextView android:id="@+id/text_preview"
android:textFontWeight="500"
android:padding="8dp"
android:gravity="center|start"
android:ellipsize="end"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="10sp"
android:autoSizeMaxTextSize="200sp"
android:textColor="?android:attr/textColorPrimary"
android:layout_width="@dimen/screenshot_x_scale"
android:layout_height="@dimen/screenshot_x_scale"/>
<ImageView
android:id="@+id/image_preview"
android:scaleType="fitCenter"
android:adjustViewBounds="true"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</FrameLayout>
</FrameLayout>

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<Button
android:id="@+id/copy_button"
style="@android:style/Widget.DeviceDefault.Button.Colored"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:text="@string/clipboard_edit_text_copy"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/attribution"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintStart_toStartOf="@+id/copy_button"
app:layout_constraintTop_toBottomOf="@+id/copy_button" />
<ImageButton
android:id="@+id/share"
style="@android:style/Widget.Material.Button.Borderless"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="8dp"
android:padding="12dp"
android:scaleType="fitCenter"
android:contentDescription="@*android:string/share"
android:tooltipText="@*android:string/share"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/copy_button"
android:src="@drawable/ic_screenshot_share" />
<ScrollView
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintStart_toStartOf="@+id/copy_button"
app:layout_constraintTop_toBottomOf="@+id/attribution">
<EditText
android:id="@+id/edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="start|top"
android:textSize="24sp" />
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2021 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.
-->
<com.android.systemui.clipboardoverlay.DraggableConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_gravity="bottom"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/actions_container_background"
android:visibility="gone"
android:layout_height="0dp"
android:layout_width="0dp"
android:elevation="1dp"
android:background="@drawable/action_chip_container_background"
android:layout_marginStart="@dimen/screenshot_action_container_margin_horizontal"
app:layout_constraintBottom_toBottomOf="@+id/actions_container"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/actions_container"
app:layout_constraintEnd_toEndOf="@+id/actions_container"/>
<HorizontalScrollView
android:id="@+id/actions_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/screenshot_action_container_margin_horizontal"
android:paddingEnd="@dimen/screenshot_action_container_padding_right"
android:paddingVertical="@dimen/screenshot_action_container_padding_vertical"
android:elevation="1dp"
android:scrollbars="none"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintWidth_percent="1.0"
app:layout_constraintWidth_max="wrap"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/preview_border"
app:layout_constraintEnd_toEndOf="parent">
<LinearLayout
android:id="@+id/actions"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<include layout="@layout/screenshot_action_chip"
android:id="@+id/remote_copy_chip"/>
<include layout="@layout/screenshot_action_chip"
android:id="@+id/edit_chip"/>
</LinearLayout>
</HorizontalScrollView>
<include layout="@layout/clipboard_content_preview" />
</com.android.systemui.clipboardoverlay.DraggableConstraintLayout>

View File

@@ -38,6 +38,15 @@
<item name="android:textColorSecondary">?android:attr/textColorPrimaryInverse</item>
</style>
<!-- Clipboard overlay's edit text activity. -->
<style name="EditTextActivity" parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="android:windowNoTitle">true</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar">false</item>
<item name="android:navigationBarColor">?android:attr/colorBackgroundFloating</item>
<item name="android:textColorSecondary">?android:attr/textColorPrimaryInverse</item>
</style>
<style name="Screenshot" parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="android:textColorPrimary">?android:attr/textColorPrimaryInverse</item>
</style>

View File

@@ -316,6 +316,7 @@
<item>com.android.systemui.accessibility.SystemActions</item>
<item>com.android.systemui.toast.ToastUI</item>
<item>com.android.systemui.wmshell.WMShell</item>
<item>com.android.systemui.clipboardoverlay.ClipboardListener</item>
</string-array>
<!-- SystemUI Services: The classes of the additional stuff to start. Services here are

View File

@@ -2349,4 +2349,9 @@
<string name="fgs_manager_dialog_title">Apps running in the background</string>
<!-- Label of the button to stop the app from running in the background [CHAR LIMIT=12]-->
<string name="fgs_manager_app_item_stop_button_label">Stop</string>
<!-- Label for button to copy edited text back to the clipboard [CHAR LIMIT=20] -->
<string name="clipboard_edit_text_copy">Copy</string>
<!-- Text informing user that content has been copied to the system clipboard [CHAR LIMIT=NONE] -->
<string name="clipboard_overlay_text_copied">Copied</string>
</resources>

View File

@@ -666,6 +666,14 @@
<style name="Screenshot" parent="@android:style/Theme.DeviceDefault.DayNight"/>
<!-- Clipboard overlay's edit text activity. -->
<style name="EditTextActivity" parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="android:windowNoTitle">true</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowLightNavigationBar">true</item>
<item name="android:navigationBarColor">?android:attr/colorBackgroundFloating</item>
</style>
<!-- Privacy dialog -->
<style name="PrivacyDialog" parent="Theme.SystemUI.QuickSettings.Dialog">
<item name="android:windowIsTranslucent">true</item>

View File

@@ -0,0 +1,72 @@
/*
* Copyright (C) 2021 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 static com.android.internal.config.sysui.SystemUiDeviceConfigFlags.CLIPBOARD_OVERLAY_ENABLED;
import static java.util.Objects.requireNonNull;
import android.content.ClipboardManager;
import android.content.Context;
import android.provider.DeviceConfig;
import com.android.systemui.CoreStartable;
import com.android.systemui.dagger.SysUISingleton;
import com.android.systemui.screenshot.TimeoutHandler;
import javax.inject.Inject;
/**
* ClipboardListener brings up a clipboard overlay when something is copied to the clipboard.
*/
@SysUISingleton
public class ClipboardListener extends CoreStartable
implements ClipboardManager.OnPrimaryClipChangedListener {
private ClipboardOverlayController mClipboardOverlayController;
private ClipboardManager mClipboardManager;
@Inject
public ClipboardListener(Context context) {
super(context);
}
@Override
public void start() {
if (DeviceConfig.getBoolean(
DeviceConfig.NAMESPACE_SYSTEMUI, CLIPBOARD_OVERLAY_ENABLED, false)) {
mClipboardManager = requireNonNull(mContext.getSystemService(ClipboardManager.class));
mClipboardManager.addPrimaryClipChangedListener(this);
}
}
@Override
public void onPrimaryClipChanged() {
if (!mClipboardManager.hasPrimaryClip()) {
return;
}
if (mClipboardOverlayController == null) {
mClipboardOverlayController = new ClipboardOverlayController(mContext,
new TimeoutHandler(mContext));
}
mClipboardOverlayController.setClipData(mClipboardManager.getPrimaryClip());
mClipboardOverlayController.setOnSessionCompleteListener(() -> {
// Session is complete, free memory until it's needed again.
mClipboardOverlayController = null;
});
}
}

View File

@@ -0,0 +1,471 @@
/*
* Copyright (C) 2021 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 static android.content.Intent.ACTION_CLOSE_SYSTEM_DIALOGS;
import static android.content.res.Configuration.ORIENTATION_PORTRAIT;
import static android.view.Display.DEFAULT_DISPLAY;
import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
import static android.view.WindowManager.LayoutParams.TYPE_SCREENSHOT;
import static java.util.Objects.requireNonNull;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ValueAnimator;
import android.annotation.MainThread;
import android.content.BroadcastReceiver;
import android.content.ClipData;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.graphics.Insets;
import android.graphics.Rect;
import android.graphics.drawable.Icon;
import android.hardware.display.DisplayManager;
import android.hardware.input.InputManager;
import android.net.Uri;
import android.os.Looper;
import android.text.TextUtils;
import android.util.Log;
import android.util.Size;
import android.view.Display;
import android.view.DisplayCutout;
import android.view.Gravity;
import android.view.InputEvent;
import android.view.InputEventReceiver;
import android.view.InputMonitor;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewTreeObserver;
import android.view.WindowInsets;
import android.view.WindowManager;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import com.android.internal.policy.PhoneWindow;
import com.android.systemui.R;
import com.android.systemui.screenshot.FloatingWindowUtil;
import com.android.systemui.screenshot.ScreenshotActionChip;
import com.android.systemui.screenshot.TimeoutHandler;
import java.io.IOException;
/**
* Controls state and UI for the overlay that appears when something is added to the clipboard
*/
public class ClipboardOverlayController {
private static final String TAG = "ClipboardOverlayCtrlr";
private static final String REMOTE_COPY_ACTION = "android.intent.action.REMOTE_COPY";
/** Constants for screenshot/copy deconflicting */
public static final String SCREENSHOT_ACTION = "com.android.systemui.SCREENSHOT";
public static final String SELF_PERMISSION = "com.android.systemui.permission.SELF";
public static final String COPY_OVERLAY_ACTION = "com.android.systemui.COPY";
private static final int CLIPBOARD_DEFAULT_TIMEOUT_MILLIS = 6000;
private final Context mContext;
private final DisplayManager mDisplayManager;
private final WindowManager mWindowManager;
private final WindowManager.LayoutParams mWindowLayoutParams;
private final PhoneWindow mWindow;
private final TimeoutHandler mTimeoutHandler;
private final DraggableConstraintLayout mView;
private final ImageView mImagePreview;
private final TextView mTextPreview;
private final ScreenshotActionChip mEditChip;
private final ScreenshotActionChip mRemoteCopyChip;
private final View mActionContainerBackground;
private Runnable mOnSessionCompleteListener;
private InputEventReceiver mInputEventReceiver;
private BroadcastReceiver mCloseDialogsReceiver;
private BroadcastReceiver mScreenshotReceiver;
private boolean mBlockAttach = false;
public ClipboardOverlayController(Context context, TimeoutHandler timeoutHandler) {
mDisplayManager = requireNonNull(context.getSystemService(DisplayManager.class));
final Context displayContext = context.createDisplayContext(getDefaultDisplay());
mContext = displayContext.createWindowContext(TYPE_SCREENSHOT, null);
mWindowManager = mContext.getSystemService(WindowManager.class);
mTimeoutHandler = timeoutHandler;
mTimeoutHandler.setDefaultTimeoutMillis(CLIPBOARD_DEFAULT_TIMEOUT_MILLIS);
// Setup the window that we are going to use
mWindowLayoutParams = FloatingWindowUtil.getFloatingWindowParams();
mWindowLayoutParams.flags |= WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
mWindowLayoutParams.height = WRAP_CONTENT;
mWindowLayoutParams.gravity = Gravity.BOTTOM;
mWindowLayoutParams.setTitle("ClipboardOverlay");
mWindow = FloatingWindowUtil.getFloatingWindow(mContext);
mWindow.setWindowManager(mWindowManager, null, null);
mView = (DraggableConstraintLayout)
LayoutInflater.from(mContext).inflate(R.layout.clipboard_overlay, null);
mActionContainerBackground = requireNonNull(
mView.findViewById(R.id.actions_container_background));
mImagePreview = requireNonNull(mView.findViewById(R.id.image_preview));
mTextPreview = requireNonNull(mView.findViewById(R.id.text_preview));
mEditChip = requireNonNull(mView.findViewById(R.id.edit_chip));
mRemoteCopyChip = requireNonNull(mView.findViewById(R.id.remote_copy_chip));
mView.setOnDismissCallback(this::hideImmediate);
mView.setOnInteractionCallback(() -> mTimeoutHandler.resetTimeout());
mEditChip.setIcon(Icon.createWithResource(mContext, R.drawable.ic_screenshot_edit), true);
mRemoteCopyChip.setIcon(
Icon.createWithResource(mContext, R.drawable.ic_baseline_devices_24), true);
// Only show remote copy if it's available.
PackageManager packageManager = mContext.getPackageManager();
if (packageManager.resolveActivity(getRemoteCopyIntent(), 0) != null) {
mRemoteCopyChip.setOnClickListener((v) -> {
showNearby();
});
mRemoteCopyChip.setAlpha(1f);
} else {
mRemoteCopyChip.setVisibility(View.GONE);
}
attachWindow();
withWindowAttached(() -> {
mWindow.setContentView(mView);
updateInsets(mWindowManager.getCurrentWindowMetrics().getWindowInsets());
getEnterAnimation().start();
});
mTimeoutHandler.setOnTimeoutRunnable(() -> animateOut());
mCloseDialogsReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (ACTION_CLOSE_SYSTEM_DIALOGS.equals(intent.getAction())) {
animateOut();
}
}
};
mContext.registerReceiver(mCloseDialogsReceiver,
new IntentFilter(ACTION_CLOSE_SYSTEM_DIALOGS));
mScreenshotReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (SCREENSHOT_ACTION.equals(intent.getAction())) {
animateOut();
}
}
};
mContext.registerReceiver(mScreenshotReceiver, new IntentFilter(SCREENSHOT_ACTION),
SELF_PERMISSION, null);
monitorOutsideTouches();
mContext.sendBroadcast(new Intent(COPY_OVERLAY_ACTION), SELF_PERMISSION);
}
void setClipData(ClipData clipData) {
reset();
if (clipData == null || clipData.getItemCount() == 0) {
showTextPreview(
mContext.getResources().getString(R.string.clipboard_overlay_text_copied));
} else if (!TextUtils.isEmpty(clipData.getItemAt(0).getText())) {
showEditableText(clipData.getItemAt(0).getText());
} else if (clipData.getItemAt(0).getUri() != null) {
// How to handle non-image URIs?
showEditableImage(clipData.getItemAt(0).getUri());
} else {
showTextPreview(
mContext.getResources().getString(R.string.clipboard_overlay_text_copied));
}
mTimeoutHandler.resetTimeout();
}
void setOnSessionCompleteListener(Runnable runnable) {
mOnSessionCompleteListener = runnable;
}
private void monitorOutsideTouches() {
InputManager inputManager = mContext.getSystemService(InputManager.class);
InputMonitor monitor = inputManager.monitorGestureInput("clipboard overlay", 0);
mInputEventReceiver = new InputEventReceiver(monitor.getInputChannel(),
Looper.getMainLooper()) {
@Override
public void onInputEvent(InputEvent event) {
if (event instanceof MotionEvent) {
MotionEvent motionEvent = (MotionEvent) event;
if (motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) {
int[] pt = new int[2];
mView.getLocationOnScreen(pt);
Rect rect = new Rect(pt[0], pt[1], pt[0] + mView.getWidth(),
pt[1] + mView.getHeight());
if (!rect.contains((int) motionEvent.getRawX(),
(int) motionEvent.getRawY())) {
animateOut();
}
}
}
finishInputEvent(event, true /* handled */);
}
};
}
private void editImage(Uri uri) {
String editorPackage = mContext.getString(R.string.config_screenshotEditor);
Intent editIntent = new Intent(Intent.ACTION_EDIT);
if (!TextUtils.isEmpty(editorPackage)) {
editIntent.setComponent(ComponentName.unflattenFromString(editorPackage));
}
editIntent.setDataAndType(uri, "image/*");
editIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
editIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
mContext.startActivity(editIntent);
animateOut();
}
private void editText() {
Intent editIntent = new Intent(mContext, EditTextActivity.class);
editIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
mContext.startActivity(editIntent);
animateOut();
}
private void showNearby() {
mContext.startActivity(getRemoteCopyIntent());
animateOut();
}
private void showTextPreview(CharSequence text) {
mTextPreview.setVisibility(View.VISIBLE);
mImagePreview.setVisibility(View.GONE);
mTextPreview.setText(text);
mEditChip.setVisibility(View.GONE);
}
private void showEditableText(CharSequence text) {
showTextPreview(text);
mEditChip.setVisibility(View.VISIBLE);
mEditChip.setAlpha(1f);
View.OnClickListener listener = v -> editText();
mEditChip.setOnClickListener(listener);
mTextPreview.setOnClickListener(listener);
}
private void showEditableImage(Uri uri) {
mTextPreview.setVisibility(View.GONE);
mImagePreview.setVisibility(View.VISIBLE);
mEditChip.setAlpha(1f);
ContentResolver resolver = mContext.getContentResolver();
try {
int size = mContext.getResources().getDimensionPixelSize(R.dimen.screenshot_x_scale);
// The width of the view is capped, height maintains aspect ratio, so allow it to be
// taller if needed.
Bitmap thumbnail = resolver.loadThumbnail(uri, new Size(size, size * 4), null);
mImagePreview.setImageBitmap(thumbnail);
} catch (IOException e) {
Log.e(TAG, "Thumbnail loading failed", e);
}
View.OnClickListener listener = v -> editImage(uri);
mEditChip.setOnClickListener(listener);
mImagePreview.setOnClickListener(listener);
}
private Intent getRemoteCopyIntent() {
Intent nearbyIntent = new Intent(REMOTE_COPY_ACTION);
nearbyIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
return nearbyIntent;
}
private void animateOut() {
getExitAnimation().start();
}
private ValueAnimator getEnterAnimation() {
ValueAnimator anim = ValueAnimator.ofFloat(0, 1);
mView.setAlpha(0);
final View previewBorder = requireNonNull(mView.findViewById(R.id.preview_border));
final View actionBackground = requireNonNull(
mView.findViewById(R.id.actions_container_background));
mImagePreview.setVisibility(View.VISIBLE);
mActionContainerBackground.setVisibility(View.VISIBLE);
anim.addUpdateListener(animation -> {
mView.setAlpha(animation.getAnimatedFraction());
float scale = 0.6f + 0.4f * animation.getAnimatedFraction();
mView.setPivotY(mView.getHeight() - previewBorder.getHeight() / 2f);
mView.setPivotX(actionBackground.getWidth() / 2f);
mView.setScaleX(scale);
mView.setScaleY(scale);
});
anim.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
mView.setAlpha(1);
mTimeoutHandler.resetTimeout();
}
});
return anim;
}
private ValueAnimator getExitAnimation() {
ValueAnimator anim = ValueAnimator.ofFloat(0, 1);
anim.addUpdateListener(animation -> {
mView.setAlpha(1 - animation.getAnimatedFraction());
final View actionBackground = requireNonNull(
mView.findViewById(R.id.actions_container_background));
mView.setTranslationX(
-animation.getAnimatedFraction() * actionBackground.getWidth() / 2);
});
anim.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
hideImmediate();
}
});
return anim;
}
private void hideImmediate() {
// Note this may be called multiple times if multiple dismissal events happen at the same
// time.
mTimeoutHandler.cancelTimeout();
final View decorView = mWindow.peekDecorView();
if (decorView != null && decorView.isAttachedToWindow()) {
mWindowManager.removeViewImmediate(decorView);
}
if (mCloseDialogsReceiver != null) {
mContext.unregisterReceiver(mCloseDialogsReceiver);
mCloseDialogsReceiver = null;
}
if (mScreenshotReceiver != null) {
mContext.unregisterReceiver(mScreenshotReceiver);
mScreenshotReceiver = null;
}
if (mInputEventReceiver != null) {
mInputEventReceiver.dispose();
mInputEventReceiver = null;
}
if (mOnSessionCompleteListener != null) {
mOnSessionCompleteListener.run();
}
}
private void reset() {
mView.setTranslationX(0);
mView.setAlpha(1);
mTimeoutHandler.cancelTimeout();
}
@MainThread
private void attachWindow() {
View decorView = mWindow.getDecorView();
if (decorView.isAttachedToWindow() || mBlockAttach) {
return;
}
mBlockAttach = true;
mWindowManager.addView(decorView, mWindowLayoutParams);
decorView.requestApplyInsets();
mView.requestApplyInsets();
decorView.getViewTreeObserver().addOnWindowAttachListener(
new ViewTreeObserver.OnWindowAttachListener() {
@Override
public void onWindowAttached() {
mBlockAttach = false;
}
@Override
public void onWindowDetached() {
}
}
);
}
private void withWindowAttached(Runnable action) {
View decorView = mWindow.getDecorView();
if (decorView.isAttachedToWindow()) {
action.run();
} else {
decorView.getViewTreeObserver().addOnWindowAttachListener(
new ViewTreeObserver.OnWindowAttachListener() {
@Override
public void onWindowAttached() {
mBlockAttach = false;
decorView.getViewTreeObserver().removeOnWindowAttachListener(this);
action.run();
}
@Override
public void onWindowDetached() {
}
});
}
}
private void updateInsets(WindowInsets insets) {
int orientation = mContext.getResources().getConfiguration().orientation;
FrameLayout.LayoutParams p = (FrameLayout.LayoutParams) mView.getLayoutParams();
if (p == null) {
return;
}
DisplayCutout cutout = insets.getDisplayCutout();
Insets navBarInsets = insets.getInsets(WindowInsets.Type.navigationBars());
if (cutout == null) {
p.setMargins(0, 0, 0, navBarInsets.bottom);
} else {
Insets waterfall = cutout.getWaterfallInsets();
if (orientation == ORIENTATION_PORTRAIT) {
p.setMargins(
waterfall.left,
Math.max(cutout.getSafeInsetTop(), waterfall.top),
waterfall.right,
Math.max(cutout.getSafeInsetBottom(),
Math.max(navBarInsets.bottom, waterfall.bottom)));
} else {
p.setMargins(
Math.max(cutout.getSafeInsetLeft(), waterfall.left),
waterfall.top,
Math.max(cutout.getSafeInsetRight(), waterfall.right),
Math.max(navBarInsets.bottom, waterfall.bottom));
}
}
mView.setLayoutParams(p);
mView.requestLayout();
}
private Display getDefaultDisplay() {
return mDisplayManager.getDisplay(DEFAULT_DISPLAY);
}
}

View File

@@ -0,0 +1,108 @@
/*
* Copyright (C) 2021 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.Rect;
import android.util.AttributeSet;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import androidx.constraintlayout.widget.ConstraintLayout;
import com.android.systemui.R;
import com.android.systemui.screenshot.SwipeDismissHandler;
/**
* ConstraintLayout that is draggable when touched in a specific region
*/
public class DraggableConstraintLayout extends ConstraintLayout {
private final SwipeDismissHandler mSwipeDismissHandler;
private final GestureDetector mSwipeDetector;
private Runnable mOnDismiss;
private Runnable mOnInteraction;
public DraggableConstraintLayout(Context context) {
this(context, null);
}
public DraggableConstraintLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public DraggableConstraintLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mSwipeDismissHandler = new SwipeDismissHandler(mContext, this,
new SwipeDismissHandler.SwipeDismissCallbacks() {
@Override
public void onInteraction() {
if (mOnInteraction != null) {
mOnInteraction.run();
}
}
@Override
public void onDismiss() {
if (mOnDismiss != null) {
mOnDismiss.run();
}
}
});
setOnTouchListener(mSwipeDismissHandler);
mSwipeDetector = new GestureDetector(mContext,
new GestureDetector.SimpleOnGestureListener() {
final Rect mActionsRect = new Rect();
@Override
public boolean onScroll(
MotionEvent ev1, MotionEvent ev2, float distanceX, float distanceY) {
View actionsContainer = findViewById(R.id.actions_container);
actionsContainer.getBoundsOnScreen(mActionsRect);
// return true if we aren't in the actions bar, or if we are but it isn't
// scrollable in the direction of movement
return !mActionsRect.contains((int) ev2.getRawX(), (int) ev2.getRawY())
|| !actionsContainer.canScrollHorizontally((int) distanceX);
}
});
mSwipeDetector.setIsLongpressEnabled(false);
}
@Override // View
protected void onFinishInflate() {
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) {
mSwipeDismissHandler.onTouch(this, ev);
}
return mSwipeDetector.onTouchEvent(ev);
}
public void setOnDismissCallback(Runnable callback) {
mOnDismiss = callback;
}
public void setOnInteractionCallback(Runnable callback) {
mOnInteraction = callback;
}
}

View File

@@ -0,0 +1,82 @@
/*
* Copyright (C) 2021 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 static java.util.Objects.requireNonNull;
import android.app.Activity;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Intent;
import android.os.Bundle;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import com.android.systemui.R;
/**
* Lightweight activity for editing text clipboard contents
*/
public class EditTextActivity extends Activity {
private EditText mEditText;
private ClipboardManager mClipboardManager;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.clipboard_edit_text_activity);
findViewById(R.id.copy_button).setOnClickListener((v) -> saveToClipboard());
findViewById(R.id.share).setOnClickListener((v) -> share());
mEditText = findViewById(R.id.edit_text);
mClipboardManager = requireNonNull(getSystemService(ClipboardManager.class));
}
@Override
protected void onStart() {
super.onStart();
ClipData clip = mClipboardManager.getPrimaryClip();
if (clip == null) {
finish();
return;
}
// TODO: put clip attribution in R.id.attribution TextView
mEditText.setText(clip.getItemAt(0).getText());
mEditText.requestFocus();
}
private void saveToClipboard() {
ClipData clip = ClipData.newPlainText("text", mEditText.getText());
mClipboardManager.setPrimaryClip(clip);
hideImeAndFinish();
}
private void share() {
Intent sendIntent = new Intent();
sendIntent.setAction(Intent.ACTION_SEND);
sendIntent.putExtra(Intent.EXTRA_TEXT, mEditText.getText());
sendIntent.setType("text/plain");
Intent shareIntent = Intent.createChooser(sendIntent, null);
startActivity(shareIntent);
}
private void hideImeAndFinish() {
InputMethodManager imm = getSystemService(InputMethodManager.class);
imm.hideSoftInputFromWindow(mEditText.getWindowToken(), 0);
finish();
}
}

View File

@@ -24,6 +24,7 @@ import com.android.systemui.SliceBroadcastRelayHandler;
import com.android.systemui.accessibility.SystemActions;
import com.android.systemui.accessibility.WindowMagnification;
import com.android.systemui.biometrics.AuthController;
import com.android.systemui.clipboardoverlay.ClipboardListener;
import com.android.systemui.communal.CommunalManagerUpdater;
import com.android.systemui.dreams.DreamOverlayRegistrant;
import com.android.systemui.dreams.appwidgets.ComplicationPrimer;
@@ -73,6 +74,12 @@ public abstract class SystemUIBinder {
@ClassKey(GarbageMonitor.Service.class)
public abstract CoreStartable bindGarbageMonitorService(GarbageMonitor.Service sysui);
/** Inject into ClipboardListener. */
@Binds
@IntoMap
@ClassKey(ClipboardListener.class)
public abstract CoreStartable bindClipboardListener(ClipboardListener sysui);
/** Inject into GlobalActionsComponent. */
@Binds
@IntoMap

View File

@@ -0,0 +1,75 @@
/*
* Copyright (C) 2021 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.screenshot;
import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
import static android.view.WindowManager.LayoutParams.TYPE_SCREENSHOT;
import android.content.Context;
import android.graphics.PixelFormat;
import android.util.DisplayMetrics;
import android.view.Window;
import android.view.WindowManager;
import com.android.internal.policy.PhoneWindow;
/**
* Utility methods for setting up a floating window
*/
public class FloatingWindowUtil {
/**
* Convert input dp to pixels given DisplayMetrics
*/
public static float dpToPx(DisplayMetrics metrics, float dp) {
return dp * metrics.densityDpi / (float) DisplayMetrics.DENSITY_DEFAULT;
}
/**
* Sets up window params for a floating window
*/
public static WindowManager.LayoutParams getFloatingWindowParams() {
WindowManager.LayoutParams params = new WindowManager.LayoutParams(
MATCH_PARENT, MATCH_PARENT, /* xpos */ 0, /* ypos */ 0, TYPE_SCREENSHOT,
WindowManager.LayoutParams.FLAG_FULLSCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
| WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
| WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
| WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
| WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM,
PixelFormat.TRANSLUCENT);
params.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS;
params.setFitInsetsTypes(0);
// This is needed to let touches pass through outside the touchable areas
params.privateFlags |= WindowManager.LayoutParams.PRIVATE_FLAG_TRUSTED_OVERLAY;
return params;
}
/**
* Constructs a transparent floating window
*/
public static PhoneWindow getFloatingWindow(Context context) {
PhoneWindow window = new PhoneWindow(context);
window.requestFeature(Window.FEATURE_NO_TITLE);
window.requestFeature(Window.FEATURE_ACTIVITY_TRANSITIONS);
window.setBackgroundDrawableResource(android.R.color.transparent);
return window;
}
}

View File

@@ -70,19 +70,28 @@ public class ScreenshotActionChip extends FrameLayout {
super.setPressed(mIsPending || pressed);
}
void setIcon(Icon icon, boolean tint) {
/**
* Set chip icon and whether to tint with theme color
*/
public void setIcon(Icon icon, boolean tint) {
mIconView.setImageIcon(icon);
if (!tint) {
mIconView.setImageTintList(null);
}
}
void setText(CharSequence text) {
/**
* Set chip text
*/
public void setText(CharSequence text) {
mTextView.setText(text);
updatePadding(text.length() > 0);
}
void setPendingIntent(PendingIntent intent, Runnable finisher) {
/**
* Set PendingIntent to be sent and Runnable to be run, when chip is clicked
*/
public void setPendingIntent(PendingIntent intent, Runnable finisher) {
setOnClickListener(v -> {
try {
intent.send();
@@ -93,7 +102,10 @@ public class ScreenshotActionChip extends FrameLayout {
});
}
void setIsPending(boolean isPending) {
/**
* Set pressed state of chip (to be used when chip is clicked before underlying intent is ready)
*/
public void setIsPending(boolean isPending) {
mIsPending = isPending;
setPressed(mIsPending);
}

View File

@@ -18,7 +18,6 @@ package com.android.systemui.screenshot;
import static android.content.res.Configuration.ORIENTATION_PORTRAIT;
import static android.view.Display.DEFAULT_DISPLAY;
import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
import static android.view.WindowManager.LayoutParams.TYPE_SCREENSHOT;
import static com.android.systemui.screenshot.LogConfig.DEBUG_ANIM;
@@ -41,23 +40,21 @@ import android.app.ExitTransitionCoordinator;
import android.app.ExitTransitionCoordinator.ExitTransitionCallbacks;
import android.app.ICompatCameraControlCallback;
import android.app.Notification;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.ActivityInfo;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.graphics.Insets;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.hardware.display.DisplayManager;
import android.media.MediaActionSound;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.Message;
import android.os.RemoteException;
import android.provider.Settings;
import android.util.DisplayMetrics;
@@ -76,7 +73,6 @@ import android.view.SurfaceControl;
import android.view.View;
import android.view.ViewRootImpl;
import android.view.ViewTreeObserver;
import android.view.Window;
import android.view.WindowInsets;
import android.view.WindowManager;
import android.view.WindowManagerGlobal;
@@ -90,6 +86,7 @@ import com.android.internal.logging.UiEventLogger;
import com.android.internal.policy.PhoneWindow;
import com.android.settingslib.applications.InterestingConfigChanges;
import com.android.systemui.R;
import com.android.systemui.clipboardoverlay.ClipboardOverlayController;
import com.android.systemui.dagger.qualifiers.Main;
import com.android.systemui.screenshot.ScreenshotController.SavedImageData.ActionTransition;
import com.android.systemui.screenshot.TakeScreenshotService.RequestCallback;
@@ -235,13 +232,11 @@ public class ScreenshotController {
static final String EXTRA_CANCEL_NOTIFICATION = "android:screenshot_cancel_notification";
static final String EXTRA_DISALLOW_ENTER_PIP = "android:screenshot_disallow_enter_pip";
private static final int MESSAGE_CORNER_TIMEOUT = 2;
private static final int SCREENSHOT_CORNER_DEFAULT_TIMEOUT_MILLIS = 6000;
// From WizardManagerHelper.java
private static final String SETTINGS_SECURE_USER_SETUP_COMPLETE = "user_setup_complete";
private static final int SCREENSHOT_CORNER_DEFAULT_TIMEOUT_MILLIS = 6000;
private final WindowContext mContext;
private final ScreenshotNotificationsController mNotificationsController;
private final ScreenshotSmartActions mScreenshotSmartActions;
@@ -260,6 +255,7 @@ public class ScreenshotController {
private final ScrollCaptureController mScrollCaptureController;
private final LongScreenshotData mLongScreenshotHolder;
private final boolean mIsLowRamDevice;
private final TimeoutHandler mScreenshotHandler;
private ScreenshotView mScreenshotView;
private Bitmap mScreenBitmap;
@@ -270,24 +266,8 @@ public class ScreenshotController {
private Animator mScreenshotAnimation;
private RequestCallback mCurrentRequestCallback;
private String mPackageName = "";
private BroadcastReceiver mCopyBroadcastReceiver;
private final Handler mScreenshotHandler = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case MESSAGE_CORNER_TIMEOUT:
if (DEBUG_UI) {
Log.d(TAG, "Corner timeout hit");
}
mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_INTERACTION_TIMEOUT, 0,
mPackageName);
ScreenshotController.this.dismissScreenshot(false);
break;
default:
break;
}
}
};
/** Tracks config changes that require re-creating UI */
private final InterestingConfigChanges mConfigChanges = new InterestingConfigChanges(
@@ -309,7 +289,8 @@ public class ScreenshotController {
@Main Executor mainExecutor,
ScrollCaptureController scrollCaptureController,
LongScreenshotData longScreenshotHolder,
ActivityManager activityManager) {
ActivityManager activityManager,
TimeoutHandler timeoutHandler) {
mScreenshotSmartActions = screenshotSmartActions;
mNotificationsController = screenshotNotificationsController;
mScrollCaptureClient = scrollCaptureClient;
@@ -321,6 +302,17 @@ public class ScreenshotController {
mIsLowRamDevice = activityManager.isLowRamDevice();
mBgExecutor = Executors.newSingleThreadExecutor();
mScreenshotHandler = timeoutHandler;
mScreenshotHandler.setDefaultTimeoutMillis(SCREENSHOT_CORNER_DEFAULT_TIMEOUT_MILLIS);
mScreenshotHandler.setOnTimeoutRunnable(() -> {
if (DEBUG_UI) {
Log.d(TAG, "Corner timeout hit");
}
mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_INTERACTION_TIMEOUT, 0,
mPackageName);
ScreenshotController.this.dismissScreenshot(false);
});
mDisplayManager = requireNonNull(context.getSystemService(DisplayManager.class));
final Context displayContext = context.createDisplayContext(getDefaultDisplay());
mContext = (WindowContext) displayContext.createWindowContext(TYPE_SCREENSHOT, null);
@@ -329,27 +321,11 @@ public class ScreenshotController {
mAccessibilityManager = AccessibilityManager.getInstance(mContext);
// Setup the window that we are going to use
mWindowLayoutParams = new WindowManager.LayoutParams(
MATCH_PARENT, MATCH_PARENT, /* xpos */ 0, /* ypos */ 0, TYPE_SCREENSHOT,
WindowManager.LayoutParams.FLAG_FULLSCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
| WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
| WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
| WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
| WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM,
PixelFormat.TRANSLUCENT);
mWindowLayoutParams = FloatingWindowUtil.getFloatingWindowParams();
mWindowLayoutParams.setTitle("ScreenshotAnimation");
mWindowLayoutParams.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS;
mWindowLayoutParams.setFitInsetsTypes(0);
// This is needed to let touches pass through outside the touchable areas
mWindowLayoutParams.privateFlags |= WindowManager.LayoutParams.PRIVATE_FLAG_TRUSTED_OVERLAY;
mWindow = new PhoneWindow(mContext);
mWindow = FloatingWindowUtil.getFloatingWindow(mContext);
mWindow.setWindowManager(mWindowManager, null, null);
mWindow.requestFeature(Window.FEATURE_NO_TITLE);
mWindow.requestFeature(Window.FEATURE_ACTIVITY_TRANSITIONS);
mWindow.setBackgroundDrawableResource(android.R.color.transparent);
mConfigChanges.applyNewConfig(context.getResources());
reloadAssets();
@@ -357,6 +333,18 @@ public class ScreenshotController {
// Setup the Camera shutter sound
mCameraSound = new MediaActionSound();
mCameraSound.load(MediaActionSound.SHUTTER_CLICK);
mCopyBroadcastReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (ClipboardOverlayController.COPY_OVERLAY_ACTION.equals(intent.getAction())) {
dismissScreenshot(false);
}
}
};
mContext.registerReceiver(mCopyBroadcastReceiver, new IntentFilter(
ClipboardOverlayController.COPY_OVERLAY_ACTION),
ClipboardOverlayController.SELF_PERMISSION, null);
}
void takeScreenshotFullscreen(ComponentName topComponent, Consumer<Uri> finisher,
@@ -424,7 +412,7 @@ public class ScreenshotController {
}
return;
}
cancelTimeout();
mScreenshotHandler.cancelTimeout();
if (immediate) {
finishDismiss();
} else {
@@ -440,6 +428,7 @@ public class ScreenshotController {
* Release the constructed window context.
*/
void releaseContext() {
mContext.unregisterReceiver(mCopyBroadcastReceiver);
mContext.release();
mCameraSound.release();
mBgExecutor.shutdownNow();
@@ -462,7 +451,7 @@ public class ScreenshotController {
if (DEBUG_INPUT) {
Log.d(TAG, "onUserInteraction");
}
resetTimeout();
mScreenshotHandler.resetTimeout();
}
@Override
@@ -517,6 +506,9 @@ public class ScreenshotController {
}
saveScreenshot(screenshot, finisher, screenRect, Insets.NONE, topComponent, true);
mContext.sendBroadcast(new Intent(ClipboardOverlayController.SCREENSHOT_ACTION),
ClipboardOverlayController.SELF_PERMISSION);
}
private Bitmap captureScreenshot(Rect crop) {
@@ -651,7 +643,7 @@ public class ScreenshotController {
// ignore system bar insets for the purpose of window layout
mWindow.getDecorView().setOnApplyWindowInsetsListener(
(v, insets) -> WindowInsets.CONSUMED);
cancelTimeout(); // restarted after animation
mScreenshotHandler.cancelTimeout(); // restarted after animation
}
private void requestScrollCapture() {
@@ -878,7 +870,7 @@ public class ScreenshotController {
}
mScreenshotView.reset();
removeWindow();
cancelTimeout();
mScreenshotHandler.cancelTimeout();
}
/**
@@ -905,30 +897,6 @@ public class ScreenshotController {
mSaveInBgTask.execute();
}
private void cancelTimeout() {
if (DEBUG_DISMISS) {
Log.d(TAG, "cancel timeout");
}
mScreenshotHandler.removeMessages(MESSAGE_CORNER_TIMEOUT);
}
private void resetTimeout() {
cancelTimeout();
AccessibilityManager accessibilityManager = (AccessibilityManager)
mContext.getSystemService(Context.ACCESSIBILITY_SERVICE);
long timeoutMs = accessibilityManager.getRecommendedTimeoutMillis(
SCREENSHOT_CORNER_DEFAULT_TIMEOUT_MILLIS,
AccessibilityManager.FLAG_CONTENT_CONTROLS);
mScreenshotHandler.sendMessageDelayed(
mScreenshotHandler.obtainMessage(MESSAGE_CORNER_TIMEOUT),
timeoutMs);
if (DEBUG_DISMISS) {
Log.d(TAG, "dismiss timeout: " + timeoutMs + " ms");
}
}
/**
* Sets up the action shade and its entrance animation, once we get the screenshot URI.
@@ -939,7 +907,7 @@ public class ScreenshotController {
Log.d(TAG, "Showing UI actions");
}
resetTimeout();
mScreenshotHandler.resetTimeout();
if (imageData.uri != null) {
mScreenshotHandler.post(() -> {

View File

@@ -222,7 +222,6 @@ public class ScreenshotView extends FrameLayout implements
}
});
mSwipeDetector.setIsLongpressEnabled(false);
mSwipeDismissHandler = new SwipeDismissHandler();
addOnAttachStateChangeListener(new OnAttachStateChangeListener() {
@Override
public void onViewAttachedToWindow(View v) {
@@ -244,7 +243,7 @@ public class ScreenshotView extends FrameLayout implements
* Called to display the scroll action chip when support is detected.
*
* @param packageName the owning package of the window to be captured
* @param onClick the action to take when the chip is clicked.
* @param onClick the action to take when the chip is clicked.
*/
public void showScrollChip(String packageName, Runnable onClick) {
if (DEBUG_SCROLL) {
@@ -273,10 +272,12 @@ public class ScreenshotView extends FrameLayout implements
final Rect tmpRect = new Rect();
mScreenshotPreview.getBoundsOnScreen(tmpRect);
tmpRect.inset((int) dpToPx(-SWIPE_PADDING_DP), (int) dpToPx(-SWIPE_PADDING_DP));
tmpRect.inset((int) FloatingWindowUtil.dpToPx(mDisplayMetrics, -SWIPE_PADDING_DP),
(int) FloatingWindowUtil.dpToPx(mDisplayMetrics, -SWIPE_PADDING_DP));
touchRegion.op(tmpRect, Region.Op.UNION);
mActionsContainerBackground.getBoundsOnScreen(tmpRect);
tmpRect.inset((int) dpToPx(-SWIPE_PADDING_DP), (int) dpToPx(-SWIPE_PADDING_DP));
tmpRect.inset((int) FloatingWindowUtil.dpToPx(mDisplayMetrics, -SWIPE_PADDING_DP),
(int) FloatingWindowUtil.dpToPx(mDisplayMetrics, -SWIPE_PADDING_DP));
touchRegion.op(tmpRect, Region.Op.UNION);
mDismissButton.getBoundsOnScreen(tmpRect);
touchRegion.op(tmpRect, Region.Op.UNION);
@@ -365,7 +366,7 @@ public class ScreenshotView extends FrameLayout implements
mEditChip = requireNonNull(mActionsContainer.findViewById(R.id.screenshot_edit_chip));
mScrollChip = requireNonNull(mActionsContainer.findViewById(R.id.screenshot_scroll_chip));
int swipePaddingPx = (int) dpToPx(SWIPE_PADDING_DP);
int swipePaddingPx = (int) FloatingWindowUtil.dpToPx(mDisplayMetrics, SWIPE_PADDING_DP);
TouchDelegate previewDelegate = new TouchDelegate(
new Rect(swipePaddingPx, swipePaddingPx, swipePaddingPx, swipePaddingPx),
mScreenshotPreview);
@@ -390,6 +391,24 @@ public class ScreenshotView extends FrameLayout implements
// Get focus so that the key events go to the layout.
setFocusableInTouchMode(true);
requestFocus();
mSwipeDismissHandler = new SwipeDismissHandler(mContext, mScreenshotStatic,
new SwipeDismissHandler.SwipeDismissCallbacks() {
@Override
public void onInteraction() {
mCallbacks.onUserInteraction();
}
@Override
public void onDismiss() {
if (DEBUG_DISMISS) {
Log.d(ScreenshotView.TAG, "dismiss triggered via swipe gesture");
}
mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_SWIPE_DISMISSED, 0,
mPackageName);
mCallbacks.onDismiss();
}
});
}
View getScreenshotPreview() {
@@ -859,8 +878,8 @@ public class ScreenshotView extends FrameLayout implements
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
mCallbacks.onDismiss();
}
mCallbacks.onDismiss();
}
});
animSet.start();
}
@@ -934,38 +953,7 @@ public class ScreenshotView extends FrameLayout implements
}
void animateDismissal() {
animateDismissal(createScreenshotTranslateDismissAnimation());
}
private void animateDismissal(Animator dismissAnimation) {
mDismissAnimation = dismissAnimation;
mDismissAnimation.addListener(new AnimatorListenerAdapter() {
private boolean mCancelled = false;
@Override
public void onAnimationCancel(Animator animation) {
super.onAnimationCancel(animation);
if (DEBUG_ANIM) {
Log.d(TAG, "Cancelled dismiss animation");
}
mCancelled = true;
}
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
if (!mCancelled) {
if (DEBUG_ANIM) {
Log.d(TAG, "after dismiss animation, calling onDismissRunnable.run()");
}
mCallbacks.onDismiss();
}
}
});
if (DEBUG_ANIM) {
Log.d(TAG, "Starting dismiss animation");
}
mDismissAnimation.start();
mSwipeDismissHandler.dismiss();
}
void reset() {
@@ -979,6 +967,7 @@ public class ScreenshotView extends FrameLayout implements
}
mDismissAnimation.cancel();
}
mSwipeDismissHandler.cancel();
if (DEBUG_WINDOW) {
Log.d(TAG, "removing OnComputeInternalInsetsListener");
}
@@ -1042,8 +1031,8 @@ public class ScreenshotView extends FrameLayout implements
xAnim.setInterpolator(mAccelerateInterpolator);
xAnim.setDuration(SCREENSHOT_DISMISS_X_DURATION_MS);
float deltaX = mDirectionLTR
? -1 * (mScreenshotPreviewBorder.getX() + mScreenshotPreviewBorder.getWidth())
: (mDisplayMetrics.widthPixels - mScreenshotPreviewBorder.getX());
? -1 * (mScreenshotPreviewBorder.getX() + mScreenshotPreviewBorder.getWidth())
: (mDisplayMetrics.widthPixels - mScreenshotPreviewBorder.getX());
xAnim.addUpdateListener(animation -> {
float currXDelta = MathUtils.lerp(0, deltaX, animation.getAnimatedFraction());
mScreenshotStatic.setTranslationX(currXDelta);
@@ -1100,130 +1089,4 @@ public class ScreenshotView extends FrameLayout implements
return insetDrawable;
}
}
private float dpToPx(float dp) {
return dp * mDisplayMetrics.densityDpi / (float) DisplayMetrics.DENSITY_DEFAULT;
}
class SwipeDismissHandler implements OnTouchListener {
// distance needed to register a dismissal
private static final float DISMISS_DISTANCE_THRESHOLD_DP = 20;
private final GestureDetector mGestureDetector;
private float mStartX;
// Keeps track of the most recent direction (between the last two move events).
// -1 for left; +1 for right.
private int mDirectionX;
private float mPreviousX;
SwipeDismissHandler() {
GestureDetector.OnGestureListener gestureListener = new SwipeDismissGestureListener();
mGestureDetector = new GestureDetector(mContext, gestureListener);
}
@Override
public boolean onTouch(View view, MotionEvent event) {
boolean gestureResult = mGestureDetector.onTouchEvent(event);
mCallbacks.onUserInteraction();
if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
mStartX = event.getRawX();
mPreviousX = mStartX;
return true;
} else if (event.getActionMasked() == MotionEvent.ACTION_UP) {
if (mDismissAnimation != null && mDismissAnimation.isRunning()) {
return true;
}
if (isPastDismissThreshold()) {
if (DEBUG_DISMISS) {
Log.d(TAG, "dismiss triggered via swipe gesture");
}
mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_SWIPE_DISMISSED, 0, mPackageName);
animateDismissal(createSwipeDismissAnimation());
} else {
// if we've moved, but not past the threshold, start the return animation
if (DEBUG_DISMISS) {
Log.d(TAG, "swipe gesture abandoned");
}
createSwipeReturnAnimation().start();
}
return true;
}
return gestureResult;
}
class SwipeDismissGestureListener extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onScroll(
MotionEvent ev1, MotionEvent ev2, float distanceX, float distanceY) {
mScreenshotStatic.setTranslationX(ev2.getRawX() - mStartX);
mDirectionX = (ev2.getRawX() < mPreviousX) ? -1 : 1;
mPreviousX = ev2.getRawX();
return true;
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX,
float velocityY) {
if (mScreenshotStatic.getTranslationX() * velocityX > 0
&& (mDismissAnimation == null || !mDismissAnimation.isRunning())) {
animateDismissal(createSwipeDismissAnimation(velocityX / (float) 1000));
return true;
}
return false;
}
}
private boolean isPastDismissThreshold() {
float translationX = mScreenshotStatic.getTranslationX();
// Determines whether the absolute translation from the start is in the same direction
// as the current movement. For example, if the user moves most of the way to the right,
// but then starts dragging back left, we do not dismiss even though the absolute
// distance is greater than the threshold.
if (translationX * mDirectionX > 0) {
return Math.abs(translationX) >= dpToPx(DISMISS_DISTANCE_THRESHOLD_DP);
}
return false;
}
private ValueAnimator createSwipeDismissAnimation() {
return createSwipeDismissAnimation(1);
}
private ValueAnimator createSwipeDismissAnimation(float velocity) {
// velocity is measured in pixels per millisecond
velocity = Math.min(3, Math.max(1, velocity));
ValueAnimator anim = ValueAnimator.ofFloat(0, 1);
float startX = mScreenshotStatic.getTranslationX();
// make sure the UI gets all the way off the screen in the direction of movement
// (the actions container background is guaranteed to be both the leftmost and
// rightmost UI element in LTR and RTL)
float finalX = startX < 0
? -1 * mActionsContainerBackground.getRight()
: mDisplayMetrics.widthPixels;
float distance = Math.abs(finalX - startX);
anim.addUpdateListener(animation -> {
float translation = MathUtils.lerp(startX, finalX, animation.getAnimatedFraction());
mScreenshotStatic.setTranslationX(translation);
setAlpha(1 - animation.getAnimatedFraction());
});
anim.setDuration((long) (distance / Math.abs(velocity)));
return anim;
}
private ValueAnimator createSwipeReturnAnimation() {
ValueAnimator anim = ValueAnimator.ofFloat(0, 1);
float startX = mScreenshotStatic.getTranslationX();
float finalX = 0;
anim.addUpdateListener(animation -> {
float translation = MathUtils.lerp(
startX, finalX, animation.getAnimatedFraction());
mScreenshotStatic.setTranslationX(translation);
});
return anim;
}
}
}

View File

@@ -0,0 +1,210 @@
/*
* Copyright (C) 2021 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.screenshot;
import static com.android.systemui.screenshot.LogConfig.DEBUG_DISMISS;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ValueAnimator;
import android.content.Context;
import android.util.DisplayMetrics;
import android.util.Log;
import android.util.MathUtils;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
/**
* Allows a view to be swipe-dismissed, or returned to its location if distance threshold is not met
*/
public class SwipeDismissHandler implements View.OnTouchListener {
private static final String TAG = "SwipeDismissHandler";
// distance needed to register a dismissal
private static final float DISMISS_DISTANCE_THRESHOLD_DP = 20;
/**
* Stores the callbacks when the view is interacted with or dismissed.
*/
public interface SwipeDismissCallbacks {
/**
* Run when the view is interacted with (touched)
*/
void onInteraction();
/**
* Run when the view is dismissed (the distance threshold is met), post-dismissal animation
*/
void onDismiss();
}
private final View mView;
private final SwipeDismissCallbacks mCallbacks;
private final GestureDetector mGestureDetector;
private DisplayMetrics mDisplayMetrics;
private ValueAnimator mDismissAnimation;
private float mStartX;
// Keeps track of the most recent direction (between the last two move events).
// -1 for left; +1 for right.
private int mDirectionX;
private float mPreviousX;
public SwipeDismissHandler(Context context, View view, SwipeDismissCallbacks callbacks) {
mView = view;
mCallbacks = callbacks;
GestureDetector.OnGestureListener gestureListener = new SwipeDismissGestureListener();
mGestureDetector = new GestureDetector(context, gestureListener);
mDisplayMetrics = new DisplayMetrics();
context.getDisplay().getRealMetrics(mDisplayMetrics);
}
@Override
public boolean onTouch(View view, MotionEvent event) {
boolean gestureResult = mGestureDetector.onTouchEvent(event);
mCallbacks.onInteraction();
if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
mStartX = event.getRawX();
mPreviousX = mStartX;
return true;
} else if (event.getActionMasked() == MotionEvent.ACTION_UP) {
if (mDismissAnimation != null && mDismissAnimation.isRunning()) {
return true;
}
if (isPastDismissThreshold()) {
dismiss();
} else {
// if we've moved, but not past the threshold, start the return animation
if (DEBUG_DISMISS) {
Log.d(TAG, "swipe gesture abandoned");
}
createSwipeReturnAnimation().start();
}
return true;
}
return gestureResult;
}
class SwipeDismissGestureListener extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onScroll(
MotionEvent ev1, MotionEvent ev2, float distanceX, float distanceY) {
mView.setTranslationX(ev2.getRawX() - mStartX);
mDirectionX = (ev2.getRawX() < mPreviousX) ? -1 : 1;
mPreviousX = ev2.getRawX();
return true;
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX,
float velocityY) {
if (mView.getTranslationX() * velocityX > 0
&& (mDismissAnimation == null || !mDismissAnimation.isRunning())) {
dismiss(velocityX / (float) 1000);
return true;
}
return false;
}
}
private boolean isPastDismissThreshold() {
float translationX = mView.getTranslationX();
// Determines whether the absolute translation from the start is in the same direction
// as the current movement. For example, if the user moves most of the way to the right,
// but then starts dragging back left, we do not dismiss even though the absolute
// distance is greater than the threshold.
if (translationX * mDirectionX > 0) {
return Math.abs(translationX) >= FloatingWindowUtil.dpToPx(mDisplayMetrics,
DISMISS_DISTANCE_THRESHOLD_DP);
}
return false;
}
/**
* Cancel the currently-running dismissal animation, if any.
*/
public void cancel() {
if (mDismissAnimation != null && mDismissAnimation.isRunning()) {
mDismissAnimation.cancel();
}
}
/**
* Start dismissal animation (will run onDismiss callback when animation complete)
*/
public void dismiss() {
dismiss(1);
}
private void dismiss(float velocity) {
mDismissAnimation = createSwipeDismissAnimation(velocity);
mDismissAnimation.addListener(new AnimatorListenerAdapter() {
private boolean mCancelled;
@Override
public void onAnimationCancel(Animator animation) {
super.onAnimationCancel(animation);
mCancelled = true;
}
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
if (!mCancelled) {
mCallbacks.onDismiss();
}
}
});
mDismissAnimation.start();
}
private ValueAnimator createSwipeDismissAnimation(float velocity) {
// velocity is measured in pixels per millisecond
velocity = Math.min(3, Math.max(1, velocity));
ValueAnimator anim = ValueAnimator.ofFloat(0, 1);
float startX = mView.getTranslationX();
// make sure the UI gets all the way off the screen in the direction of movement
// (the actions container background is guaranteed to be both the leftmost and
// rightmost UI element in LTR and RTL)
float finalX = startX <= 0 ? -1 * mView.getRight() : mDisplayMetrics.widthPixels;
float distance = Math.abs(finalX - startX);
anim.addUpdateListener(animation -> {
float translation = MathUtils.lerp(startX, finalX, animation.getAnimatedFraction());
mView.setTranslationX(translation);
mView.setAlpha(1 - animation.getAnimatedFraction());
});
anim.setDuration((long) (distance / Math.abs(velocity)));
return anim;
}
private ValueAnimator createSwipeReturnAnimation() {
ValueAnimator anim = ValueAnimator.ofFloat(0, 1);
float startX = mView.getTranslationX();
float finalX = 0;
anim.addUpdateListener(animation -> {
float translation = MathUtils.lerp(
startX, finalX, animation.getAnimatedFraction());
mView.setTranslationX(translation);
});
return anim;
}
}

View File

@@ -83,7 +83,7 @@ public class TakeScreenshotService extends Service {
/** Informs about coarse grained state of the Controller. */
interface RequestCallback {
/** Respond to the current request indicating the screenshot request failed.*/
/** Respond to the current request indicating the screenshot request failed. */
void reportError();
/** The controller has completed handling this request UI has been removed */
@@ -230,7 +230,7 @@ public class TakeScreenshotService extends Service {
return false;
}
return true;
};
}
private static void sendComplete(Messenger target) {
try {

View File

@@ -0,0 +1,102 @@
/*
* Copyright (C) 2021 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.screenshot;
import static com.android.systemui.screenshot.LogConfig.DEBUG_DISMISS;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.util.Log;
import android.view.accessibility.AccessibilityManager;
import javax.inject.Inject;
/**
* Starts a configurable runnable on timeout. Can be cancelled. Used for automatically dismissing
* floating overlays.
*/
public class TimeoutHandler extends Handler {
private static final String TAG = "TimeoutHandler";
private static final int MESSAGE_CORNER_TIMEOUT = 2;
private static final int DEFAULT_TIMEOUT_MILLIS = 6000;
private final Context mContext;
private Runnable mOnTimeout;
private int mDefaultTimeout = DEFAULT_TIMEOUT_MILLIS;
@Inject
public TimeoutHandler(Context context) {
super(Looper.getMainLooper());
mContext = context;
mOnTimeout = () -> {
};
}
public void setOnTimeoutRunnable(Runnable onTimeout) {
mOnTimeout = onTimeout;
}
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case MESSAGE_CORNER_TIMEOUT:
mOnTimeout.run();
break;
default:
break;
}
}
/**
* Set the default timeout (if not overridden by accessibility)
*/
public void setDefaultTimeoutMillis(int timeout) {
mDefaultTimeout = timeout;
}
/**
* Cancel the current timeout, if any. To reset the delayed runnable use resetTimeout instead.
*/
public void cancelTimeout() {
if (DEBUG_DISMISS) {
Log.d(TAG, "cancel timeout");
}
removeMessages(MESSAGE_CORNER_TIMEOUT);
}
/**
* Reset the timeout.
*/
public void resetTimeout() {
cancelTimeout();
AccessibilityManager accessibilityManager = (AccessibilityManager)
mContext.getSystemService(Context.ACCESSIBILITY_SERVICE);
long timeoutMs = accessibilityManager.getRecommendedTimeoutMillis(
mDefaultTimeout,
AccessibilityManager.FLAG_CONTENT_CONTROLS);
sendMessageDelayed(obtainMessage(MESSAGE_CORNER_TIMEOUT), timeoutMs);
if (DEBUG_DISMISS) {
Log.d(TAG, "dismiss timeout: " + timeoutMs + " ms");
}
}
}