Merge "Add the charging ripple effect, currently guarded by flag_charging_ripple." into sc-dev

This commit is contained in:
Shan Huang
2021-03-19 08:00:23 +00:00
committed by Android (Google) Code Review
9 changed files with 504 additions and 0 deletions

View File

@@ -50,4 +50,6 @@
<bool name="flag_pm_lite">false</bool>
<bool name="flag_alarm_tile">false</bool>
<bool name="flag_charging_ripple">false</bool>
</resources>

View File

@@ -98,4 +98,8 @@ public class FeatureFlags {
public boolean isAlarmTileAvailable() {
return mFlagReader.isEnabled(R.bool.flag_alarm_tile);
}
public boolean isChargingRippleEnabled() {
return mFlagReader.isEnabled(R.bool.flag_charging_ripple);
}
}

View File

@@ -0,0 +1,86 @@
/*
* 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.statusbar.charging
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PointF
import android.util.AttributeSet
import android.view.View
import kotlin.math.max
private const val RIPPLE_ANIMATION_DURATION: Long = 1500
private const val RIPPLE_SPARKLE_STRENGTH: Float = 0.3f
/**
* Expanding ripple effect that shows when charging begins.
*/
class ChargingRippleView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
private var rippleInProgress: Boolean = false
private val rippleShader = RippleShader()
private val defaultColor: Int = 0xffffffff.toInt()
private val ripplePaint = Paint()
init {
rippleShader.color = defaultColor
rippleShader.progress = 0f
rippleShader.sparkleStrength = RIPPLE_SPARKLE_STRENGTH
ripplePaint.shader = rippleShader
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
rippleShader.origin = PointF(measuredWidth / 2f, measuredHeight.toFloat())
rippleShader.radius = max(measuredWidth, measuredHeight).toFloat()
super.onLayout(changed, left, top, right, bottom)
}
fun startRipple() {
if (rippleInProgress) {
return // Ignore if ripple effect is already playing
}
val animator = ValueAnimator.ofFloat(0f, 1f)
animator.duration = RIPPLE_ANIMATION_DURATION
animator.addUpdateListener { animator ->
val now = animator.currentPlayTime
val phase = now / 30000f
rippleShader.progress = animator.animatedValue as Float
rippleShader.noisePhase = phase
invalidate()
}
animator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
rippleInProgress = false
visibility = View.GONE
}
})
animator.start()
visibility = View.VISIBLE
rippleInProgress = true
}
fun setColor(color: Int) {
rippleShader.color = color
}
override fun onDraw(canvas: Canvas?) {
canvas?.drawRect(0f, 0f, width.toFloat(), height.toFloat(), ripplePaint)
}
}

View File

