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