1 /*
2  * Copyright (C) 2021 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.statusbar.charging
18 
19 import android.content.Context
20 import android.content.res.Configuration
21 import android.graphics.PixelFormat
22 import android.graphics.PointF
23 import android.os.SystemProperties
24 import android.util.DisplayMetrics
25 import android.view.View
26 import android.view.WindowManager
27 import com.android.internal.annotations.VisibleForTesting
28 import com.android.internal.logging.UiEvent
29 import com.android.internal.logging.UiEventLogger
30 import com.android.settingslib.Utils
31 import com.android.systemui.dagger.SysUISingleton
32 import com.android.systemui.flags.FeatureFlags
33 import com.android.systemui.statusbar.commandline.Command
34 import com.android.systemui.statusbar.commandline.CommandRegistry
35 import com.android.systemui.statusbar.policy.BatteryController
36 import com.android.systemui.statusbar.policy.ConfigurationController
37 import com.android.systemui.util.leak.RotationUtils
38 import com.android.systemui.R
39 import com.android.systemui.util.time.SystemClock
40 import java.io.PrintWriter
41 import javax.inject.Inject
42 import kotlin.math.min
43 import kotlin.math.pow
44 
45 private const val MAX_DEBOUNCE_LEVEL = 3
46 private const val BASE_DEBOUNCE_TIME = 2000
47 
48 /***
49  * Controls the ripple effect that shows when wired charging begins.
50  * The ripple uses the accent color of the current theme.
51  */
52 @SysUISingleton
53 class WiredChargingRippleController @Inject constructor(
54     commandRegistry: CommandRegistry,
55     batteryController: BatteryController,
56     configurationController: ConfigurationController,
57     featureFlags: FeatureFlags,
58     private val context: Context,
59     private val windowManager: WindowManager,
60     private val systemClock: SystemClock,
61     private val uiEventLogger: UiEventLogger
62 ) {
63     private var pluggedIn: Boolean? = null
64     private val rippleEnabled: Boolean = featureFlags.isChargingRippleEnabled &&
65             !SystemProperties.getBoolean("persist.debug.suppress-charging-ripple", false)
66     private var normalizedPortPosX: Float = context.resources.getFloat(
67             R.dimen.physical_charger_port_location_normalized_x)
68     private var normalizedPortPosY: Float = context.resources.getFloat(
69             R.dimen.physical_charger_port_location_normalized_y)
70     private val windowLayoutParams = WindowManager.LayoutParams().apply {
71         width = WindowManager.LayoutParams.MATCH_PARENT
72         height = WindowManager.LayoutParams.MATCH_PARENT
73         layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
74         format = PixelFormat.TRANSLUCENT
75         type = WindowManager.LayoutParams.TYPE_SYSTEM_OVERLAY
76         fitInsetsTypes = 0 // Ignore insets from all system bars
77         title = "Wired Charging Animation"
78         flags = (WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
79                 or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
80         setTrustedOverlay()
81     }
82     private var lastTriggerTime: Long? = null
83     private var debounceLevel = 0
84 
85     @VisibleForTesting
86     var rippleView: ChargingRippleView = ChargingRippleView(context, attrs = null)
87 
88     init {
89         pluggedIn = batteryController.isPluggedIn
90         val batteryStateChangeCallback = object : BatteryController.BatteryStateChangeCallback {
91             override fun onBatteryLevelChanged(
92                 level: Int,
93                 nowPluggedIn: Boolean,
94                 charging: Boolean
95             ) {
96                 // Suppresses the ripple when the state change comes from wireless charging.
97                 if (batteryController.isPluggedInWireless) {
98                     return
99                 }
100                 val wasPluggedIn = pluggedIn
101                 pluggedIn = nowPluggedIn
102                 if ((wasPluggedIn == null || !wasPluggedIn) && nowPluggedIn) {
103                     startRippleWithDebounce()
104                 }
105             }
106         }
107         batteryController.addCallback(batteryStateChangeCallback)
108 
109         val configurationChangedListener = object : ConfigurationController.ConfigurationListener {
110             override fun onUiModeChanged() {
111                 updateRippleColor()
112             }
113             override fun onThemeChanged() {
114                 updateRippleColor()
115             }
116 
117             override fun onConfigChanged(newConfig: Configuration?) {
118                 normalizedPortPosX = context.resources.getFloat(
119                         R.dimen.physical_charger_port_location_normalized_x)
120                 normalizedPortPosY = context.resources.getFloat(
121                         R.dimen.physical_charger_port_location_normalized_y)
122             }
123         }
124         configurationController.addCallback(configurationChangedListener)
125 
126         commandRegistry.registerCommand("charging-ripple") { ChargingRippleCommand() }
127         updateRippleColor()
128     }
129 
130     // Lazily debounce ripple to avoid triggering ripple constantly (e.g. from flaky chargers).
131     internal fun startRippleWithDebounce() {
132         val now = systemClock.elapsedRealtime()
133         // Debounce wait time = 2 ^ debounce level
134         if (lastTriggerTime == null ||
135                 (now - lastTriggerTime!!) > BASE_DEBOUNCE_TIME * (2.0.pow(debounceLevel))) {
136             // Not waiting for debounce. Start ripple.
137             startRipple()
138             debounceLevel = 0
139         } else {
140             // Still waiting for debounce. Ignore ripple and bump debounce level.
141             debounceLevel = min(MAX_DEBOUNCE_LEVEL, debounceLevel + 1)
142         }
143         lastTriggerTime = now
144     }
145 
146     fun startRipple() {
147         if (rippleView.rippleInProgress || rippleView.parent != null) {
148             // Skip if ripple is still playing, or not playing but already added the parent
149             // (which might happen just before the animation starts or right after
150             // the animation ends.)
151             return
152         }
153         windowLayoutParams.packageName = context.opPackageName
154         rippleView.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
155             override fun onViewDetachedFromWindow(view: View?) {}
156 
157             override fun onViewAttachedToWindow(view: View?) {
158                 layoutRipple()
159                 rippleView.startRipple(Runnable {
160                     windowManager.removeView(rippleView)
161                 })
162                 rippleView.removeOnAttachStateChangeListener(this)
163             }
164         })
165         windowManager.addView(rippleView, windowLayoutParams)
166         uiEventLogger.log(WiredChargingRippleEvent.CHARGING_RIPPLE_PLAYED)
167     }
168 
169     private fun layoutRipple() {
170         val displayMetrics = DisplayMetrics()
171         context.display.getRealMetrics(displayMetrics)
172         val width = displayMetrics.widthPixels
173         val height = displayMetrics.heightPixels
174         rippleView.radius = Integer.max(width, height).toFloat()
175         rippleView.origin = when (RotationUtils.getRotation(context)) {
176             RotationUtils.ROTATION_LANDSCAPE -> {
177                 PointF(width * normalizedPortPosY, height * (1 - normalizedPortPosX))
178             }
179             RotationUtils.ROTATION_UPSIDE_DOWN -> {
180                 PointF(width * (1 - normalizedPortPosX), height * (1 - normalizedPortPosY))
181             }
182             RotationUtils.ROTATION_SEASCAPE -> {
183                 PointF(width * (1 - normalizedPortPosY), height * normalizedPortPosX)
184             }
185             else -> {
186                 // ROTATION_NONE
187                 PointF(width * normalizedPortPosX, height * normalizedPortPosY)
188             }
189         }
190     }
191 
192     private fun updateRippleColor() {
193         rippleView.setColor(
194                 Utils.getColorAttr(context, android.R.attr.colorAccent).defaultColor)
195     }
196 
197     inner class ChargingRippleCommand : Command {
198         override fun execute(pw: PrintWriter, args: List<String>) {
199             startRipple()
200         }
201 
202         override fun help(pw: PrintWriter) {
203             pw.println("Usage: adb shell cmd statusbar charging-ripple")
204         }
205     }
206 
207     enum class WiredChargingRippleEvent(private val _id: Int) : UiEventLogger.UiEventEnum {
208         @UiEvent(doc = "Wired charging ripple effect played")
209         CHARGING_RIPPLE_PLAYED(829);
210 
211         override fun getId() = _id
212     }
213 }
214