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