@@ -0,0 +1,147 @@
/*
* 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.statusbar.charging
import android.graphics.Color
import android.graphics.PointF
import android.graphics.RuntimeShader
/**
* Shader class that renders an expanding charging ripple effect. A charging ripple contains
* three elements:
* 1. an expanding filled circle that appears in the beginning and quickly fades away
* 2. an expanding ring that appears throughout the effect
* 3. an expanding ring-shaped area that reveals noise over #2.
*
* Modeled after frameworks/base/graphics/java/android/graphics/drawable/RippleShader.java.
*/
class RippleShader internal constructor() : RuntimeShader(SHADER, false) {
companion object {
private const val SHADER_UNIFORMS = """uniform vec2 in_origin;
uniform float in_progress;
uniform float in_maxRadius;
uniform float in_noisePhase;
uniform vec4 in_color;
uniform float in_sparkle_strength;"""
private const val SHADER_LIB = """float triangleNoise(vec2 n) {
n = fract(n * vec2(5.3987, 5.4421));
n += dot(n.yx, n.xy + vec2(21.5351, 14.3137));
float xy = n.x * n.y;
return fract(xy * 95.4307) + fract(xy * 75.04961) - 1.0;
}
const float PI = 3.1415926535897932384626;
float threshold(float v, float l, float h) {
return step(l, v) * (1.0 - step(h, v));
}
float sparkles(vec2 uv, float t) {
float n = triangleNoise(uv);
float s = 0.0;
for (float i = 0; i < 4; i += 1) {
float l = i * 0.25;
float h = l + 0.025;
float o = abs(sin(0.1 * PI * (t + i)));
s += threshold(n + o, l, h);
}
return saturate(s);
}
float softCircle(vec2 uv, vec2 xy, float radius, float blur) {
float blurHalf = blur * 0.5;
float d = distance(uv, xy);
return 1. - smoothstep(1. - blurHalf, 1. + blurHalf, d / radius);
}
float softRing(vec2 uv, vec2 xy, float radius, float blur) {
float thickness = 0.4;
float circle_outer = softCircle(uv, xy,
radius + thickness * radius * 0.5, blur);
float circle_inner = softCircle(uv, xy,
radius - thickness * radius * 0.5, blur);
return circle_outer - circle_inner;
}
float subProgress(float start, float end, float progress) {
float sub = clamp(progress, start, end);
return (sub - start) / (end - start);
}
float smoothstop2(float t) {
return 1 - (1 - t) * (1 - t);
}"""
private const val SHADER_MAIN = """vec4 main(vec2 p) {
float fadeIn = subProgress(0., 0.1, in_progress);
float fadeOutNoise = subProgress(0.8, 1., in_progress);
float fadeOutRipple = subProgress(0.7, 1., in_progress);
float fadeCircle = subProgress(0., 0.5, in_progress);
float radius = smoothstop2(in_progress) * in_maxRadius;
float sparkleRing = softRing(p, in_origin, radius, 0.5);
float sparkleAlpha = min(fadeIn, 1. - fadeOutNoise);
float sparkle = sparkles(p, in_noisePhase) * sparkleRing * sparkleAlpha;
float circle = softCircle(p, in_origin, radius * 1.2, 0.5)
* (1 - fadeCircle);
float fadeRipple = min(fadeIn, 1.-fadeOutRipple);
float rippleAlpha = softRing(p, in_origin, radius, 0.5)
* fadeRipple * in_color.a;
vec4 ripple = in_color * max(circle, rippleAlpha) * 0.4;
return mix(ripple, vec4(sparkle), sparkle * in_sparkle_strength);
}"""
private const val SHADER = SHADER_UNIFORMS + SHADER_LIB + SHADER_MAIN
}
/**
* Maximum radius of the ripple.
*/
var radius: Float = 0.0f
set(value) { setUniform("in_maxRadius", value) }
/**
* Origin coordinate of the ripple.
*/
var origin: PointF = PointF()
set(value) { setUniform("in_origin", floatArrayOf(value.x, value.y)) }
/**
* Progress of the ripple. Float value between [0, 1].
*/
var progress: Float = 0.0f
set(value) { setUniform("in_progress", value) }
/**
* Continuous offset used as noise phase.
*/
var noisePhase: Float = 0.0f
set(value) { setUniform("in_noisePhase", value) }
/**
* A hex value representing the ripple color, in the format of ARGB
*/
var color: Int = 0xffffff.toInt()
set(value) {
val color = Color.valueOf(value)
setUniform("in_color", floatArrayOf(color.red(),
color.green(), color.blue(), color.alpha()))
}
/**
* Noise sparkle intensity. Expected value between [0, 1]. The sparkle is white, and thus
* with strength 0 it's transparent, leaving the ripple fully smooth, while with strength 1
* it's opaque white and looks the most grainy.
*/
var sparkleStrength: Float = 0.0f
set(value) { setUniform("in_sparkle_strength", value) }
}

View File

