Merge "Add the charging ripple effect, currently guarded by flag_charging_ripple." into sc-dev
diff --git a/packages/SystemUI/res/values/flags.xml b/packages/SystemUI/res/values/flags.xml
index 2163806..52a97b1 100644
--- a/packages/SystemUI/res/values/flags.xml
+++ b/packages/SystemUI/res/values/flags.xml
@@ -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>
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/FeatureFlags.java b/packages/SystemUI/src/com/android/systemui/statusbar/FeatureFlags.java
index c3de81c..708bdfe 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/FeatureFlags.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/FeatureFlags.java
@@ -98,4 +98,8 @@
     public boolean isAlarmTileAvailable() {
         return mFlagReader.isEnabled(R.bool.flag_alarm_tile);
     }
+
+    public boolean isChargingRippleEnabled() {
+        return mFlagReader.isEnabled(R.bool.flag_charging_ripple);
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/charging/ChargingRippleView.kt b/packages/SystemUI/src/com/android/systemui/statusbar/charging/ChargingRippleView.kt
new file mode 100644
index 0000000..6f80317
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/charging/ChargingRippleView.kt
@@ -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)
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/charging/RippleShader.kt b/packages/SystemUI/src/com/android/systemui/statusbar/charging/RippleShader.kt
new file mode 100644
index 0000000..5547c1e
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/charging/RippleShader.kt
@@ -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) }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/charging/WiredChargingRippleController.kt b/packages/SystemUI/src/com/android/systemui/statusbar/charging/WiredChargingRippleController.kt
new file mode 100644
index 0000000..b567ad4
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/charging/WiredChargingRippleController.kt
@@ -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")
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java
index 6dd00a5..427df5e 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java
@@ -202,6 +202,8 @@
 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 @@
     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 @@
             StatusBarTouchableRegionManager statusBarTouchableRegionManager,
             NotificationIconAreaController notificationIconAreaController,
             BrightnessSlider.Factory brightnessSliderFactory,
+            WiredChargingRippleController chargingRippleAnimationController,
             FeatureFlags featureFlags) {
         super(context);
         mNotificationsController = notificationsController;
@@ -855,6 +860,7 @@
         mDemoModeController = demoModeController;
         mNotificationIconAreaController = notificationIconAreaController;
         mBrightnessSliderFactory = brightnessSliderFactory;
+        mChargingRippleAnimationController = chargingRippleAnimationController;
         mFeatureFlags = featureFlags;
 
         mExpansionChangedListeners = new ArrayList<>();
@@ -1198,6 +1204,7 @@
         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);
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/dagger/StatusBarPhoneModule.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/dagger/StatusBarPhoneModule.java
index 17bb449..4f32712 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/dagger/StatusBarPhoneModule.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/dagger/StatusBarPhoneModule.java
@@ -60,6 +60,7 @@
 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 @@
             StatusBarTouchableRegionManager statusBarTouchableRegionManager,
             NotificationIconAreaController notificationIconAreaController,
             BrightnessSlider.Factory brightnessSliderFactory,
+            WiredChargingRippleController chargingRippleAnimationController,
             FeatureFlags featureFlags) {
         return new StatusBar(
                 context,
@@ -285,6 +287,7 @@
                 statusBarTouchableRegionManager,
                 notificationIconAreaController,
                 brightnessSliderFactory,
+                chargingRippleAnimationController,
                 featureFlags);
     }
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/charging/WiredChargingRippleControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/charging/WiredChargingRippleControllerTest.kt
new file mode 100644
index 0000000..3701b91
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/charging/WiredChargingRippleControllerTest.kt
@@ -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())
+    }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarTest.java
index 0b454bf..781cde6c 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarTest.java
@@ -113,6 +113,7 @@
 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 @@
     @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 @@
                 mStatusBarTouchableRegionManager,
                 mNotificationIconAreaController,
                 mBrightnessSliderFactory,
+                mWiredChargingRippleController,
                 mFeatureFlags);
 
         when(mNotificationShadeWindowView.findViewById(R.id.lock_icon_container)).thenReturn(