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 package com.android.launcher3.util.window;
17 
18 import static android.view.Display.DEFAULT_DISPLAY;
19 
20 import static com.android.launcher3.Utilities.dpToPx;
21 import static com.android.launcher3.Utilities.dpiFromPx;
22 import static com.android.launcher3.testing.shared.ResourceUtils.INVALID_RESOURCE_HANDLE;
23 import static com.android.launcher3.testing.shared.ResourceUtils.NAVBAR_HEIGHT;
24 import static com.android.launcher3.testing.shared.ResourceUtils.NAVBAR_HEIGHT_LANDSCAPE;
25 import static com.android.launcher3.testing.shared.ResourceUtils.NAVBAR_LANDSCAPE_LEFT_RIGHT_SIZE;
26 import static com.android.launcher3.testing.shared.ResourceUtils.NAV_BAR_INTERACTION_MODE_RES_NAME;
27 import static com.android.launcher3.testing.shared.ResourceUtils.STATUS_BAR_HEIGHT;
28 import static com.android.launcher3.testing.shared.ResourceUtils.STATUS_BAR_HEIGHT_LANDSCAPE;
29 import static com.android.launcher3.testing.shared.ResourceUtils.STATUS_BAR_HEIGHT_PORTRAIT;
30 import static com.android.launcher3.util.MainThreadInitializedObject.forOverride;
31 import static com.android.launcher3.util.RotationUtils.deltaRotation;
32 import static com.android.launcher3.util.RotationUtils.rotateRect;
33 import static com.android.launcher3.util.RotationUtils.rotateSize;
34 
35 import android.annotation.TargetApi;
36 import android.content.Context;
37 import android.content.res.Configuration;
38 import android.content.res.Resources;
39 import android.graphics.Insets;
40 import android.graphics.Point;
41 import android.graphics.Rect;
42 import android.hardware.display.DisplayManager;
43 import android.os.Build;
44 import android.util.ArrayMap;
45 import android.util.Log;
46 import android.view.Display;
47 import android.view.DisplayCutout;
48 import android.view.Surface;
49 import android.view.WindowInsets;
50 import android.view.WindowManager;
51 import android.view.WindowMetrics;
52 
53 import androidx.annotation.NonNull;
54 import androidx.annotation.VisibleForTesting;
55 
56 import com.android.launcher3.R;
57 import com.android.launcher3.Utilities;
58 import com.android.launcher3.testing.shared.ResourceUtils;
59 import com.android.launcher3.util.MainThreadInitializedObject;
60 import com.android.launcher3.util.NavigationMode;
61 import com.android.launcher3.util.ResourceBasedOverride;
62 import com.android.launcher3.util.SafeCloseable;
63 import com.android.launcher3.util.WindowBounds;
64 
65 import java.util.ArrayList;
66 import java.util.List;
67 
68 /**
69  * Utility class for mocking some window manager behaviours
70  */
71 public class WindowManagerProxy implements ResourceBasedOverride, SafeCloseable {
72 
73     private static final String TAG = "WindowManagerProxy";
74     public static final int MIN_TABLET_WIDTH = 600;
75 
76     public static final MainThreadInitializedObject<WindowManagerProxy> INSTANCE =
77             forOverride(WindowManagerProxy.class, R.string.window_manager_proxy_class);
78 
79     protected final boolean mTaskbarDrawnInProcess;
80 
81     /**
82      * Creates a new instance of proxy, applying any overrides
83      */
newInstance(Context context)84     public static WindowManagerProxy newInstance(Context context) {
85         return Overrides.getObject(WindowManagerProxy.class, context,
86                 R.string.window_manager_proxy_class);
87     }
88 
WindowManagerProxy()89     public WindowManagerProxy() {
90         this(false);
91     }
92 
WindowManagerProxy(boolean taskbarDrawnInProcess)93     protected WindowManagerProxy(boolean taskbarDrawnInProcess) {
94         mTaskbarDrawnInProcess = taskbarDrawnInProcess;
95     }
96 
97     /**
98      * Returns true if taskbar is drawn in process
99      */
isTaskbarDrawnInProcess()100     public boolean isTaskbarDrawnInProcess() {
101         return mTaskbarDrawnInProcess;
102     }
103 
104     /**
105      * Returns a map of normalized info of internal displays to estimated window bounds
106      * for that display
107      */
estimateInternalDisplayBounds( Context displayInfoContext)108     public ArrayMap<CachedDisplayInfo, List<WindowBounds>> estimateInternalDisplayBounds(
109             Context displayInfoContext) {
110         CachedDisplayInfo info = getDisplayInfo(displayInfoContext).normalize(this);
111         List<WindowBounds> bounds = estimateWindowBounds(displayInfoContext, info);
112         ArrayMap<CachedDisplayInfo, List<WindowBounds>> result = new ArrayMap<>();
113         result.put(info, bounds);
114         return result;
115     }
116 
117     /**
118      * Returns if we are in desktop mode or not.
119      */
isInDesktopMode()120     public boolean isInDesktopMode() {
121         return false;
122     }
123 
124     /**
125      * Returns the real bounds for the provided display after applying any insets normalization
126      */
getRealBounds(Context displayInfoContext, CachedDisplayInfo info)127     public WindowBounds getRealBounds(Context displayInfoContext, CachedDisplayInfo info) {
128         WindowMetrics windowMetrics = displayInfoContext.getSystemService(WindowManager.class)
129                 .getMaximumWindowMetrics();
130         Rect insets = new Rect();
131         normalizeWindowInsets(displayInfoContext, windowMetrics.getWindowInsets(), insets);
132         return new WindowBounds(windowMetrics.getBounds(), insets, info.rotation);
133     }
134 
135     /**
136      * Returns an updated insets, accounting for various Launcher UI specific overrides like taskbar
137      */
normalizeWindowInsets(Context context, WindowInsets oldInsets, Rect outInsets)138     public WindowInsets normalizeWindowInsets(Context context, WindowInsets oldInsets,
139             Rect outInsets) {
140         if (!mTaskbarDrawnInProcess) {
141             outInsets.set(oldInsets.getSystemWindowInsetLeft(), oldInsets.getSystemWindowInsetTop(),
142                     oldInsets.getSystemWindowInsetRight(), oldInsets.getSystemWindowInsetBottom());
143             return oldInsets;
144         }
145 
146         WindowInsets.Builder insetsBuilder = new WindowInsets.Builder(oldInsets);
147         Insets navInsets = oldInsets.getInsets(WindowInsets.Type.navigationBars());
148 
149         Resources systemRes = context.getResources();
150         Configuration config = systemRes.getConfiguration();
151 
152         boolean isLargeScreen = config.smallestScreenWidthDp > MIN_TABLET_WIDTH;
153         boolean isGesture = isGestureNav(context);
154         boolean isPortrait = config.screenHeightDp > config.screenWidthDp;
155 
156         int bottomNav = isLargeScreen
157                 ? 0
158                 : (isPortrait
159                         ? getDimenByName(systemRes, NAVBAR_HEIGHT)
160                         : (isGesture
161                                 ? getDimenByName(systemRes, NAVBAR_HEIGHT_LANDSCAPE)
162                                 : 0));
163         int leftNav = navInsets.left;
164         int rightNav = navInsets.right;
165         if (!isLargeScreen && !isGesture && !isPortrait) {
166             // In 3-button landscape/seascape, Launcher should always have nav insets regardless if
167             // it's initiated from fullscreen apps.
168             int navBarWidth = getDimenByName(systemRes, NAVBAR_LANDSCAPE_LEFT_RIGHT_SIZE);
169             switch (getRotation(context)) {
170                 case Surface.ROTATION_90 -> rightNav = navBarWidth;
171                 case Surface.ROTATION_270 -> leftNav = navBarWidth;
172             }
173         }
174         Insets newNavInsets = Insets.of(leftNav, navInsets.top, rightNav, bottomNav);
175         insetsBuilder.setInsets(WindowInsets.Type.navigationBars(), newNavInsets);
176         insetsBuilder.setInsetsIgnoringVisibility(WindowInsets.Type.navigationBars(), newNavInsets);
177 
178         Insets statusBarInsets = oldInsets.getInsets(WindowInsets.Type.statusBars());
179 
180         Insets newStatusBarInsets = Insets.of(
181                 statusBarInsets.left,
182                 getStatusBarHeight(context, isPortrait, statusBarInsets.top),
183                 statusBarInsets.right,
184                 statusBarInsets.bottom);
185         insetsBuilder.setInsets(WindowInsets.Type.statusBars(), newStatusBarInsets);
186         insetsBuilder.setInsetsIgnoringVisibility(
187                 WindowInsets.Type.statusBars(), newStatusBarInsets);
188 
189         // Override the tappable insets to be 0 on the bottom for gesture nav (otherwise taskbar
190         // would count towards it). This is used for the bottom protection in All Apps for example.
191         if (isGesture) {
192             Insets oldTappableInsets = oldInsets.getInsets(WindowInsets.Type.tappableElement());
193             Insets newTappableInsets = Insets.of(oldTappableInsets.left, oldTappableInsets.top,
194                     oldTappableInsets.right, 0);
195             insetsBuilder.setInsets(WindowInsets.Type.tappableElement(), newTappableInsets);
196         }
197 
198         applyDisplayCutoutBottomInsetOverrideOnLargeScreen(
199                 context, isLargeScreen, dpToPx(config.screenWidthDp), oldInsets, insetsBuilder);
200 
201         WindowInsets result = insetsBuilder.build();
202         Insets systemWindowInsets = result.getInsetsIgnoringVisibility(
203                 WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout());
204         outInsets.set(systemWindowInsets.left, systemWindowInsets.top, systemWindowInsets.right,
205                 systemWindowInsets.bottom);
206         return result;
207     }
208 
209     /**
210      * For large screen, when display cutout is at bottom left/right corner of screen, override
211      * display cutout's bottom inset to 0, because launcher allows drawing content over that area.
212      */
applyDisplayCutoutBottomInsetOverrideOnLargeScreen( @onNull Context context, boolean isLargeScreen, int screenWidthPx, @NonNull WindowInsets windowInsets, @NonNull WindowInsets.Builder insetsBuilder)213     private static void applyDisplayCutoutBottomInsetOverrideOnLargeScreen(
214             @NonNull Context context,
215             boolean isLargeScreen,
216             int screenWidthPx,
217             @NonNull WindowInsets windowInsets,
218             @NonNull WindowInsets.Builder insetsBuilder) {
219         if (!isLargeScreen || !Utilities.ATLEAST_S) {
220             return;
221         }
222 
223         final DisplayCutout displayCutout = windowInsets.getDisplayCutout();
224         if (displayCutout == null) {
225             return;
226         }
227 
228         if (!areBottomDisplayCutoutsSmallAndAtCorners(
229                 displayCutout.getBoundingRectBottom(), screenWidthPx, context.getResources())) {
230             return;
231         }
232 
233         Insets oldDisplayCutoutInset = windowInsets.getInsets(WindowInsets.Type.displayCutout());
234         Insets newDisplayCutoutInset = Insets.of(
235                 oldDisplayCutoutInset.left,
236                 oldDisplayCutoutInset.top,
237                 oldDisplayCutoutInset.right,
238                 0);
239         insetsBuilder.setInsetsIgnoringVisibility(
240                 WindowInsets.Type.displayCutout(), newDisplayCutoutInset);
241     }
242 
243     /**
244      * @see doc at {@link #areBottomDisplayCutoutsSmallAndAtCorners(Rect, int, int)}
245      */
areBottomDisplayCutoutsSmallAndAtCorners( @onNull Rect cutoutRectBottom, int screenWidthPx, @NonNull Resources res)246     private static boolean areBottomDisplayCutoutsSmallAndAtCorners(
247             @NonNull Rect cutoutRectBottom, int screenWidthPx, @NonNull Resources res) {
248         return areBottomDisplayCutoutsSmallAndAtCorners(cutoutRectBottom, screenWidthPx,
249                 res.getDimensionPixelSize(R.dimen.max_width_and_height_of_small_display_cutout));
250     }
251 
252     /**
253      * Return true if bottom display cutouts are at bottom left/right corners, AND has width or
254      * height <= maxWidthAndHeightOfSmallCutoutPx. Note that display cutout rect and screenWidthPx
255      * passed to this method should be in the SAME screen rotation.
256      *
257      * @param cutoutRectBottom bottom display cutout rect, this is based on current screen rotation
258      * @param screenWidthPx screen width in px based on current screen rotation
259      * @param maxWidthAndHeightOfSmallCutoutPx maximum width and height pixels of cutout.
260      */
261     @VisibleForTesting
areBottomDisplayCutoutsSmallAndAtCorners( @onNull Rect cutoutRectBottom, int screenWidthPx, int maxWidthAndHeightOfSmallCutoutPx)262     static boolean areBottomDisplayCutoutsSmallAndAtCorners(
263             @NonNull Rect cutoutRectBottom, int screenWidthPx,
264             int maxWidthAndHeightOfSmallCutoutPx) {
265         // Empty cutoutRectBottom means there is no display cutout at the bottom. We should ignore
266         // it by returning false.
267         if (cutoutRectBottom.isEmpty()) {
268             return false;
269         }
270         return (cutoutRectBottom.right <= maxWidthAndHeightOfSmallCutoutPx)
271                 || cutoutRectBottom.left >= (screenWidthPx - maxWidthAndHeightOfSmallCutoutPx);
272     }
273 
getStatusBarHeight(Context context, boolean isPortrait, int statusBarInset)274     protected int getStatusBarHeight(Context context, boolean isPortrait, int statusBarInset) {
275         Resources systemRes = context.getResources();
276         int statusBarHeight = getDimenByName(systemRes,
277                 isPortrait ? STATUS_BAR_HEIGHT_PORTRAIT : STATUS_BAR_HEIGHT_LANDSCAPE,
278                 STATUS_BAR_HEIGHT);
279 
280         return Math.max(statusBarInset, statusBarHeight);
281     }
282 
283     /**
284      * Returns a list of possible WindowBounds for the display keyed on the 4 surface rotations
285      */
estimateWindowBounds(Context context, final CachedDisplayInfo displayInfo)286     protected List<WindowBounds> estimateWindowBounds(Context context,
287             final CachedDisplayInfo displayInfo) {
288         int densityDpi = context.getResources().getConfiguration().densityDpi;
289         final int rotation = displayInfo.rotation;
290 
291         int minSize = Math.min(displayInfo.size.x, displayInfo.size.y);
292         int swDp = (int) dpiFromPx(minSize, densityDpi);
293 
294         Resources systemRes;
295         {
296             Configuration conf = new Configuration();
297             conf.smallestScreenWidthDp = swDp;
298             systemRes = context.createConfigurationContext(conf).getResources();
299         }
300 
301         boolean isTablet = swDp >= MIN_TABLET_WIDTH;
302         boolean isTabletOrGesture = isTablet || isGestureNav(context);
303 
304         // Use the status bar height resources because current system API to get the status bar
305         // height doesn't allow to do this for an arbitrary display, it returns value only
306         // for the current active display (see com.android.internal.policy.StatusBarUtils)
307         int statusBarHeightPortrait = getDimenByName(systemRes,
308                 STATUS_BAR_HEIGHT_PORTRAIT, STATUS_BAR_HEIGHT);
309         int statusBarHeightLandscape = getDimenByName(systemRes,
310                 STATUS_BAR_HEIGHT_LANDSCAPE, STATUS_BAR_HEIGHT);
311 
312         int navBarHeightPortrait, navBarHeightLandscape, navbarWidthLandscape;
313 
314         navBarHeightPortrait = isTablet
315                 ? (mTaskbarDrawnInProcess
316                         ? 0 : context.getResources().getDimensionPixelSize(R.dimen.taskbar_size))
317                 : getDimenByName(systemRes, NAVBAR_HEIGHT);
318 
319         navBarHeightLandscape = isTablet
320                 ? (mTaskbarDrawnInProcess
321                         ? 0 : context.getResources().getDimensionPixelSize(R.dimen.taskbar_size))
322                 : (isTabletOrGesture
323                         ? getDimenByName(systemRes, NAVBAR_HEIGHT_LANDSCAPE) : 0);
324         navbarWidthLandscape = isTabletOrGesture
325                 ? 0
326                 : getDimenByName(systemRes, NAVBAR_LANDSCAPE_LEFT_RIGHT_SIZE);
327 
328         List<WindowBounds> result = new ArrayList<>(4);
329         Point tempSize = new Point();
330         for (int i = 0; i < 4; i++) {
331             int rotationChange = deltaRotation(rotation, i);
332             tempSize.set(displayInfo.size.x, displayInfo.size.y);
333             rotateSize(tempSize, rotationChange);
334             Rect bounds = new Rect(0, 0, tempSize.x, tempSize.y);
335 
336             int navBarHeight, navbarWidth, statusBarHeight;
337             if (tempSize.y > tempSize.x) {
338                 navBarHeight = navBarHeightPortrait;
339                 navbarWidth = 0;
340                 statusBarHeight = statusBarHeightPortrait;
341             } else {
342                 navBarHeight = navBarHeightLandscape;
343                 navbarWidth = navbarWidthLandscape;
344                 statusBarHeight = statusBarHeightLandscape;
345             }
346 
347             DisplayCutout rotatedCutout = rotateCutout(
348                     displayInfo.cutout, displayInfo.size.x, displayInfo.size.y, rotation, i);
349             Rect insets = getSafeInsets(rotatedCutout);
350             if (areBottomDisplayCutoutsSmallAndAtCorners(
351                     rotatedCutout.getBoundingRectBottom(),
352                     bounds.width(),
353                     context.getResources())) {
354                 insets.bottom = 0;
355             }
356             insets.top = Math.max(insets.top, statusBarHeight);
357             insets.bottom = Math.max(insets.bottom, navBarHeight);
358 
359             if (i == Surface.ROTATION_270 || i == Surface.ROTATION_180) {
360                 // On reverse landscape (and in rare-case when the natural orientation of the
361                 // device is landscape), navigation bar is on the right.
362                 insets.left = Math.max(insets.left, navbarWidth);
363             } else {
364                 insets.right = Math.max(insets.right, navbarWidth);
365             }
366             result.add(new WindowBounds(bounds, insets, i));
367         }
368         return result;
369     }
370 
371     /**
372      * Wrapper around the utility method for easier emulation
373      */
getDimenByName(Resources res, String resName)374     protected int getDimenByName(Resources res, String resName) {
375         return ResourceUtils.getDimenByName(resName, res, 0);
376     }
377 
378     /**
379      * Wrapper around the utility method for easier emulation
380      */
getDimenByName(Resources res, String resName, String fallback)381     protected int getDimenByName(Resources res, String resName, String fallback) {
382         int dimen = ResourceUtils.getDimenByName(resName, res, -1);
383         return dimen > -1 ? dimen : getDimenByName(res, fallback);
384     }
385 
isGestureNav(Context context)386     protected boolean isGestureNav(Context context) {
387         return ResourceUtils.getIntegerByName("config_navBarInteractionMode",
388                 context.getResources(), INVALID_RESOURCE_HANDLE) == 2;
389     }
390 
391     /**
392      * Returns a CachedDisplayInfo initialized for the current display
393      */
394     @TargetApi(Build.VERSION_CODES.S)
getDisplayInfo(Context displayInfoContext)395     public CachedDisplayInfo getDisplayInfo(Context displayInfoContext) {
396         int rotation = getRotation(displayInfoContext);
397         if (Utilities.ATLEAST_S) {
398             WindowMetrics windowMetrics = displayInfoContext.getSystemService(WindowManager.class)
399                     .getMaximumWindowMetrics();
400             return getDisplayInfo(windowMetrics, rotation);
401         } else {
402             Point size = new Point();
403             Display display = getDisplay(displayInfoContext);
404             display.getRealSize(size);
405             return new CachedDisplayInfo(size, rotation);
406         }
407     }
408 
409     /**
410      * Returns a CachedDisplayInfo initialized for the current display
411      */
412     @TargetApi(Build.VERSION_CODES.S)
getDisplayInfo(WindowMetrics windowMetrics, int rotation)413     protected CachedDisplayInfo getDisplayInfo(WindowMetrics windowMetrics, int rotation) {
414         Point size = new Point(windowMetrics.getBounds().right, windowMetrics.getBounds().bottom);
415         return new CachedDisplayInfo(size, rotation,
416                 windowMetrics.getWindowInsets().getDisplayCutout());
417     }
418 
419     /**
420      * Returns bounds of the display associated with the context, or bounds of DEFAULT_DISPLAY
421      * if the context isn't associated with a display.
422      */
getCurrentBounds(Context displayInfoContext)423     public Rect getCurrentBounds(Context displayInfoContext) {
424         Resources res = displayInfoContext.getResources();
425         Configuration config = res.getConfiguration();
426 
427         float screenWidth = config.screenWidthDp * res.getDisplayMetrics().density;
428         float screenHeight = config.screenHeightDp * res.getDisplayMetrics().density;
429 
430         return new Rect(0, 0, (int) screenWidth, (int) screenHeight);
431     }
432 
433     /**
434      * Returns rotation of the display associated with the context, or rotation of DEFAULT_DISPLAY
435      * if the context isn't associated with a display.
436      */
getRotation(Context displayInfoContext)437     public int getRotation(Context displayInfoContext) {
438         return getDisplay(displayInfoContext).getRotation();
439     }
440 
441     /**
442      * Returns the display associated with the context, or DEFAULT_DISPLAY if the context isn't
443      * associated with a display.
444      */
getDisplay(Context displayInfoContext)445     protected Display getDisplay(Context displayInfoContext) {
446         try {
447             return displayInfoContext.getDisplay();
448         } catch (UnsupportedOperationException e) {
449             // Ignore
450         }
451         return displayInfoContext.getSystemService(DisplayManager.class).getDisplay(
452                 DEFAULT_DISPLAY);
453     }
454 
455     /**
456      * Returns a DisplayCutout which represents a rotated version of the original
457      */
rotateCutout(DisplayCutout original, int startWidth, int startHeight, int fromRotation, int toRotation)458     protected DisplayCutout rotateCutout(DisplayCutout original, int startWidth, int startHeight,
459             int fromRotation, int toRotation) {
460         Rect safeCutout = getSafeInsets(original);
461         rotateRect(safeCutout, deltaRotation(fromRotation, toRotation));
462         return new DisplayCutout(Insets.of(safeCutout), null, null, null, null);
463     }
464 
465     /**
466      * Returns the current navigation mode from resource.
467      */
getNavigationMode(Context context)468     public NavigationMode getNavigationMode(Context context) {
469         int modeInt = ResourceUtils.getIntegerByName(NAV_BAR_INTERACTION_MODE_RES_NAME,
470                 context.getResources(), INVALID_RESOURCE_HANDLE);
471 
472         if (modeInt == INVALID_RESOURCE_HANDLE) {
473             Log.e(TAG, "Failed to get system resource ID. Incompatible framework version?");
474         } else {
475             for (NavigationMode m : NavigationMode.values()) {
476                 if (m.resValue == modeInt) {
477                     return m;
478                 }
479             }
480         }
481         return Utilities.ATLEAST_S ? NavigationMode.NO_BUTTON :
482                 NavigationMode.THREE_BUTTONS;
483     }
484 
485     @Override
close()486     public void close() { }
487 
488     /**
489      * @see DisplayCutout#getSafeInsets
490      */
getSafeInsets(DisplayCutout cutout)491     public static Rect getSafeInsets(DisplayCutout cutout) {
492         return new Rect(cutout.getSafeInsetLeft(), cutout.getSafeInsetTop(),
493                 cutout.getSafeInsetRight(), cutout.getSafeInsetBottom());
494     }
495 }
496