Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 3a13219b authored by Shan Huang's avatar Shan Huang
Browse files

Add the charging ripple effect, currently guarded by flag_charging_ripple.

The effect is drawn in a full screen view in StatusBar, using a shader adapted from graphics/java/android/graphics/drawable/RippleShader.java. It plays whenever a phone is plugged in and wired charging begins.

Next steps:
- Add distortion effect to the first half of the animation.
- More API to support the UDFPS reveal effect.

Videos: https://drive.google.com/drive/folders/1c_QduSrELp4-Y4oJdHIllnypQkguDd-x?usp=sharing

Test: atest SystemUITests
Test: Manually
Bug: 182719493

Change-Id: If8885022a906f617332ca21132c3194ab26c09a4
parent 1dfe5cf4
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -48,4 +48,6 @@
    <bool name="flag_pm_lite">false</bool>

    <bool name="flag_alarm_tile">false</bool>

    <bool name="flag_charging_ripple">false</bool>
</resources>
+4 −0
Original line number Diff line number Diff line
@@ -94,4 +94,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);
    }
}
+86 −0
Original line number Diff line number Diff line
/*
 * 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)
    }
}
+147 −0
Original line number Diff line number Diff line
/*
 * 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) }
}
+137 −0
Original line number Diff line number Diff line
/*
 * 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")
        }
    }
}
Loading