1 /*
2  * Copyright 2021 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.shared.rotation;
18 
19 import static android.content.pm.PackageManager.FEATURE_PC;
20 import static android.os.Process.THREAD_PRIORITY_BACKGROUND;
21 import static android.view.Display.DEFAULT_DISPLAY;
22 
23 import static com.android.internal.view.RotationPolicy.NATURAL_ROTATION;
24 import static com.android.systemui.shared.system.QuickStepContract.isGesturalMode;
25 
26 import android.animation.Animator;
27 import android.animation.AnimatorListenerAdapter;
28 import android.animation.ObjectAnimator;
29 import android.annotation.ColorInt;
30 import android.annotation.DrawableRes;
31 import android.annotation.SuppressLint;
32 import android.app.StatusBarManager;
33 import android.content.BroadcastReceiver;
34 import android.content.ContentResolver;
35 import android.content.Context;
36 import android.content.Intent;
37 import android.content.IntentFilter;
38 import android.graphics.drawable.AnimatedVectorDrawable;
39 import android.graphics.drawable.Drawable;
40 import android.os.Handler;
41 import android.os.HandlerExecutor;
42 import android.os.HandlerThread;
43 import android.os.Looper;
44 import android.os.RemoteException;
45 import android.os.SystemProperties;
46 import android.provider.Settings;
47 import android.util.Log;
48 import android.view.HapticFeedbackConstants;
49 import android.view.IRotationWatcher;
50 import android.view.MotionEvent;
51 import android.view.Surface;
52 import android.view.View;
53 import android.view.WindowInsetsController;
54 import android.view.WindowManagerGlobal;
55 import android.view.accessibility.AccessibilityManager;
56 import android.view.animation.Interpolator;
57 import android.view.animation.LinearInterpolator;
58 
59 import com.android.internal.annotations.VisibleForTesting;
60 import com.android.internal.logging.UiEvent;
61 import com.android.internal.logging.UiEventLogger;
62 import com.android.internal.logging.UiEventLoggerImpl;
63 import com.android.internal.view.RotationPolicy;
64 import com.android.systemui.shared.recents.utilities.Utilities;
65 import com.android.systemui.shared.recents.utilities.ViewRippler;
66 import com.android.systemui.shared.rotation.RotationButton.RotationButtonUpdatesCallback;
67 import com.android.systemui.shared.system.ActivityManagerWrapper;
68 import com.android.systemui.shared.system.TaskStackChangeListener;
69 import com.android.systemui.shared.system.TaskStackChangeListeners;
70 
71 import java.io.PrintWriter;
72 import java.util.Optional;
73 import java.util.concurrent.Executor;
74 import java.util.function.Supplier;
75 
76 /**
77  * Contains logic that deals with showing a rotate suggestion button with animation.
78  */
79 public class RotationButtonController {
80 
81     private static final String TAG = "RotationButtonController";
82     private static final int BUTTON_FADE_IN_OUT_DURATION_MS = 100;
83     private static final int NAVBAR_HIDDEN_PENDING_ICON_TIMEOUT_MS = 20000;
84     private static final boolean OEM_DISALLOW_ROTATION_IN_SUW =
85             SystemProperties.getBoolean("ro.setupwizard.rotation_locked", false);
86     private static final Interpolator LINEAR_INTERPOLATOR = new LinearInterpolator();
87 
88     private static final int NUM_ACCEPTED_ROTATION_SUGGESTIONS_FOR_INTRODUCTION = 3;
89 
90     private final Context mContext;
91     private final Handler mMainThreadHandler = new Handler(Looper.getMainLooper());
92     private final UiEventLogger mUiEventLogger = new UiEventLoggerImpl();
93     private final ViewRippler mViewRippler = new ViewRippler();
94     private final Supplier<Integer> mWindowRotationProvider;
95     private RotationButton mRotationButton;
96 
97     private boolean mIsRecentsAnimationRunning;
98     private boolean mDocked;
99     private boolean mHomeRotationEnabled;
100     private int mLastRotationSuggestion;
101     private boolean mPendingRotationSuggestion;
102     private boolean mHoveringRotationSuggestion;
103     private final AccessibilityManager mAccessibilityManager;
104     private final TaskStackListenerImpl mTaskStackListener;
105 
106     private boolean mListenersRegistered = false;
107     private boolean mRotationWatcherRegistered = false;
108     private boolean mIsNavigationBarShowing;
109     @SuppressLint("InlinedApi")
110     private @WindowInsetsController.Behavior
111     int mBehavior = WindowInsetsController.BEHAVIOR_DEFAULT;
112     private int mNavBarMode;
113     private boolean mTaskBarVisible = false;
114     private boolean mSkipOverrideUserLockPrefsOnce;
115     private final int mLightIconColor;
116     private final int mDarkIconColor;
117 
118     @DrawableRes
119     private final int mIconCcwStart0ResId;
120     @DrawableRes
121     private final int mIconCcwStart90ResId;
122     @DrawableRes
123     private final int mIconCwStart0ResId;
124     @DrawableRes
125     private final int mIconCwStart90ResId;
126     /** Defaults to mainExecutor if not set via {@link #setBgExecutor(Executor)}. */
127     private Executor mBgExecutor;
128 
129     @DrawableRes
130     private int mIconResId;
131 
132     private final Runnable mRemoveRotationProposal =
133             () -> setRotateSuggestionButtonState(false /* visible */);
134     private final Runnable mCancelPendingRotationProposal =
135             () -> mPendingRotationSuggestion = false;
136     private Animator mRotateHideAnimator;
137 
138     private final BroadcastReceiver mDockedReceiver = new BroadcastReceiver() {
139         @Override
140         public void onReceive(Context context, Intent intent) {
141             updateDockedState(intent);
142         }
143     };
144 
145     private final IRotationWatcher.Stub mRotationWatcher = new IRotationWatcher.Stub() {
146         @Override
147         public void onRotationChanged(final int rotation) {
148             // We need this to be scheduled as early as possible to beat the redrawing of
149             // window in response to the orientation change.
150             mMainThreadHandler.postAtFrontOfQueue(() -> {
151                 onRotationWatcherChanged(rotation);
152             });
153         }
154     };
155 
156     /**
157      * Determines if rotation suggestions disabled2 flag exists in flag
158      *
159      * @param disable2Flags see if rotation suggestion flag exists in this flag
160      * @return whether flag exists
161      */
hasDisable2RotateSuggestionFlag(int disable2Flags)162     public static boolean hasDisable2RotateSuggestionFlag(int disable2Flags) {
163         return (disable2Flags & StatusBarManager.DISABLE2_ROTATE_SUGGESTIONS) != 0;
164     }
165 
RotationButtonController(Context context, @ColorInt int lightIconColor, @ColorInt int darkIconColor, @DrawableRes int iconCcwStart0ResId, @DrawableRes int iconCcwStart90ResId, @DrawableRes int iconCwStart0ResId, @DrawableRes int iconCwStart90ResId, Supplier<Integer> windowRotationProvider)166     public RotationButtonController(Context context,
167         @ColorInt int lightIconColor, @ColorInt int darkIconColor,
168         @DrawableRes int iconCcwStart0ResId,
169         @DrawableRes int iconCcwStart90ResId,
170         @DrawableRes int iconCwStart0ResId,
171         @DrawableRes int iconCwStart90ResId,
172         Supplier<Integer> windowRotationProvider) {
173 
174         mContext = context;
175         mLightIconColor = lightIconColor;
176         mDarkIconColor = darkIconColor;
177 
178         mIconCcwStart0ResId = iconCcwStart0ResId;
179         mIconCcwStart90ResId = iconCcwStart90ResId;
180         mIconCwStart0ResId = iconCwStart0ResId;
181         mIconCwStart90ResId = iconCwStart90ResId;
182         mIconResId = mIconCcwStart90ResId;
183 
184         mAccessibilityManager = AccessibilityManager.getInstance(context);
185         mTaskStackListener = new TaskStackListenerImpl();
186         mWindowRotationProvider = windowRotationProvider;
187 
188         mBgExecutor = context.getMainExecutor();
189     }
190 
setRotationButton(RotationButton rotationButton, RotationButtonUpdatesCallback updatesCallback)191     public void setRotationButton(RotationButton rotationButton,
192                                   RotationButtonUpdatesCallback updatesCallback) {
193         mRotationButton = rotationButton;
194         mRotationButton.setRotationButtonController(this);
195         mRotationButton.setOnClickListener(this::onRotateSuggestionClick);
196         mRotationButton.setOnHoverListener(this::onRotateSuggestionHover);
197         mRotationButton.setUpdatesCallback(updatesCallback);
198     }
199 
getContext()200     public Context getContext() {
201         return mContext;
202     }
203 
setBgExecutor(Executor bgExecutor)204     public void setBgExecutor(Executor bgExecutor) {
205         mBgExecutor = bgExecutor;
206     }
207 
208     /**
209      * Called during Taskbar initialization.
210      */
init()211     public void init() {
212         registerListeners(true /* registerRotationWatcher */);
213         if (mContext.getDisplay().getDisplayId() != DEFAULT_DISPLAY) {
214             // Currently there is no accelerometer sensor on non-default display, disable fixed
215             // rotation for non-default display
216             onDisable2FlagChanged(StatusBarManager.DISABLE2_ROTATE_SUGGESTIONS);
217         }
218     }
219 
220     /**
221      * Called during Taskbar uninitialization.
222      */
onDestroy()223     public void onDestroy() {
224         unregisterListeners();
225     }
226 
registerListeners(boolean registerRotationWatcher)227     public void registerListeners(boolean registerRotationWatcher) {
228         if (mListenersRegistered || getContext().getPackageManager().hasSystemFeature(FEATURE_PC)) {
229             return;
230         }
231 
232         mListenersRegistered = true;
233 
234         mBgExecutor.execute(() -> {
235             final Intent intent = mContext.registerReceiver(mDockedReceiver,
236                     new IntentFilter(Intent.ACTION_DOCK_EVENT));
237             mContext.getMainExecutor().execute(() -> updateDockedState(intent));
238         });
239 
240         if (registerRotationWatcher) {
241             try {
242                 WindowManagerGlobal.getWindowManagerService()
243                         .watchRotation(mRotationWatcher, DEFAULT_DISPLAY);
244                 mRotationWatcherRegistered = true;
245             } catch (IllegalArgumentException e) {
246                 mListenersRegistered = false;
247                 Log.w(TAG, "RegisterListeners for the display failed", e);
248             } catch (RemoteException e) {
249                 Log.e(TAG, "RegisterListeners caught a RemoteException", e);
250                 return;
251             }
252         }
253 
254         TaskStackChangeListeners.getInstance().registerTaskStackListener(mTaskStackListener);
255     }
256 
unregisterListeners()257     public void unregisterListeners() {
258         if (!mListenersRegistered) {
259             return;
260         }
261 
262         mListenersRegistered = false;
263 
264         mBgExecutor.execute(() -> {
265             try {
266                 mContext.unregisterReceiver(mDockedReceiver);
267             } catch (IllegalArgumentException e) {
268                 Log.e(TAG, "Docked receiver already unregistered", e);
269             }
270         });
271 
272         if (mRotationWatcherRegistered) {
273             try {
274                 WindowManagerGlobal.getWindowManagerService().removeRotationWatcher(
275                         mRotationWatcher);
276             } catch (RemoteException e) {
277                 Log.e(TAG, "UnregisterListeners caught a RemoteException", e);
278                 return;
279             }
280         }
281 
282         TaskStackChangeListeners.getInstance().unregisterTaskStackListener(mTaskStackListener);
283     }
284 
setRotationLockedAtAngle(int rotationSuggestion)285     public void setRotationLockedAtAngle(int rotationSuggestion) {
286         final Boolean isLocked = isRotationLocked();
287         if (isLocked == null) {
288             // Ignore if we can't read the setting for the current user
289             return;
290         }
291         RotationPolicy.setRotationLockAtAngle(mContext, /* enabled= */ isLocked,
292                 /* rotation= */ rotationSuggestion);
293     }
294 
295     /**
296      * @return whether rotation is currently locked, or <code>null</code> if the setting couldn't
297      *         be read
298      */
isRotationLocked()299     public Boolean isRotationLocked() {
300         try {
301             return RotationPolicy.isRotationLocked(mContext);
302         } catch (SecurityException e) {
303             // TODO(b/279561841): RotationPolicy uses the current user to resolve the setting which
304             //                    may change before the rotation watcher can be unregistered
305             Log.e(TAG, "Failed to get isRotationLocked", e);
306             return null;
307         }
308     }
309 
setRotateSuggestionButtonState(boolean visible)310     public void setRotateSuggestionButtonState(boolean visible) {
311         setRotateSuggestionButtonState(visible, false /* force */);
312     }
313 
setRotateSuggestionButtonState(final boolean visible, final boolean force)314     void setRotateSuggestionButtonState(final boolean visible, final boolean force) {
315         // At any point the button can become invisible because an a11y service became active.
316         // Similarly, a call to make the button visible may be rejected because an a11y service is
317         // active. Must account for this.
318         // Rerun a show animation to indicate change but don't rerun a hide animation
319         if (!visible && !mRotationButton.isVisible()) return;
320 
321         final View view = mRotationButton.getCurrentView();
322         if (view == null) return;
323 
324         final Drawable currentDrawable = mRotationButton.getImageDrawable();
325         if (currentDrawable == null) return;
326 
327         // Clear any pending suggestion flag as it has either been nullified or is being shown
328         mPendingRotationSuggestion = false;
329         mMainThreadHandler.removeCallbacks(mCancelPendingRotationProposal);
330 
331         // Handle the visibility change and animation
332         if (visible) { // Appear and change (cannot force)
333             // Stop and clear any currently running hide animations
334             if (mRotateHideAnimator != null && mRotateHideAnimator.isRunning()) {
335                 mRotateHideAnimator.cancel();
336             }
337             mRotateHideAnimator = null;
338 
339             // Reset the alpha if any has changed due to hide animation
340             view.setAlpha(1f);
341 
342             // Run the rotate icon's animation if it has one
343             if (currentDrawable instanceof AnimatedVectorDrawable) {
344                 ((AnimatedVectorDrawable) currentDrawable).reset();
345                 ((AnimatedVectorDrawable) currentDrawable).start();
346             }
347 
348             // TODO(b/187754252): No idea why this doesn't work. If we remove the "false"
349             //  we see the animation show the pressed state... but it only shows the first time.
350             if (!isRotateSuggestionIntroduced()) mViewRippler.start(view);
351 
352             // Set visibility unless a11y service is active.
353             mRotationButton.show();
354         } else { // Hide
355             mViewRippler.stop(); // Prevent any pending ripples, force hide or not
356 
357             if (force) {
358                 // If a hide animator is running stop it and make invisible
359                 if (mRotateHideAnimator != null && mRotateHideAnimator.isRunning()) {
360                     mRotateHideAnimator.pause();
361                 }
362                 mRotationButton.hide();
363                 return;
364             }
365 
366             // Don't start any new hide animations if one is running
367             if (mRotateHideAnimator != null && mRotateHideAnimator.isRunning()) return;
368 
369             ObjectAnimator fadeOut = ObjectAnimator.ofFloat(view, "alpha", 0f);
370             fadeOut.setDuration(BUTTON_FADE_IN_OUT_DURATION_MS);
371             fadeOut.setInterpolator(LINEAR_INTERPOLATOR);
372             fadeOut.addListener(new AnimatorListenerAdapter() {
373                 @Override
374                 public void onAnimationEnd(Animator animation) {
375                     mRotationButton.hide();
376                 }
377             });
378 
379             mRotateHideAnimator = fadeOut;
380             fadeOut.start();
381         }
382     }
383 
setDarkIntensity(float darkIntensity)384     public void setDarkIntensity(float darkIntensity) {
385         mRotationButton.setDarkIntensity(darkIntensity);
386     }
387 
setRecentsAnimationRunning(boolean running)388     public void setRecentsAnimationRunning(boolean running) {
389         mIsRecentsAnimationRunning = running;
390         updateRotationButtonStateInOverview();
391     }
392 
setHomeRotationEnabled(boolean enabled)393     public void setHomeRotationEnabled(boolean enabled) {
394         mHomeRotationEnabled = enabled;
395         updateRotationButtonStateInOverview();
396     }
397 
updateDockedState(Intent intent)398     private void updateDockedState(Intent intent) {
399         if (intent == null) {
400             return;
401         }
402 
403         mDocked = intent.getIntExtra(Intent.EXTRA_DOCK_STATE, Intent.EXTRA_DOCK_STATE_UNDOCKED)
404                 != Intent.EXTRA_DOCK_STATE_UNDOCKED;
405     }
406 
updateRotationButtonStateInOverview()407     private void updateRotationButtonStateInOverview() {
408         if (mIsRecentsAnimationRunning && !mHomeRotationEnabled) {
409             setRotateSuggestionButtonState(false, true /* hideImmediately */);
410         }
411     }
412 
onRotationProposal(int rotation, boolean isValid)413     public void onRotationProposal(int rotation, boolean isValid) {
414         boolean isUserSetupComplete = Settings.Secure.getInt(mContext.getContentResolver(),
415                 Settings.Secure.USER_SETUP_COMPLETE, 0) != 0;
416         if (!isUserSetupComplete && OEM_DISALLOW_ROTATION_IN_SUW) {
417             return;
418         }
419 
420         int windowRotation = mWindowRotationProvider.get();
421 
422         if (!mRotationButton.acceptRotationProposal()) {
423             return;
424         }
425 
426         if (!mHomeRotationEnabled && mIsRecentsAnimationRunning) {
427             return;
428         }
429 
430         // This method will be called on rotation suggestion changes even if the proposed rotation
431         // is not valid for the top app. Use invalid rotation choices as a signal to remove the
432         // rotate button if shown.
433         if (!isValid) {
434             setRotateSuggestionButtonState(false /* visible */);
435             return;
436         }
437 
438         // If window rotation matches suggested rotation, remove any current suggestions
439         if (rotation == windowRotation) {
440             mMainThreadHandler.removeCallbacks(mRemoveRotationProposal);
441             setRotateSuggestionButtonState(false /* visible */);
442             return;
443         }
444 
445         // Prepare to show the navbar icon by updating the icon style to change anim params
446         Log.i(TAG, "onRotationProposal(rotation=" + rotation + ")");
447         mLastRotationSuggestion = rotation; // Remember rotation for click
448         final boolean rotationCCW = Utilities.isRotationAnimationCCW(windowRotation, rotation);
449         if (windowRotation == Surface.ROTATION_0 || windowRotation == Surface.ROTATION_180) {
450             mIconResId = rotationCCW ? mIconCcwStart0ResId : mIconCwStart0ResId;
451         } else { // 90 or 270
452             mIconResId = rotationCCW ? mIconCcwStart90ResId : mIconCwStart90ResId;
453         }
454         mRotationButton.updateIcon(mLightIconColor, mDarkIconColor);
455 
456         if (canShowRotationButton()) {
457             // The navbar is visible / it's in visual immersive mode, so show the icon right away
458             showAndLogRotationSuggestion();
459         } else {
460             // If the navbar isn't shown, flag the rotate icon to be shown should the navbar become
461             // visible given some time limit.
462             mPendingRotationSuggestion = true;
463             mMainThreadHandler.removeCallbacks(mCancelPendingRotationProposal);
464             mMainThreadHandler.postDelayed(mCancelPendingRotationProposal,
465                     NAVBAR_HIDDEN_PENDING_ICON_TIMEOUT_MS);
466         }
467     }
468 
469     /**
470      * Called when the rotation watcher rotation changes, either from the watcher registered
471      * internally in this class, or a signal propagated from NavBarHelper.
472      */
onRotationWatcherChanged(int rotation)473     public void onRotationWatcherChanged(int rotation) {
474         if (!mListenersRegistered) {
475             // Ignore if not registered
476             return;
477         }
478 
479         // If the screen rotation changes while locked, potentially update lock to flow with
480         // new screen rotation and hide any showing suggestions.
481         Boolean rotationLocked = isRotationLocked();
482         if (rotationLocked == null) {
483             // Ignore if we can't read the setting for the current user
484             return;
485         }
486         // The isVisible check makes the rotation button disappear when we are not locked
487         // (e.g. for tabletop auto-rotate).
488         if (rotationLocked || mRotationButton.isVisible()) {
489             // Do not allow a change in rotation to set user rotation when docked.
490             if (shouldOverrideUserLockPrefs(rotation) && rotationLocked && !mDocked) {
491                 setRotationLockedAtAngle(rotation);
492             }
493             setRotateSuggestionButtonState(false /* visible */, true /* forced */);
494         }
495     }
496 
onDisable2FlagChanged(int state2)497     public void onDisable2FlagChanged(int state2) {
498         final boolean rotateSuggestionsDisabled = hasDisable2RotateSuggestionFlag(state2);
499         if (rotateSuggestionsDisabled) onRotationSuggestionsDisabled();
500     }
501 
onNavigationModeChanged(int mode)502     public void onNavigationModeChanged(int mode) {
503         mNavBarMode = mode;
504     }
505 
onBehaviorChanged(int displayId, @WindowInsetsController.Behavior int behavior)506     public void onBehaviorChanged(int displayId, @WindowInsetsController.Behavior int behavior) {
507         if (DEFAULT_DISPLAY != displayId) {
508             return;
509         }
510 
511         if (mBehavior != behavior) {
512             mBehavior = behavior;
513             showPendingRotationButtonIfNeeded();
514         }
515     }
516 
onNavigationBarWindowVisibilityChange(boolean showing)517     public void onNavigationBarWindowVisibilityChange(boolean showing) {
518         if (mIsNavigationBarShowing != showing) {
519             mIsNavigationBarShowing = showing;
520             showPendingRotationButtonIfNeeded();
521         }
522     }
523 
onTaskbarStateChange(boolean visible, boolean stashed)524     public void onTaskbarStateChange(boolean visible, boolean stashed) {
525         mTaskBarVisible = visible;
526         if (getRotationButton() == null) {
527             return;
528         }
529         getRotationButton().onTaskbarStateChanged(visible, stashed);
530     }
531 
showPendingRotationButtonIfNeeded()532     private void showPendingRotationButtonIfNeeded() {
533         if (canShowRotationButton() && mPendingRotationSuggestion) {
534             showAndLogRotationSuggestion();
535         }
536     }
537 
538     /**
539      * Return true when either the task bar is visible or it's in visual immersive mode.
540      */
541     @SuppressLint("InlinedApi")
542     @VisibleForTesting
canShowRotationButton()543     boolean canShowRotationButton() {
544         return mIsNavigationBarShowing
545             || mBehavior == WindowInsetsController.BEHAVIOR_DEFAULT
546             || isGesturalMode(mNavBarMode);
547     }
548 
549     @DrawableRes
getIconResId()550     public int getIconResId() {
551         return mIconResId;
552     }
553 
554     @ColorInt
getLightIconColor()555     public int getLightIconColor() {
556         return mLightIconColor;
557     }
558 
559     @ColorInt
getDarkIconColor()560     public int getDarkIconColor() {
561         return mDarkIconColor;
562     }
563 
dumpLogs(String prefix, PrintWriter pw)564     public void dumpLogs(String prefix, PrintWriter pw) {
565         pw.println(prefix + "RotationButtonController:");
566 
567         pw.println(String.format(
568                 "%s\tmIsRecentsAnimationRunning=%b", prefix, mIsRecentsAnimationRunning));
569         pw.println(String.format("%s\tmHomeRotationEnabled=%b", prefix, mHomeRotationEnabled));
570         pw.println(String.format(
571                 "%s\tmLastRotationSuggestion=%d", prefix, mLastRotationSuggestion));
572         pw.println(String.format(
573                 "%s\tmPendingRotationSuggestion=%b", prefix, mPendingRotationSuggestion));
574         pw.println(String.format(
575                 "%s\tmHoveringRotationSuggestion=%b", prefix, mHoveringRotationSuggestion));
576         pw.println(String.format("%s\tmListenersRegistered=%b", prefix, mListenersRegistered));
577         pw.println(String.format(
578                 "%s\tmIsNavigationBarShowing=%b", prefix, mIsNavigationBarShowing));
579         pw.println(String.format("%s\tmBehavior=%d", prefix, mBehavior));
580         pw.println(String.format(
581                 "%s\tmSkipOverrideUserLockPrefsOnce=%b", prefix, mSkipOverrideUserLockPrefsOnce));
582         pw.println(String.format(
583                 "%s\tmLightIconColor=0x%s", prefix, Integer.toHexString(mLightIconColor)));
584         pw.println(String.format(
585                 "%s\tmDarkIconColor=0x%s", prefix, Integer.toHexString(mDarkIconColor)));
586     }
587 
getRotationButton()588     public RotationButton getRotationButton() {
589         return mRotationButton;
590     }
591 
onRotateSuggestionClick(View v)592     private void onRotateSuggestionClick(View v) {
593         mUiEventLogger.log(RotationButtonEvent.ROTATION_SUGGESTION_ACCEPTED);
594         incrementNumAcceptedRotationSuggestionsIfNeeded();
595         setRotationLockedAtAngle(mLastRotationSuggestion);
596         Log.i(TAG, "onRotateSuggestionClick() mLastRotationSuggestion=" + mLastRotationSuggestion);
597         v.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
598     }
599 
onRotateSuggestionHover(View v, MotionEvent event)600     private boolean onRotateSuggestionHover(View v, MotionEvent event) {
601         final int action = event.getActionMasked();
602         mHoveringRotationSuggestion = (action == MotionEvent.ACTION_HOVER_ENTER)
603                 || (action == MotionEvent.ACTION_HOVER_MOVE);
604         rescheduleRotationTimeout(true /* reasonHover */);
605         return false; // Must return false so a11y hover events are dispatched correctly.
606     }
607 
onRotationSuggestionsDisabled()608     private void onRotationSuggestionsDisabled() {
609         // Immediately hide the rotate button and clear any planned removal
610         setRotateSuggestionButtonState(false /* visible */, true /* force */);
611         mMainThreadHandler.removeCallbacks(mRemoveRotationProposal);
612     }
613 
showAndLogRotationSuggestion()614     private void showAndLogRotationSuggestion() {
615         setRotateSuggestionButtonState(true /* visible */);
616         rescheduleRotationTimeout(false /* reasonHover */);
617         mUiEventLogger.log(RotationButtonEvent.ROTATION_SUGGESTION_SHOWN);
618     }
619 
620     /**
621      * Makes {@link #shouldOverrideUserLockPrefs} always return {@code false} once. It is used to
622      * avoid losing original user rotation when display rotation is changed by entering the fixed
623      * orientation overview.
624      */
setSkipOverrideUserLockPrefsOnce()625     public void setSkipOverrideUserLockPrefsOnce() {
626         // If live-tile is enabled (recents animation keeps running in overview), there is no
627         // activity switch so the display rotation is not changed, then it is no need to skip.
628         mSkipOverrideUserLockPrefsOnce = !mIsRecentsAnimationRunning;
629     }
630 
shouldOverrideUserLockPrefs(final int rotation)631     private boolean shouldOverrideUserLockPrefs(final int rotation) {
632         if (mSkipOverrideUserLockPrefsOnce) {
633             mSkipOverrideUserLockPrefsOnce = false;
634             return false;
635         }
636         // Only override user prefs when returning to the natural rotation (normally portrait).
637         // Don't let apps that force landscape or 180 alter user lock.
638         return rotation == NATURAL_ROTATION;
639     }
640 
rescheduleRotationTimeout(final boolean reasonHover)641     private void rescheduleRotationTimeout(final boolean reasonHover) {
642         // May be called due to a new rotation proposal or a change in hover state
643         if (reasonHover) {
644             // Don't reschedule if a hide animator is running
645             if (mRotateHideAnimator != null && mRotateHideAnimator.isRunning()) return;
646             // Don't reschedule if not visible
647             if (!mRotationButton.isVisible()) return;
648         }
649 
650         // Stop any pending removal
651         mMainThreadHandler.removeCallbacks(mRemoveRotationProposal);
652         // Schedule timeout
653         mMainThreadHandler.postDelayed(mRemoveRotationProposal,
654                 computeRotationProposalTimeout());
655     }
656 
computeRotationProposalTimeout()657     private int computeRotationProposalTimeout() {
658         return mAccessibilityManager.getRecommendedTimeoutMillis(
659                 mHoveringRotationSuggestion ? 16000 : 5000,
660                 AccessibilityManager.FLAG_CONTENT_CONTROLS);
661     }
662 
isRotateSuggestionIntroduced()663     private boolean isRotateSuggestionIntroduced() {
664         ContentResolver cr = mContext.getContentResolver();
665         return Settings.Secure.getInt(cr, Settings.Secure.NUM_ROTATION_SUGGESTIONS_ACCEPTED, 0)
666                 >= NUM_ACCEPTED_ROTATION_SUGGESTIONS_FOR_INTRODUCTION;
667     }
668 
incrementNumAcceptedRotationSuggestionsIfNeeded()669     private void incrementNumAcceptedRotationSuggestionsIfNeeded() {
670         // Get the number of accepted suggestions
671         ContentResolver cr = mContext.getContentResolver();
672         final int numSuggestions = Settings.Secure.getInt(cr,
673                 Settings.Secure.NUM_ROTATION_SUGGESTIONS_ACCEPTED, 0);
674 
675         // Increment the number of accepted suggestions only if it would change intro mode
676         if (numSuggestions < NUM_ACCEPTED_ROTATION_SUGGESTIONS_FOR_INTRODUCTION) {
677             Settings.Secure.putInt(cr, Settings.Secure.NUM_ROTATION_SUGGESTIONS_ACCEPTED,
678                     numSuggestions + 1);
679         }
680     }
681 
682     private class TaskStackListenerImpl implements TaskStackChangeListener {
683         // Invalidate any rotation suggestion on task change or activity orientation change
684         // Note: all callbacks happen on main thread
685 
686         @Override
onTaskStackChanged()687         public void onTaskStackChanged() {
688             setRotateSuggestionButtonState(false /* visible */);
689         }
690 
691         @Override
onTaskRemoved(int taskId)692         public void onTaskRemoved(int taskId) {
693             setRotateSuggestionButtonState(false /* visible */);
694         }
695 
696         @Override
onTaskMovedToFront(int taskId)697         public void onTaskMovedToFront(int taskId) {
698             setRotateSuggestionButtonState(false /* visible */);
699         }
700 
701         @Override
onActivityRequestedOrientationChanged(int taskId, int requestedOrientation)702         public void onActivityRequestedOrientationChanged(int taskId, int requestedOrientation) {
703             // Only hide the icon if the top task changes its requestedOrientation
704             // Launcher can alter its requestedOrientation while it's not on top, don't hide on this
705             Optional.ofNullable(ActivityManagerWrapper.getInstance())
706                     .map(ActivityManagerWrapper::getRunningTask)
707                     .ifPresent(a -> {
708                         if (a.id == taskId) setRotateSuggestionButtonState(false /* visible */);
709                     });
710         }
711     }
712 
713     enum RotationButtonEvent implements UiEventLogger.UiEventEnum {
714         @UiEvent(doc = "The rotation button was shown")
715         ROTATION_SUGGESTION_SHOWN(206),
716         @UiEvent(doc = "The rotation button was clicked")
717         ROTATION_SUGGESTION_ACCEPTED(207);
718 
719         private final int mId;
720 
RotationButtonEvent(int id)721         RotationButtonEvent(int id) {
722             mId = id;
723         }
724 
725         @Override
getId()726         public int getId() {
727             return mId;
728         }
729     }
730 }
731