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