@@ -0,0 +1,137 @@
/*
* 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.statusbar.charging
import android.content.Context
import android.content.res.Configuration
import android.util.DisplayMetrics
import android.view.View
import android.view.ViewGroupOverlay
import com.android.internal.annotations.VisibleForTesting
import com.android.settingslib.Utils
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.statusbar.FeatureFlags
import com.android.systemui.statusbar.commandline.Command
import com.android.systemui.statusbar.commandline.CommandRegistry
import com.android.systemui.statusbar.policy.BatteryController
import com.android.systemui.statusbar.policy.ConfigurationController
import com.android.systemui.statusbar.policy.KeyguardStateController
import java.io.PrintWriter
import javax.inject.Inject
/***
* Controls the ripple effect that shows when wired charging begins.
* The ripple uses the accent color of the current theme.
*/
@SysUISingleton
class WiredChargingRippleController @Inject constructor(
commandRegistry: CommandRegistry,
batteryController: BatteryController,
configurationController: ConfigurationController,
featureFlags: FeatureFlags,
private val context: Context,
private val keyguardStateController: KeyguardStateController
) {
private var pluggedIn: Boolean? = null
private val rippleEnabled: Boolean = featureFlags.isChargingRippleEnabled
@VisibleForTesting
var rippleView: ChargingRippleView = ChargingRippleView(context, attrs = null)
init {
val batteryStateChangeCallback = object : BatteryController.BatteryStateChangeCallback {
override fun onBatteryLevelChanged(
level: Int,
nowPluggedIn: Boolean,
charging: Boolean
) {
if (!rippleEnabled) {
return
}
val wasPluggedIn = pluggedIn
pluggedIn = nowPluggedIn
// Only triggers when the keyguard is active and the device is just plugged in.
if (wasPluggedIn == false && nowPluggedIn && keyguardStateController.isShowing) {
rippleView.startRipple()
}
}
}
batteryController.addCallback(batteryStateChangeCallback)
val configurationChangedListener = object : ConfigurationController.ConfigurationListener {
override fun onUiModeChanged() {
updateRippleColor()
}
override fun onThemeChanged() {
updateRippleColor()
}
override fun onOverlayChanged() {
updateRippleColor()
}
override fun onConfigChanged(newConfig: Configuration?) {
layoutRippleView()
}
}
configurationController.addCallback(configurationChangedListener)
commandRegistry.registerCommand("charging-ripple") { ChargingRippleCommand() }
}
fun setViewHost(viewHost: View) {
// Add the ripple view as an overlay of the root view so that it always
// shows on top.
viewHost.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewDetachedFromWindow(view: View?) {}
override fun onViewAttachedToWindow(view: View?) {
(viewHost.viewRootImpl.view.overlay as ViewGroupOverlay).add(rippleView)
layoutRippleView()
viewHost.removeOnAttachStateChangeListener(this)
}
})
updateRippleColor()
}
private fun layoutRippleView() {
// Overlays are not auto measured and laid out so we do it manually here.
val displayMetrics = DisplayMetrics()
context.display.getRealMetrics(displayMetrics)
val width = displayMetrics.widthPixels
val height = displayMetrics.heightPixels
if (width != rippleView.width || height != rippleView.height) {
rippleView.measure(
View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY))
rippleView.layout(0, 0, width, height)
}
}
private fun updateRippleColor() {
rippleView.setColor(
Utils.getColorAttr(context, android.R.attr.colorAccent).defaultColor)
}
inner class ChargingRippleCommand : Command {
override fun execute(pw: PrintWriter, args: List<String>) {
rippleView.startRipple()
}
override fun help(pw: PrintWriter) {
pw.println("Usage: adb shell cmd statusbar charging-ripple")
}
}
}

View File

