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.android.systemui.statusbar.phone;
18 
19 import static com.android.internal.view.RotationPolicy.NATURAL_ROTATION;
20 
21 import android.animation.Animator;
22 import android.animation.AnimatorListenerAdapter;
23 import android.animation.ObjectAnimator;
24 import android.annotation.StyleRes;
25 import android.app.StatusBarManager;
26 import android.content.ContentResolver;
27 import android.content.Context;
28 import android.os.Handler;
29 import android.os.Looper;
30 import android.os.RemoteException;
31 import android.provider.Settings;
32 import android.util.Log;
33 import android.view.IRotationWatcher.Stub;
34 import android.view.MotionEvent;
35 import android.view.Surface;
36 import android.view.View;
37 import android.view.WindowManagerGlobal;
38 import android.view.accessibility.AccessibilityManager;
39 
40 import com.android.internal.logging.UiEvent;
41 import com.android.internal.logging.UiEventLogger;
42 import com.android.internal.logging.UiEventLoggerImpl;
43 import com.android.systemui.Dependency;
44 import com.android.systemui.Interpolators;
45 import com.android.systemui.R;
46 import com.android.systemui.shared.system.ActivityManagerWrapper;
47 import com.android.systemui.shared.system.TaskStackChangeListener;
48 import com.android.systemui.statusbar.policy.AccessibilityManagerWrapper;
49 import com.android.systemui.statusbar.policy.KeyButtonDrawable;
50 import com.android.systemui.statusbar.policy.RotationLockController;
51 
52 import java.util.Optional;
53 import java.util.function.Consumer;
54 
55 /** Contains logic that deals with showing a rotate suggestion button with animation. */
56 public class RotationButtonController {
57 
58     private static final String TAG = "StatusBar/RotationButtonController";
59     private static final int BUTTON_FADE_IN_OUT_DURATION_MS = 100;
60     private static final int NAVBAR_HIDDEN_PENDING_ICON_TIMEOUT_MS = 20000;
61 
62     private static final int NUM_ACCEPTED_ROTATION_SUGGESTIONS_FOR_INTRODUCTION = 3;
63 
64     private final UiEventLogger mUiEventLogger = new UiEventLoggerImpl();
65     private final ViewRippler mViewRippler = new ViewRippler();
66 
67     private @StyleRes int mStyleRes;
68     private int mLastRotationSuggestion;
69     private boolean mPendingRotationSuggestion;
70     private boolean mHoveringRotationSuggestion;
71     private RotationLockController mRotationLockController;
72     private AccessibilityManagerWrapper mAccessibilityManagerWrapper;
73     private TaskStackListenerImpl mTaskStackListener;
74     private Consumer<Integer> mRotWatcherListener;
75     private boolean mListenersRegistered = false;
76     private boolean mIsNavigationBarShowing;
77     private boolean mSkipOverrideUserLockPrefsOnce;
78 
79     private final Runnable mRemoveRotationProposal =
80             () -> setRotateSuggestionButtonState(false /* visible */);
81     private final Runnable mCancelPendingRotationProposal =
82             () -> mPendingRotationSuggestion = false;
83     private Animator mRotateHideAnimator;
84 
85     private final Context mContext;
86     private final RotationButton mRotationButton;
87     private final Handler mMainThreadHandler = new Handler(Looper.getMainLooper());
88 
89     private final Stub mRotationWatcher = new Stub() {
90         @Override
91         public void onRotationChanged(final int rotation) throws RemoteException {
92             // We need this to be scheduled as early as possible to beat the redrawing of
93             // window in response to the orientation change.
94             mMainThreadHandler.postAtFrontOfQueue(() -> {
95                 // If the screen rotation changes while locked, potentially update lock to flow with
96                 // new screen rotation and hide any showing suggestions.
97                 if (mRotationLockController.isRotationLocked()) {
98                     if (shouldOverrideUserLockPrefs(rotation)) {
99                         setRotationLockedAtAngle(rotation);
100                     }
101                     setRotateSuggestionButtonState(false /* visible */, true /* forced */);
102                 }
103 
104                 if (mRotWatcherListener != null) {
105                     mRotWatcherListener.accept(rotation);
106                 }
107             });
108         }
109     };
110 
111     /**
112      * Determines if rotation suggestions disabled2 flag exists in flag
113      * @param disable2Flags see if rotation suggestion flag exists in this flag
114      * @return whether flag exists
115      */
hasDisable2RotateSuggestionFlag(int disable2Flags)116     static boolean hasDisable2RotateSuggestionFlag(int disable2Flags) {
117         return (disable2Flags & StatusBarManager.DISABLE2_ROTATE_SUGGESTIONS) != 0;
118     }
119 
RotationButtonController(Context context, @StyleRes int style, RotationButton rotationButton)120     RotationButtonController(Context context, @StyleRes int style, RotationButton rotationButton) {
121         mContext = context;
122         mRotationButton = rotationButton;
123         mRotationButton.setRotationButtonController(this);
124 
125         mStyleRes = style;
126         mIsNavigationBarShowing = true;
127         mRotationLockController = Dependency.get(RotationLockController.class);
128         mAccessibilityManagerWrapper = Dependency.get(AccessibilityManagerWrapper.class);
129 
130         // Register the task stack listener
131         mTaskStackListener = new TaskStackListenerImpl();
132         mRotationButton.setOnClickListener(this::onRotateSuggestionClick);
133         mRotationButton.setOnHoverListener(this::onRotateSuggestionHover);
134     }
135 
registerListeners()136     void registerListeners() {
137         if (mListenersRegistered) {
138             return;
139         }
140 
141         mListenersRegistered = true;
142         try {
143             WindowManagerGlobal.getWindowManagerService()
144                     .watchRotation(mRotationWatcher, mContext.getDisplay().getDisplayId());
145         } catch (IllegalArgumentException e) {
146             mListenersRegistered = false;
147             Log.w(TAG, "RegisterListeners for the display failed");
148         } catch (RemoteException e) {
149             throw e.rethrowFromSystemServer();
150         }
151 
152         ActivityManagerWrapper.getInstance().registerTaskStackListener(mTaskStackListener);
153     }
154 
unregisterListeners()155     void unregisterListeners() {
156         if (!mListenersRegistered) {
157             return;
158         }
159 
160         mListenersRegistered = false;
161         try {
162             WindowManagerGlobal.getWindowManagerService().removeRotationWatcher(mRotationWatcher);
163         } catch (RemoteException e) {
164             throw e.rethrowFromSystemServer();
165         }
166 
167         ActivityManagerWrapper.getInstance().unregisterTaskStackListener(mTaskStackListener);
168     }
169 
addRotationCallback(Consumer<Integer> watcher)170     void addRotationCallback(Consumer<Integer> watcher) {
171         mRotWatcherListener = watcher;
172     }
173 
setRotationLockedAtAngle(int rotationSuggestion)174     void setRotationLockedAtAngle(int rotationSuggestion) {
175         mRotationLockController.setRotationLockedAtAngle(true /* locked */, rotationSuggestion);
176     }
177 
isRotationLocked()178     public boolean isRotationLocked() {
179         return mRotationLockController.isRotationLocked();
180     }
181 
setRotateSuggestionButtonState(boolean visible)182     void setRotateSuggestionButtonState(boolean visible) {
183         setRotateSuggestionButtonState(visible, false /* force */);
184     }
185 
setRotateSuggestionButtonState(final boolean visible, final boolean force)186     void setRotateSuggestionButtonState(final boolean visible, final boolean force) {
187         // At any point the the button can become invisible because an a11y service became active.
188         // Similarly, a call to make the button visible may be rejected because an a11y service is
189         // active. Must account for this.
190         // Rerun a show animation to indicate change but don't rerun a hide animation
191         if (!visible && !mRotationButton.isVisible()) return;
192 
193         final View view = mRotationButton.getCurrentView();
194         if (view == null) return;
195 
196         final KeyButtonDrawable currentDrawable = mRotationButton.getImageDrawable();
197         if (currentDrawable == null) return;
198 
199         // Clear any pending suggestion flag as it has either been nullified or is being shown
200         mPendingRotationSuggestion = false;
201         mMainThreadHandler.removeCallbacks(mCancelPendingRotationProposal);
202 
203         // Handle the visibility change and animation
204         if (visible) { // Appear and change (cannot force)
205             // Stop and clear any currently running hide animations
206             if (mRotateHideAnimator != null && mRotateHideAnimator.isRunning()) {
207                 mRotateHideAnimator.cancel();
208             }
209             mRotateHideAnimator = null;
210 
211             // Reset the alpha if any has changed due to hide animation
212             view.setAlpha(1f);
213 
214             // Run the rotate icon's animation if it has one
215             if (currentDrawable.canAnimate()) {
216                 currentDrawable.resetAnimation();
217                 currentDrawable.startAnimation();
218             }
219 
220             if (!isRotateSuggestionIntroduced()) mViewRippler.start(view);
221 
222             // Set visibility unless a11y service is active.
223             mRotationButton.show();
224         } else { // Hide
225             mViewRippler.stop(); // Prevent any pending ripples, force hide or not
226 
227             if (force) {
228                 // If a hide animator is running stop it and make invisible
229                 if (mRotateHideAnimator != null && mRotateHideAnimator.isRunning()) {
230                     mRotateHideAnimator.pause();
231                 }
232                 mRotationButton.hide();
233                 return;
234             }
235 
236             // Don't start any new hide animations if one is running
237             if (mRotateHideAnimator != null && mRotateHideAnimator.isRunning()) return;
238 
239             ObjectAnimator fadeOut = ObjectAnimator.ofFloat(view, "alpha", 0f);
240             fadeOut.setDuration(BUTTON_FADE_IN_OUT_DURATION_MS);
241             fadeOut.setInterpolator(Interpolators.LINEAR);
242             fadeOut.addListener(new AnimatorListenerAdapter() {
243                 @Override
244                 public void onAnimationEnd(Animator animation) {
245                     mRotationButton.hide();
246                 }
247             });
248 
249             mRotateHideAnimator = fadeOut;
250             fadeOut.start();
251         }
252     }
253 
setDarkIntensity(float darkIntensity)254     void setDarkIntensity(float darkIntensity) {
255         mRotationButton.setDarkIntensity(darkIntensity);
256     }
257 
onRotationProposal(int rotation, int windowRotation, boolean isValid)258     void onRotationProposal(int rotation, int windowRotation, boolean isValid) {
259         if (!mRotationButton.acceptRotationProposal()) {
260             return;
261         }
262 
263         // This method will be called on rotation suggestion changes even if the proposed rotation
264         // is not valid for the top app. Use invalid rotation choices as a signal to remove the
265         // rotate button if shown.
266         if (!isValid) {
267             setRotateSuggestionButtonState(false /* visible */);
268             return;
269         }
270 
271         // If window rotation matches suggested rotation, remove any current suggestions
272         if (rotation == windowRotation) {
273             mMainThreadHandler.removeCallbacks(mRemoveRotationProposal);
274             setRotateSuggestionButtonState(false /* visible */);
275             return;
276         }
277 
278         // Prepare to show the navbar icon by updating the icon style to change anim params
279         mLastRotationSuggestion = rotation; // Remember rotation for click
280         final boolean rotationCCW = isRotationAnimationCCW(windowRotation, rotation);
281         int style;
282         if (windowRotation == Surface.ROTATION_0 || windowRotation == Surface.ROTATION_180) {
283             style = rotationCCW ? R.style.RotateButtonCCWStart90 : R.style.RotateButtonCWStart90;
284         } else { // 90 or 270
285             style = rotationCCW ? R.style.RotateButtonCCWStart0 : R.style.RotateButtonCWStart0;
286         }
287         mStyleRes = style;
288         mRotationButton.updateIcon();
289 
290         if (mIsNavigationBarShowing) {
291             // The navbar is visible so show the icon right away
292             showAndLogRotationSuggestion();
293         } else {
294             // If the navbar isn't shown, flag the rotate icon to be shown should the navbar become
295             // visible given some time limit.
296             mPendingRotationSuggestion = true;
297             mMainThreadHandler.removeCallbacks(mCancelPendingRotationProposal);
298             mMainThreadHandler.postDelayed(mCancelPendingRotationProposal,
299                     NAVBAR_HIDDEN_PENDING_ICON_TIMEOUT_MS);
300         }
301     }
302 
onDisable2FlagChanged(int state2)303     void onDisable2FlagChanged(int state2) {
304         final boolean rotateSuggestionsDisabled = hasDisable2RotateSuggestionFlag(state2);
305         if (rotateSuggestionsDisabled) onRotationSuggestionsDisabled();
306     }
307 
onNavigationBarWindowVisibilityChange(boolean showing)308     void onNavigationBarWindowVisibilityChange(boolean showing) {
309         if (mIsNavigationBarShowing != showing) {
310             mIsNavigationBarShowing = showing;
311 
312             // If the navbar is visible, show the rotate button if there's a pending suggestion
313             if (showing && mPendingRotationSuggestion) {
314                 showAndLogRotationSuggestion();
315             }
316         }
317     }
318 
getStyleRes()319     @StyleRes int getStyleRes() {
320         return mStyleRes;
321     }
322 
getRotationButton()323     RotationButton getRotationButton() {
324         return mRotationButton;
325     }
326 
onRotateSuggestionClick(View v)327     private void onRotateSuggestionClick(View v) {
328         mUiEventLogger.log(RotationButtonEvent.ROTATION_SUGGESTION_ACCEPTED);
329         incrementNumAcceptedRotationSuggestionsIfNeeded();
330         setRotationLockedAtAngle(mLastRotationSuggestion);
331     }
332 
onRotateSuggestionHover(View v, MotionEvent event)333     private boolean onRotateSuggestionHover(View v, MotionEvent event) {
334         final int action = event.getActionMasked();
335         mHoveringRotationSuggestion = (action == MotionEvent.ACTION_HOVER_ENTER)
336                 || (action == MotionEvent.ACTION_HOVER_MOVE);
337         rescheduleRotationTimeout(true /* reasonHover */);
338         return false; // Must return false so a11y hover events are dispatched correctly.
339     }
340 
onRotationSuggestionsDisabled()341     private void onRotationSuggestionsDisabled() {
342         // Immediately hide the rotate button and clear any planned removal
343         setRotateSuggestionButtonState(false /* visible */, true /* force */);
344         mMainThreadHandler.removeCallbacks(mRemoveRotationProposal);
345     }
346 
showAndLogRotationSuggestion()347     private void showAndLogRotationSuggestion() {
348         setRotateSuggestionButtonState(true /* visible */);
349         rescheduleRotationTimeout(false /* reasonHover */);
350         mUiEventLogger.log(RotationButtonEvent.ROTATION_SUGGESTION_SHOWN);
351     }
352 
353     /**
354      * Makes {@link #shouldOverrideUserLockPrefs} always return {@code false} once. It is used to
355      * avoid losing original user rotation when display rotation is changed by entering the fixed
356      * orientation overview.
357      */
setSkipOverrideUserLockPrefsOnce()358     void setSkipOverrideUserLockPrefsOnce() {
359         mSkipOverrideUserLockPrefsOnce = true;
360     }
361 
shouldOverrideUserLockPrefs(final int rotation)362     private boolean shouldOverrideUserLockPrefs(final int rotation) {
363         if (mSkipOverrideUserLockPrefsOnce) {
364             mSkipOverrideUserLockPrefsOnce = false;
365             return false;
366         }
367         // Only override user prefs when returning to the natural rotation (normally portrait).
368         // Don't let apps that force landscape or 180 alter user lock.
369         return rotation == NATURAL_ROTATION;
370     }
371 
isRotationAnimationCCW(int from, int to)372     private boolean isRotationAnimationCCW(int from, int to) {
373         // All 180deg WM rotation animations are CCW, match that
374         if (from == Surface.ROTATION_0 && to == Surface.ROTATION_90) return false;
375         if (from == Surface.ROTATION_0 && to == Surface.ROTATION_180) return true; //180d so CCW
376         if (from == Surface.ROTATION_0 && to == Surface.ROTATION_270) return true;
377         if (from == Surface.ROTATION_90 && to == Surface.ROTATION_0) return true;
378         if (from == Surface.ROTATION_90 && to == Surface.ROTATION_180) return false;
379         if (from == Surface.ROTATION_90 && to == Surface.ROTATION_270) return true; //180d so CCW
380         if (from == Surface.ROTATION_180 && to == Surface.ROTATION_0) return true; //180d so CCW
381         if (from == Surface.ROTATION_180 && to == Surface.ROTATION_90) return true;
382         if (from == Surface.ROTATION_180 && to == Surface.ROTATION_270) return false;
383         if (from == Surface.ROTATION_270 && to == Surface.ROTATION_0) return false;
384         if (from == Surface.ROTATION_270 && to == Surface.ROTATION_90) return true; //180d so CCW
385         if (from == Surface.ROTATION_270 && to == Surface.ROTATION_180) return true;
386         return false; // Default
387     }
388 
rescheduleRotationTimeout(final boolean reasonHover)389     private void rescheduleRotationTimeout(final boolean reasonHover) {
390         // May be called due to a new rotation proposal or a change in hover state
391         if (reasonHover) {
392             // Don't reschedule if a hide animator is running
393             if (mRotateHideAnimator != null && mRotateHideAnimator.isRunning()) return;
394             // Don't reschedule if not visible
395             if (!mRotationButton.isVisible()) return;
396         }
397 
398         // Stop any pending removal
399         mMainThreadHandler.removeCallbacks(mRemoveRotationProposal);
400         // Schedule timeout
401         mMainThreadHandler.postDelayed(mRemoveRotationProposal,
402                 computeRotationProposalTimeout());
403     }
404 
computeRotationProposalTimeout()405     private int computeRotationProposalTimeout() {
406         return mAccessibilityManagerWrapper.getRecommendedTimeoutMillis(
407                 mHoveringRotationSuggestion ? 16000 : 5000,
408                 AccessibilityManager.FLAG_CONTENT_CONTROLS);
409     }
410 
isRotateSuggestionIntroduced()411     private boolean isRotateSuggestionIntroduced() {
412         ContentResolver cr = mContext.getContentResolver();
413         return Settings.Secure.getInt(cr, Settings.Secure.NUM_ROTATION_SUGGESTIONS_ACCEPTED, 0)
414                 >= NUM_ACCEPTED_ROTATION_SUGGESTIONS_FOR_INTRODUCTION;
415     }
416 
incrementNumAcceptedRotationSuggestionsIfNeeded()417     private void incrementNumAcceptedRotationSuggestionsIfNeeded() {
418         // Get the number of accepted suggestions
419         ContentResolver cr = mContext.getContentResolver();
420         final int numSuggestions = Settings.Secure.getInt(cr,
421                 Settings.Secure.NUM_ROTATION_SUGGESTIONS_ACCEPTED, 0);
422 
423         // Increment the number of accepted suggestions only if it would change intro mode
424         if (numSuggestions < NUM_ACCEPTED_ROTATION_SUGGESTIONS_FOR_INTRODUCTION) {
425             Settings.Secure.putInt(cr, Settings.Secure.NUM_ROTATION_SUGGESTIONS_ACCEPTED,
426                     numSuggestions + 1);
427         }
428     }
429 
430     private class TaskStackListenerImpl extends TaskStackChangeListener {
431         // Invalidate any rotation suggestion on task change or activity orientation change
432         // Note: all callbacks happen on main thread
433 
434         @Override
onTaskStackChanged()435         public void onTaskStackChanged() {
436             setRotateSuggestionButtonState(false /* visible */);
437         }
438 
439         @Override
onTaskRemoved(int taskId)440         public void onTaskRemoved(int taskId) {
441             setRotateSuggestionButtonState(false /* visible */);
442         }
443 
444         @Override
onTaskMovedToFront(int taskId)445         public void onTaskMovedToFront(int taskId) {
446             setRotateSuggestionButtonState(false /* visible */);
447         }
448 
449         @Override
onActivityRequestedOrientationChanged(int taskId, int requestedOrientation)450         public void onActivityRequestedOrientationChanged(int taskId, int requestedOrientation) {
451             // Only hide the icon if the top task changes its requestedOrientation
452             // Launcher can alter its requestedOrientation while it's not on top, don't hide on this
453             Optional.ofNullable(ActivityManagerWrapper.getInstance())
454                     .map(ActivityManagerWrapper::getRunningTask)
455                     .ifPresent(a -> {
456                         if (a.id == taskId) setRotateSuggestionButtonState(false /* visible */);
457                     });
458         }
459     }
460 
461     private class ViewRippler {
462         private static final int RIPPLE_OFFSET_MS = 50;
463         private static final int RIPPLE_INTERVAL_MS = 2000;
464         private View mRoot;
465 
start(View root)466         public void start(View root) {
467             stop(); // Stop any pending ripple animations
468 
469             mRoot = root;
470 
471             // Schedule pending ripples, offset the 1st to avoid problems with visibility change
472             mRoot.postOnAnimationDelayed(mRipple, RIPPLE_OFFSET_MS);
473             mRoot.postOnAnimationDelayed(mRipple, RIPPLE_INTERVAL_MS);
474             mRoot.postOnAnimationDelayed(mRipple, 2 * RIPPLE_INTERVAL_MS);
475             mRoot.postOnAnimationDelayed(mRipple, 3 * RIPPLE_INTERVAL_MS);
476             mRoot.postOnAnimationDelayed(mRipple, 4 * RIPPLE_INTERVAL_MS);
477         }
478 
stop()479         public void stop() {
480             if (mRoot != null) mRoot.removeCallbacks(mRipple);
481         }
482 
483         private final Runnable mRipple = new Runnable() {
484             @Override
485             public void run() { // Cause the ripple to fire via false presses
486                 if (!mRoot.isAttachedToWindow()) return;
487                 mRoot.setPressed(true /* pressed */);
488                 mRoot.setPressed(false /* pressed */);
489             }
490         };
491     }
492 
493     enum RotationButtonEvent implements UiEventLogger.UiEventEnum {
494         @UiEvent(doc = "The rotation button was shown")
495         ROTATION_SUGGESTION_SHOWN(206),
496         @UiEvent(doc = "The rotation button was clicked")
497         ROTATION_SUGGESTION_ACCEPTED(207);
498 
499         private final int mId;
RotationButtonEvent(int id)500         RotationButtonEvent(int id) {
501             mId = id;
502         }
getId()503         @Override public int getId() {
504             return mId;
505         }
506     }
507 }
508