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