@@ -202,6 +202,8 @@ import com.android.systemui.statusbar.StatusBarState;
import com.android.systemui.statusbar.SuperStatusBarViewFactory;
import com.android.systemui.statusbar.SysuiStatusBarStateController;
import com.android.systemui.statusbar.VibratorHelper;
import com.android.systemui.statusbar.charging.ChargingRippleView;
import com.android.systemui.statusbar.charging.WiredChargingRippleController;
import com.android.systemui.statusbar.notification.ActivityLaunchAnimator;
import com.android.systemui.statusbar.notification.DynamicPrivacyController;
import com.android.systemui.statusbar.notification.NotificationActivityStarter;
@@ -377,6 +379,8 @@ public class StatusBar extends SystemUI implements DemoMode,
private boolean mWakeUpComingFromTouch;
private PointF mWakeUpTouchLocation;
private LightRevealScrim mLightRevealScrim;
private ChargingRippleView mChargingRipple;
private WiredChargingRippleController mChargingRippleAnimationController;
private PowerButtonReveal mPowerButtonReveal;
private final Object mQueueLock = new Object();
@@ -777,6 +781,7 @@ public class StatusBar extends SystemUI implements DemoMode,
StatusBarTouchableRegionManager statusBarTouchableRegionManager,
NotificationIconAreaController notificationIconAreaController,
BrightnessSlider.Factory brightnessSliderFactory,
WiredChargingRippleController chargingRippleAnimationController,
FeatureFlags featureFlags) {
super(context);
mNotificationsController = notificationsController;
@@ -855,6 +860,7 @@ public class StatusBar extends SystemUI implements DemoMode,
mDemoModeController = demoModeController;
mNotificationIconAreaController = notificationIconAreaController;
mBrightnessSliderFactory = brightnessSliderFactory;
mChargingRippleAnimationController = chargingRippleAnimationController;
mFeatureFlags = featureFlags;
mExpansionChangedListeners = new ArrayList<>();
@@ -1198,6 +1204,7 @@ public class StatusBar extends SystemUI implements DemoMode,
mScrimController.attachViews(scrimBehind, scrimInFront, scrimForBubble);
mLightRevealScrim = mNotificationShadeWindowView.findViewById(R.id.light_reveal_scrim);
mChargingRippleAnimationController.setViewHost(mNotificationShadeWindowView);
if (mFeatureFlags.useNewLockscreenAnimations() && mDozeParameters.getAlwaysOn()) {
mLightRevealScrim.setVisibility(View.VISIBLE);

View File

@@ -60,6 +60,7 @@ import com.android.systemui.statusbar.PulseExpansionHandler;
import com.android.systemui.statusbar.SuperStatusBarViewFactory;
import com.android.systemui.statusbar.SysuiStatusBarStateController;
import com.android.systemui.statusbar.VibratorHelper;
import com.android.systemui.statusbar.charging.WiredChargingRippleController;
import com.android.systemui.statusbar.notification.DynamicPrivacyController;
import com.android.systemui.statusbar.notification.NotificationWakeUpCoordinator;
import com.android.systemui.statusbar.notification.collection.legacy.VisualStabilityManager;
@@ -204,6 +205,7 @@ public interface StatusBarPhoneModule {
StatusBarTouchableRegionManager statusBarTouchableRegionManager,
NotificationIconAreaController notificationIconAreaController,
BrightnessSlider.Factory brightnessSliderFactory,
WiredChargingRippleController chargingRippleAnimationController,
FeatureFlags featureFlags) {
return new StatusBar(
context,
@@ -285,6 +287,7 @@ public interface StatusBarPhoneModule {
statusBarTouchableRegionManager,
notificationIconAreaController,
brightnessSliderFactory,
chargingRippleAnimationController,
featureFlags);
}
}

View File

@@ -0,0 +1,115 @@
/*
* 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.statusbar.charging
import android.testing.AndroidTestingRunner
import android.view.View
import android.view.ViewGroupOverlay
import android.view.ViewRootImpl
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.statusbar.FeatureFlags
import com.android.systemui.statusbar.commandline.CommandRegistry
import com.android.systemui.statusbar.policy.BatteryController
import com.android.systemui.statusbar.policy.ConfigurationController
import com.android.systemui.statusbar.policy.KeyguardStateController
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers
import org.mockito.Mock
import org.mockito.Mockito.`when`
import org.mockito.Mockito.never
import org.mockito.Mockito.reset
import org.mockito.Mockito.verify
import org.mockito.MockitoAnnotations
@SmallTest
@RunWith(AndroidTestingRunner::class)
class WiredChargingRippleControllerTest : SysuiTestCase() {
private lateinit var controller: WiredChargingRippleController
@Mock private lateinit var commandRegistry: CommandRegistry
@Mock private lateinit var batteryController: BatteryController
@Mock private lateinit var featureFlags: FeatureFlags
@Mock private lateinit var configurationController: ConfigurationController
@Mock private lateinit var keyguardStateController: KeyguardStateController
@Mock private lateinit var rippleView: ChargingRippleView
@Mock private lateinit var viewHost: View
@Mock private lateinit var viewHostRootImpl: ViewRootImpl
@Mock private lateinit var viewGroupOverlay: ViewGroupOverlay
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
`when`(viewHost.viewRootImpl).thenReturn(viewHostRootImpl)
`when`(viewHostRootImpl.view).thenReturn(viewHost)
`when`(viewHost.overlay).thenReturn(viewGroupOverlay)
`when`(featureFlags.isChargingRippleEnabled).thenReturn(true)
`when`(keyguardStateController.isShowing).thenReturn(true)
controller = WiredChargingRippleController(
commandRegistry, batteryController, configurationController,
featureFlags, context, keyguardStateController)
controller.rippleView = rippleView // Replace the real ripple view with a mock instance
controller.setViewHost(viewHost)
}
@Test
fun testSetRippleViewAsOverlay() {
val listenerCaptor = ArgumentCaptor.forClass(View.OnAttachStateChangeListener::class.java)
verify(viewHost).addOnAttachStateChangeListener(listenerCaptor.capture())
// Fake attach to window
listenerCaptor.value.onViewAttachedToWindow(viewHost)
verify(viewGroupOverlay).add(rippleView)
}
@Test
fun testTriggerRipple() {
val captor = ArgumentCaptor
.forClass(BatteryController.BatteryStateChangeCallback::class.java)
verify(batteryController).addCallback(captor.capture())
val unusedBatteryLevel = 0
captor.value.onBatteryLevelChanged(
unusedBatteryLevel,
false /* plugged in */,
false /* charging */)
verify(rippleView, never()).startRipple()
captor.value.onBatteryLevelChanged(
unusedBatteryLevel,
true /* plugged in */,
false /* charging */)
verify(rippleView).startRipple()
}
@Test
fun testUpdateRippleColor() {
val captor = ArgumentCaptor
.forClass(ConfigurationController.ConfigurationListener::class.java)
verify(configurationController).addCallback(captor.capture())
reset(rippleView)
captor.value.onThemeChanged()
verify(rippleView).setColor(ArgumentMatchers.anyInt())
reset(rippleView)
captor.value.onUiModeChanged()
verify(rippleView).setColor(ArgumentMatchers.anyInt())
}
}

