1 /* 2 * Copyright (C) 2022 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.stylus 18 19 import android.Manifest 20 import android.app.ActivityManager 21 import android.app.PendingIntent 22 import android.content.ActivityNotFoundException 23 import android.content.BroadcastReceiver 24 import android.content.Context 25 import android.content.Intent 26 import android.content.IntentFilter 27 import android.hardware.BatteryState 28 import android.hardware.input.InputManager 29 import android.os.Bundle 30 import android.os.Handler 31 import android.os.UserHandle 32 import android.util.Log 33 import androidx.core.app.NotificationCompat 34 import androidx.core.app.NotificationManagerCompat 35 import com.android.internal.annotations.VisibleForTesting 36 import com.android.internal.logging.InstanceId 37 import com.android.internal.logging.InstanceIdSequence 38 import com.android.internal.logging.UiEventLogger 39 import com.android.systemui.res.R 40 import com.android.systemui.dagger.SysUISingleton 41 import com.android.systemui.dagger.qualifiers.Background 42 import com.android.systemui.log.DebugLogger.debugLog 43 import com.android.systemui.shared.hardware.hasInputDevice 44 import com.android.systemui.shared.hardware.isAnyStylusSource 45 import com.android.systemui.util.NotificationChannels 46 import java.text.NumberFormat 47 import javax.inject.Inject 48 49 /** 50 * UI controller for the notification that shows when a USI stylus battery is low. The 51 * [StylusUsiPowerStartable], which listens to battery events, uses this controller. 52 */ 53 @SysUISingleton 54 class StylusUsiPowerUI 55 @Inject 56 constructor( 57 private val context: Context, 58 private val notificationManager: NotificationManagerCompat, 59 private val inputManager: InputManager, 60 @Background private val handler: Handler, 61 private val uiEventLogger: UiEventLogger, 62 ) { 63 64 // These values must only be accessed on the handler. 65 private var batteryCapacity = 1.0f 66 private var suppressed = false 67 private var instanceId: InstanceId? = null 68 @VisibleForTesting var inputDeviceId: Int? = null 69 private set 70 @VisibleForTesting var instanceIdSequence = InstanceIdSequence(1 shl 13) 71 initnull72 fun init() { 73 val filter = 74 IntentFilter().also { 75 it.addAction(ACTION_DISMISSED_LOW_BATTERY) 76 it.addAction(ACTION_CLICKED_LOW_BATTERY) 77 } 78 79 context.registerReceiverAsUser( 80 receiver, 81 UserHandle.ALL, 82 filter, 83 Manifest.permission.DEVICE_POWER, 84 handler, 85 Context.RECEIVER_NOT_EXPORTED, 86 ) 87 } 88 refreshnull89 fun refresh() { 90 handler.post refreshNotification@{ 91 val batteryBelowThreshold = isBatteryBelowThreshold() 92 if (!suppressed && !hasConnectedBluetoothStylus() && batteryBelowThreshold) { 93 showOrUpdateNotification() 94 return@refreshNotification 95 } 96 97 // Only hide notification in two cases: battery has been recharged above the 98 // threshold, or user has dismissed or clicked notification ("suppression"). 99 if (suppressed || !batteryBelowThreshold) { 100 hideNotification() 101 } 102 103 if (!batteryBelowThreshold) { 104 // Reset suppression when stylus battery is recharged, so that the next time 105 // it reaches a low battery, the notification will show again. 106 suppressed = false 107 } 108 } 109 } 110 updateBatteryStatenull111 fun updateBatteryState(deviceId: Int, batteryState: BatteryState) { 112 handler.post updateBattery@{ 113 inputDeviceId = deviceId 114 if (batteryState.capacity == batteryCapacity || batteryState.capacity <= 0f) 115 return@updateBattery 116 117 batteryCapacity = batteryState.capacity 118 debugLog { 119 "Updating notification battery state to $batteryCapacity " + 120 "for InputDevice $deviceId." 121 } 122 refresh() 123 } 124 } 125 126 /** 127 * Suppression happens when the notification is dismissed by the user. This is to prevent 128 * further battery events with capacities below the threshold from reopening the suppressed 129 * notification. 130 * 131 * Suppression can only be removed when the battery has been recharged - thus restarting the 132 * notification cycle (i.e. next low battery event, notification should show). 133 */ updateSuppressionnull134 fun updateSuppression(suppress: Boolean) { 135 handler.post updateSuppressed@{ 136 if (suppressed == suppress) return@updateSuppressed 137 138 debugLog { "Updating notification suppression to $suppress." } 139 suppressed = suppress 140 refresh() 141 } 142 } 143 hideNotificationnull144 private fun hideNotification() { 145 debugLog { "Cancelling USI low battery notification." } 146 instanceId = null 147 notificationManager.cancel(USI_NOTIFICATION_ID) 148 } 149 showOrUpdateNotificationnull150 private fun showOrUpdateNotification() { 151 val notification = 152 NotificationCompat.Builder(context, NotificationChannels.BATTERY) 153 .setSmallIcon(R.drawable.ic_power_low) 154 .setDeleteIntent(getPendingBroadcast(ACTION_DISMISSED_LOW_BATTERY)) 155 .setContentIntent(getPendingBroadcast(ACTION_CLICKED_LOW_BATTERY)) 156 .setContentTitle( 157 context.getString( 158 R.string.stylus_battery_low_percentage, 159 NumberFormat.getPercentInstance().format(batteryCapacity) 160 ) 161 ) 162 .setContentText(context.getString(R.string.stylus_battery_low_subtitle)) 163 .setPriority(NotificationCompat.PRIORITY_DEFAULT) 164 .setLocalOnly(true) 165 .setOnlyAlertOnce(true) 166 .setAutoCancel(true) 167 .build() 168 169 debugLog { "Show or update USI low battery notification at $batteryCapacity." } 170 logUiEvent(StylusUiEvent.STYLUS_LOW_BATTERY_NOTIFICATION_SHOWN) 171 notificationManager.notify(USI_NOTIFICATION_ID, notification) 172 } 173 isBatteryBelowThresholdnull174 private fun isBatteryBelowThreshold(): Boolean { 175 return batteryCapacity <= LOW_BATTERY_THRESHOLD 176 } 177 hasConnectedBluetoothStylusnull178 private fun hasConnectedBluetoothStylus(): Boolean { 179 return inputManager.hasInputDevice { it.isAnyStylusSource && it.bluetoothAddress != null } 180 } 181 getPendingBroadcastnull182 private fun getPendingBroadcast(action: String): PendingIntent? { 183 return PendingIntent.getBroadcast( 184 context, 185 0, 186 Intent(action).setPackage(context.packageName), 187 PendingIntent.FLAG_IMMUTABLE, 188 ) 189 } 190 191 @VisibleForTesting 192 internal val receiver: BroadcastReceiver = 193 object : BroadcastReceiver() { onReceivenull194 override fun onReceive(context: Context, intent: Intent) { 195 when (intent.action) { 196 ACTION_DISMISSED_LOW_BATTERY -> { 197 debugLog { "USI low battery notification dismissed." } 198 logUiEvent(StylusUiEvent.STYLUS_LOW_BATTERY_NOTIFICATION_DISMISSED) 199 updateSuppression(true) 200 } 201 ACTION_CLICKED_LOW_BATTERY -> { 202 debugLog { "USI low battery notification clicked." } 203 logUiEvent(StylusUiEvent.STYLUS_LOW_BATTERY_NOTIFICATION_CLICKED) 204 updateSuppression(true) 205 if (inputDeviceId == null) return 206 207 val args = Bundle() 208 args.putInt(KEY_DEVICE_INPUT_ID, inputDeviceId!!) 209 try { 210 context.startActivity( 211 Intent(ACTION_STYLUS_USI_DETAILS) 212 .putExtra(KEY_SETTINGS_FRAGMENT_ARGS, args) 213 .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) 214 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 215 ) 216 } catch (e: ActivityNotFoundException) { 217 // In the rare scenario where the Settings app manifest doesn't contain 218 // the USI details activity, ignore the intent. 219 Log.e( 220 StylusUsiPowerUI::class.java.simpleName, 221 "Cannot open USI details page." 222 ) 223 } 224 } 225 } 226 } 227 } 228 229 /** 230 * Logs a stylus USI battery event with instance ID and battery level. The instance ID 231 * represents the notification instance, and is reset when a notification is cancelled. 232 */ logUiEventnull233 private fun logUiEvent(metricId: StylusUiEvent) { 234 uiEventLogger.logWithInstanceIdAndPosition( 235 metricId, 236 ActivityManager.getCurrentUser(), 237 context.packageName, 238 getInstanceId(), 239 (batteryCapacity * 100.0).toInt() 240 ) 241 } 242 243 @VisibleForTesting getInstanceIdnull244 fun getInstanceId(): InstanceId? { 245 if (instanceId == null) { 246 instanceId = instanceId ?: instanceIdSequence.newInstanceId() 247 } 248 return instanceId 249 } 250 251 companion object { 252 val TAG = StylusUsiPowerUI::class.simpleName.orEmpty() 253 254 // Low battery threshold matches CrOS, see: 255 // https://source.chromium.org/chromium/chromium/src/+/main:ash/system/power/peripheral_battery_notifier.cc;l=41 256 private const val LOW_BATTERY_THRESHOLD = 0.16f 257 258 private val USI_NOTIFICATION_ID = R.string.stylus_battery_low_percentage 259 260 @VisibleForTesting const val ACTION_DISMISSED_LOW_BATTERY = "StylusUsiPowerUI.dismiss" 261 262 @VisibleForTesting const val ACTION_CLICKED_LOW_BATTERY = "StylusUsiPowerUI.click" 263 264 @VisibleForTesting 265 const val ACTION_STYLUS_USI_DETAILS = "com.android.settings.STYLUS_USI_DETAILS_SETTINGS" 266 267 @VisibleForTesting const val KEY_DEVICE_INPUT_ID = "device_input_id" 268 269 @VisibleForTesting const val KEY_SETTINGS_FRAGMENT_ARGS = ":settings:show_fragment_args" 270 } 271 } 272