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