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