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