From 4309e6ec424a940481961320413bb5235a560209 Mon Sep 17 00:00:00 2001 From: Nick Chameyev Date: Thu, 21 Oct 2021 15:31:53 +0100 Subject: Move floating rotation button handling to Launcher Moves handling of floating rotation button when navigation bar is not created to the launcher. This button was not showing when taskbar is visible as it was initialized in navigation bar (which is not created for large screens). Bug: 200103245 Test: rotate phone when autorotate disabled on inner screen Test: showing rotate suggestion when gesture nav enabled/disabled Change-Id: I0619acd9d24eb4ba36bdb601517f9a8370ee999a Merged-In: I0619acd9d24eb4ba36bdb601517f9a8370ee999a --- packages/SystemUI/res/layout/rotate_suggestion.xml | 32 -- packages/SystemUI/res/values-sw600dp/dimens.xml | 1 - packages/SystemUI/res/values-sw900dp/dimens.xml | 7 - packages/SystemUI/res/values/dimens.xml | 11 - packages/SystemUI/shared/Android.bp | 4 +- .../shared/res/layout/rotate_suggestion.xml | 30 ++ .../SystemUI/shared/res/values-sw600dp/dimens.xml | 19 + .../SystemUI/shared/res/values-sw900dp/dimens.xml | 23 + packages/SystemUI/shared/res/values/dimens.xml | 30 ++ .../navigationbar/buttons/KeyButtonRipple.java | 522 ++++++++++++++++++ .../shared/rotation/FloatingRotationButton.java | 250 +++++++++ .../FloatingRotationButtonPositionCalculator.kt | 65 +++ .../rotation/FloatingRotationButtonView.java | 81 +++ .../systemui/shared/rotation/RotationButton.java | 59 +++ .../shared/rotation/RotationButtonController.java | 587 +++++++++++++++++++++ .../systemui/navigationbar/NavigationBar.java | 6 +- .../systemui/navigationbar/NavigationBarView.java | 20 +- .../systemui/navigationbar/RotationButton.java | 48 -- .../navigationbar/RotationButtonController.java | 523 ------------------ .../navigationbar/buttons/KeyButtonRipple.java | 520 ------------------ .../buttons/RotationContextButton.java | 4 +- .../gestural/FloatingRotationButton.java | 243 --------- .../FloatingRotationButtonPositionCalculator.kt | 65 --- .../NavigationBarRotationContextTest.java | 35 +- ...FloatingRotationButtonPositionCalculatorTest.kt | 3 +- 25 files changed, 1715 insertions(+), 1473 deletions(-) delete mode 100644 packages/SystemUI/res/layout/rotate_suggestion.xml create mode 100644 packages/SystemUI/shared/res/layout/rotate_suggestion.xml create mode 100644 packages/SystemUI/shared/res/values-sw600dp/dimens.xml create mode 100644 packages/SystemUI/shared/res/values-sw900dp/dimens.xml create mode 100644 packages/SystemUI/shared/res/values/dimens.xml create mode 100644 packages/SystemUI/shared/src/com/android/systemui/navigationbar/buttons/KeyButtonRipple.java create mode 100644 packages/SystemUI/shared/src/com/android/systemui/shared/rotation/FloatingRotationButton.java create mode 100644 packages/SystemUI/shared/src/com/android/systemui/shared/rotation/FloatingRotationButtonPositionCalculator.kt create mode 100644 packages/SystemUI/shared/src/com/android/systemui/shared/rotation/FloatingRotationButtonView.java create mode 100644 packages/SystemUI/shared/src/com/android/systemui/shared/rotation/RotationButton.java create mode 100644 packages/SystemUI/shared/src/com/android/systemui/shared/rotation/RotationButtonController.java delete mode 100644 packages/SystemUI/src/com/android/systemui/navigationbar/RotationButton.java delete mode 100644 packages/SystemUI/src/com/android/systemui/navigationbar/RotationButtonController.java delete mode 100644 packages/SystemUI/src/com/android/systemui/navigationbar/buttons/KeyButtonRipple.java delete mode 100644 packages/SystemUI/src/com/android/systemui/navigationbar/gestural/FloatingRotationButton.java delete mode 100644 packages/SystemUI/src/com/android/systemui/navigationbar/gestural/FloatingRotationButtonPositionCalculator.kt diff --git a/packages/SystemUI/res/layout/rotate_suggestion.xml b/packages/SystemUI/res/layout/rotate_suggestion.xml deleted file mode 100644 index 1c3eedba4f6f..000000000000 --- a/packages/SystemUI/res/layout/rotate_suggestion.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/packages/SystemUI/res/values-sw600dp/dimens.xml b/packages/SystemUI/res/values-sw600dp/dimens.xml index 4e578615f30f..c1c15d12dee1 100644 --- a/packages/SystemUI/res/values-sw600dp/dimens.xml +++ b/packages/SystemUI/res/values-sw600dp/dimens.xml @@ -81,7 +81,6 @@ 24dp 128dp - 25dp 488dp diff --git a/packages/SystemUI/res/values-sw900dp/dimens.xml b/packages/SystemUI/res/values-sw900dp/dimens.xml index 2cff97692d9d..ebae8c4bfa4c 100644 --- a/packages/SystemUI/res/values-sw900dp/dimens.xml +++ b/packages/SystemUI/res/values-sw900dp/dimens.xml @@ -21,11 +21,4 @@ @dimen/button_size @dimen/button_size @dimen/button_size - - - 76dp - - - 0dp - diff --git a/packages/SystemUI/res/values/dimens.xml b/packages/SystemUI/res/values/dimens.xml index db6985d2b61f..46869a0087a4 100644 --- a/packages/SystemUI/res/values/dimens.xml +++ b/packages/SystemUI/res/values/dimens.xml @@ -56,11 +56,6 @@ 48dp - 40dp - 20dp - 20dp - 10dp - @*android:dimen/status_bar_icon_size @@ -361,8 +356,6 @@ 32dp - 0dp - 36dp @@ -974,9 +967,6 @@ 3dp - - 95dp - 1dp @@ -1166,7 +1156,6 @@ 8dp - 0dp 0dp 24dp 10dp diff --git a/packages/SystemUI/shared/Android.bp b/packages/SystemUI/shared/Android.bp index 4880b124fcdb..62e9d8bb2395 100644 --- a/packages/SystemUI/shared/Android.bp +++ b/packages/SystemUI/shared/Android.bp @@ -45,7 +45,9 @@ android_library { ":wm_shell-aidls", ":wm_shell_util-sources", ], - + resource_dirs: [ + "res", + ], static_libs: [ "PluginCoreLib", "androidx.dynamicanimation_dynamicanimation", diff --git a/packages/SystemUI/shared/res/layout/rotate_suggestion.xml b/packages/SystemUI/shared/res/layout/rotate_suggestion.xml new file mode 100644 index 000000000000..2fb775cbc9be --- /dev/null +++ b/packages/SystemUI/shared/res/layout/rotate_suggestion.xml @@ -0,0 +1,30 @@ + + + + + diff --git a/packages/SystemUI/shared/res/values-sw600dp/dimens.xml b/packages/SystemUI/shared/res/values-sw600dp/dimens.xml new file mode 100644 index 000000000000..5d9e0596b5c1 --- /dev/null +++ b/packages/SystemUI/shared/res/values-sw600dp/dimens.xml @@ -0,0 +1,19 @@ + + + 25dp + diff --git a/packages/SystemUI/shared/res/values-sw900dp/dimens.xml b/packages/SystemUI/shared/res/values-sw900dp/dimens.xml new file mode 100644 index 000000000000..3efa5e3ecf70 --- /dev/null +++ b/packages/SystemUI/shared/res/values-sw900dp/dimens.xml @@ -0,0 +1,23 @@ + + + + 76dp + + + 0dp + \ No newline at end of file diff --git a/packages/SystemUI/shared/res/values/dimens.xml b/packages/SystemUI/shared/res/values/dimens.xml new file mode 100644 index 000000000000..b7f332846858 --- /dev/null +++ b/packages/SystemUI/shared/res/values/dimens.xml @@ -0,0 +1,30 @@ + + + + 95dp + + 0dp + + 0dp + + + 40dp + 20dp + 20dp + 10dp + diff --git a/packages/SystemUI/shared/src/com/android/systemui/navigationbar/buttons/KeyButtonRipple.java b/packages/SystemUI/shared/src/com/android/systemui/navigationbar/buttons/KeyButtonRipple.java new file mode 100644 index 000000000000..53df0f3ab533 --- /dev/null +++ b/packages/SystemUI/shared/src/com/android/systemui/navigationbar/buttons/KeyButtonRipple.java @@ -0,0 +1,522 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.navigationbar.buttons; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.CanvasProperty; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.RecordingCanvas; +import android.graphics.drawable.Drawable; +import android.os.Handler; +import android.os.Trace; +import android.view.RenderNodeAnimator; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.animation.Interpolator; +import android.view.animation.PathInterpolator; + +import androidx.annotation.Keep; + +import com.android.systemui.shared.R; + +import java.util.ArrayList; +import java.util.HashSet; + +public class KeyButtonRipple extends Drawable { + + private static final float GLOW_MAX_SCALE_FACTOR = 1.35f; + private static final float GLOW_MAX_ALPHA = 0.2f; + private static final float GLOW_MAX_ALPHA_DARK = 0.1f; + private static final int ANIMATION_DURATION_SCALE = 350; + private static final int ANIMATION_DURATION_FADE = 450; + private static final Interpolator ALPHA_OUT_INTERPOLATOR = + new PathInterpolator(0f, 0f, 0.8f, 1f); + + private Paint mRipplePaint; + private CanvasProperty mLeftProp; + private CanvasProperty mTopProp; + private CanvasProperty mRightProp; + private CanvasProperty mBottomProp; + private CanvasProperty mRxProp; + private CanvasProperty mRyProp; + private CanvasProperty mPaintProp; + private float mGlowAlpha = 0f; + private float mGlowScale = 1f; + private boolean mPressed; + private boolean mVisible; + private boolean mDrawingHardwareGlow; + private int mMaxWidth; + private boolean mLastDark; + private boolean mDark; + private boolean mDelayTouchFeedback; + + private final Interpolator mInterpolator = new LogInterpolator(); + private boolean mSupportHardware; + private final View mTargetView; + private final Handler mHandler = new Handler(); + + private final HashSet mRunningAnimations = new HashSet<>(); + private final ArrayList mTmpArray = new ArrayList<>(); + + private final TraceAnimatorListener mExitHwTraceAnimator = + new TraceAnimatorListener("exitHardware"); + private final TraceAnimatorListener mEnterHwTraceAnimator = + new TraceAnimatorListener("enterHardware"); + + public enum Type { + OVAL, + ROUNDED_RECT + } + + private Type mType = Type.ROUNDED_RECT; + + public KeyButtonRipple(Context ctx, View targetView) { + mMaxWidth = ctx.getResources().getDimensionPixelSize(R.dimen.key_button_ripple_max_width); + mTargetView = targetView; + } + + public void setDarkIntensity(float darkIntensity) { + mDark = darkIntensity >= 0.5f; + } + + public void setDelayTouchFeedback(boolean delay) { + mDelayTouchFeedback = delay; + } + + public void setType(Type type) { + mType = type; + } + + private Paint getRipplePaint() { + if (mRipplePaint == null) { + mRipplePaint = new Paint(); + mRipplePaint.setAntiAlias(true); + mRipplePaint.setColor(mLastDark ? 0xff000000 : 0xffffffff); + } + return mRipplePaint; + } + + private void drawSoftware(Canvas canvas) { + if (mGlowAlpha > 0f) { + final Paint p = getRipplePaint(); + p.setAlpha((int)(mGlowAlpha * 255f)); + + final float w = getBounds().width(); + final float h = getBounds().height(); + final boolean horizontal = w > h; + final float diameter = getRippleSize() * mGlowScale; + final float radius = diameter * .5f; + final float cx = w * .5f; + final float cy = h * .5f; + final float rx = horizontal ? radius : cx; + final float ry = horizontal ? cy : radius; + final float corner = horizontal ? cy : cx; + + if (mType == Type.ROUNDED_RECT) { + canvas.drawRoundRect(cx - rx, cy - ry, cx + rx, cy + ry, corner, corner, p); + } else { + canvas.save(); + canvas.translate(cx, cy); + float r = Math.min(rx, ry); + canvas.drawOval(-r, -r, r, r, p); + canvas.restore(); + } + } + } + + @Override + public void draw(Canvas canvas) { + mSupportHardware = canvas.isHardwareAccelerated(); + if (mSupportHardware) { + drawHardware((RecordingCanvas) canvas); + } else { + drawSoftware(canvas); + } + } + + @Override + public void setAlpha(int alpha) { + // Not supported. + } + + @Override + public void setColorFilter(ColorFilter colorFilter) { + // Not supported. + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } + + private boolean isHorizontal() { + return getBounds().width() > getBounds().height(); + } + + private void drawHardware(RecordingCanvas c) { + if (mDrawingHardwareGlow) { + if (mType == Type.ROUNDED_RECT) { + c.drawRoundRect(mLeftProp, mTopProp, mRightProp, mBottomProp, mRxProp, mRyProp, + mPaintProp); + } else { + CanvasProperty cx = CanvasProperty.createFloat(getBounds().width() / 2); + CanvasProperty cy = CanvasProperty.createFloat(getBounds().height() / 2); + int d = Math.min(getBounds().width(), getBounds().height()); + CanvasProperty r = CanvasProperty.createFloat(1.0f * d / 2); + c.drawCircle(cx, cy, r, mPaintProp); + } + } + } + + /** Gets the glow alpha, used by {@link android.animation.ObjectAnimator} via reflection. */ + @Keep + public float getGlowAlpha() { + return mGlowAlpha; + } + + /** Sets the glow alpha, used by {@link android.animation.ObjectAnimator} via reflection. */ + @Keep + public void setGlowAlpha(float x) { + mGlowAlpha = x; + invalidateSelf(); + } + + /** Gets the glow scale, used by {@link android.animation.ObjectAnimator} via reflection. */ + @Keep + public float getGlowScale() { + return mGlowScale; + } + + /** Sets the glow scale, used by {@link android.animation.ObjectAnimator} via reflection. */ + @Keep + public void setGlowScale(float x) { + mGlowScale = x; + invalidateSelf(); + } + + private float getMaxGlowAlpha() { + return mLastDark ? GLOW_MAX_ALPHA_DARK : GLOW_MAX_ALPHA; + } + + @Override + protected boolean onStateChange(int[] state) { + boolean pressed = false; + for (int i = 0; i < state.length; i++) { + if (state[i] == android.R.attr.state_pressed) { + pressed = true; + break; + } + } + if (pressed != mPressed) { + setPressed(pressed); + mPressed = pressed; + return true; + } else { + return false; + } + } + + @Override + public boolean setVisible(boolean visible, boolean restart) { + boolean changed = super.setVisible(visible, restart); + if (changed) { + // End any existing animations when the visibility changes + jumpToCurrentState(); + } + return changed; + } + + @Override + public void jumpToCurrentState() { + endAnimations("jumpToCurrentState", false /* cancel */); + } + + @Override + public boolean isStateful() { + return true; + } + + @Override + public boolean hasFocusStateSpecified() { + return true; + } + + public void setPressed(boolean pressed) { + if (mDark != mLastDark && pressed) { + mRipplePaint = null; + mLastDark = mDark; + } + if (mSupportHardware) { + setPressedHardware(pressed); + } else { + setPressedSoftware(pressed); + } + } + + /** + * Abort the ripple while it is delayed and before shown used only when setShouldDelayStartTouch + * is enabled. + */ + public void abortDelayedRipple() { + mHandler.removeCallbacksAndMessages(null); + } + + private void endAnimations(String reason, boolean cancel) { + Trace.beginSection("KeyButtonRipple.endAnim: reason=" + reason + " cancel=" + cancel); + Trace.endSection(); + mVisible = false; + mTmpArray.addAll(mRunningAnimations); + int size = mTmpArray.size(); + for (int i = 0; i < size; i++) { + Animator a = mTmpArray.get(i); + if (cancel) { + a.cancel(); + } else { + a.end(); + } + } + mTmpArray.clear(); + mRunningAnimations.clear(); + mHandler.removeCallbacksAndMessages(null); + } + + private void setPressedSoftware(boolean pressed) { + if (pressed) { + if (mDelayTouchFeedback) { + if (mRunningAnimations.isEmpty()) { + mHandler.removeCallbacksAndMessages(null); + mHandler.postDelayed(this::enterSoftware, ViewConfiguration.getTapTimeout()); + } else if (mVisible) { + enterSoftware(); + } + } else { + enterSoftware(); + } + } else { + exitSoftware(); + } + } + + private void enterSoftware() { + endAnimations("enterSoftware", true /* cancel */); + mVisible = true; + mGlowAlpha = getMaxGlowAlpha(); + ObjectAnimator scaleAnimator = ObjectAnimator.ofFloat(this, "glowScale", + 0f, GLOW_MAX_SCALE_FACTOR); + scaleAnimator.setInterpolator(mInterpolator); + scaleAnimator.setDuration(ANIMATION_DURATION_SCALE); + scaleAnimator.addListener(mAnimatorListener); + scaleAnimator.start(); + mRunningAnimations.add(scaleAnimator); + + // With the delay, it could eventually animate the enter animation with no pressed state, + // then immediately show the exit animation. If this is skipped there will be no ripple. + if (mDelayTouchFeedback && !mPressed) { + exitSoftware(); + } + } + + private void exitSoftware() { + ObjectAnimator alphaAnimator = ObjectAnimator.ofFloat(this, "glowAlpha", mGlowAlpha, 0f); + alphaAnimator.setInterpolator(ALPHA_OUT_INTERPOLATOR); + alphaAnimator.setDuration(ANIMATION_DURATION_FADE); + alphaAnimator.addListener(mAnimatorListener); + alphaAnimator.start(); + mRunningAnimations.add(alphaAnimator); + } + + private void setPressedHardware(boolean pressed) { + if (pressed) { + if (mDelayTouchFeedback) { + if (mRunningAnimations.isEmpty()) { + mHandler.removeCallbacksAndMessages(null); + mHandler.postDelayed(this::enterHardware, ViewConfiguration.getTapTimeout()); + } else if (mVisible) { + enterHardware(); + } + } else { + enterHardware(); + } + } else { + exitHardware(); + } + } + + /** + * Sets the left/top property for the round rect to {@code prop} depending on whether we are + * horizontal or vertical mode. + */ + private void setExtendStart(CanvasProperty prop) { + if (isHorizontal()) { + mLeftProp = prop; + } else { + mTopProp = prop; + } + } + + private CanvasProperty getExtendStart() { + return isHorizontal() ? mLeftProp : mTopProp; + } + + /** + * Sets the right/bottom property for the round rect to {@code prop} depending on whether we are + * horizontal or vertical mode. + */ + private void setExtendEnd(CanvasProperty prop) { + if (isHorizontal()) { + mRightProp = prop; + } else { + mBottomProp = prop; + } + } + + private CanvasProperty getExtendEnd() { + return isHorizontal() ? mRightProp : mBottomProp; + } + + private int getExtendSize() { + return isHorizontal() ? getBounds().width() : getBounds().height(); + } + + private int getRippleSize() { + int size = isHorizontal() ? getBounds().width() : getBounds().height(); + return Math.min(size, mMaxWidth); + } + + private void enterHardware() { + endAnimations("enterHardware", true /* cancel */); + mVisible = true; + mDrawingHardwareGlow = true; + setExtendStart(CanvasProperty.createFloat(getExtendSize() / 2)); + final RenderNodeAnimator startAnim = new RenderNodeAnimator(getExtendStart(), + getExtendSize()/2 - GLOW_MAX_SCALE_FACTOR * getRippleSize()/2); + startAnim.setDuration(ANIMATION_DURATION_SCALE); + startAnim.setInterpolator(mInterpolator); + startAnim.addListener(mAnimatorListener); + startAnim.setTarget(mTargetView); + + setExtendEnd(CanvasProperty.createFloat(getExtendSize() / 2)); + final RenderNodeAnimator endAnim = new RenderNodeAnimator(getExtendEnd(), + getExtendSize()/2 + GLOW_MAX_SCALE_FACTOR * getRippleSize()/2); + endAnim.setDuration(ANIMATION_DURATION_SCALE); + endAnim.setInterpolator(mInterpolator); + endAnim.addListener(mAnimatorListener); + endAnim.addListener(mEnterHwTraceAnimator); + endAnim.setTarget(mTargetView); + + if (isHorizontal()) { + mTopProp = CanvasProperty.createFloat(0f); + mBottomProp = CanvasProperty.createFloat(getBounds().height()); + mRxProp = CanvasProperty.createFloat(getBounds().height()/2); + mRyProp = CanvasProperty.createFloat(getBounds().height()/2); + } else { + mLeftProp = CanvasProperty.createFloat(0f); + mRightProp = CanvasProperty.createFloat(getBounds().width()); + mRxProp = CanvasProperty.createFloat(getBounds().width()/2); + mRyProp = CanvasProperty.createFloat(getBounds().width()/2); + } + + mGlowScale = GLOW_MAX_SCALE_FACTOR; + mGlowAlpha = getMaxGlowAlpha(); + mRipplePaint = getRipplePaint(); + mRipplePaint.setAlpha((int) (mGlowAlpha * 255)); + mPaintProp = CanvasProperty.createPaint(mRipplePaint); + + startAnim.start(); + endAnim.start(); + mRunningAnimations.add(startAnim); + mRunningAnimations.add(endAnim); + + invalidateSelf(); + + // With the delay, it could eventually animate the enter animation with no pressed state, + // then immediately show the exit animation. If this is skipped there will be no ripple. + if (mDelayTouchFeedback && !mPressed) { + exitHardware(); + } + } + + private void exitHardware() { + mPaintProp = CanvasProperty.createPaint(getRipplePaint()); + final RenderNodeAnimator opacityAnim = new RenderNodeAnimator(mPaintProp, + RenderNodeAnimator.PAINT_ALPHA, 0); + opacityAnim.setDuration(ANIMATION_DURATION_FADE); + opacityAnim.setInterpolator(ALPHA_OUT_INTERPOLATOR); + opacityAnim.addListener(mAnimatorListener); + opacityAnim.addListener(mExitHwTraceAnimator); + opacityAnim.setTarget(mTargetView); + + opacityAnim.start(); + mRunningAnimations.add(opacityAnim); + + invalidateSelf(); + } + + private final AnimatorListenerAdapter mAnimatorListener = + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + mRunningAnimations.remove(animation); + if (mRunningAnimations.isEmpty() && !mPressed) { + mVisible = false; + mDrawingHardwareGlow = false; + invalidateSelf(); + } + } + }; + + private static final class TraceAnimatorListener extends AnimatorListenerAdapter { + private final String mName; + TraceAnimatorListener(String name) { + mName = name; + } + + @Override + public void onAnimationStart(Animator animation) { + Trace.beginSection("KeyButtonRipple.start." + mName); + Trace.endSection(); + } + + @Override + public void onAnimationCancel(Animator animation) { + Trace.beginSection("KeyButtonRipple.cancel." + mName); + Trace.endSection(); + } + + @Override + public void onAnimationEnd(Animator animation) { + Trace.beginSection("KeyButtonRipple.end." + mName); + Trace.endSection(); + } + } + + /** + * Interpolator with a smooth log deceleration + */ + private static final class LogInterpolator implements Interpolator { + @Override + public float getInterpolation(float input) { + return 1 - (float) Math.pow(400, -input * 1.4); + } + } +} diff --git a/packages/SystemUI/shared/src/com/android/systemui/shared/rotation/FloatingRotationButton.java b/packages/SystemUI/shared/src/com/android/systemui/shared/rotation/FloatingRotationButton.java new file mode 100644 index 000000000000..be3d7800bb74 --- /dev/null +++ b/packages/SystemUI/shared/src/com/android/systemui/shared/rotation/FloatingRotationButton.java @@ -0,0 +1,250 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.shared.rotation; + +import android.annotation.StringRes; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.PixelFormat; +import android.graphics.drawable.AnimatedVectorDrawable; +import android.graphics.drawable.Drawable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.widget.FrameLayout; + +import androidx.core.view.OneShotPreDrawListener; + +import com.android.systemui.shared.R; +import com.android.systemui.shared.rotation.FloatingRotationButtonPositionCalculator.Position; + +/** + * Containing logic for the rotation button on the physical left bottom corner of the screen. + */ +public class FloatingRotationButton implements RotationButton { + + private static final int MARGIN_ANIMATION_DURATION_MILLIS = 300; + + private final WindowManager mWindowManager; + private final ViewGroup mKeyButtonContainer; + private final FloatingRotationButtonView mKeyButtonView; + + private final int mContainerSize; + + private AnimatedVectorDrawable mAnimatedDrawable; + private boolean mIsShowing; + private boolean mCanShow = true; + private int mDisplayRotation; + + private boolean mIsTaskbarVisible = false; + private boolean mIsTaskbarStashed = false; + + private final FloatingRotationButtonPositionCalculator mPositionCalculator; + + private RotationButtonController mRotationButtonController; + private RotationButtonUpdatesCallback mUpdatesCallback; + private Position mPosition; + + public FloatingRotationButton(Context context, @StringRes int contentDescription) { + mWindowManager = context.getSystemService(WindowManager.class); + mKeyButtonContainer = (ViewGroup) LayoutInflater.from(context).inflate( + R.layout.rotate_suggestion, null); + mKeyButtonView = mKeyButtonContainer.findViewById(R.id.rotate_suggestion); + mKeyButtonView.setVisibility(View.VISIBLE); + mKeyButtonView.setContentDescription(context.getString(contentDescription)); + + Resources res = context.getResources(); + + int defaultMargin = Math.max( + res.getDimensionPixelSize(R.dimen.floating_rotation_button_min_margin), + res.getDimensionPixelSize(R.dimen.rounded_corner_content_padding)); + + int taskbarMarginLeft = + res.getDimensionPixelSize(R.dimen.floating_rotation_button_taskbar_left_margin); + int taskbarMarginBottom = + res.getDimensionPixelSize(R.dimen.floating_rotation_button_taskbar_bottom_margin); + + mPositionCalculator = new FloatingRotationButtonPositionCalculator(defaultMargin, + taskbarMarginLeft, taskbarMarginBottom); + + final int diameter = res.getDimensionPixelSize(R.dimen.floating_rotation_button_diameter); + mContainerSize = diameter + Math.max(defaultMargin, Math.max(taskbarMarginLeft, + taskbarMarginBottom)); + } + + @Override + public void setRotationButtonController(RotationButtonController rotationButtonController) { + mRotationButtonController = rotationButtonController; + updateIcon(mRotationButtonController.getLightIconColor(), + mRotationButtonController.getDarkIconColor()); + } + + @Override + public void setUpdatesCallback(RotationButtonUpdatesCallback updatesCallback) { + mUpdatesCallback = updatesCallback; + } + + @Override + public View getCurrentView() { + return mKeyButtonView; + } + + @Override + public boolean show() { + if (!mCanShow || mIsShowing) { + return false; + } + + mIsShowing = true; + int flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; + + // TODO(b/200103245): add new window type that has z-index above + // TYPE_NAVIGATION_BAR_PANEL as currently it could be below the taskbar which has + // the same window type + final WindowManager.LayoutParams lp = new WindowManager.LayoutParams( + mContainerSize, + mContainerSize, + 0, 0, WindowManager.LayoutParams.TYPE_NAVIGATION_BAR_PANEL, flags, + PixelFormat.TRANSLUCENT); + + lp.privateFlags |= WindowManager.LayoutParams.SYSTEM_FLAG_SHOW_FOR_ALL_USERS; + lp.setTitle("FloatingRotationButton"); + lp.setFitInsetsTypes(0 /*types */); + + mDisplayRotation = mWindowManager.getDefaultDisplay().getRotation(); + mPosition = mPositionCalculator + .calculatePosition(mDisplayRotation, mIsTaskbarVisible, mIsTaskbarStashed); + + lp.gravity = mPosition.getGravity(); + ((FrameLayout.LayoutParams) mKeyButtonView.getLayoutParams()).gravity = + mPosition.getGravity(); + + updateTranslation(mPosition, /* animate */ false); + + mWindowManager.addView(mKeyButtonContainer, lp); + if (mAnimatedDrawable != null) { + mAnimatedDrawable.reset(); + mAnimatedDrawable.start(); + } + + // Notify about visibility only after first traversal so we can properly calculate + // the touch region for the button + OneShotPreDrawListener.add(mKeyButtonView, () -> { + if (mIsShowing && mUpdatesCallback != null) { + mUpdatesCallback.onVisibilityChanged(true); + } + }); + + return true; + } + + @Override + public boolean hide() { + if (!mIsShowing) { + return false; + } + mWindowManager.removeViewImmediate(mKeyButtonContainer); + mIsShowing = false; + if (mUpdatesCallback != null) { + mUpdatesCallback.onVisibilityChanged(false); + } + return true; + } + + @Override + public boolean isVisible() { + return mIsShowing; + } + + @Override + public void updateIcon(int lightIconColor, int darkIconColor) { + mAnimatedDrawable = (AnimatedVectorDrawable) mKeyButtonView.getContext() + .getDrawable(mRotationButtonController.getIconResId()); + mKeyButtonView.setImageDrawable(mAnimatedDrawable); + mKeyButtonView.setColors(lightIconColor, darkIconColor); + } + + @Override + public void setOnClickListener(View.OnClickListener onClickListener) { + mKeyButtonView.setOnClickListener(onClickListener); + } + + @Override + public void setOnHoverListener(View.OnHoverListener onHoverListener) { + mKeyButtonView.setOnHoverListener(onHoverListener); + } + + @Override + public Drawable getImageDrawable() { + return mAnimatedDrawable; + } + + @Override + public void setDarkIntensity(float darkIntensity) { + mKeyButtonView.setDarkIntensity(darkIntensity); + } + + @Override + public void setCanShowRotationButton(boolean canShow) { + mCanShow = canShow; + if (!mCanShow) { + hide(); + } + } + + @Override + public void onTaskbarStateChanged(boolean taskbarVisible, boolean taskbarStashed) { + mIsTaskbarVisible = taskbarVisible; + mIsTaskbarStashed = taskbarStashed; + + if (!mIsShowing) return; + + final Position newPosition = mPositionCalculator + .calculatePosition(mDisplayRotation, mIsTaskbarVisible, mIsTaskbarStashed); + + if (newPosition.getTranslationX() != mPosition.getTranslationX() + || newPosition.getTranslationY() != mPosition.getTranslationY()) { + updateTranslation(newPosition, /* animate */ true); + mPosition = newPosition; + } + } + + private void updateTranslation(Position position, boolean animate) { + final int translationX = position.getTranslationX(); + final int translationY = position.getTranslationY(); + + if (animate) { + mKeyButtonView + .animate() + .translationX(translationX) + .translationY(translationY) + .setDuration(MARGIN_ANIMATION_DURATION_MILLIS) + .setInterpolator(new AccelerateDecelerateInterpolator()) + .withEndAction(() -> { + if (mUpdatesCallback != null && mIsShowing) { + mUpdatesCallback.onPositionChanged(); + } + }) + .start(); + } else { + mKeyButtonView.setTranslationX(translationX); + mKeyButtonView.setTranslationY(translationY); + } + } +} diff --git a/packages/SystemUI/shared/src/com/android/systemui/shared/rotation/FloatingRotationButtonPositionCalculator.kt b/packages/SystemUI/shared/src/com/android/systemui/shared/rotation/FloatingRotationButtonPositionCalculator.kt new file mode 100644 index 000000000000..ec3c073dc68d --- /dev/null +++ b/packages/SystemUI/shared/src/com/android/systemui/shared/rotation/FloatingRotationButtonPositionCalculator.kt @@ -0,0 +1,65 @@ +package com.android.systemui.shared.rotation + +import android.view.Gravity +import android.view.Surface + +/** + * Calculates gravity and translation that is necessary to display + * the button in the correct position based on the current state + */ +class FloatingRotationButtonPositionCalculator( + private val defaultMargin: Int, + private val taskbarMarginLeft: Int, + private val taskbarMarginBottom: Int +) { + + fun calculatePosition( + currentRotation: Int, + taskbarVisible: Boolean, + taskbarStashed: Boolean + ): Position { + + val isTaskbarSide = currentRotation == Surface.ROTATION_0 + || currentRotation == Surface.ROTATION_90 + val useTaskbarMargin = isTaskbarSide && taskbarVisible && !taskbarStashed + + val gravity = resolveGravity(currentRotation) + + val marginLeft = if (useTaskbarMargin) taskbarMarginLeft else defaultMargin + val marginBottom = if (useTaskbarMargin) taskbarMarginBottom else defaultMargin + + val translationX = + if (gravity and Gravity.RIGHT == Gravity.RIGHT) { + -marginLeft + } else { + marginLeft + } + val translationY = + if (gravity and Gravity.BOTTOM == Gravity.BOTTOM) { + -marginBottom + } else { + marginBottom + } + + return Position( + gravity = gravity, + translationX = translationX, + translationY = translationY + ) + } + + data class Position( + val gravity: Int, + val translationX: Int, + val translationY: Int + ) + + private fun resolveGravity(rotation: Int): Int = + when (rotation) { + Surface.ROTATION_0 -> Gravity.BOTTOM or Gravity.LEFT + Surface.ROTATION_90 -> Gravity.BOTTOM or Gravity.RIGHT + Surface.ROTATION_180 -> Gravity.TOP or Gravity.RIGHT + Surface.ROTATION_270 -> Gravity.TOP or Gravity.LEFT + else -> throw IllegalArgumentException("Invalid rotation $rotation") + } +} diff --git a/packages/SystemUI/shared/src/com/android/systemui/shared/rotation/FloatingRotationButtonView.java b/packages/SystemUI/shared/src/com/android/systemui/shared/rotation/FloatingRotationButtonView.java new file mode 100644 index 000000000000..e0187f41c68a --- /dev/null +++ b/packages/SystemUI/shared/src/com/android/systemui/shared/rotation/FloatingRotationButtonView.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.shared.rotation; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.util.AttributeSet; +import android.view.View; +import android.widget.ImageView; + +import com.android.systemui.navigationbar.buttons.KeyButtonRipple; + +public class FloatingRotationButtonView extends ImageView { + + private static final float BACKGROUND_ALPHA = 0.92f; + + private final KeyButtonRipple mRipple; + private final Paint mOvalBgPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG); + + public FloatingRotationButtonView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public FloatingRotationButtonView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + setClickable(true); + + mRipple = new KeyButtonRipple(context, this); + setBackground(mRipple); + setWillNotDraw(false); + forceHasOverlappingRendering(false); + } + + @Override + protected void onWindowVisibilityChanged(int visibility) { + super.onWindowVisibilityChanged(visibility); + if (visibility != View.VISIBLE) { + jumpDrawablesToCurrentState(); + } + } + + public void setColors(int lightColor, int darkColor) { + getDrawable().setColorFilter(new PorterDuffColorFilter(lightColor, PorterDuff.Mode.SRC_IN)); + + final int ovalBackgroundColor = Color.valueOf(Color.red(darkColor), + Color.green(darkColor), Color.blue(darkColor), BACKGROUND_ALPHA).toArgb(); + + mOvalBgPaint.setColor(ovalBackgroundColor); + mRipple.setType(KeyButtonRipple.Type.OVAL); + } + + public void setDarkIntensity(float darkIntensity) { + mRipple.setDarkIntensity(darkIntensity); + } + + @Override + public void draw(Canvas canvas) { + int d = Math.min(getWidth(), getHeight()); + canvas.drawOval(0, 0, d, d, mOvalBgPaint); + super.draw(canvas); + } +} diff --git a/packages/SystemUI/shared/src/com/android/systemui/shared/rotation/RotationButton.java b/packages/SystemUI/shared/src/com/android/systemui/shared/rotation/RotationButton.java new file mode 100644 index 000000000000..89f71ebf3dce --- /dev/null +++ b/packages/SystemUI/shared/src/com/android/systemui/shared/rotation/RotationButton.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.shared.rotation; + +import android.graphics.drawable.Drawable; +import android.view.View; + +/** + * Interface of a rotation button that interacts {@link RotationButtonController}. + * This interface exists because of the two different styles of rotation button in Sysui, + * one in contextual for 3 button nav and a floating rotation button for gestural. + */ +public interface RotationButton { + default void setRotationButtonController(RotationButtonController rotationButtonController) { } + default void setUpdatesCallback(RotationButtonUpdatesCallback updatesCallback) { } + + default View getCurrentView() { + return null; + } + default boolean show() { return false; } + default boolean hide() { return false; } + default boolean isVisible() { + return false; + } + default void setCanShowRotationButton(boolean canShow) {} + default void onTaskbarStateChanged(boolean taskbarVisible, boolean taskbarStashed) {} + default void updateIcon(int lightIconColor, int darkIconColor) { } + default void setOnClickListener(View.OnClickListener onClickListener) { } + default void setOnHoverListener(View.OnHoverListener onHoverListener) { } + default Drawable getImageDrawable() { + return null; + } + default void setDarkIntensity(float darkIntensity) { } + default boolean acceptRotationProposal() { + return getCurrentView() != null; + } + + /** + * Callback for updates provided by a rotation button + */ + interface RotationButtonUpdatesCallback { + default void onVisibilityChanged(boolean isVisible) {}; + default void onPositionChanged() {}; + } +} diff --git a/packages/SystemUI/shared/src/com/android/systemui/shared/rotation/RotationButtonController.java b/packages/SystemUI/shared/src/com/android/systemui/shared/rotation/RotationButtonController.java new file mode 100644 index 000000000000..2dbd5dee76aa --- /dev/null +++ b/packages/SystemUI/shared/src/com/android/systemui/shared/rotation/RotationButtonController.java @@ -0,0 +1,587 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.shared.rotation; + +import static android.view.Display.DEFAULT_DISPLAY; + +import static com.android.internal.view.RotationPolicy.NATURAL_ROTATION; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.annotation.ColorInt; +import android.annotation.DrawableRes; +import android.annotation.SuppressLint; +import android.app.StatusBarManager; +import android.content.ContentResolver; +import android.content.Context; +import android.graphics.drawable.AnimatedVectorDrawable; +import android.graphics.drawable.Drawable; +import android.os.Handler; +import android.os.Looper; +import android.os.RemoteException; +import android.provider.Settings; +import android.util.Log; +import android.view.IRotationWatcher; +import android.view.MotionEvent; +import android.view.Surface; +import android.view.View; +import android.view.WindowInsetsController; +import android.view.WindowManagerGlobal; +import android.view.accessibility.AccessibilityManager; +import android.view.animation.Interpolator; +import android.view.animation.LinearInterpolator; + +import com.android.internal.logging.UiEvent; +import com.android.internal.logging.UiEventLogger; +import com.android.internal.logging.UiEventLoggerImpl; +import com.android.internal.view.RotationPolicy; +import com.android.systemui.shared.rotation.RotationButton.RotationButtonUpdatesCallback; +import com.android.systemui.shared.recents.utilities.Utilities; +import com.android.systemui.shared.recents.utilities.ViewRippler; +import com.android.systemui.shared.system.ActivityManagerWrapper; +import com.android.systemui.shared.system.TaskStackChangeListener; +import com.android.systemui.shared.system.TaskStackChangeListeners; + +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * Contains logic that deals with showing a rotate suggestion button with animation. + */ +public class RotationButtonController { + + private static final String TAG = "StatusBar/RotationButtonController"; + private static final int BUTTON_FADE_IN_OUT_DURATION_MS = 100; + private static final int NAVBAR_HIDDEN_PENDING_ICON_TIMEOUT_MS = 20000; + private static final Interpolator LINEAR_INTERPOLATOR = new LinearInterpolator(); + + private static final int NUM_ACCEPTED_ROTATION_SUGGESTIONS_FOR_INTRODUCTION = 3; + + private final Context mContext; + private final Handler mMainThreadHandler = new Handler(Looper.getMainLooper()); + private final UiEventLogger mUiEventLogger = new UiEventLoggerImpl(); + private final ViewRippler mViewRippler = new ViewRippler(); + private final Supplier mWindowRotationProvider; + private RotationButton mRotationButton; + + private boolean mIsRecentsAnimationRunning; + private boolean mHomeRotationEnabled; + private int mLastRotationSuggestion; + private boolean mPendingRotationSuggestion; + private boolean mHoveringRotationSuggestion; + private final AccessibilityManager mAccessibilityManager; + private final TaskStackListenerImpl mTaskStackListener; + private Consumer mRotWatcherListener; + + private boolean mListenersRegistered = false; + private boolean mIsNavigationBarShowing; + @SuppressLint("InlinedApi") + private @WindowInsetsController.Behavior + int mBehavior = WindowInsetsController.BEHAVIOR_DEFAULT; + private boolean mSkipOverrideUserLockPrefsOnce; + private final int mLightIconColor; + private final int mDarkIconColor; + + @DrawableRes + private final int mIconCcwStart0ResId; + @DrawableRes + private final int mIconCcwStart90ResId; + @DrawableRes + private final int mIconCwStart0ResId; + @DrawableRes + private final int mIconCwStart90ResId; + + @DrawableRes + private int mIconResId; + + private final Runnable mRemoveRotationProposal = + () -> setRotateSuggestionButtonState(false /* visible */); + private final Runnable mCancelPendingRotationProposal = + () -> mPendingRotationSuggestion = false; + private Animator mRotateHideAnimator; + + + private final IRotationWatcher.Stub mRotationWatcher = new IRotationWatcher.Stub() { + @Override + public void onRotationChanged(final int rotation) { + // We need this to be scheduled as early as possible to beat the redrawing of + // window in response to the orientation change. + mMainThreadHandler.postAtFrontOfQueue(() -> { + // If the screen rotation changes while locked, potentially update lock to flow with + // new screen rotation and hide any showing suggestions. + if (isRotationLocked()) { + if (shouldOverrideUserLockPrefs(rotation)) { + setRotationLockedAtAngle(rotation); + } + setRotateSuggestionButtonState(false /* visible */, true /* forced */); + } + + if (mRotWatcherListener != null) { + mRotWatcherListener.accept(rotation); + } + }); + } + }; + + /** + * Determines if rotation suggestions disabled2 flag exists in flag + * + * @param disable2Flags see if rotation suggestion flag exists in this flag + * @return whether flag exists + */ + public static boolean hasDisable2RotateSuggestionFlag(int disable2Flags) { + return (disable2Flags & StatusBarManager.DISABLE2_ROTATE_SUGGESTIONS) != 0; + } + + public RotationButtonController(Context context, + @ColorInt int lightIconColor, @ColorInt int darkIconColor, + @DrawableRes int iconCcwStart0ResId, + @DrawableRes int iconCcwStart90ResId, + @DrawableRes int iconCwStart0ResId, + @DrawableRes int iconCwStart90ResId, + Supplier windowRotationProvider) { + + mContext = context; + mLightIconColor = lightIconColor; + mDarkIconColor = darkIconColor; + + mIconCcwStart0ResId = iconCcwStart0ResId; + mIconCcwStart90ResId = iconCcwStart90ResId; + mIconCwStart0ResId = iconCwStart0ResId; + mIconCwStart90ResId = iconCwStart90ResId; + mIconResId = mIconCcwStart90ResId; + + mAccessibilityManager = AccessibilityManager.getInstance(context); + mTaskStackListener = new TaskStackListenerImpl(); + mWindowRotationProvider = windowRotationProvider; + } + + public void setRotationButton(RotationButton rotationButton, + RotationButtonUpdatesCallback updatesCallback) { + mRotationButton = rotationButton; + mRotationButton.setRotationButtonController(this); + mRotationButton.setOnClickListener(this::onRotateSuggestionClick); + mRotationButton.setOnHoverListener(this::onRotateSuggestionHover); + mRotationButton.setUpdatesCallback(updatesCallback); + } + + public Context getContext() { + return mContext; + } + + public void init() { + registerListeners(); + if (mContext.getDisplay().getDisplayId() != DEFAULT_DISPLAY) { + // Currently there is no accelerometer sensor on non-default display, disable fixed + // rotation for non-default display + onDisable2FlagChanged(StatusBarManager.DISABLE2_ROTATE_SUGGESTIONS); + } + } + + public void onDestroy() { + unregisterListeners(); + } + + public void registerListeners() { + if (mListenersRegistered) { + return; + } + + mListenersRegistered = true; + try { + WindowManagerGlobal.getWindowManagerService() + .watchRotation(mRotationWatcher, DEFAULT_DISPLAY); + } catch (IllegalArgumentException e) { + mListenersRegistered = false; + Log.w(TAG, "RegisterListeners for the display failed"); + } catch (RemoteException e) { + Log.e(TAG, "RegisterListeners caught a RemoteException", e); + return; + } + + TaskStackChangeListeners.getInstance().registerTaskStackListener(mTaskStackListener); + } + + public void unregisterListeners() { + if (!mListenersRegistered) { + return; + } + + mListenersRegistered = false; + try { + WindowManagerGlobal.getWindowManagerService().removeRotationWatcher(mRotationWatcher); + } catch (RemoteException e) { + Log.e(TAG, "UnregisterListeners caught a RemoteException", e); + return; + } + + TaskStackChangeListeners.getInstance().unregisterTaskStackListener(mTaskStackListener); + } + + public void setRotationCallback(Consumer watcher) { + mRotWatcherListener = watcher; + } + + public void setRotationLockedAtAngle(int rotationSuggestion) { + RotationPolicy.setRotationLockAtAngle(mContext, true, rotationSuggestion); + } + + public boolean isRotationLocked() { + return RotationPolicy.isRotationLocked(mContext); + } + + public void setRotateSuggestionButtonState(boolean visible) { + setRotateSuggestionButtonState(visible, false /* force */); + } + + void setRotateSuggestionButtonState(final boolean visible, final boolean force) { + // At any point the button can become invisible because an a11y service became active. + // Similarly, a call to make the button visible may be rejected because an a11y service is + // active. Must account for this. + // Rerun a show animation to indicate change but don't rerun a hide animation + if (!visible && !mRotationButton.isVisible()) return; + + final View view = mRotationButton.getCurrentView(); + if (view == null) return; + + final Drawable currentDrawable = mRotationButton.getImageDrawable(); + if (currentDrawable == null) return; + + // Clear any pending suggestion flag as it has either been nullified or is being shown + mPendingRotationSuggestion = false; + mMainThreadHandler.removeCallbacks(mCancelPendingRotationProposal); + + // Handle the visibility change and animation + if (visible) { // Appear and change (cannot force) + // Stop and clear any currently running hide animations + if (mRotateHideAnimator != null && mRotateHideAnimator.isRunning()) { + mRotateHideAnimator.cancel(); + } + mRotateHideAnimator = null; + + // Reset the alpha if any has changed due to hide animation + view.setAlpha(1f); + + // Run the rotate icon's animation if it has one + if (currentDrawable instanceof AnimatedVectorDrawable) { + ((AnimatedVectorDrawable) currentDrawable).reset(); + ((AnimatedVectorDrawable) currentDrawable).start(); + } + + // TODO(b/187754252): No idea why this doesn't work. If we remove the "false" + // we see the animation show the pressed state... but it only shows the first time. + if (!isRotateSuggestionIntroduced()) mViewRippler.start(view); + + // Set visibility unless a11y service is active. + mRotationButton.show(); + } else { // Hide + mViewRippler.stop(); // Prevent any pending ripples, force hide or not + + if (force) { + // If a hide animator is running stop it and make invisible + if (mRotateHideAnimator != null && mRotateHideAnimator.isRunning()) { + mRotateHideAnimator.pause(); + } + mRotationButton.hide(); + return; + } + + // Don't start any new hide animations if one is running + if (mRotateHideAnimator != null && mRotateHideAnimator.isRunning()) return; + + ObjectAnimator fadeOut = ObjectAnimator.ofFloat(view, "alpha", 0f); + fadeOut.setDuration(BUTTON_FADE_IN_OUT_DURATION_MS); + fadeOut.setInterpolator(LINEAR_INTERPOLATOR); + fadeOut.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + mRotationButton.hide(); + } + }); + + mRotateHideAnimator = fadeOut; + fadeOut.start(); + } + } + + public void setDarkIntensity(float darkIntensity) { + mRotationButton.setDarkIntensity(darkIntensity); + } + + public void setRecentsAnimationRunning(boolean running) { + mIsRecentsAnimationRunning = running; + updateRotationButtonStateInOverview(); + } + + public void setHomeRotationEnabled(boolean enabled) { + mHomeRotationEnabled = enabled; + updateRotationButtonStateInOverview(); + } + + private void updateRotationButtonStateInOverview() { + if (mIsRecentsAnimationRunning && !mHomeRotationEnabled) { + setRotateSuggestionButtonState(false, true /* hideImmediately */); + } + } + + public void onRotationProposal(int rotation, boolean isValid) { + int windowRotation = mWindowRotationProvider.get(); + + if (!mRotationButton.acceptRotationProposal()) { + return; + } + + if (!mHomeRotationEnabled && mIsRecentsAnimationRunning) { + return; + } + + // This method will be called on rotation suggestion changes even if the proposed rotation + // is not valid for the top app. Use invalid rotation choices as a signal to remove the + // rotate button if shown. + if (!isValid) { + setRotateSuggestionButtonState(false /* visible */); + return; + } + + // If window rotation matches suggested rotation, remove any current suggestions + if (rotation == windowRotation) { + mMainThreadHandler.removeCallbacks(mRemoveRotationProposal); + setRotateSuggestionButtonState(false /* visible */); + return; + } + + // Prepare to show the navbar icon by updating the icon style to change anim params + mLastRotationSuggestion = rotation; // Remember rotation for click + final boolean rotationCCW = Utilities.isRotationAnimationCCW(windowRotation, rotation); + if (windowRotation == Surface.ROTATION_0 || windowRotation == Surface.ROTATION_180) { + mIconResId = rotationCCW ? mIconCcwStart0ResId : mIconCwStart0ResId; + } else { // 90 or 270 + mIconResId = rotationCCW ? mIconCcwStart90ResId : mIconCwStart90ResId; + } + mRotationButton.updateIcon(mLightIconColor, mDarkIconColor); + + if (canShowRotationButton()) { + // The navbar is visible / it's in visual immersive mode, so show the icon right away + showAndLogRotationSuggestion(); + } else { + // If the navbar isn't shown, flag the rotate icon to be shown should the navbar become + // visible given some time limit. + mPendingRotationSuggestion = true; + mMainThreadHandler.removeCallbacks(mCancelPendingRotationProposal); + mMainThreadHandler.postDelayed(mCancelPendingRotationProposal, + NAVBAR_HIDDEN_PENDING_ICON_TIMEOUT_MS); + } + } + + public void onDisable2FlagChanged(int state2) { + final boolean rotateSuggestionsDisabled = hasDisable2RotateSuggestionFlag(state2); + if (rotateSuggestionsDisabled) onRotationSuggestionsDisabled(); + } + + public void onBehaviorChanged(int displayId, @WindowInsetsController.Behavior int behavior) { + if (DEFAULT_DISPLAY != displayId) { + return; + } + + if (mBehavior != behavior) { + mBehavior = behavior; + showPendingRotationButtonIfNeeded(); + } + } + + public void onNavigationBarWindowVisibilityChange(boolean showing) { + if (mIsNavigationBarShowing != showing) { + mIsNavigationBarShowing = showing; + showPendingRotationButtonIfNeeded(); + } + } + + public void onTaskbarStateChange(boolean visible, boolean stashed) { + getRotationButton().onTaskbarStateChanged(visible, stashed); + } + + private void showPendingRotationButtonIfNeeded() { + if (canShowRotationButton() && mPendingRotationSuggestion) { + showAndLogRotationSuggestion(); + } + } + + /** + * Return true when either the task bar is visible or it's in visual immersive mode. + */ + @SuppressLint("InlinedApi") + private boolean canShowRotationButton() { + return mIsNavigationBarShowing || mBehavior == WindowInsetsController.BEHAVIOR_DEFAULT; + } + + @DrawableRes + public int getIconResId() { + return mIconResId; + } + + @ColorInt + public int getLightIconColor() { + return mLightIconColor; + } + + @ColorInt + public int getDarkIconColor() { + return mDarkIconColor; + } + + public RotationButton getRotationButton() { + return mRotationButton; + } + + private void onRotateSuggestionClick(View v) { + mUiEventLogger.log(RotationButtonEvent.ROTATION_SUGGESTION_ACCEPTED); + incrementNumAcceptedRotationSuggestionsIfNeeded(); + setRotationLockedAtAngle(mLastRotationSuggestion); + } + + private boolean onRotateSuggestionHover(View v, MotionEvent event) { + final int action = event.getActionMasked(); + mHoveringRotationSuggestion = (action == MotionEvent.ACTION_HOVER_ENTER) + || (action == MotionEvent.ACTION_HOVER_MOVE); + rescheduleRotationTimeout(true /* reasonHover */); + return false; // Must return false so a11y hover events are dispatched correctly. + } + + private void onRotationSuggestionsDisabled() { + // Immediately hide the rotate button and clear any planned removal + setRotateSuggestionButtonState(false /* visible */, true /* force */); + mMainThreadHandler.removeCallbacks(mRemoveRotationProposal); + } + + private void showAndLogRotationSuggestion() { + setRotateSuggestionButtonState(true /* visible */); + rescheduleRotationTimeout(false /* reasonHover */); + mUiEventLogger.log(RotationButtonEvent.ROTATION_SUGGESTION_SHOWN); + } + + /** + * Makes {@link #shouldOverrideUserLockPrefs} always return {@code false} once. It is used to + * avoid losing original user rotation when display rotation is changed by entering the fixed + * orientation overview. + */ + public void setSkipOverrideUserLockPrefsOnce() { + mSkipOverrideUserLockPrefsOnce = true; + } + + private boolean shouldOverrideUserLockPrefs(final int rotation) { + if (mSkipOverrideUserLockPrefsOnce) { + mSkipOverrideUserLockPrefsOnce = false; + return false; + } + // Only override user prefs when returning to the natural rotation (normally portrait). + // Don't let apps that force landscape or 180 alter user lock. + return rotation == NATURAL_ROTATION; + } + + private void rescheduleRotationTimeout(final boolean reasonHover) { + // May be called due to a new rotation proposal or a change in hover state + if (reasonHover) { + // Don't reschedule if a hide animator is running + if (mRotateHideAnimator != null && mRotateHideAnimator.isRunning()) return; + // Don't reschedule if not visible + if (!mRotationButton.isVisible()) return; + } + + // Stop any pending removal + mMainThreadHandler.removeCallbacks(mRemoveRotationProposal); + // Schedule timeout + mMainThreadHandler.postDelayed(mRemoveRotationProposal, + computeRotationProposalTimeout()); + } + + private int computeRotationProposalTimeout() { + return mAccessibilityManager.getRecommendedTimeoutMillis( + mHoveringRotationSuggestion ? 16000 : 5000, + AccessibilityManager.FLAG_CONTENT_CONTROLS); + } + + private boolean isRotateSuggestionIntroduced() { + ContentResolver cr = mContext.getContentResolver(); + return Settings.Secure.getInt(cr, Settings.Secure.NUM_ROTATION_SUGGESTIONS_ACCEPTED, 0) + >= NUM_ACCEPTED_ROTATION_SUGGESTIONS_FOR_INTRODUCTION; + } + + private void incrementNumAcceptedRotationSuggestionsIfNeeded() { + // Get the number of accepted suggestions + ContentResolver cr = mContext.getContentResolver(); + final int numSuggestions = Settings.Secure.getInt(cr, + Settings.Secure.NUM_ROTATION_SUGGESTIONS_ACCEPTED, 0); + + // Increment the number of accepted suggestions only if it would change intro mode + if (numSuggestions < NUM_ACCEPTED_ROTATION_SUGGESTIONS_FOR_INTRODUCTION) { + Settings.Secure.putInt(cr, Settings.Secure.NUM_ROTATION_SUGGESTIONS_ACCEPTED, + numSuggestions + 1); + } + } + + private class TaskStackListenerImpl extends TaskStackChangeListener { + // Invalidate any rotation suggestion on task change or activity orientation change + // Note: all callbacks happen on main thread + + @Override + public void onTaskStackChanged() { + setRotateSuggestionButtonState(false /* visible */); + } + + @Override + public void onTaskRemoved(int taskId) { + setRotateSuggestionButtonState(false /* visible */); + } + + @Override + public void onTaskMovedToFront(int taskId) { + setRotateSuggestionButtonState(false /* visible */); + } + + @Override + public void onActivityRequestedOrientationChanged(int taskId, int requestedOrientation) { + // Only hide the icon if the top task changes its requestedOrientation + // Launcher can alter its requestedOrientation while it's not on top, don't hide on this + Optional.ofNullable(ActivityManagerWrapper.getInstance()) + .map(ActivityManagerWrapper::getRunningTask) + .ifPresent(a -> { + if (a.id == taskId) setRotateSuggestionButtonState(false /* visible */); + }); + } + } + + enum RotationButtonEvent implements UiEventLogger.UiEventEnum { + @UiEvent(doc = "The rotation button was shown") + ROTATION_SUGGESTION_SHOWN(206), + @UiEvent(doc = "The rotation button was clicked") + ROTATION_SUGGESTION_ACCEPTED(207); + + private final int mId; + + RotationButtonEvent(int id) { + mId = id; + } + + @Override + public int getId() { + return mId; + } + } +} + diff --git a/packages/SystemUI/src/com/android/systemui/navigationbar/NavigationBar.java b/packages/SystemUI/src/com/android/systemui/navigationbar/NavigationBar.java index 7809b5fb83ce..6a1eae75f9a9 100644 --- a/packages/SystemUI/src/com/android/systemui/navigationbar/NavigationBar.java +++ b/packages/SystemUI/src/com/android/systemui/navigationbar/NavigationBar.java @@ -131,6 +131,8 @@ import com.android.systemui.navigationbar.gestural.QuickswitchOrientedNavHandle; import com.android.systemui.plugins.statusbar.StatusBarStateController; import com.android.systemui.recents.OverviewProxyService; import com.android.systemui.recents.Recents; +import com.android.systemui.shared.rotation.RotationButton; +import com.android.systemui.shared.rotation.RotationButtonController; import com.android.systemui.settings.UserTracker; import com.android.systemui.shared.recents.utilities.Utilities; import com.android.systemui.shared.system.ActivityManagerWrapper; @@ -944,7 +946,6 @@ public class NavigationBar implements View.OnAttachStateChangeListener, // not valid. Just ignore the rotation in this case. if (!mNavigationBarView.isAttachedToWindow()) return; - final int winRotation = mNavigationBarView.getDisplay().getRotation(); final boolean rotateSuggestionsDisabled = RotationButtonController .hasDisable2RotateSuggestionFlag(mDisabledFlags2); final RotationButtonController rotationButtonController = @@ -953,7 +954,6 @@ public class NavigationBar implements View.OnAttachStateChangeListener, if (RotationContextButton.DEBUG_ROTATION) { Log.v(TAG, "onRotationProposal proposedRotation=" + Surface.rotationToString(rotation) - + ", winRotation=" + Surface.rotationToString(winRotation) + ", isValid=" + isValid + ", mNavBarWindowState=" + StatusBarManager.windowStateToString(mNavigationBarWindowState) + ", rotateSuggestionsDisabled=" + rotateSuggestionsDisabled @@ -963,7 +963,7 @@ public class NavigationBar implements View.OnAttachStateChangeListener, // Respect the disabled flag, no need for action as flag change callback will handle hiding if (rotateSuggestionsDisabled) return; - rotationButtonController.onRotationProposal(rotation, winRotation, isValid); + rotationButtonController.onRotationProposal(rotation, isValid); } @Override diff --git a/packages/SystemUI/src/com/android/systemui/navigationbar/NavigationBarView.java b/packages/SystemUI/src/com/android/systemui/navigationbar/NavigationBarView.java index c02cc8dda4c4..cba76e004558 100644 --- a/packages/SystemUI/src/com/android/systemui/navigationbar/NavigationBarView.java +++ b/packages/SystemUI/src/com/android/systemui/navigationbar/NavigationBarView.java @@ -67,7 +67,6 @@ import com.android.systemui.Dependency; import com.android.systemui.R; import com.android.systemui.animation.Interpolators; import com.android.systemui.model.SysUiState; -import com.android.systemui.navigationbar.RotationButton.RotationButtonUpdatesCallback; import com.android.systemui.navigationbar.buttons.ButtonDispatcher; import com.android.systemui.navigationbar.buttons.ContextualButton; import com.android.systemui.navigationbar.buttons.ContextualButtonGroup; @@ -76,9 +75,11 @@ import com.android.systemui.navigationbar.buttons.KeyButtonDrawable; import com.android.systemui.navigationbar.buttons.NearestTouchFrame; import com.android.systemui.navigationbar.buttons.RotationContextButton; import com.android.systemui.navigationbar.gestural.EdgeBackGestureHandler; -import com.android.systemui.navigationbar.gestural.FloatingRotationButton; +import com.android.systemui.shared.rotation.FloatingRotationButton; import com.android.systemui.recents.OverviewProxyService; import com.android.systemui.recents.Recents; +import com.android.systemui.shared.rotation.RotationButton.RotationButtonUpdatesCallback; +import com.android.systemui.shared.rotation.RotationButtonController; import com.android.systemui.shared.navigationbar.RegionSamplingHelper; import com.android.systemui.shared.system.ActivityManagerWrapper; import com.android.systemui.shared.system.QuickStepContract; @@ -322,9 +323,15 @@ public class NavigationBarView extends FrameLayout implements mContextualButtonGroup.addButton(accessibilityButton); mRotationContextButton = new RotationContextButton(R.id.rotate_suggestion, mLightContext, R.drawable.ic_sysbar_rotate_button_ccw_start_0); - mFloatingRotationButton = new FloatingRotationButton(context); - mRotationButtonController = new RotationButtonController(mLightContext, - mLightIconColor, mDarkIconColor); + mFloatingRotationButton = new FloatingRotationButton(context, + R.string.accessibility_rotate_button); + mRotationButtonController = new RotationButtonController(mLightContext, mLightIconColor, + mDarkIconColor, R.drawable.ic_sysbar_rotate_button_ccw_start_0, + R.drawable.ic_sysbar_rotate_button_ccw_start_90, + R.drawable.ic_sysbar_rotate_button_cw_start_0, + R.drawable.ic_sysbar_rotate_button_cw_start_90, + () -> getDisplay().getRotation()); + updateRotationButton(); mOverviewProxyService = Dependency.get(OverviewProxyService.class); @@ -661,7 +668,7 @@ public class NavigationBarView extends FrameLayout implements } public void setBehavior(@Behavior int behavior) { - mRotationButtonController.onBehaviorChanged(behavior); + mRotationButtonController.onBehaviorChanged(Display.DEFAULT_DISPLAY, behavior); } @Override @@ -1277,6 +1284,7 @@ public class NavigationBarView extends FrameLayout implements mButtonDispatchers.valueAt(i).onDestroy(); } if (mRotationButtonController != null) { + mFloatingRotationButton.hide(); mRotationButtonController.unregisterListeners(); } diff --git a/packages/SystemUI/src/com/android/systemui/navigationbar/RotationButton.java b/packages/SystemUI/src/com/android/systemui/navigationbar/RotationButton.java deleted file mode 100644 index 3486c6e75931..000000000000 --- a/packages/SystemUI/src/com/android/systemui/navigationbar/RotationButton.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.systemui.navigationbar; - -import android.view.View; - -import com.android.systemui.navigationbar.buttons.KeyButtonDrawable; - -/** Interface of a rotation button that interacts {@link RotationButtonController}. */ -public interface RotationButton { - void setRotationButtonController(RotationButtonController rotationButtonController); - void setUpdatesCallback(RotationButtonUpdatesCallback updatesCallback); - View getCurrentView(); - boolean show(); - boolean hide(); - boolean isVisible(); - void updateIcon(int lightIconColor, int darkIconColor); - void setOnClickListener(View.OnClickListener onClickListener); - void setOnHoverListener(View.OnHoverListener onHoverListener); - KeyButtonDrawable getImageDrawable(); - void setDarkIntensity(float darkIntensity); - default void setCanShowRotationButton(boolean canShow) {} - default boolean acceptRotationProposal() { - return getCurrentView() != null; - } - - /** - * Callback for updates provided by a rotation button - */ - interface RotationButtonUpdatesCallback { - void onVisibilityChanged(boolean isVisible); - void onPositionChanged(); - } -} diff --git a/packages/SystemUI/src/com/android/systemui/navigationbar/RotationButtonController.java b/packages/SystemUI/src/com/android/systemui/navigationbar/RotationButtonController.java deleted file mode 100644 index 0f5c03a2a596..000000000000 --- a/packages/SystemUI/src/com/android/systemui/navigationbar/RotationButtonController.java +++ /dev/null @@ -1,523 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.systemui.navigationbar; - -import static com.android.internal.view.RotationPolicy.NATURAL_ROTATION; - -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.animation.ObjectAnimator; -import android.annotation.ColorInt; -import android.annotation.DrawableRes; -import android.app.StatusBarManager; -import android.content.ContentResolver; -import android.content.Context; -import android.os.Handler; -import android.os.Looper; -import android.os.RemoteException; -import android.provider.Settings; -import android.util.Log; -import android.view.IRotationWatcher.Stub; -import android.view.MotionEvent; -import android.view.Surface; -import android.view.View; -import android.view.WindowInsetsController; -import android.view.WindowInsetsController.Behavior; -import android.view.WindowManagerGlobal; -import android.view.accessibility.AccessibilityManager; - -import com.android.internal.logging.UiEvent; -import com.android.internal.logging.UiEventLogger; -import com.android.internal.logging.UiEventLoggerImpl; -import com.android.systemui.Dependency; -import com.android.systemui.R; -import com.android.systemui.animation.Interpolators; -import com.android.systemui.navigationbar.RotationButton.RotationButtonUpdatesCallback; -import com.android.systemui.navigationbar.buttons.KeyButtonDrawable; -import com.android.systemui.shared.recents.utilities.Utilities; -import com.android.systemui.shared.recents.utilities.ViewRippler; -import com.android.systemui.shared.system.ActivityManagerWrapper; -import com.android.systemui.shared.system.TaskStackChangeListener; -import com.android.systemui.shared.system.TaskStackChangeListeners; -import com.android.systemui.statusbar.policy.AccessibilityManagerWrapper; -import com.android.systemui.statusbar.policy.RotationLockController; - -import java.util.Optional; -import java.util.function.Consumer; - -/** Contains logic that deals with showing a rotate suggestion button with animation. */ -public class RotationButtonController { - - private static final String TAG = "StatusBar/RotationButtonController"; - private static final int BUTTON_FADE_IN_OUT_DURATION_MS = 100; - private static final int NAVBAR_HIDDEN_PENDING_ICON_TIMEOUT_MS = 20000; - - private static final int NUM_ACCEPTED_ROTATION_SUGGESTIONS_FOR_INTRODUCTION = 3; - - private final Context mContext; - private final Handler mMainThreadHandler = new Handler(Looper.getMainLooper()); - private final UiEventLogger mUiEventLogger = new UiEventLoggerImpl(); - private final ViewRippler mViewRippler = new ViewRippler(); - private RotationButton mRotationButton; - - private boolean mIsRecentsAnimationRunning; - private boolean mHomeRotationEnabled; - private int mLastRotationSuggestion; - private boolean mPendingRotationSuggestion; - private boolean mHoveringRotationSuggestion; - private RotationLockController mRotationLockController; - private AccessibilityManagerWrapper mAccessibilityManagerWrapper; - private TaskStackListenerImpl mTaskStackListener; - private Consumer mRotWatcherListener; - private boolean mListenersRegistered = false; - private boolean mIsNavigationBarShowing; - private @Behavior int mBehavior = WindowInsetsController.BEHAVIOR_DEFAULT; - private boolean mSkipOverrideUserLockPrefsOnce; - private int mLightIconColor; - private int mDarkIconColor; - private int mIconResId = R.drawable.ic_sysbar_rotate_button_ccw_start_90; - - private final Runnable mRemoveRotationProposal = - () -> setRotateSuggestionButtonState(false /* visible */); - private final Runnable mCancelPendingRotationProposal = - () -> mPendingRotationSuggestion = false; - private Animator mRotateHideAnimator; - - private final Stub mRotationWatcher = new Stub() { - @Override - public void onRotationChanged(final int rotation) throws RemoteException { - // We need this to be scheduled as early as possible to beat the redrawing of - // window in response to the orientation change. - mMainThreadHandler.postAtFrontOfQueue(() -> { - // If the screen rotation changes while locked, potentially update lock to flow with - // new screen rotation and hide any showing suggestions. - if (mRotationLockController.isRotationLocked()) { - if (shouldOverrideUserLockPrefs(rotation)) { - setRotationLockedAtAngle(rotation); - } - setRotateSuggestionButtonState(false /* visible */, true /* hideImmediately */); - } - - if (mRotWatcherListener != null) { - mRotWatcherListener.accept(rotation); - } - }); - } - }; - - /** - * Determines if rotation suggestions disabled2 flag exists in flag - * @param disable2Flags see if rotation suggestion flag exists in this flag - * @return whether flag exists - */ - static boolean hasDisable2RotateSuggestionFlag(int disable2Flags) { - return (disable2Flags & StatusBarManager.DISABLE2_ROTATE_SUGGESTIONS) != 0; - } - - RotationButtonController(Context context, @ColorInt int lightIconColor, - @ColorInt int darkIconColor) { - mContext = context; - mLightIconColor = lightIconColor; - mDarkIconColor = darkIconColor; - - mIsNavigationBarShowing = true; - mRotationLockController = Dependency.get(RotationLockController.class); - mAccessibilityManagerWrapper = Dependency.get(AccessibilityManagerWrapper.class); - mTaskStackListener = new TaskStackListenerImpl(); - } - - void setRotationButton(RotationButton rotationButton, - RotationButtonUpdatesCallback updatesCallback) { - mRotationButton = rotationButton; - mRotationButton.setRotationButtonController(this); - mRotationButton.setOnClickListener(this::onRotateSuggestionClick); - mRotationButton.setOnHoverListener(this::onRotateSuggestionHover); - mRotationButton.setUpdatesCallback(updatesCallback); - } - - void registerListeners() { - if (mListenersRegistered) { - return; - } - - mListenersRegistered = true; - try { - WindowManagerGlobal.getWindowManagerService() - .watchRotation(mRotationWatcher, mContext.getDisplay().getDisplayId()); - } catch (IllegalArgumentException e) { - mListenersRegistered = false; - Log.w(TAG, "RegisterListeners for the display failed"); - } catch (RemoteException e) { - throw e.rethrowFromSystemServer(); - } - - TaskStackChangeListeners.getInstance().registerTaskStackListener(mTaskStackListener); - } - - void unregisterListeners() { - if (!mListenersRegistered) { - return; - } - - mListenersRegistered = false; - try { - WindowManagerGlobal.getWindowManagerService().removeRotationWatcher(mRotationWatcher); - } catch (RemoteException e) { - throw e.rethrowFromSystemServer(); - } - - TaskStackChangeListeners.getInstance().unregisterTaskStackListener(mTaskStackListener); - } - - void setRotationCallback(Consumer watcher) { - mRotWatcherListener = watcher; - } - - void setRotationLockedAtAngle(int rotationSuggestion) { - mRotationLockController.setRotationLockedAtAngle(true /* locked */, rotationSuggestion); - } - - public boolean isRotationLocked() { - return mRotationLockController.isRotationLocked(); - } - - void setRotateSuggestionButtonState(boolean visible) { - setRotateSuggestionButtonState(visible, false /* hideImmediately */); - } - - /** - * Change the visibility of rotate suggestion button. If {@code hideImmediately} is true, - * it doesn't wait until the completion of the running animation. - */ - void setRotateSuggestionButtonState(final boolean visible, final boolean hideImmediately) { - // At any point the the button can become invisible because an a11y service became active. - // Similarly, a call to make the button visible may be rejected because an a11y service is - // active. Must account for this. - // Rerun a show animation to indicate change but don't rerun a hide animation - if (!visible && !mRotationButton.isVisible()) return; - - final View view = mRotationButton.getCurrentView(); - if (view == null) return; - - final KeyButtonDrawable currentDrawable = mRotationButton.getImageDrawable(); - if (currentDrawable == null) return; - - // Clear any pending suggestion flag as it has either been nullified or is being shown - mPendingRotationSuggestion = false; - mMainThreadHandler.removeCallbacks(mCancelPendingRotationProposal); - - // Handle the visibility change and animation - if (visible) { // Appear and change (cannot force) - // Stop and clear any currently running hide animations - if (mRotateHideAnimator != null && mRotateHideAnimator.isRunning()) { - mRotateHideAnimator.cancel(); - } - mRotateHideAnimator = null; - - // Reset the alpha if any has changed due to hide animation - view.setAlpha(1f); - - // Run the rotate icon's animation if it has one - if (currentDrawable.canAnimate()) { - currentDrawable.resetAnimation(); - currentDrawable.startAnimation(); - } - - if (!isRotateSuggestionIntroduced()) mViewRippler.start(view); - - // Set visibility unless a11y service is active. - mRotationButton.show(); - } else { // Hide - mViewRippler.stop(); // Prevent any pending ripples, force hide or not - - if (hideImmediately) { - // If a hide animator is running stop it and make invisible - if (mRotateHideAnimator != null && mRotateHideAnimator.isRunning()) { - mRotateHideAnimator.pause(); - } - mRotationButton.hide(); - return; - } - - // Don't start any new hide animations if one is running - if (mRotateHideAnimator != null && mRotateHideAnimator.isRunning()) return; - - ObjectAnimator fadeOut = ObjectAnimator.ofFloat(view, "alpha", 0f); - fadeOut.setDuration(BUTTON_FADE_IN_OUT_DURATION_MS); - fadeOut.setInterpolator(Interpolators.LINEAR); - fadeOut.addListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationEnd(Animator animation) { - mRotationButton.hide(); - } - }); - - mRotateHideAnimator = fadeOut; - fadeOut.start(); - } - } - - void setRecentsAnimationRunning(boolean running) { - mIsRecentsAnimationRunning = running; - updateRotationButtonStateInOverview(); - } - - void setHomeRotationEnabled(boolean enabled) { - mHomeRotationEnabled = enabled; - updateRotationButtonStateInOverview(); - } - - private void updateRotationButtonStateInOverview() { - if (mIsRecentsAnimationRunning && !mHomeRotationEnabled) { - setRotateSuggestionButtonState(false, true /* hideImmediately */ ); - } - } - - void setDarkIntensity(float darkIntensity) { - mRotationButton.setDarkIntensity(darkIntensity); - } - - void onRotationProposal(int rotation, int windowRotation, boolean isValid) { - if (!mRotationButton.acceptRotationProposal() || (!mHomeRotationEnabled - && mIsRecentsAnimationRunning)) { - return; - } - - // This method will be called on rotation suggestion changes even if the proposed rotation - // is not valid for the top app. Use invalid rotation choices as a signal to remove the - // rotate button if shown. - if (!isValid) { - setRotateSuggestionButtonState(false /* visible */); - return; - } - - // If window rotation matches suggested rotation, remove any current suggestions - if (rotation == windowRotation) { - mMainThreadHandler.removeCallbacks(mRemoveRotationProposal); - setRotateSuggestionButtonState(false /* visible */); - return; - } - - // Prepare to show the navbar icon by updating the icon style to change anim params - mLastRotationSuggestion = rotation; // Remember rotation for click - final boolean rotationCCW = Utilities.isRotationAnimationCCW(windowRotation, rotation); - if (windowRotation == Surface.ROTATION_0 || windowRotation == Surface.ROTATION_180) { - mIconResId = rotationCCW - ? R.drawable.ic_sysbar_rotate_button_ccw_start_90 - : R.drawable.ic_sysbar_rotate_button_cw_start_90; - } else { // 90 or 270 - mIconResId = rotationCCW - ? R.drawable.ic_sysbar_rotate_button_ccw_start_0 - : R.drawable.ic_sysbar_rotate_button_ccw_start_0; - } - mRotationButton.updateIcon(mLightIconColor, mDarkIconColor); - - if (canShowRotationButton()) { - // The navbar is visible / it's in visual immersive mode, so show the icon right away - showAndLogRotationSuggestion(); - } else { - // If the navbar isn't shown, flag the rotate icon to be shown should the navbar become - // visible given some time limit. - mPendingRotationSuggestion = true; - mMainThreadHandler.removeCallbacks(mCancelPendingRotationProposal); - mMainThreadHandler.postDelayed(mCancelPendingRotationProposal, - NAVBAR_HIDDEN_PENDING_ICON_TIMEOUT_MS); - } - } - - void onDisable2FlagChanged(int state2) { - final boolean rotateSuggestionsDisabled = hasDisable2RotateSuggestionFlag(state2); - if (rotateSuggestionsDisabled) onRotationSuggestionsDisabled(); - } - - void onNavigationBarWindowVisibilityChange(boolean showing) { - if (mIsNavigationBarShowing != showing) { - mIsNavigationBarShowing = showing; - showPendingRotationButtonIfNeeded(); - } - } - - void onBehaviorChanged(@Behavior int behavior) { - if (mBehavior != behavior) { - mBehavior = behavior; - showPendingRotationButtonIfNeeded(); - } - } - - private void showPendingRotationButtonIfNeeded() { - if (canShowRotationButton() && mPendingRotationSuggestion) { - showAndLogRotationSuggestion(); - } - } - - /** Return true when either the nav bar is visible or it's in visual immersive mode. */ - private boolean canShowRotationButton() { - return mIsNavigationBarShowing || mBehavior == WindowInsetsController.BEHAVIOR_DEFAULT; - } - - public Context getContext() { - return mContext; - } - - RotationButton getRotationButton() { - return mRotationButton; - } - - public @DrawableRes int getIconResId() { - return mIconResId; - } - - public @ColorInt int getLightIconColor() { - return mLightIconColor; - } - - public @ColorInt int getDarkIconColor() { - return mDarkIconColor; - } - - private void onRotateSuggestionClick(View v) { - mUiEventLogger.log(RotationButtonEvent.ROTATION_SUGGESTION_ACCEPTED); - incrementNumAcceptedRotationSuggestionsIfNeeded(); - setRotationLockedAtAngle(mLastRotationSuggestion); - } - - private boolean onRotateSuggestionHover(View v, MotionEvent event) { - final int action = event.getActionMasked(); - mHoveringRotationSuggestion = (action == MotionEvent.ACTION_HOVER_ENTER) - || (action == MotionEvent.ACTION_HOVER_MOVE); - rescheduleRotationTimeout(true /* reasonHover */); - return false; // Must return false so a11y hover events are dispatched correctly. - } - - private void onRotationSuggestionsDisabled() { - // Immediately hide the rotate button and clear any planned removal - setRotateSuggestionButtonState(false /* visible */, true /* force */); - mMainThreadHandler.removeCallbacks(mRemoveRotationProposal); - } - - private void showAndLogRotationSuggestion() { - setRotateSuggestionButtonState(true /* visible */); - rescheduleRotationTimeout(false /* reasonHover */); - mUiEventLogger.log(RotationButtonEvent.ROTATION_SUGGESTION_SHOWN); - } - - /** - * Makes {@link #shouldOverrideUserLockPrefs} always return {@code false} once. It is used to - * avoid losing original user rotation when display rotation is changed by entering the fixed - * orientation overview. - */ - void setSkipOverrideUserLockPrefsOnce() { - mSkipOverrideUserLockPrefsOnce = true; - } - - private boolean shouldOverrideUserLockPrefs(final int rotation) { - if (mSkipOverrideUserLockPrefsOnce) { - mSkipOverrideUserLockPrefsOnce = false; - return false; - } - // Only override user prefs when returning to the natural rotation (normally portrait). - // Don't let apps that force landscape or 180 alter user lock. - return rotation == NATURAL_ROTATION; - } - - private void rescheduleRotationTimeout(final boolean reasonHover) { - // May be called due to a new rotation proposal or a change in hover state - if (reasonHover) { - // Don't reschedule if a hide animator is running - if (mRotateHideAnimator != null && mRotateHideAnimator.isRunning()) return; - // Don't reschedule if not visible - if (!mRotationButton.isVisible()) return; - } - - // Stop any pending removal - mMainThreadHandler.removeCallbacks(mRemoveRotationProposal); - // Schedule timeout - mMainThreadHandler.postDelayed(mRemoveRotationProposal, - computeRotationProposalTimeout()); - } - - private int computeRotationProposalTimeout() { - return mAccessibilityManagerWrapper.getRecommendedTimeoutMillis( - mHoveringRotationSuggestion ? 16000 : 5000, - AccessibilityManager.FLAG_CONTENT_CONTROLS); - } - - private boolean isRotateSuggestionIntroduced() { - ContentResolver cr = mContext.getContentResolver(); - return Settings.Secure.getInt(cr, Settings.Secure.NUM_ROTATION_SUGGESTIONS_ACCEPTED, 0) - >= NUM_ACCEPTED_ROTATION_SUGGESTIONS_FOR_INTRODUCTION; - } - - private void incrementNumAcceptedRotationSuggestionsIfNeeded() { - // Get the number of accepted suggestions - ContentResolver cr = mContext.getContentResolver(); - final int numSuggestions = Settings.Secure.getInt(cr, - Settings.Secure.NUM_ROTATION_SUGGESTIONS_ACCEPTED, 0); - - // Increment the number of accepted suggestions only if it would change intro mode - if (numSuggestions < NUM_ACCEPTED_ROTATION_SUGGESTIONS_FOR_INTRODUCTION) { - Settings.Secure.putInt(cr, Settings.Secure.NUM_ROTATION_SUGGESTIONS_ACCEPTED, - numSuggestions + 1); - } - } - - private class TaskStackListenerImpl extends TaskStackChangeListener { - // Invalidate any rotation suggestion on task change or activity orientation change - // Note: all callbacks happen on main thread - - @Override - public void onTaskStackChanged() { - setRotateSuggestionButtonState(false /* visible */); - } - - @Override - public void onTaskRemoved(int taskId) { - setRotateSuggestionButtonState(false /* visible */); - } - - @Override - public void onTaskMovedToFront(int taskId) { - setRotateSuggestionButtonState(false /* visible */); - } - - @Override - public void onActivityRequestedOrientationChanged(int taskId, int requestedOrientation) { - // Only hide the icon if the top task changes its requestedOrientation - // Launcher can alter its requestedOrientation while it's not on top, don't hide on this - Optional.ofNullable(ActivityManagerWrapper.getInstance()) - .map(ActivityManagerWrapper::getRunningTask) - .ifPresent(a -> { - if (a.id == taskId) setRotateSuggestionButtonState(false /* visible */); - }); - } - } - - enum RotationButtonEvent implements UiEventLogger.UiEventEnum { - @UiEvent(doc = "The rotation button was shown") - ROTATION_SUGGESTION_SHOWN(206), - @UiEvent(doc = "The rotation button was clicked") - ROTATION_SUGGESTION_ACCEPTED(207); - - private final int mId; - RotationButtonEvent(int id) { - mId = id; - } - @Override public int getId() { - return mId; - } - } -} diff --git a/packages/SystemUI/src/com/android/systemui/navigationbar/buttons/KeyButtonRipple.java b/packages/SystemUI/src/com/android/systemui/navigationbar/buttons/KeyButtonRipple.java deleted file mode 100644 index 00124ac01cc4..000000000000 --- a/packages/SystemUI/src/com/android/systemui/navigationbar/buttons/KeyButtonRipple.java +++ /dev/null @@ -1,520 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.systemui.navigationbar.buttons; - -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.animation.ObjectAnimator; -import android.content.Context; -import android.graphics.Canvas; -import android.graphics.CanvasProperty; -import android.graphics.ColorFilter; -import android.graphics.Paint; -import android.graphics.PixelFormat; -import android.graphics.RecordingCanvas; -import android.graphics.drawable.Drawable; -import android.os.Handler; -import android.os.Trace; -import android.view.RenderNodeAnimator; -import android.view.View; -import android.view.ViewConfiguration; -import android.view.animation.Interpolator; - -import com.android.systemui.R; -import com.android.systemui.animation.Interpolators; - -import androidx.annotation.Keep; - -import java.util.ArrayList; -import java.util.HashSet; - -public class KeyButtonRipple extends Drawable { - - private static final float GLOW_MAX_SCALE_FACTOR = 1.35f; - private static final float GLOW_MAX_ALPHA = 0.2f; - private static final float GLOW_MAX_ALPHA_DARK = 0.1f; - private static final int ANIMATION_DURATION_SCALE = 350; - private static final int ANIMATION_DURATION_FADE = 450; - - private Paint mRipplePaint; - private CanvasProperty mLeftProp; - private CanvasProperty mTopProp; - private CanvasProperty mRightProp; - private CanvasProperty mBottomProp; - private CanvasProperty mRxProp; - private CanvasProperty mRyProp; - private CanvasProperty mPaintProp; - private float mGlowAlpha = 0f; - private float mGlowScale = 1f; - private boolean mPressed; - private boolean mVisible; - private boolean mDrawingHardwareGlow; - private int mMaxWidth; - private boolean mLastDark; - private boolean mDark; - private boolean mDelayTouchFeedback; - - private final Interpolator mInterpolator = new LogInterpolator(); - private boolean mSupportHardware; - private final View mTargetView; - private final Handler mHandler = new Handler(); - - private final HashSet mRunningAnimations = new HashSet<>(); - private final ArrayList mTmpArray = new ArrayList<>(); - - private final TraceAnimatorListener mExitHwTraceAnimator = - new TraceAnimatorListener("exitHardware"); - private final TraceAnimatorListener mEnterHwTraceAnimator = - new TraceAnimatorListener("enterHardware"); - - public enum Type { - OVAL, - ROUNDED_RECT - } - - private Type mType = Type.ROUNDED_RECT; - - public KeyButtonRipple(Context ctx, View targetView) { - mMaxWidth = ctx.getResources().getDimensionPixelSize(R.dimen.key_button_ripple_max_width); - mTargetView = targetView; - } - - public void setDarkIntensity(float darkIntensity) { - mDark = darkIntensity >= 0.5f; - } - - public void setDelayTouchFeedback(boolean delay) { - mDelayTouchFeedback = delay; - } - - public void setType(Type type) { - mType = type; - } - - private Paint getRipplePaint() { - if (mRipplePaint == null) { - mRipplePaint = new Paint(); - mRipplePaint.setAntiAlias(true); - mRipplePaint.setColor(mLastDark ? 0xff000000 : 0xffffffff); - } - return mRipplePaint; - } - - private void drawSoftware(Canvas canvas) { - if (mGlowAlpha > 0f) { - final Paint p = getRipplePaint(); - p.setAlpha((int)(mGlowAlpha * 255f)); - - final float w = getBounds().width(); - final float h = getBounds().height(); - final boolean horizontal = w > h; - final float diameter = getRippleSize() * mGlowScale; - final float radius = diameter * .5f; - final float cx = w * .5f; - final float cy = h * .5f; - final float rx = horizontal ? radius : cx; - final float ry = horizontal ? cy : radius; - final float corner = horizontal ? cy : cx; - - if (mType == Type.ROUNDED_RECT) { - canvas.drawRoundRect(cx - rx, cy - ry, cx + rx, cy + ry, corner, corner, p); - } else { - canvas.save(); - canvas.translate(cx, cy); - float r = Math.min(rx, ry); - canvas.drawOval(-r, -r, r, r, p); - canvas.restore(); - } - } - } - - @Override - public void draw(Canvas canvas) { - mSupportHardware = canvas.isHardwareAccelerated(); - if (mSupportHardware) { - drawHardware((RecordingCanvas) canvas); - } else { - drawSoftware(canvas); - } - } - - @Override - public void setAlpha(int alpha) { - // Not supported. - } - - @Override - public void setColorFilter(ColorFilter colorFilter) { - // Not supported. - } - - @Override - public int getOpacity() { - return PixelFormat.TRANSLUCENT; - } - - private boolean isHorizontal() { - return getBounds().width() > getBounds().height(); - } - - private void drawHardware(RecordingCanvas c) { - if (mDrawingHardwareGlow) { - if (mType == Type.ROUNDED_RECT) { - c.drawRoundRect(mLeftProp, mTopProp, mRightProp, mBottomProp, mRxProp, mRyProp, - mPaintProp); - } else { - CanvasProperty cx = CanvasProperty.createFloat(getBounds().width() / 2); - CanvasProperty cy = CanvasProperty.createFloat(getBounds().height() / 2); - int d = Math.min(getBounds().width(), getBounds().height()); - CanvasProperty r = CanvasProperty.createFloat(1.0f * d / 2); - c.drawCircle(cx, cy, r, mPaintProp); - } - } - } - - /** Gets the glow alpha, used by {@link android.animation.ObjectAnimator} via reflection. */ - @Keep - public float getGlowAlpha() { - return mGlowAlpha; - } - - /** Sets the glow alpha, used by {@link android.animation.ObjectAnimator} via reflection. */ - @Keep - public void setGlowAlpha(float x) { - mGlowAlpha = x; - invalidateSelf(); - } - - /** Gets the glow scale, used by {@link android.animation.ObjectAnimator} via reflection. */ - @Keep - public float getGlowScale() { - return mGlowScale; - } - - /** Sets the glow scale, used by {@link android.animation.ObjectAnimator} via reflection. */ - @Keep - public void setGlowScale(float x) { - mGlowScale = x; - invalidateSelf(); - } - - private float getMaxGlowAlpha() { - return mLastDark ? GLOW_MAX_ALPHA_DARK : GLOW_MAX_ALPHA; - } - - @Override - protected boolean onStateChange(int[] state) { - boolean pressed = false; - for (int i = 0; i < state.length; i++) { - if (state[i] == android.R.attr.state_pressed) { - pressed = true; - break; - } - } - if (pressed != mPressed) { - setPressed(pressed); - mPressed = pressed; - return true; - } else { - return false; - } - } - - @Override - public boolean setVisible(boolean visible, boolean restart) { - boolean changed = super.setVisible(visible, restart); - if (changed) { - // End any existing animations when the visibility changes - jumpToCurrentState(); - } - return changed; - } - - @Override - public void jumpToCurrentState() { - endAnimations("jumpToCurrentState", false /* cancel */); - } - - @Override - public boolean isStateful() { - return true; - } - - @Override - public boolean hasFocusStateSpecified() { - return true; - } - - public void setPressed(boolean pressed) { - if (mDark != mLastDark && pressed) { - mRipplePaint = null; - mLastDark = mDark; - } - if (mSupportHardware) { - setPressedHardware(pressed); - } else { - setPressedSoftware(pressed); - } - } - - /** - * Abort the ripple while it is delayed and before shown used only when setShouldDelayStartTouch - * is enabled. - */ - public void abortDelayedRipple() { - mHandler.removeCallbacksAndMessages(null); - } - - private void endAnimations(String reason, boolean cancel) { - Trace.beginSection("KeyButtonRipple.endAnim: reason=" + reason + " cancel=" + cancel); - Trace.endSection(); - mVisible = false; - mTmpArray.addAll(mRunningAnimations); - int size = mTmpArray.size(); - for (int i = 0; i < size; i++) { - Animator a = mTmpArray.get(i); - if (cancel) { - a.cancel(); - } else { - a.end(); - } - } - mTmpArray.clear(); - mRunningAnimations.clear(); - mHandler.removeCallbacksAndMessages(null); - } - - private void setPressedSoftware(boolean pressed) { - if (pressed) { - if (mDelayTouchFeedback) { - if (mRunningAnimations.isEmpty()) { - mHandler.removeCallbacksAndMessages(null); - mHandler.postDelayed(this::enterSoftware, ViewConfiguration.getTapTimeout()); - } else if (mVisible) { - enterSoftware(); - } - } else { - enterSoftware(); - } - } else { - exitSoftware(); - } - } - - private void enterSoftware() { - endAnimations("enterSoftware", true /* cancel */); - mVisible = true; - mGlowAlpha = getMaxGlowAlpha(); - ObjectAnimator scaleAnimator = ObjectAnimator.ofFloat(this, "glowScale", - 0f, GLOW_MAX_SCALE_FACTOR); - scaleAnimator.setInterpolator(mInterpolator); - scaleAnimator.setDuration(ANIMATION_DURATION_SCALE); - scaleAnimator.addListener(mAnimatorListener); - scaleAnimator.start(); - mRunningAnimations.add(scaleAnimator); - - // With the delay, it could eventually animate the enter animation with no pressed state, - // then immediately show the exit animation. If this is skipped there will be no ripple. - if (mDelayTouchFeedback && !mPressed) { - exitSoftware(); - } - } - - private void exitSoftware() { - ObjectAnimator alphaAnimator = ObjectAnimator.ofFloat(this, "glowAlpha", mGlowAlpha, 0f); - alphaAnimator.setInterpolator(Interpolators.ALPHA_OUT); - alphaAnimator.setDuration(ANIMATION_DURATION_FADE); - alphaAnimator.addListener(mAnimatorListener); - alphaAnimator.start(); - mRunningAnimations.add(alphaAnimator); - } - - private void setPressedHardware(boolean pressed) { - if (pressed) { - if (mDelayTouchFeedback) { - if (mRunningAnimations.isEmpty()) { - mHandler.removeCallbacksAndMessages(null); - mHandler.postDelayed(this::enterHardware, ViewConfiguration.getTapTimeout()); - } else if (mVisible) { - enterHardware(); - } - } else { - enterHardware(); - } - } else { - exitHardware(); - } - } - - /** - * Sets the left/top property for the round rect to {@code prop} depending on whether we are - * horizontal or vertical mode. - */ - private void setExtendStart(CanvasProperty prop) { - if (isHorizontal()) { - mLeftProp = prop; - } else { - mTopProp = prop; - } - } - - private CanvasProperty getExtendStart() { - return isHorizontal() ? mLeftProp : mTopProp; - } - - /** - * Sets the right/bottom property for the round rect to {@code prop} depending on whether we are - * horizontal or vertical mode. - */ - private void setExtendEnd(CanvasProperty prop) { - if (isHorizontal()) { - mRightProp = prop; - } else { - mBottomProp = prop; - } - } - - private CanvasProperty getExtendEnd() { - return isHorizontal() ? mRightProp : mBottomProp; - } - - private int getExtendSize() { - return isHorizontal() ? getBounds().width() : getBounds().height(); - } - - private int getRippleSize() { - int size = isHorizontal() ? getBounds().width() : getBounds().height(); - return Math.min(size, mMaxWidth); - } - - private void enterHardware() { - endAnimations("enterHardware", true /* cancel */); - mVisible = true; - mDrawingHardwareGlow = true; - setExtendStart(CanvasProperty.createFloat(getExtendSize() / 2)); - final RenderNodeAnimator startAnim = new RenderNodeAnimator(getExtendStart(), - getExtendSize()/2 - GLOW_MAX_SCALE_FACTOR * getRippleSize()/2); - startAnim.setDuration(ANIMATION_DURATION_SCALE); - startAnim.setInterpolator(mInterpolator); - startAnim.addListener(mAnimatorListener); - startAnim.setTarget(mTargetView); - - setExtendEnd(CanvasProperty.createFloat(getExtendSize() / 2)); - final RenderNodeAnimator endAnim = new RenderNodeAnimator(getExtendEnd(), - getExtendSize()/2 + GLOW_MAX_SCALE_FACTOR * getRippleSize()/2); - endAnim.setDuration(ANIMATION_DURATION_SCALE); - endAnim.setInterpolator(mInterpolator); - endAnim.addListener(mAnimatorListener); - endAnim.addListener(mEnterHwTraceAnimator); - endAnim.setTarget(mTargetView); - - if (isHorizontal()) { - mTopProp = CanvasProperty.createFloat(0f); - mBottomProp = CanvasProperty.createFloat(getBounds().height()); - mRxProp = CanvasProperty.createFloat(getBounds().height()/2); - mRyProp = CanvasProperty.createFloat(getBounds().height()/2); - } else { - mLeftProp = CanvasProperty.createFloat(0f); - mRightProp = CanvasProperty.createFloat(getBounds().width()); - mRxProp = CanvasProperty.createFloat(getBounds().width()/2); - mRyProp = CanvasProperty.createFloat(getBounds().width()/2); - } - - mGlowScale = GLOW_MAX_SCALE_FACTOR; - mGlowAlpha = getMaxGlowAlpha(); - mRipplePaint = getRipplePaint(); - mRipplePaint.setAlpha((int) (mGlowAlpha * 255)); - mPaintProp = CanvasProperty.createPaint(mRipplePaint); - - startAnim.start(); - endAnim.start(); - mRunningAnimations.add(startAnim); - mRunningAnimations.add(endAnim); - - invalidateSelf(); - - // With the delay, it could eventually animate the enter animation with no pressed state, - // then immediately show the exit animation. If this is skipped there will be no ripple. - if (mDelayTouchFeedback && !mPressed) { - exitHardware(); - } - } - - private void exitHardware() { - mPaintProp = CanvasProperty.createPaint(getRipplePaint()); - final RenderNodeAnimator opacityAnim = new RenderNodeAnimator(mPaintProp, - RenderNodeAnimator.PAINT_ALPHA, 0); - opacityAnim.setDuration(ANIMATION_DURATION_FADE); - opacityAnim.setInterpolator(Interpolators.ALPHA_OUT); - opacityAnim.addListener(mAnimatorListener); - opacityAnim.addListener(mExitHwTraceAnimator); - opacityAnim.setTarget(mTargetView); - - opacityAnim.start(); - mRunningAnimations.add(opacityAnim); - - invalidateSelf(); - } - - private final AnimatorListenerAdapter mAnimatorListener = - new AnimatorListenerAdapter() { - @Override - public void onAnimationEnd(Animator animation) { - mRunningAnimations.remove(animation); - if (mRunningAnimations.isEmpty() && !mPressed) { - mVisible = false; - mDrawingHardwareGlow = false; - invalidateSelf(); - } - } - }; - - private static final class TraceAnimatorListener extends AnimatorListenerAdapter { - private final String mName; - TraceAnimatorListener(String name) { - mName = name; - } - - @Override - public void onAnimationStart(Animator animation) { - Trace.beginSection("KeyButtonRipple.start." + mName); - Trace.endSection(); - } - - @Override - public void onAnimationCancel(Animator animation) { - Trace.beginSection("KeyButtonRipple.cancel." + mName); - Trace.endSection(); - } - - @Override - public void onAnimationEnd(Animator animation) { - Trace.beginSection("KeyButtonRipple.end." + mName); - Trace.endSection(); - } - } - - /** - * Interpolator with a smooth log deceleration - */ - private static final class LogInterpolator implements Interpolator { - @Override - public float getInterpolation(float input) { - return 1 - (float) Math.pow(400, -input * 1.4); - } - } -} diff --git a/packages/SystemUI/src/com/android/systemui/navigationbar/buttons/RotationContextButton.java b/packages/SystemUI/src/com/android/systemui/navigationbar/buttons/RotationContextButton.java index ebb67af43a37..ac014b5b4a64 100644 --- a/packages/SystemUI/src/com/android/systemui/navigationbar/buttons/RotationContextButton.java +++ b/packages/SystemUI/src/com/android/systemui/navigationbar/buttons/RotationContextButton.java @@ -21,8 +21,8 @@ import android.annotation.IdRes; import android.content.Context; import android.view.View; -import com.android.systemui.navigationbar.RotationButton; -import com.android.systemui.navigationbar.RotationButtonController; +import com.android.systemui.shared.rotation.RotationButton; +import com.android.systemui.shared.rotation.RotationButtonController; /** Containing logic for the rotation button in nav bar. */ public class RotationContextButton extends ContextualButton implements RotationButton { diff --git a/packages/SystemUI/src/com/android/systemui/navigationbar/gestural/FloatingRotationButton.java b/packages/SystemUI/src/com/android/systemui/navigationbar/gestural/FloatingRotationButton.java deleted file mode 100644 index 46057952e079..000000000000 --- a/packages/SystemUI/src/com/android/systemui/navigationbar/gestural/FloatingRotationButton.java +++ /dev/null @@ -1,243 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.systemui.navigationbar.gestural; - -import android.content.Context; -import android.content.res.Resources; -import android.graphics.Color; -import android.graphics.PixelFormat; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowManager; -import android.view.animation.AccelerateDecelerateInterpolator; -import android.widget.FrameLayout; - -import com.android.systemui.R; -import com.android.systemui.navigationbar.RotationButton; -import com.android.systemui.navigationbar.RotationButtonController; -import com.android.systemui.navigationbar.buttons.KeyButtonDrawable; -import com.android.systemui.navigationbar.buttons.KeyButtonView; -import com.android.systemui.navigationbar.gestural.FloatingRotationButtonPositionCalculator.Position; - -/** - * Containing logic for the rotation button on the physical left bottom corner of the screen. - */ -public class FloatingRotationButton implements RotationButton { - - private static final float BACKGROUND_ALPHA = 0.92f; - private static final int MARGIN_ANIMATION_DURATION_MILLIS = 300; - - private final WindowManager mWindowManager; - private final ViewGroup mKeyButtonContainer; - private final KeyButtonView mKeyButtonView; - - private final int mContainerSize; - - private KeyButtonDrawable mKeyButtonDrawable; - private boolean mIsShowing; - private boolean mCanShow = true; - private int mDisplayRotation; - - private boolean mIsTaskbarVisible = false; - private boolean mIsTaskbarStashed = false; - - private final FloatingRotationButtonPositionCalculator mPositionCalculator; - - private RotationButtonController mRotationButtonController; - private RotationButtonUpdatesCallback mUpdatesCallback; - private Position mPosition; - - public FloatingRotationButton(Context context) { - mWindowManager = context.getSystemService(WindowManager.class); - mKeyButtonContainer = (ViewGroup) LayoutInflater.from(context).inflate( - R.layout.rotate_suggestion, null); - mKeyButtonView = mKeyButtonContainer.findViewById(R.id.rotate_suggestion); - mKeyButtonView.setVisibility(View.VISIBLE); - - Resources res = context.getResources(); - - int defaultMargin = Math.max( - res.getDimensionPixelSize(R.dimen.floating_rotation_button_min_margin), - res.getDimensionPixelSize(R.dimen.rounded_corner_content_padding)); - - int taskbarMarginLeft = - res.getDimensionPixelSize(R.dimen.floating_rotation_button_taskbar_left_margin); - int taskbarMarginBottom = - res.getDimensionPixelSize(R.dimen.floating_rotation_button_taskbar_bottom_margin); - - mPositionCalculator = new FloatingRotationButtonPositionCalculator(defaultMargin, - taskbarMarginLeft, taskbarMarginBottom); - - final int diameter = res.getDimensionPixelSize(R.dimen.floating_rotation_button_diameter); - mContainerSize = diameter + Math.max(defaultMargin, Math.max(taskbarMarginLeft, - taskbarMarginBottom)); - } - - @Override - public void setRotationButtonController(RotationButtonController rotationButtonController) { - mRotationButtonController = rotationButtonController; - updateIcon(mRotationButtonController.getLightIconColor(), - mRotationButtonController.getDarkIconColor()); - } - - @Override - public void setUpdatesCallback(RotationButtonUpdatesCallback updatesCallback) { - mUpdatesCallback = updatesCallback; - } - - @Override - public View getCurrentView() { - return mKeyButtonView; - } - - @Override - public boolean show() { - if (!mCanShow || mIsShowing) { - return false; - } - - mIsShowing = true; - int flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; - final WindowManager.LayoutParams lp = new WindowManager.LayoutParams( - mContainerSize, - mContainerSize, - 0, 0, WindowManager.LayoutParams.TYPE_NAVIGATION_BAR_PANEL, flags, - PixelFormat.TRANSLUCENT); - - lp.privateFlags |= WindowManager.LayoutParams.SYSTEM_FLAG_SHOW_FOR_ALL_USERS; - lp.setTitle("FloatingRotationButton"); - lp.setFitInsetsTypes(0 /*types */); - - mDisplayRotation = mWindowManager.getDefaultDisplay().getRotation(); - mPosition = mPositionCalculator - .calculatePosition(mDisplayRotation, mIsTaskbarVisible, mIsTaskbarStashed); - - lp.gravity = mPosition.getGravity(); - ((FrameLayout.LayoutParams) mKeyButtonView.getLayoutParams()).gravity = - mPosition.getGravity(); - - updateTranslation(mPosition, /* animate */ false); - - mWindowManager.addView(mKeyButtonContainer, lp); - if (mKeyButtonDrawable != null && mKeyButtonDrawable.canAnimate()) { - mKeyButtonDrawable.resetAnimation(); - mKeyButtonDrawable.startAnimation(); - } - - if (mUpdatesCallback != null) { - mUpdatesCallback.onVisibilityChanged(true); - } - - return true; - } - - @Override - public boolean hide() { - if (!mIsShowing) { - return false; - } - mWindowManager.removeViewImmediate(mKeyButtonContainer); - mIsShowing = false; - if (mUpdatesCallback != null) { - mUpdatesCallback.onVisibilityChanged(false); - } - return true; - } - - @Override - public boolean isVisible() { - return mIsShowing; - } - - @Override - public void updateIcon(int lightIconColor, int darkIconColor) { - Color ovalBackgroundColor = Color.valueOf(Color.red(darkIconColor), - Color.green(darkIconColor), Color.blue(darkIconColor), BACKGROUND_ALPHA); - mKeyButtonDrawable = KeyButtonDrawable.create(mRotationButtonController.getContext(), - lightIconColor, darkIconColor, mRotationButtonController.getIconResId(), - false /* shadow */, ovalBackgroundColor); - mKeyButtonView.setImageDrawable(mKeyButtonDrawable); - } - - @Override - public void setOnClickListener(View.OnClickListener onClickListener) { - mKeyButtonView.setOnClickListener(onClickListener); - } - - @Override - public void setOnHoverListener(View.OnHoverListener onHoverListener) { - mKeyButtonView.setOnHoverListener(onHoverListener); - } - - @Override - public KeyButtonDrawable getImageDrawable() { - return mKeyButtonDrawable; - } - - @Override - public void setDarkIntensity(float darkIntensity) { - mKeyButtonView.setDarkIntensity(darkIntensity); - } - - @Override - public void setCanShowRotationButton(boolean canShow) { - mCanShow = canShow; - if (!mCanShow) { - hide(); - } - } - - public void onTaskbarStateChanged(boolean taskbarVisible, boolean taskbarStashed) { - mIsTaskbarVisible = taskbarVisible; - mIsTaskbarStashed = taskbarStashed; - - if (!mIsShowing) return; - - final Position newPosition = mPositionCalculator - .calculatePosition(mDisplayRotation, mIsTaskbarVisible, mIsTaskbarStashed); - - if (newPosition.getTranslationX() != mPosition.getTranslationX() - || newPosition.getTranslationY() != mPosition.getTranslationY()) { - updateTranslation(newPosition, /* animate */ true); - mPosition = newPosition; - } - } - - private void updateTranslation(Position position, boolean animate) { - final int translationX = position.getTranslationX(); - final int translationY = position.getTranslationY(); - - if (animate) { - mKeyButtonView - .animate() - .translationX(translationX) - .translationY(translationY) - .setDuration(MARGIN_ANIMATION_DURATION_MILLIS) - .setInterpolator(new AccelerateDecelerateInterpolator()) - .withEndAction(() -> { - if (mUpdatesCallback != null && mIsShowing) { - mUpdatesCallback.onPositionChanged(); - } - }) - .start(); - } else { - mKeyButtonView.setTranslationX(translationX); - mKeyButtonView.setTranslationY(translationY); - } - } -} diff --git a/packages/SystemUI/src/com/android/systemui/navigationbar/gestural/FloatingRotationButtonPositionCalculator.kt b/packages/SystemUI/src/com/android/systemui/navigationbar/gestural/FloatingRotationButtonPositionCalculator.kt deleted file mode 100644 index 3ce51ad331c5..000000000000 --- a/packages/SystemUI/src/com/android/systemui/navigationbar/gestural/FloatingRotationButtonPositionCalculator.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.android.systemui.navigationbar.gestural - -import android.view.Gravity -import android.view.Surface - -/** - * Calculates gravity and translation that is necessary to display - * the button in the correct position based on the current state - */ -internal class FloatingRotationButtonPositionCalculator( - private val defaultMargin: Int, - private val taskbarMarginLeft: Int, - private val taskbarMarginBottom: Int -) { - - fun calculatePosition( - currentRotation: Int, - taskbarVisible: Boolean, - taskbarStashed: Boolean - ): Position { - - val isTaskbarSide = currentRotation == Surface.ROTATION_0 - || currentRotation == Surface.ROTATION_90 - val useTaskbarMargin = isTaskbarSide && taskbarVisible && !taskbarStashed - - val gravity = resolveGravity(currentRotation) - - val marginLeft = if (useTaskbarMargin) taskbarMarginLeft else defaultMargin - val marginBottom = if (useTaskbarMargin) taskbarMarginBottom else defaultMargin - - val translationX = - if (gravity and Gravity.RIGHT == Gravity.RIGHT) { - -marginLeft - } else { - marginLeft - } - val translationY = - if (gravity and Gravity.BOTTOM == Gravity.BOTTOM) { - -marginBottom - } else { - marginBottom - } - - return Position( - gravity = gravity, - translationX = translationX, - translationY = translationY - ) - } - - data class Position( - val gravity: Int, - val translationX: Int, - val translationY: Int - ) - - private fun resolveGravity(rotation: Int): Int = - when (rotation) { - Surface.ROTATION_0 -> Gravity.BOTTOM or Gravity.LEFT - Surface.ROTATION_90 -> Gravity.BOTTOM or Gravity.RIGHT - Surface.ROTATION_180 -> Gravity.TOP or Gravity.RIGHT - Surface.ROTATION_270 -> Gravity.TOP or Gravity.LEFT - else -> throw IllegalArgumentException("Invalid rotation $rotation") - } -} diff --git a/packages/SystemUI/tests/src/com/android/systemui/navigationbar/NavigationBarRotationContextTest.java b/packages/SystemUI/tests/src/com/android/systemui/navigationbar/NavigationBarRotationContextTest.java index a6ff2e8d2e15..85bc634c28b1 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/navigationbar/NavigationBarRotationContextTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/navigationbar/NavigationBarRotationContextTest.java @@ -22,6 +22,7 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import android.view.Display; import android.view.View; import android.view.WindowInsetsController; @@ -31,6 +32,8 @@ import androidx.test.runner.AndroidJUnit4; import com.android.systemui.SysuiTestCase; import com.android.systemui.SysuiTestableContext; +import com.android.systemui.shared.rotation.RotationButton; +import com.android.systemui.shared.rotation.RotationButtonController; import com.android.systemui.statusbar.policy.RotationLockController; import org.junit.Before; @@ -39,6 +42,8 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.MockitoAnnotations; +import java.util.function.Supplier; + /** atest NavigationBarRotationContextTest */ @SmallTest @RunWith(AndroidJUnit4.class) @@ -50,6 +55,8 @@ public class NavigationBarRotationContextTest extends SysuiTestCase { InstrumentationRegistry.getContext(), getLeakCheck()); private RotationButtonController mRotationButtonController; private RotationButton mRotationButton; + private int mWindowRotation = DEFAULT_ROTATE; + private Supplier mWindowRotationSupplier = () -> mWindowRotation; @Before public void setup() { @@ -58,7 +65,15 @@ public class NavigationBarRotationContextTest extends SysuiTestCase { final View view = new View(mContext); mRotationButton = mock(RotationButton.class); - mRotationButtonController = new RotationButtonController(mContext, 0, 0); + mRotationButtonController = new RotationButtonController(mContext, + /* lightIconColor */ 0, + /* darkIconColor */ 0, + /* iconCcwStart0 */ 0, + /* iconCcwStart90 */ 0, + /* iconCwStart0 */ 0, + /* iconCwStart90 */ 0, + mWindowRotationSupplier + ); mRotationButtonController.setRotationButton(mRotationButton, new RotationButton.RotationButtonUpdatesCallback() { @Override @@ -77,16 +92,16 @@ public class NavigationBarRotationContextTest extends SysuiTestCase { @Test public void testOnInvalidRotationProposal() { - mRotationButtonController.onRotationProposal(DEFAULT_ROTATE, DEFAULT_ROTATE + 1, - false /* isValid */); + mWindowRotation = DEFAULT_ROTATE + 1; + mRotationButtonController.onRotationProposal(DEFAULT_ROTATE, false /* isValid */); verify(mRotationButtonController, times(1)).setRotateSuggestionButtonState( false /* visible */); } @Test public void testOnSameRotationProposal() { - mRotationButtonController.onRotationProposal(DEFAULT_ROTATE, DEFAULT_ROTATE, - true /* isValid */); + mWindowRotation = DEFAULT_ROTATE; + mRotationButtonController.onRotationProposal(DEFAULT_ROTATE, true /* isValid */); verify(mRotationButtonController, times(1)).setRotateSuggestionButtonState( false /* visible */); } @@ -94,17 +109,17 @@ public class NavigationBarRotationContextTest extends SysuiTestCase { @Test public void testOnRotationProposalShowButtonShowNav() { // No navigation bar should not call to set visibility state - mRotationButtonController.onBehaviorChanged( + mRotationButtonController.onBehaviorChanged(Display.DEFAULT_DISPLAY, WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); mRotationButtonController.onNavigationBarWindowVisibilityChange(false /* showing */); verify(mRotationButtonController, times(0)).setRotateSuggestionButtonState( false /* visible */); verify(mRotationButtonController, times(0)).setRotateSuggestionButtonState( true /* visible */); + mWindowRotation = DEFAULT_ROTATE + 1; // No navigation bar with rotation change should not call to set visibility state - mRotationButtonController.onRotationProposal(DEFAULT_ROTATE, DEFAULT_ROTATE + 1, - true /* isValid */); + mRotationButtonController.onRotationProposal(DEFAULT_ROTATE, true /* isValid */); verify(mRotationButtonController, times(0)).setRotateSuggestionButtonState( false /* visible */); verify(mRotationButtonController, times(0)).setRotateSuggestionButtonState( @@ -124,10 +139,10 @@ public class NavigationBarRotationContextTest extends SysuiTestCase { false /* visible */); verify(mRotationButtonController, times(0)).setRotateSuggestionButtonState( true /* visible */); + mWindowRotation = DEFAULT_ROTATE + 1; // Navigation bar is visible and rotation requested - mRotationButtonController.onRotationProposal(DEFAULT_ROTATE, DEFAULT_ROTATE + 1, - true /* isValid */); + mRotationButtonController.onRotationProposal(DEFAULT_ROTATE, true /* isValid */); verify(mRotationButtonController, times(1)).setRotateSuggestionButtonState( true /* visible */); } diff --git a/packages/SystemUI/tests/src/com/android/systemui/navigationbar/gestural/FloatingRotationButtonPositionCalculatorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/navigationbar/gestural/FloatingRotationButtonPositionCalculatorTest.kt index 0a2000107053..36e02cb1df06 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/navigationbar/gestural/FloatingRotationButtonPositionCalculatorTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/navigationbar/gestural/FloatingRotationButtonPositionCalculatorTest.kt @@ -4,7 +4,8 @@ import android.view.Gravity import android.view.Surface import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase -import com.android.systemui.navigationbar.gestural.FloatingRotationButtonPositionCalculator.Position +import com.android.systemui.shared.rotation.FloatingRotationButtonPositionCalculator +import com.android.systemui.shared.rotation.FloatingRotationButtonPositionCalculator.Position import com.google.common.truth.Truth.assertThat import org.junit.Test import org.junit.runner.RunWith -- cgit v1.2.3-59-g8ed1b