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