1 /*
2  * Copyright (C) 2023 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.wm.shell.desktopmode;
18 
19 import static android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM;
20 import static android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN;
21 import static android.app.WindowConfiguration.WINDOWING_MODE_MULTI_WINDOW;
22 import static android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
23 import static android.view.WindowManager.LayoutParams.TYPE_APPLICATION;
24 
25 import android.animation.Animator;
26 import android.animation.AnimatorListenerAdapter;
27 import android.animation.RectEvaluator;
28 import android.animation.ValueAnimator;
29 import android.annotation.NonNull;
30 import android.app.ActivityManager;
31 import android.app.WindowConfiguration;
32 import android.content.Context;
33 import android.content.res.Resources;
34 import android.graphics.PixelFormat;
35 import android.graphics.PointF;
36 import android.graphics.Rect;
37 import android.graphics.Region;
38 import android.graphics.drawable.LayerDrawable;
39 import android.util.DisplayMetrics;
40 import android.view.SurfaceControl;
41 import android.view.SurfaceControlViewHost;
42 import android.view.View;
43 import android.view.WindowManager;
44 import android.view.WindowlessWindowManager;
45 import android.view.animation.DecelerateInterpolator;
46 
47 import androidx.annotation.VisibleForTesting;
48 
49 import com.android.wm.shell.R;
50 import com.android.wm.shell.RootTaskDisplayAreaOrganizer;
51 import com.android.wm.shell.common.DisplayController;
52 import com.android.wm.shell.common.DisplayLayout;
53 import com.android.wm.shell.common.SyncTransactionQueue;
54 
55 /**
56  * Animated visual indicator for Desktop Mode windowing transitions.
57  */
58 public class DesktopModeVisualIndicator {
59     public enum IndicatorType {
60         /** To be used when we don't want to indicate any transition */
61         NO_INDICATOR,
62         /** Indicates impending transition into desktop mode */
63         TO_DESKTOP_INDICATOR,
64         /** Indicates impending transition into fullscreen */
65         TO_FULLSCREEN_INDICATOR,
66         /** Indicates impending transition into split select on the left side */
67         TO_SPLIT_LEFT_INDICATOR,
68         /** Indicates impending transition into split select on the right side */
69         TO_SPLIT_RIGHT_INDICATOR
70     }
71 
72     private final Context mContext;
73     private final DisplayController mDisplayController;
74     private final RootTaskDisplayAreaOrganizer mRootTdaOrganizer;
75     private final ActivityManager.RunningTaskInfo mTaskInfo;
76     private final SurfaceControl mTaskSurface;
77     private SurfaceControl mLeash;
78 
79     private final SyncTransactionQueue mSyncQueue;
80     private SurfaceControlViewHost mViewHost;
81 
82     private View mView;
83     private IndicatorType mCurrentType;
84 
DesktopModeVisualIndicator(SyncTransactionQueue syncQueue, ActivityManager.RunningTaskInfo taskInfo, DisplayController displayController, Context context, SurfaceControl taskSurface, RootTaskDisplayAreaOrganizer taskDisplayAreaOrganizer)85     public DesktopModeVisualIndicator(SyncTransactionQueue syncQueue,
86             ActivityManager.RunningTaskInfo taskInfo, DisplayController displayController,
87             Context context, SurfaceControl taskSurface,
88             RootTaskDisplayAreaOrganizer taskDisplayAreaOrganizer) {
89         mSyncQueue = syncQueue;
90         mTaskInfo = taskInfo;
91         mDisplayController = displayController;
92         mContext = context;
93         mTaskSurface = taskSurface;
94         mRootTdaOrganizer = taskDisplayAreaOrganizer;
95         mCurrentType = IndicatorType.NO_INDICATOR;
96     }
97 
98     /**
99      * Based on the coordinates of the current drag event, determine which indicator type we should
100      * display, including no visible indicator.
101      */
102     @NonNull
updateIndicatorType(PointF inputCoordinates, int windowingMode)103     IndicatorType updateIndicatorType(PointF inputCoordinates, int windowingMode) {
104         final DisplayLayout layout = mDisplayController.getDisplayLayout(mTaskInfo.displayId);
105         // If we are in freeform, we don't want a visible indicator in the "freeform" drag zone.
106         IndicatorType result = IndicatorType.NO_INDICATOR;
107         final int transitionAreaWidth = mContext.getResources().getDimensionPixelSize(
108                 com.android.wm.shell.R.dimen.desktop_mode_transition_area_width);
109         // Because drags in freeform use task position for indicator calculation, we need to
110         // account for the possibility of the task going off the top of the screen by captionHeight
111         final int captionHeight = mContext.getResources().getDimensionPixelSize(
112                 com.android.wm.shell.R.dimen.desktop_mode_freeform_decor_caption_height);
113         final Region fullscreenRegion = calculateFullscreenRegion(layout, windowingMode,
114                 captionHeight);
115         final Region splitLeftRegion = calculateSplitLeftRegion(layout, windowingMode,
116                 transitionAreaWidth, captionHeight);
117         final Region splitRightRegion = calculateSplitRightRegion(layout, windowingMode,
118                 transitionAreaWidth, captionHeight);
119         final Region toDesktopRegion = calculateToDesktopRegion(layout, windowingMode,
120                 splitLeftRegion, splitRightRegion, fullscreenRegion);
121         if (fullscreenRegion.contains((int) inputCoordinates.x, (int) inputCoordinates.y)) {
122             result = IndicatorType.TO_FULLSCREEN_INDICATOR;
123         }
124         if (splitLeftRegion.contains((int) inputCoordinates.x, (int) inputCoordinates.y)) {
125             result = IndicatorType.TO_SPLIT_LEFT_INDICATOR;
126         }
127         if (splitRightRegion.contains((int) inputCoordinates.x, (int) inputCoordinates.y)) {
128             result = IndicatorType.TO_SPLIT_RIGHT_INDICATOR;
129         }
130         if (toDesktopRegion.contains((int) inputCoordinates.x, (int) inputCoordinates.y)) {
131             result = IndicatorType.TO_DESKTOP_INDICATOR;
132         }
133         transitionIndicator(result);
134         return result;
135     }
136 
137     @VisibleForTesting
calculateFullscreenRegion(DisplayLayout layout, @WindowConfiguration.WindowingMode int windowingMode, int captionHeight)138     Region calculateFullscreenRegion(DisplayLayout layout,
139             @WindowConfiguration.WindowingMode int windowingMode, int captionHeight) {
140         final Region region = new Region();
141         int transitionHeight = windowingMode == WINDOWING_MODE_FREEFORM
142                 ? mContext.getResources().getDimensionPixelSize(
143                 com.android.wm.shell.R.dimen.desktop_mode_fullscreen_from_desktop_height)
144                 : 2 * layout.stableInsets().top;
145         // A thin, short Rect at the top of the screen.
146         if (windowingMode == WINDOWING_MODE_FREEFORM) {
147             int fromFreeformWidth = mContext.getResources().getDimensionPixelSize(
148                     com.android.wm.shell.R.dimen.desktop_mode_fullscreen_from_desktop_width);
149             region.union(new Rect((layout.width() / 2) - (fromFreeformWidth / 2),
150                     -captionHeight,
151                     (layout.width() / 2) + (fromFreeformWidth / 2),
152                     transitionHeight));
153         }
154         // A screen-wide, shorter Rect if the task is in fullscreen or split.
155         if (windowingMode == WINDOWING_MODE_FULLSCREEN
156                 || windowingMode == WINDOWING_MODE_MULTI_WINDOW) {
157             region.union(new Rect(0,
158                     -captionHeight,
159                     layout.width(),
160                     transitionHeight));
161         }
162         return region;
163     }
164 
165     @VisibleForTesting
calculateToDesktopRegion(DisplayLayout layout, @WindowConfiguration.WindowingMode int windowingMode, Region splitLeftRegion, Region splitRightRegion, Region toFullscreenRegion)166     Region calculateToDesktopRegion(DisplayLayout layout,
167             @WindowConfiguration.WindowingMode int windowingMode,
168             Region splitLeftRegion, Region splitRightRegion,
169             Region toFullscreenRegion) {
170         final Region region = new Region();
171         // If in desktop, we need no region. Otherwise it's the same for all windowing modes.
172         if (windowingMode != WINDOWING_MODE_FREEFORM) {
173             region.union(new Rect(0, 0, layout.width(), layout.height()));
174             region.op(splitLeftRegion, Region.Op.DIFFERENCE);
175             region.op(splitRightRegion, Region.Op.DIFFERENCE);
176             region.op(toFullscreenRegion, Region.Op.DIFFERENCE);
177         }
178         return region;
179     }
180 
181     @VisibleForTesting
calculateSplitLeftRegion(DisplayLayout layout, @WindowConfiguration.WindowingMode int windowingMode, int transitionEdgeWidth, int captionHeight)182     Region calculateSplitLeftRegion(DisplayLayout layout,
183             @WindowConfiguration.WindowingMode int windowingMode,
184             int transitionEdgeWidth, int captionHeight) {
185         final Region region = new Region();
186         // In freeform, keep the top corners clear.
187         int transitionHeight = windowingMode == WINDOWING_MODE_FREEFORM
188                 ? mContext.getResources().getDimensionPixelSize(
189                 com.android.wm.shell.R.dimen.desktop_mode_split_from_desktop_height) :
190                 -captionHeight;
191         region.union(new Rect(0, transitionHeight, transitionEdgeWidth, layout.height()));
192         return region;
193     }
194 
195     @VisibleForTesting
calculateSplitRightRegion(DisplayLayout layout, @WindowConfiguration.WindowingMode int windowingMode, int transitionEdgeWidth, int captionHeight)196     Region calculateSplitRightRegion(DisplayLayout layout,
197             @WindowConfiguration.WindowingMode int windowingMode,
198             int transitionEdgeWidth, int captionHeight) {
199         final Region region = new Region();
200         // In freeform, keep the top corners clear.
201         int transitionHeight = windowingMode == WINDOWING_MODE_FREEFORM
202                 ? mContext.getResources().getDimensionPixelSize(
203                 com.android.wm.shell.R.dimen.desktop_mode_split_from_desktop_height) :
204                 -captionHeight;
205         region.union(new Rect(layout.width() - transitionEdgeWidth, transitionHeight,
206                 layout.width(), layout.height()));
207         return region;
208     }
209 
210     /**
211      * Create a fullscreen indicator with no animation
212      */
createView()213     private void createView() {
214         final SurfaceControl.Transaction t = new SurfaceControl.Transaction();
215         final Resources resources = mContext.getResources();
216         final DisplayMetrics metrics = resources.getDisplayMetrics();
217         final int screenWidth = metrics.widthPixels;
218         final int screenHeight = metrics.heightPixels;
219 
220         mView = new View(mContext);
221         final SurfaceControl.Builder builder = new SurfaceControl.Builder();
222         mRootTdaOrganizer.attachToDisplayArea(mTaskInfo.displayId, builder);
223         mLeash = builder
224                 .setName("Desktop Mode Visual Indicator")
225                 .setContainerLayer()
226                 .setCallsite("DesktopModeVisualIndicator.createView")
227                 .build();
228         t.show(mLeash);
229         final WindowManager.LayoutParams lp =
230                 new WindowManager.LayoutParams(screenWidth, screenHeight, TYPE_APPLICATION,
231                         FLAG_NOT_FOCUSABLE, PixelFormat.TRANSPARENT);
232         lp.setTitle("Desktop Mode Visual Indicator");
233         lp.setTrustedOverlay();
234         final WindowlessWindowManager windowManager = new WindowlessWindowManager(
235                 mTaskInfo.configuration, mLeash,
236                 null /* hostInputToken */);
237         mViewHost = new SurfaceControlViewHost(mContext,
238                 mDisplayController.getDisplay(mTaskInfo.displayId), windowManager,
239                 "DesktopModeVisualIndicator");
240         mViewHost.setView(mView, lp);
241         // We want this indicator to be behind the dragged task, but in front of all others.
242         t.setRelativeLayer(mLeash, mTaskSurface, -1);
243 
244         mSyncQueue.runInSync(transaction -> {
245             transaction.merge(t);
246             t.close();
247         });
248     }
249 
250     /**
251      * Fade indicator in as provided type. Animator fades it in while expanding the bounds outwards.
252      */
fadeInIndicator(IndicatorType type)253     private void fadeInIndicator(IndicatorType type) {
254         mView.setBackgroundResource(R.drawable.desktop_windowing_transition_background);
255         final VisualIndicatorAnimator animator = VisualIndicatorAnimator
256                 .fadeBoundsIn(mView, type,
257                         mDisplayController.getDisplayLayout(mTaskInfo.displayId));
258         animator.start();
259         mCurrentType = type;
260     }
261 
262     /**
263      * Fade out indicator without fully releasing it. Animator fades it out while shrinking bounds.
264      */
fadeOutIndicator()265     private void fadeOutIndicator() {
266         final VisualIndicatorAnimator animator = VisualIndicatorAnimator
267                 .fadeBoundsOut(mView, mCurrentType,
268                         mDisplayController.getDisplayLayout(mTaskInfo.displayId));
269         animator.start();
270         mCurrentType = IndicatorType.NO_INDICATOR;
271     }
272 
273     /**
274      * Takes existing indicator and animates it to bounds reflecting a new indicator type.
275      */
transitionIndicator(IndicatorType newType)276     private void transitionIndicator(IndicatorType newType) {
277         if (mCurrentType == newType) return;
278         if (mView == null) {
279             createView();
280         }
281         if (mCurrentType == IndicatorType.NO_INDICATOR) {
282             fadeInIndicator(newType);
283         } else if (newType == IndicatorType.NO_INDICATOR) {
284             fadeOutIndicator();
285         } else {
286             final VisualIndicatorAnimator animator = VisualIndicatorAnimator.animateIndicatorType(
287                     mView, mDisplayController.getDisplayLayout(mTaskInfo.displayId), mCurrentType,
288                     newType);
289             mCurrentType = newType;
290             animator.start();
291         }
292     }
293 
294     /**
295      * Release the indicator and its components when it is no longer needed.
296      */
releaseVisualIndicator(SurfaceControl.Transaction t)297     public void releaseVisualIndicator(SurfaceControl.Transaction t) {
298         if (mViewHost == null) return;
299         if (mViewHost != null) {
300             mViewHost.release();
301             mViewHost = null;
302         }
303 
304         if (mLeash != null) {
305             t.remove(mLeash);
306             mLeash = null;
307         }
308     }
309 
310     /**
311      * Animator for Desktop Mode transitions which supports bounds and alpha animation.
312      */
313     private static class VisualIndicatorAnimator extends ValueAnimator {
314         private static final int FULLSCREEN_INDICATOR_DURATION = 200;
315         private static final float FULLSCREEN_SCALE_ADJUSTMENT_PERCENT = 0.015f;
316         private static final float INDICATOR_FINAL_OPACITY = 0.35f;
317         private static final int MAXIMUM_OPACITY = 255;
318 
319         /**
320          * Determines how this animator will interact with the view's alpha:
321          * Fade in, fade out, or no change to alpha
322          */
323         private enum AlphaAnimType {
324             ALPHA_FADE_IN_ANIM, ALPHA_FADE_OUT_ANIM, ALPHA_NO_CHANGE_ANIM
325         }
326 
327         private final View mView;
328         private final Rect mStartBounds;
329         private final Rect mEndBounds;
330         private final RectEvaluator mRectEvaluator;
331 
VisualIndicatorAnimator(View view, Rect startBounds, Rect endBounds)332         private VisualIndicatorAnimator(View view, Rect startBounds,
333                 Rect endBounds) {
334             mView = view;
335             mStartBounds = new Rect(startBounds);
336             mEndBounds = endBounds;
337             setFloatValues(0, 1);
338             mRectEvaluator = new RectEvaluator(new Rect());
339         }
340 
fadeBoundsIn( @onNull View view, IndicatorType type, @NonNull DisplayLayout displayLayout)341         private static VisualIndicatorAnimator fadeBoundsIn(
342                 @NonNull View view, IndicatorType type, @NonNull DisplayLayout displayLayout) {
343             final Rect startBounds = getIndicatorBounds(displayLayout, type);
344             view.getBackground().setBounds(startBounds);
345 
346             final VisualIndicatorAnimator animator = new VisualIndicatorAnimator(
347                     view, startBounds, getMaxBounds(startBounds));
348             animator.setInterpolator(new DecelerateInterpolator());
349             setupIndicatorAnimation(animator, AlphaAnimType.ALPHA_FADE_IN_ANIM);
350             return animator;
351         }
352 
fadeBoundsOut( @onNull View view, IndicatorType type, @NonNull DisplayLayout displayLayout)353         private static VisualIndicatorAnimator fadeBoundsOut(
354                 @NonNull View view, IndicatorType type, @NonNull DisplayLayout displayLayout) {
355             final Rect endBounds = getIndicatorBounds(displayLayout, type);
356             final Rect startBounds = getMaxBounds(endBounds);
357             view.getBackground().setBounds(startBounds);
358 
359             final VisualIndicatorAnimator animator = new VisualIndicatorAnimator(
360                     view, startBounds, endBounds);
361             animator.setInterpolator(new DecelerateInterpolator());
362             setupIndicatorAnimation(animator, AlphaAnimType.ALPHA_FADE_OUT_ANIM);
363             return animator;
364         }
365 
366         /**
367          * Create animator for visual indicator changing type (i.e., fullscreen to freeform,
368          * freeform to split, etc.)
369          *
370          * @param view          the view for this indicator
371          * @param displayLayout information about the display the transitioning task is currently on
372          * @param origType      the original indicator type
373          * @param newType       the new indicator type
374          */
animateIndicatorType(@onNull View view, @NonNull DisplayLayout displayLayout, IndicatorType origType, IndicatorType newType)375         private static VisualIndicatorAnimator animateIndicatorType(@NonNull View view,
376                 @NonNull DisplayLayout displayLayout, IndicatorType origType,
377                 IndicatorType newType) {
378             final Rect startBounds = getIndicatorBounds(displayLayout, origType);
379             final Rect endBounds = getIndicatorBounds(displayLayout, newType);
380             final VisualIndicatorAnimator animator = new VisualIndicatorAnimator(
381                     view, startBounds, endBounds);
382             animator.setInterpolator(new DecelerateInterpolator());
383             setupIndicatorAnimation(animator, AlphaAnimType.ALPHA_NO_CHANGE_ANIM);
384             return animator;
385         }
386 
getIndicatorBounds(DisplayLayout layout, IndicatorType type)387         private static Rect getIndicatorBounds(DisplayLayout layout, IndicatorType type) {
388             final int padding = layout.stableInsets().top;
389             switch (type) {
390                 case TO_FULLSCREEN_INDICATOR:
391                     return new Rect(padding, padding,
392                             layout.width() - padding,
393                             layout.height() - padding);
394                 case TO_DESKTOP_INDICATOR:
395                     final float adjustmentPercentage = 1f
396                             - DesktopTasksController.DESKTOP_MODE_INITIAL_BOUNDS_SCALE;
397                     return new Rect((int) (adjustmentPercentage * layout.width() / 2),
398                             (int) (adjustmentPercentage * layout.height() / 2),
399                             (int) (layout.width() - (adjustmentPercentage * layout.width() / 2)),
400                             (int) (layout.height() - (adjustmentPercentage * layout.height() / 2)));
401                 case TO_SPLIT_LEFT_INDICATOR:
402                     return new Rect(padding, padding,
403                             layout.width() / 2 - padding,
404                             layout.height() - padding);
405                 case TO_SPLIT_RIGHT_INDICATOR:
406                     return new Rect(layout.width() / 2 + padding, padding,
407                             layout.width() - padding,
408                             layout.height() - padding);
409                 default:
410                     throw new IllegalArgumentException("Invalid indicator type provided.");
411             }
412         }
413 
414         /**
415          * Add necessary listener for animation of indicator
416          */
setupIndicatorAnimation(@onNull VisualIndicatorAnimator animator, AlphaAnimType animType)417         private static void setupIndicatorAnimation(@NonNull VisualIndicatorAnimator animator,
418                 AlphaAnimType animType) {
419             animator.addUpdateListener(a -> {
420                 if (animator.mView != null) {
421                     animator.updateBounds(a.getAnimatedFraction(), animator.mView);
422                     if (animType == AlphaAnimType.ALPHA_FADE_IN_ANIM) {
423                         animator.updateIndicatorAlpha(a.getAnimatedFraction(), animator.mView);
424                     } else if (animType == AlphaAnimType.ALPHA_FADE_OUT_ANIM) {
425                         animator.updateIndicatorAlpha(1 - a.getAnimatedFraction(), animator.mView);
426                     }
427                 } else {
428                     animator.cancel();
429                 }
430             });
431             animator.addListener(new AnimatorListenerAdapter() {
432                 @Override
433                 public void onAnimationEnd(Animator animation) {
434                     animator.mView.getBackground().setBounds(animator.mEndBounds);
435                 }
436             });
437             animator.setDuration(FULLSCREEN_INDICATOR_DURATION);
438         }
439 
440         /**
441          * Update bounds of view based on current animation fraction.
442          * Use of delta is to animate bounds independently, in case we need to
443          * run multiple animations simultaneously.
444          *
445          * @param fraction fraction to use, compared against previous fraction
446          * @param view     the view to update
447          */
updateBounds(float fraction, View view)448         private void updateBounds(float fraction, View view) {
449             if (mStartBounds.equals(mEndBounds)) {
450                 return;
451             }
452             final Rect currentBounds = mRectEvaluator.evaluate(fraction, mStartBounds, mEndBounds);
453             view.getBackground().setBounds(currentBounds);
454         }
455 
456         /**
457          * Fade in the fullscreen indicator
458          *
459          * @param fraction current animation fraction
460          */
updateIndicatorAlpha(float fraction, View view)461         private void updateIndicatorAlpha(float fraction, View view) {
462             final LayerDrawable drawable = (LayerDrawable) view.getBackground();
463             drawable.findDrawableByLayerId(R.id.indicator_stroke)
464                     .setAlpha((int) (MAXIMUM_OPACITY * fraction));
465             drawable.findDrawableByLayerId(R.id.indicator_solid)
466                     .setAlpha((int) (MAXIMUM_OPACITY * fraction * INDICATOR_FINAL_OPACITY));
467         }
468 
469         /**
470          * Return the max bounds of a visual indicator
471          */
getMaxBounds(Rect startBounds)472         private static Rect getMaxBounds(Rect startBounds) {
473             return new Rect((int) (startBounds.left
474                     - (FULLSCREEN_SCALE_ADJUSTMENT_PERCENT * startBounds.width())),
475                     (int) (startBounds.top
476                             - (FULLSCREEN_SCALE_ADJUSTMENT_PERCENT * startBounds.height())),
477                     (int) (startBounds.right
478                             + (FULLSCREEN_SCALE_ADJUSTMENT_PERCENT * startBounds.width())),
479                     (int) (startBounds.bottom
480                             + (FULLSCREEN_SCALE_ADJUSTMENT_PERCENT * startBounds.height())));
481         }
482     }
483 }
484