• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.clipboardoverlay;
18 
19 import static android.content.res.Configuration.ORIENTATION_PORTRAIT;
20 
21 import android.animation.Animator;
22 import android.animation.AnimatorListenerAdapter;
23 import android.animation.AnimatorSet;
24 import android.animation.ObjectAnimator;
25 import android.animation.TimeInterpolator;
26 import android.animation.ValueAnimator;
27 import android.annotation.Nullable;
28 import android.app.RemoteAction;
29 import android.content.Context;
30 import android.content.res.Resources;
31 import android.graphics.Bitmap;
32 import android.graphics.Insets;
33 import android.graphics.Paint;
34 import android.graphics.Rect;
35 import android.graphics.Region;
36 import android.graphics.drawable.Icon;
37 import android.util.AttributeSet;
38 import android.util.DisplayMetrics;
39 import android.util.MathUtils;
40 import android.util.TypedValue;
41 import android.view.DisplayCutout;
42 import android.view.Gravity;
43 import android.view.LayoutInflater;
44 import android.view.View;
45 import android.view.WindowInsets;
46 import android.view.accessibility.AccessibilityManager;
47 import android.view.animation.LinearInterpolator;
48 import android.view.animation.PathInterpolator;
49 import android.widget.FrameLayout;
50 import android.widget.ImageView;
51 import android.widget.LinearLayout;
52 import android.widget.TextView;
53 
54 import androidx.core.view.ViewCompat;
55 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
56 
57 import com.android.systemui.R;
58 import com.android.systemui.screenshot.DraggableConstraintLayout;
59 import com.android.systemui.screenshot.FloatingWindowUtil;
60 import com.android.systemui.screenshot.OverlayActionChip;
61 
62 import java.util.ArrayList;
63 
64 /**
65  * Handles the visual elements and animations for the clipboard overlay.
66  */
67 public class ClipboardOverlayView extends DraggableConstraintLayout {
68 
69     interface ClipboardOverlayCallbacks extends SwipeDismissCallbacks {
onDismissButtonTapped()70         void onDismissButtonTapped();
71 
onRemoteCopyButtonTapped()72         void onRemoteCopyButtonTapped();
73 
onShareButtonTapped()74         void onShareButtonTapped();
75 
onPreviewTapped()76         void onPreviewTapped();
77 
onMinimizedViewTapped()78         void onMinimizedViewTapped();
79     }
80 
81     private static final String TAG = "ClipboardView";
82 
83     private static final int SWIPE_PADDING_DP = 12; // extra padding around views to allow swipe
84     private static final int FONT_SEARCH_STEP_PX = 4;
85 
86     private final DisplayMetrics mDisplayMetrics;
87     private final AccessibilityManager mAccessibilityManager;
88     private final ArrayList<OverlayActionChip> mActionChips = new ArrayList<>();
89 
90     private View mClipboardPreview;
91     private ImageView mImagePreview;
92     private TextView mTextPreview;
93     private TextView mHiddenPreview;
94     private LinearLayout mMinimizedPreview;
95     private View mPreviewBorder;
96     private OverlayActionChip mShareChip;
97     private OverlayActionChip mRemoteCopyChip;
98     private View mActionContainerBackground;
99     private View mDismissButton;
100     private LinearLayout mActionContainer;
101 
ClipboardOverlayView(Context context)102     public ClipboardOverlayView(Context context) {
103         this(context, null);
104     }
105 
ClipboardOverlayView(Context context, AttributeSet attrs)106     public ClipboardOverlayView(Context context, AttributeSet attrs) {
107         this(context, attrs, 0);
108     }
109 
ClipboardOverlayView(Context context, AttributeSet attrs, int defStyleAttr)110     public ClipboardOverlayView(Context context, AttributeSet attrs, int defStyleAttr) {
111         super(context, attrs, defStyleAttr);
112         mDisplayMetrics = new DisplayMetrics();
113         mContext.getDisplay().getRealMetrics(mDisplayMetrics);
114         mAccessibilityManager = AccessibilityManager.getInstance(mContext);
115     }
116 
117     @Override
onFinishInflate()118     protected void onFinishInflate() {
119         mActionContainerBackground = requireViewById(R.id.actions_container_background);
120         mActionContainer = requireViewById(R.id.actions);
121         mClipboardPreview = requireViewById(R.id.clipboard_preview);
122         mPreviewBorder = requireViewById(R.id.preview_border);
123         mImagePreview = requireViewById(R.id.image_preview);
124         mTextPreview = requireViewById(R.id.text_preview);
125         mHiddenPreview = requireViewById(R.id.hidden_preview);
126         mMinimizedPreview = requireViewById(R.id.minimized_preview);
127         mShareChip = requireViewById(R.id.share_chip);
128         mRemoteCopyChip = requireViewById(R.id.remote_copy_chip);
129         mDismissButton = requireViewById(R.id.dismiss_button);
130 
131         mShareChip.setAlpha(1);
132         mRemoteCopyChip.setAlpha(1);
133         mShareChip.setContentDescription(mContext.getString(com.android.internal.R.string.share));
134 
135         mRemoteCopyChip.setIcon(
136                 Icon.createWithResource(mContext, R.drawable.ic_baseline_devices_24), true);
137         mShareChip.setIcon(
138                 Icon.createWithResource(mContext, R.drawable.ic_screenshot_share), true);
139 
140         mRemoteCopyChip.setContentDescription(
141                 mContext.getString(R.string.clipboard_send_nearby_description));
142 
143         mTextPreview.getViewTreeObserver().addOnPreDrawListener(() -> {
144             int availableHeight = mTextPreview.getHeight()
145                     - (mTextPreview.getPaddingTop() + mTextPreview.getPaddingBottom());
146             mTextPreview.setMaxLines(availableHeight / mTextPreview.getLineHeight());
147             return true;
148         });
149         super.onFinishInflate();
150     }
151 
152     @Override
setCallbacks(SwipeDismissCallbacks callbacks)153     public void setCallbacks(SwipeDismissCallbacks callbacks) {
154         super.setCallbacks(callbacks);
155         ClipboardOverlayCallbacks clipboardCallbacks = (ClipboardOverlayCallbacks) callbacks;
156         mShareChip.setOnClickListener(v -> clipboardCallbacks.onShareButtonTapped());
157         mDismissButton.setOnClickListener(v -> clipboardCallbacks.onDismissButtonTapped());
158         mRemoteCopyChip.setOnClickListener(v -> clipboardCallbacks.onRemoteCopyButtonTapped());
159         mClipboardPreview.setOnClickListener(v -> clipboardCallbacks.onPreviewTapped());
160         mMinimizedPreview.setOnClickListener(v -> clipboardCallbacks.onMinimizedViewTapped());
161     }
162 
setEditAccessibilityAction(boolean editable)163     void setEditAccessibilityAction(boolean editable) {
164         if (editable) {
165             ViewCompat.replaceAccessibilityAction(mClipboardPreview,
166                     AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK,
167                     mContext.getString(R.string.clipboard_edit), null);
168         } else {
169             ViewCompat.replaceAccessibilityAction(mClipboardPreview,
170                     AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK,
171                     null, null);
172         }
173     }
174 
setMinimized(boolean minimized)175     void setMinimized(boolean minimized) {
176         if (minimized) {
177             mMinimizedPreview.setVisibility(View.VISIBLE);
178             mClipboardPreview.setVisibility(View.GONE);
179             mPreviewBorder.setVisibility(View.GONE);
180             mActionContainer.setVisibility(View.GONE);
181             mActionContainerBackground.setVisibility(View.GONE);
182         } else {
183             mMinimizedPreview.setVisibility(View.GONE);
184             mClipboardPreview.setVisibility(View.VISIBLE);
185             mPreviewBorder.setVisibility(View.VISIBLE);
186             mActionContainer.setVisibility(View.VISIBLE);
187         }
188     }
189 
setInsets(WindowInsets insets, int orientation)190     void setInsets(WindowInsets insets, int orientation) {
191         FrameLayout.LayoutParams p = (FrameLayout.LayoutParams) getLayoutParams();
192         if (p == null) {
193             return;
194         }
195         Rect margins = computeMargins(insets, orientation);
196 
197         p.setMargins(margins.left, margins.top, margins.right, margins.bottom);
198         setLayoutParams(p);
199         requestLayout();
200     }
201 
isInTouchRegion(int x, int y)202     boolean isInTouchRegion(int x, int y) {
203         Region touchRegion = new Region();
204         final Rect tmpRect = new Rect();
205 
206         mPreviewBorder.getBoundsOnScreen(tmpRect);
207         tmpRect.inset(
208                 (int) FloatingWindowUtil.dpToPx(mDisplayMetrics, -SWIPE_PADDING_DP),
209                 (int) FloatingWindowUtil.dpToPx(mDisplayMetrics, -SWIPE_PADDING_DP));
210         touchRegion.op(tmpRect, Region.Op.UNION);
211 
212         mActionContainerBackground.getBoundsOnScreen(tmpRect);
213         tmpRect.inset(
214                 (int) FloatingWindowUtil.dpToPx(mDisplayMetrics, -SWIPE_PADDING_DP),
215                 (int) FloatingWindowUtil.dpToPx(mDisplayMetrics, -SWIPE_PADDING_DP));
216         touchRegion.op(tmpRect, Region.Op.UNION);
217 
218         mMinimizedPreview.getBoundsOnScreen(tmpRect);
219         tmpRect.inset(
220                 (int) FloatingWindowUtil.dpToPx(mDisplayMetrics, -SWIPE_PADDING_DP),
221                 (int) FloatingWindowUtil.dpToPx(mDisplayMetrics, -SWIPE_PADDING_DP));
222         touchRegion.op(tmpRect, Region.Op.UNION);
223 
224         mDismissButton.getBoundsOnScreen(tmpRect);
225         touchRegion.op(tmpRect, Region.Op.UNION);
226 
227         return touchRegion.contains(x, y);
228     }
229 
setRemoteCopyVisibility(boolean visible)230     void setRemoteCopyVisibility(boolean visible) {
231         if (visible) {
232             mRemoteCopyChip.setVisibility(View.VISIBLE);
233             mActionContainerBackground.setVisibility(View.VISIBLE);
234         } else {
235             mRemoteCopyChip.setVisibility(View.GONE);
236         }
237     }
238 
showDefaultTextPreview()239     void showDefaultTextPreview() {
240         String copied = mContext.getString(R.string.clipboard_overlay_text_copied);
241         showTextPreview(copied, false);
242     }
243 
showTextPreview(CharSequence text, boolean hidden)244     void showTextPreview(CharSequence text, boolean hidden) {
245         TextView textView = hidden ? mHiddenPreview : mTextPreview;
246         showSinglePreview(textView);
247         textView.setText(text.subSequence(0, Math.min(500, text.length())));
248         updateTextSize(text, textView);
249         textView.addOnLayoutChangeListener(
250                 (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
251                     if (right - left != oldRight - oldLeft) {
252                         updateTextSize(text, textView);
253                     }
254                 });
255     }
256 
showImagePreview(@ullable Bitmap thumbnail)257     void showImagePreview(@Nullable Bitmap thumbnail) {
258         if (thumbnail == null) {
259             mHiddenPreview.setText(mContext.getString(R.string.clipboard_text_hidden));
260             showSinglePreview(mHiddenPreview);
261         } else {
262             mImagePreview.setImageBitmap(thumbnail);
263             showSinglePreview(mImagePreview);
264         }
265     }
266 
showShareChip()267     void showShareChip() {
268         mShareChip.setVisibility(View.VISIBLE);
269         mActionContainerBackground.setVisibility(View.VISIBLE);
270     }
271 
reset()272     void reset() {
273         setTranslationX(0);
274         setAlpha(0);
275         mActionContainerBackground.setVisibility(View.GONE);
276         mDismissButton.setVisibility(View.GONE);
277         mShareChip.setVisibility(View.GONE);
278         mRemoteCopyChip.setVisibility(View.GONE);
279         setEditAccessibilityAction(false);
280         resetActionChips();
281     }
282 
resetActionChips()283     void resetActionChips() {
284         for (OverlayActionChip chip : mActionChips) {
285             mActionContainer.removeView(chip);
286         }
287         mActionChips.clear();
288     }
289 
getMinimizedFadeoutAnimation()290     Animator getMinimizedFadeoutAnimation() {
291         ObjectAnimator anim = ObjectAnimator.ofFloat(mMinimizedPreview, "alpha", 1, 0);
292         anim.setDuration(66);
293         anim.addListener(new AnimatorListenerAdapter() {
294             @Override
295             public void onAnimationEnd(Animator animation) {
296                 super.onAnimationEnd(animation);
297                 mMinimizedPreview.setVisibility(View.GONE);
298                 mMinimizedPreview.setAlpha(1);
299             }
300         });
301         return anim;
302     }
303 
getEnterAnimation()304     Animator getEnterAnimation() {
305         if (mAccessibilityManager.isEnabled()) {
306             mDismissButton.setVisibility(View.VISIBLE);
307         }
308         TimeInterpolator linearInterpolator = new LinearInterpolator();
309         TimeInterpolator scaleInterpolator = new PathInterpolator(0, 0, 0, 1f);
310         AnimatorSet enterAnim = new AnimatorSet();
311 
312         ValueAnimator rootAnim = ValueAnimator.ofFloat(0, 1);
313         rootAnim.setInterpolator(linearInterpolator);
314         rootAnim.setDuration(66);
315         rootAnim.addUpdateListener(animation -> {
316             setAlpha(animation.getAnimatedFraction());
317         });
318 
319         ValueAnimator scaleAnim = ValueAnimator.ofFloat(0, 1);
320         scaleAnim.setInterpolator(scaleInterpolator);
321         scaleAnim.setDuration(333);
322         scaleAnim.addUpdateListener(animation -> {
323             float previewScale = MathUtils.lerp(.9f, 1f, animation.getAnimatedFraction());
324             mMinimizedPreview.setScaleX(previewScale);
325             mMinimizedPreview.setScaleY(previewScale);
326             mClipboardPreview.setScaleX(previewScale);
327             mClipboardPreview.setScaleY(previewScale);
328             mPreviewBorder.setScaleX(previewScale);
329             mPreviewBorder.setScaleY(previewScale);
330 
331             float pivotX = mClipboardPreview.getWidth() / 2f + mClipboardPreview.getX();
332             mActionContainerBackground.setPivotX(pivotX - mActionContainerBackground.getX());
333             mActionContainer.setPivotX(pivotX - ((View) mActionContainer.getParent()).getX());
334             float actionsScaleX = MathUtils.lerp(.7f, 1f, animation.getAnimatedFraction());
335             float actionsScaleY = MathUtils.lerp(.9f, 1f, animation.getAnimatedFraction());
336             mActionContainer.setScaleX(actionsScaleX);
337             mActionContainer.setScaleY(actionsScaleY);
338             mActionContainerBackground.setScaleX(actionsScaleX);
339             mActionContainerBackground.setScaleY(actionsScaleY);
340         });
341 
342         ValueAnimator alphaAnim = ValueAnimator.ofFloat(0, 1);
343         alphaAnim.setInterpolator(linearInterpolator);
344         alphaAnim.setDuration(283);
345         alphaAnim.addUpdateListener(animation -> {
346             float alpha = animation.getAnimatedFraction();
347             mMinimizedPreview.setAlpha(alpha);
348             mClipboardPreview.setAlpha(alpha);
349             mPreviewBorder.setAlpha(alpha);
350             mDismissButton.setAlpha(alpha);
351             mActionContainer.setAlpha(alpha);
352         });
353 
354         mMinimizedPreview.setAlpha(0);
355         mActionContainer.setAlpha(0);
356         mPreviewBorder.setAlpha(0);
357         mClipboardPreview.setAlpha(0);
358         enterAnim.play(rootAnim).with(scaleAnim);
359         enterAnim.play(alphaAnim).after(50).after(rootAnim);
360 
361         enterAnim.addListener(new AnimatorListenerAdapter() {
362             @Override
363             public void onAnimationEnd(Animator animation) {
364                 super.onAnimationEnd(animation);
365                 setAlpha(1);
366             }
367         });
368         return enterAnim;
369     }
370 
getExitAnimation()371     Animator getExitAnimation() {
372         TimeInterpolator linearInterpolator = new LinearInterpolator();
373         TimeInterpolator scaleInterpolator = new PathInterpolator(.3f, 0, 1f, 1f);
374         AnimatorSet exitAnim = new AnimatorSet();
375 
376         ValueAnimator rootAnim = ValueAnimator.ofFloat(0, 1);
377         rootAnim.setInterpolator(linearInterpolator);
378         rootAnim.setDuration(100);
379         rootAnim.addUpdateListener(anim -> setAlpha(1 - anim.getAnimatedFraction()));
380 
381         ValueAnimator scaleAnim = ValueAnimator.ofFloat(0, 1);
382         scaleAnim.setInterpolator(scaleInterpolator);
383         scaleAnim.setDuration(250);
384         scaleAnim.addUpdateListener(animation -> {
385             float previewScale = MathUtils.lerp(1f, .9f, animation.getAnimatedFraction());
386             mMinimizedPreview.setScaleX(previewScale);
387             mMinimizedPreview.setScaleY(previewScale);
388             mClipboardPreview.setScaleX(previewScale);
389             mClipboardPreview.setScaleY(previewScale);
390             mPreviewBorder.setScaleX(previewScale);
391             mPreviewBorder.setScaleY(previewScale);
392 
393             float pivotX = mClipboardPreview.getWidth() / 2f + mClipboardPreview.getX();
394             mActionContainerBackground.setPivotX(pivotX - mActionContainerBackground.getX());
395             mActionContainer.setPivotX(pivotX - ((View) mActionContainer.getParent()).getX());
396             float actionScaleX = MathUtils.lerp(1f, .8f, animation.getAnimatedFraction());
397             float actionScaleY = MathUtils.lerp(1f, .9f, animation.getAnimatedFraction());
398             mActionContainer.setScaleX(actionScaleX);
399             mActionContainer.setScaleY(actionScaleY);
400             mActionContainerBackground.setScaleX(actionScaleX);
401             mActionContainerBackground.setScaleY(actionScaleY);
402         });
403 
404         ValueAnimator alphaAnim = ValueAnimator.ofFloat(0, 1);
405         alphaAnim.setInterpolator(linearInterpolator);
406         alphaAnim.setDuration(166);
407         alphaAnim.addUpdateListener(animation -> {
408             float alpha = 1 - animation.getAnimatedFraction();
409             mMinimizedPreview.setAlpha(alpha);
410             mClipboardPreview.setAlpha(alpha);
411             mPreviewBorder.setAlpha(alpha);
412             mDismissButton.setAlpha(alpha);
413             mActionContainer.setAlpha(alpha);
414         });
415 
416         exitAnim.play(alphaAnim).with(scaleAnim);
417         exitAnim.play(rootAnim).after(150).after(alphaAnim);
418         return exitAnim;
419     }
420 
setActionChip(RemoteAction action, Runnable onFinish)421     void setActionChip(RemoteAction action, Runnable onFinish) {
422         mActionContainerBackground.setVisibility(View.VISIBLE);
423         OverlayActionChip chip = constructActionChip(action, onFinish);
424         mActionContainer.addView(chip);
425         mActionChips.add(chip);
426     }
427 
showSinglePreview(View v)428     private void showSinglePreview(View v) {
429         mTextPreview.setVisibility(View.GONE);
430         mImagePreview.setVisibility(View.GONE);
431         mHiddenPreview.setVisibility(View.GONE);
432         mMinimizedPreview.setVisibility(View.GONE);
433         v.setVisibility(View.VISIBLE);
434     }
435 
constructActionChip(RemoteAction action, Runnable onFinish)436     private OverlayActionChip constructActionChip(RemoteAction action, Runnable onFinish) {
437         OverlayActionChip chip = (OverlayActionChip) LayoutInflater.from(mContext).inflate(
438                 R.layout.overlay_action_chip, mActionContainer, false);
439         chip.setText(action.getTitle());
440         chip.setContentDescription(action.getTitle());
441         chip.setIcon(action.getIcon(), false);
442         chip.setPendingIntent(action.getActionIntent(), onFinish);
443         chip.setAlpha(1);
444         return chip;
445     }
446 
updateTextSize(CharSequence text, TextView textView)447     private static void updateTextSize(CharSequence text, TextView textView) {
448         Paint paint = new Paint(textView.getPaint());
449         Resources res = textView.getResources();
450         float minFontSize = res.getDimensionPixelSize(R.dimen.clipboard_overlay_min_font);
451         float maxFontSize = res.getDimensionPixelSize(R.dimen.clipboard_overlay_max_font);
452         if (isOneWord(text) && fitsInView(text, textView, paint, minFontSize)) {
453             // If the text is a single word and would fit within the TextView at the min font size,
454             // find the biggest font size that will fit.
455             float fontSizePx = minFontSize;
456             while (fontSizePx + FONT_SEARCH_STEP_PX < maxFontSize
457                     && fitsInView(text, textView, paint, fontSizePx + FONT_SEARCH_STEP_PX)) {
458                 fontSizePx += FONT_SEARCH_STEP_PX;
459             }
460             // Need to turn off autosizing, otherwise setTextSize is a no-op.
461             textView.setAutoSizeTextTypeWithDefaults(TextView.AUTO_SIZE_TEXT_TYPE_NONE);
462             // It's possible to hit the max font size and not fill the width, so centering
463             // horizontally looks better in this case.
464             textView.setGravity(Gravity.CENTER);
465             textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, (int) fontSizePx);
466         } else {
467             // Otherwise just stick with autosize.
468             textView.setAutoSizeTextTypeUniformWithConfiguration((int) minFontSize,
469                     (int) maxFontSize, FONT_SEARCH_STEP_PX, TypedValue.COMPLEX_UNIT_PX);
470             textView.setGravity(Gravity.CENTER_VERTICAL | Gravity.START);
471         }
472     }
473 
fitsInView(CharSequence text, TextView textView, Paint paint, float fontSizePx)474     private static boolean fitsInView(CharSequence text, TextView textView, Paint paint,
475             float fontSizePx) {
476         paint.setTextSize(fontSizePx);
477         float size = paint.measureText(text.toString());
478         float availableWidth = textView.getWidth() - textView.getPaddingLeft()
479                 - textView.getPaddingRight();
480         return size < availableWidth;
481     }
482 
isOneWord(CharSequence text)483     private static boolean isOneWord(CharSequence text) {
484         return text.toString().split("\\s+", 2).length == 1;
485     }
486 
computeMargins(WindowInsets insets, int orientation)487     private static Rect computeMargins(WindowInsets insets, int orientation) {
488         DisplayCutout cutout = insets.getDisplayCutout();
489         Insets navBarInsets = insets.getInsets(WindowInsets.Type.navigationBars());
490         Insets imeInsets = insets.getInsets(WindowInsets.Type.ime());
491         if (cutout == null) {
492             return new Rect(0, 0, 0, Math.max(imeInsets.bottom, navBarInsets.bottom));
493         } else {
494             Insets waterfall = cutout.getWaterfallInsets();
495             if (orientation == ORIENTATION_PORTRAIT) {
496                 return new Rect(
497                         waterfall.left,
498                         Math.max(cutout.getSafeInsetTop(), waterfall.top),
499                         waterfall.right,
500                         Math.max(imeInsets.bottom,
501                                 Math.max(cutout.getSafeInsetBottom(),
502                                         Math.max(navBarInsets.bottom, waterfall.bottom))));
503             } else {
504                 return new Rect(
505                         waterfall.left,
506                         waterfall.top,
507                         waterfall.right,
508                         Math.max(imeInsets.bottom,
509                                 Math.max(navBarInsets.bottom, waterfall.bottom)));
510             }
511         }
512     }
513 }
514