1 /*
2  * Copyright (C) 2019 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.google.android.setupcompat.util;
18 
19 import android.annotation.SuppressLint;
20 import android.annotation.TargetApi;
21 import android.app.Dialog;
22 import android.content.Context;
23 import android.content.res.TypedArray;
24 import android.os.Build.VERSION;
25 import android.os.Build.VERSION_CODES;
26 import android.os.Handler;
27 import androidx.annotation.RequiresPermission;
28 import android.util.Log;
29 import android.view.View;
30 import android.view.ViewGroup;
31 import android.view.Window;
32 import android.view.WindowInsets;
33 import android.view.WindowManager;
34 
35 /**
36  * A helper class to manage the system navigation bar and status bar. This will add various
37  * systemUiVisibility flags to the given Window or View to make them follow the Setup Wizard style.
38  *
39  * <p>When the useImmersiveMode intent extra is true, a screen in Setup Wizard should hide the
40  * system bars using methods from this class. For Lollipop, {@link
41  * #hideSystemBars(android.view.Window)} will completely hide the system navigation bar and change
42  * the status bar to transparent, and layout the screen contents (usually the illustration) behind
43  * it.
44  */
45 public final class SystemBarHelper {
46 
47   private static final String TAG = "SystemBarHelper";
48 
49   /** Needs to be equal to View.STATUS_BAR_DISABLE_BACK */
50   private static final int STATUS_BAR_DISABLE_BACK = 0x00400000;
51 
52   @SuppressLint("InlinedApi")
53   public static final int DEFAULT_IMMERSIVE_FLAGS =
54       View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
55           | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
56           | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
57           | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION;
58 
59   @SuppressLint("InlinedApi")
60   public static final int DIALOG_IMMERSIVE_FLAGS =
61       View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
62 
63   /**
64    * The maximum number of retries when peeking the decor view. When polling for the decor view,
65    * waiting it to be installed, set a maximum number of retries.
66    */
67   private static final int PEEK_DECOR_VIEW_RETRIES = 3;
68 
69   /** Convenience method to add a visibility flag in addition to the existing ones. */
addVisibilityFlag(final View view, final int flag)70   public static void addVisibilityFlag(final View view, final int flag) {
71     final int vis = view.getSystemUiVisibility();
72     view.setSystemUiVisibility(vis | flag);
73   }
74 
75   /** Convenience method to add a visibility flag in addition to the existing ones. */
addVisibilityFlag(final Window window, final int flag)76   public static void addVisibilityFlag(final Window window, final int flag) {
77     WindowManager.LayoutParams attrs = window.getAttributes();
78     attrs.systemUiVisibility |= flag;
79     window.setAttributes(attrs);
80   }
81 
82   /**
83    * Convenience method to remove a visibility flag from the view, leaving other flags that are not
84    * specified intact.
85    */
removeVisibilityFlag(final View view, final int flag)86   public static void removeVisibilityFlag(final View view, final int flag) {
87     final int vis = view.getSystemUiVisibility();
88     view.setSystemUiVisibility(vis & ~flag);
89   }
90 
91   /**
92    * Convenience method to remove a visibility flag from the window, leaving other flags that are
93    * not specified intact.
94    */
removeVisibilityFlag(final Window window, final int flag)95   public static void removeVisibilityFlag(final Window window, final int flag) {
96     WindowManager.LayoutParams attrs = window.getAttributes();
97     attrs.systemUiVisibility &= ~flag;
98     window.setAttributes(attrs);
99   }
100 
101   /**
102    * Add the specified immersive flags to the decor view of the window, because {@link
103    * View#SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN} only takes effect when it is added to a view instead of
104    * the window.
105    */
addImmersiveFlagsToDecorView(final Window window, final int vis)106   public static void addImmersiveFlagsToDecorView(final Window window, final int vis) {
107     getDecorView(
108         window,
109         new OnDecorViewInstalledListener() {
110           @Override
111           public void onDecorViewInstalled(View decorView) {
112             addVisibilityFlag(decorView, vis);
113           }
114         });
115   }
116 
removeImmersiveFlagsFromDecorView(final Window window, final int vis)117   public static void removeImmersiveFlagsFromDecorView(final Window window, final int vis) {
118     getDecorView(
119         window,
120         new OnDecorViewInstalledListener() {
121           @Override
122           public void onDecorViewInstalled(View decorView) {
123             removeVisibilityFlag(decorView, vis);
124           }
125         });
126   }
127 
128   /**
129    * Hide the navigation bar for a dialog.
130    *
131    * <p>This will only take effect in versions Lollipop or above. Otherwise this is a no-op.
132    *
133    * @deprecated If the layout is instance of TemplateLayout, please use
134    *     SystemNavBarMixin.hideSystemBars.
135    */
136   @Deprecated
hideSystemBars(final Dialog dialog)137   public static void hideSystemBars(final Dialog dialog) {
138     if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
139       final Window window = dialog.getWindow();
140       temporarilyDisableDialogFocus(window);
141       SystemBarHelper.addVisibilityFlag(window, SystemBarHelper.DIALOG_IMMERSIVE_FLAGS);
142       SystemBarHelper.addImmersiveFlagsToDecorView(window, SystemBarHelper.DIALOG_IMMERSIVE_FLAGS);
143 
144       // Also set the navigation bar and status bar to transparent color. Note that this
145       // doesn't work if android.R.boolean.config_enableTranslucentDecor is false.
146       window.setNavigationBarColor(0);
147       window.setStatusBarColor(0);
148     }
149   }
150 
151   /**
152    * Hide the navigation bar, make the color of the status and navigation bars transparent, and
153    * specify {@link View#SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN} flag so that the content is laid-out
154    * behind the transparent status bar. This is commonly used with {@link
155    * android.app.Activity#getWindow()} to make the navigation and status bars follow the Setup
156    * Wizard style.
157    *
158    * <p>This will only take effect in versions Lollipop or above. Otherwise this is a no-op.
159    *
160    * @deprecated If the layout instance of TemplateLayout, please use
161    *     SystemNavBarMixin.hideSystemBars.
162    */
163   @Deprecated
hideSystemBars(final Window window)164   public static void hideSystemBars(final Window window) {
165     if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
166       SystemBarHelper.addVisibilityFlag(window, SystemBarHelper.DEFAULT_IMMERSIVE_FLAGS);
167       SystemBarHelper.addImmersiveFlagsToDecorView(window, SystemBarHelper.DEFAULT_IMMERSIVE_FLAGS);
168 
169       // Also set the navigation bar and status bar to transparent color. Note that this
170       // doesn't work if android.R.boolean.config_enableTranslucentDecor is false.
171       window.setNavigationBarColor(0);
172       window.setStatusBarColor(0);
173     }
174   }
175 
176   /**
177    * Revert the actions of hideSystemBars. Note that this will remove the system UI visibility flags
178    * regardless of whether it is originally present. You should also manually reset the navigation
179    * bar and status bar colors, as this method doesn't know what value to revert it to.
180    *
181    * @deprecated If the layout is instance of TemplateLayout, please use
182    *     SystemNavBarMixin.showSystemBars.
183    */
184   @Deprecated
showSystemBars(final Window window, final Context context)185   public static void showSystemBars(final Window window, final Context context) {
186     if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
187       SystemBarHelper.removeVisibilityFlag(window, SystemBarHelper.DEFAULT_IMMERSIVE_FLAGS);
188       SystemBarHelper.removeImmersiveFlagsFromDecorView(
189           window, SystemBarHelper.DEFAULT_IMMERSIVE_FLAGS);
190 
191       if (context != null) {
192         //noinspection AndroidLintInlinedApi
193         final TypedArray typedArray =
194             context.obtainStyledAttributes(
195                 new int[] {android.R.attr.statusBarColor, android.R.attr.navigationBarColor});
196         final int statusBarColor = typedArray.getColor(0, 0);
197         final int navigationBarColor = typedArray.getColor(1, 0);
198         window.setStatusBarColor(statusBarColor);
199         window.setNavigationBarColor(navigationBarColor);
200         typedArray.recycle();
201       }
202     }
203   }
204 
205   /**
206    * Sets whether the back button on the software navigation bar is visible. This only works if you
207    * have the STATUS_BAR permission. Otherwise framework will filter out this flag and this method
208    * call will not have any effect.
209    *
210    * <p>IMPORTANT: Do not assume that users have no way to go back when the back button is hidden.
211    * Many devices have physical back buttons, and accessibility services like TalkBack may have
212    * gestures mapped to back. Please use onBackPressed, onKeyDown, or other similar ways to make
213    * sure back button events are still handled (or ignored) properly.
214    */
215   @RequiresPermission("android.permission.STATUS_BAR")
setBackButtonVisible(final Window window, final boolean visible)216   public static void setBackButtonVisible(final Window window, final boolean visible) {
217     if (visible) {
218       SystemBarHelper.removeVisibilityFlag(window, STATUS_BAR_DISABLE_BACK);
219       SystemBarHelper.removeImmersiveFlagsFromDecorView(window, STATUS_BAR_DISABLE_BACK);
220     } else {
221       SystemBarHelper.addVisibilityFlag(window, STATUS_BAR_DISABLE_BACK);
222       SystemBarHelper.addImmersiveFlagsToDecorView(window, STATUS_BAR_DISABLE_BACK);
223     }
224   }
225 
226   /**
227    * Set a view to be resized when the keyboard is shown. This will set the bottom margin of the
228    * view to be immediately above the keyboard, and assumes that the view sits immediately above the
229    * navigation bar.
230    *
231    * <p>Note that you must set {@link android.R.attr#windowSoftInputMode} to {@code adjustResize}
232    * for this class to work. Otherwise window insets are not dispatched and this method will have no
233    * effect.
234    *
235    * <p>This will only take effect in versions Lollipop or above. Otherwise this is a no-op.
236    *
237    * @param view The view to be resized when the keyboard is shown.
238    */
setImeInsetView(final View view)239   public static void setImeInsetView(final View view) {
240     if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
241       view.setOnApplyWindowInsetsListener(new WindowInsetsListener());
242     }
243   }
244 
245   /**
246    * Apply a hack to temporarily set the window to not focusable, so that the navigation bar will
247    * not show up during the transition.
248    */
temporarilyDisableDialogFocus(final Window window)249   private static void temporarilyDisableDialogFocus(final Window window) {
250     window.setFlags(
251         WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
252         WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE);
253     // Add the SOFT_INPUT_IS_FORWARD_NAVIGATION_FLAG. This is normally done by the system when
254     // FLAG_NOT_FOCUSABLE is not set. Setting this flag allows IME to be shown automatically
255     // if the dialog has editable text fields.
256     window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_IS_FORWARD_NAVIGATION);
257     new Handler()
258         .post(
259             new Runnable() {
260               @Override
261               public void run() {
262                 window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE);
263               }
264             });
265   }
266 
267   @TargetApi(VERSION_CODES.LOLLIPOP)
268   private static class WindowInsetsListener implements View.OnApplyWindowInsetsListener {
269     private int bottomOffset;
270     private boolean hasCalculatedBottomOffset = false;
271 
272     @Override
onApplyWindowInsets(View view, WindowInsets insets)273     public WindowInsets onApplyWindowInsets(View view, WindowInsets insets) {
274       if (!hasCalculatedBottomOffset) {
275         bottomOffset = getBottomDistance(view);
276         hasCalculatedBottomOffset = true;
277       }
278 
279       int bottomInset = insets.getSystemWindowInsetBottom();
280 
281       final int bottomMargin = Math.max(insets.getSystemWindowInsetBottom() - bottomOffset, 0);
282 
283       final ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams) view.getLayoutParams();
284       // Check that we have enough space to apply the bottom margins before applying it.
285       // Otherwise the framework may think that the view is empty and exclude it from layout.
286       if (bottomMargin < lp.bottomMargin + view.getHeight()) {
287         lp.setMargins(lp.leftMargin, lp.topMargin, lp.rightMargin, bottomMargin);
288         view.setLayoutParams(lp);
289         bottomInset = 0;
290       }
291 
292       return insets.replaceSystemWindowInsets(
293           insets.getSystemWindowInsetLeft(),
294           insets.getSystemWindowInsetTop(),
295           insets.getSystemWindowInsetRight(),
296           bottomInset);
297     }
298   }
299 
getBottomDistance(View view)300   private static int getBottomDistance(View view) {
301     int[] coords = new int[2];
302     view.getLocationInWindow(coords);
303     return view.getRootView().getHeight() - coords[1] - view.getHeight();
304   }
305 
getDecorView(Window window, OnDecorViewInstalledListener callback)306   private static void getDecorView(Window window, OnDecorViewInstalledListener callback) {
307     new DecorViewFinder().getDecorView(window, callback, PEEK_DECOR_VIEW_RETRIES);
308   }
309 
310   private static class DecorViewFinder {
311 
312     private final Handler handler = new Handler();
313     private Window window;
314     private int retries;
315     private OnDecorViewInstalledListener callback;
316 
317     private final Runnable checkDecorViewRunnable =
318         new Runnable() {
319           @Override
320           public void run() {
321             // Use peekDecorView instead of getDecorView so that clients can still set window
322             // features after calling this method.
323             final View decorView = window.peekDecorView();
324             if (decorView != null) {
325               callback.onDecorViewInstalled(decorView);
326             } else {
327               retries--;
328               if (retries >= 0) {
329                 // If the decor view is not installed yet, try again in the next loop.
330                 handler.post(checkDecorViewRunnable);
331               } else {
332                 Log.w(TAG, "Cannot get decor view of window: " + window);
333               }
334             }
335           }
336         };
337 
getDecorView(Window window, OnDecorViewInstalledListener callback, int retries)338     public void getDecorView(Window window, OnDecorViewInstalledListener callback, int retries) {
339       this.window = window;
340       this.retries = retries;
341       this.callback = callback;
342       checkDecorViewRunnable.run();
343     }
344   }
345 
346   private interface OnDecorViewInstalledListener {
347 
onDecorViewInstalled(View decorView)348     void onDecorViewInstalled(View decorView);
349   }
350 }
351