View File

@@ -113,6 +113,7 @@ import com.android.systemui.statusbar.StatusBarState;
import com.android.systemui.statusbar.StatusBarStateControllerImpl;
import com.android.systemui.statusbar.SuperStatusBarViewFactory;
import com.android.systemui.statusbar.VibratorHelper;
import com.android.systemui.statusbar.charging.WiredChargingRippleController;
import com.android.systemui.statusbar.notification.DynamicPrivacyController;
import com.android.systemui.statusbar.notification.NotificationEntryListener;
import com.android.systemui.statusbar.notification.NotificationEntryManager;
@@ -261,6 +262,7 @@ public class StatusBarTest extends SysuiTestCase {
@Mock private DemoModeController mDemoModeController;
@Mock private Lazy<NotificationShadeDepthController> mNotificationShadeDepthControllerLazy;
@Mock private BrightnessSlider.Factory mBrightnessSliderFactory;
@Mock private WiredChargingRippleController mWiredChargingRippleController;
@Mock private FeatureFlags mFeatureFlags;
private ShadeController mShadeController;
private FakeExecutor mUiBgExecutor = new FakeExecutor(new FakeSystemClock());
@@ -424,6 +426,7 @@ public class StatusBarTest extends SysuiTestCase {
mStatusBarTouchableRegionManager,
mNotificationIconAreaController,
mBrightnessSliderFactory,
mWiredChargingRippleController,
mFeatureFlags);
when(mNotificationShadeWindowView.findViewById(R.id.lock_icon_container)).thenReturn(