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.dagger.SysUISingleton 40 import com.android.systemui.dagger.qualifiers.Background 41 import com.android.systemui.log.DebugLogger.debugLog 42 import com.android.systemui.res.R 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 69 var inputDeviceId: Int? = null 70 private set 71 72 @VisibleForTesting var instanceIdSequence = InstanceIdSequence(1 shl 13) 73 initnull74 fun init() { 75 val filter = 76 IntentFilter().also { 77 it.addAction(ACTION_DISMISSED_LOW_BATTERY) 78 it.addAction(ACTION_CLICKED_LOW_BATTERY) 79 } 80 81 context.registerReceiverAsUser( 82 receiver, 83 UserHandle.ALL, 84 filter, 85 Manifest.permission.DEVICE_POWER, 86 handler, 87 Context.RECEIVER_NOT_EXPORTED, 88 ) 89 } 90 refreshnull91 fun refresh() { 92 handler.post refreshNotification@{ 93 val batteryBelowThreshold = isBatteryBelowThreshold() 94 if (!suppressed && !hasConnectedBluetoothStylus() && batteryBelowThreshold) { 95 showOrUpdateNotification() 96 return@refreshNotification 97 } 98 99 // Only hide notification in two cases: battery has been recharged above the 100 // threshold, or user has dismissed or clicked notification ("suppression"). 101 if (suppressed || !batteryBelowThreshold) { 102 hideNotification() 103 } 104 105 if (!batteryBelowThreshold) { 106 // Reset suppression when stylus battery is recharged, so that the next time 107 // it reaches a low battery, the notification will show again. 108 suppressed = false 109 } 110 } 111 } 112 updateBatteryStatenull113 fun updateBatteryState(deviceId: Int, batteryState: BatteryState) { 114 handler.post updateBattery@{ 115 inputDeviceId = deviceId 116 if (batteryState.capacity == batteryCapacity || batteryState.capacity <= 0f) 117 return@updateBattery 118 // Note that batteryState.capacity == NaN will fall through to here 119 batteryCapacity = batteryState.capacity 120 debugLog { 121 "Updating notification battery state to $batteryCapacity " + 122 "for InputDevice $deviceId." 123 } 124 refresh() 125 } 126 } 127 128 /** 129 * Suppression happens when the notification is dismissed by the user. This is to prevent 130 * further battery events with capacities below the threshold from reopening the suppressed 131 * notification. 132 * 133 * Suppression can only be removed when the battery has been recharged - thus restarting the 134 * notification cycle (i.e. next low battery event, notification should show). 135 */ updateSuppressionnull136 fun updateSuppression(suppress: Boolean) { 137 handler.post updateSuppressed@{ 138 if (suppressed == suppress) return@updateSuppressed 139 140 debugLog { "Updating notification suppression to $suppress." } 141 suppressed = suppress 142 refresh() 143 } 144 } 145 hideNotificationnull146 private fun hideNotification() { 147 debugLog { "Cancelling USI low battery notification." } 148 instanceId = null 149 notificationManager.cancel(USI_NOTIFICATION_ID) 150 } 151 showOrUpdateNotificationnull152 private fun showOrUpdateNotification() { 153 val notification = 154 NotificationCompat.Builder(context, NotificationChannels.BATTERY) 155 .setSmallIcon(R.drawable.ic_power_low) 156 .setDeleteIntent(getPendingBroadcast(ACTION_DISMISSED_LOW_BATTERY)) 157 .setContentIntent(getPendingBroadcast(ACTION_CLICKED_LOW_BATTERY)) 158 .setContentTitle( 159 context.getString( 160 R.string.stylus_battery_low_percentage, 161 NumberFormat.getPercentInstance().format(batteryCapacity) 162 ) 163 ) 164 .setContentText(context.getString(R.string.stylus_battery_low_subtitle)) 165 .setPriority(NotificationCompat.PRIORITY_DEFAULT) 166 .setLocalOnly(true) 167 .setOnlyAlertOnce(true) 168 .setAutoCancel(true) 169 .build() 170 171 debugLog { "Show or update USI low battery notification at $batteryCapacity." } 172 logUiEvent(StylusUiEvent.STYLUS_LOW_BATTERY_NOTIFICATION_SHOWN) 173 notificationManager.notify(USI_NOTIFICATION_ID, notification) 174 } 175 isBatteryBelowThresholdnull176 private fun isBatteryBelowThreshold(): Boolean { 177 return !batteryCapacity.isNaN() && batteryCapacity <= LOW_BATTERY_THRESHOLD 178 } 179 hasConnectedBluetoothStylusnull180 private fun hasConnectedBluetoothStylus(): Boolean { 181 return inputManager.hasInputDevice { it.isAnyStylusSource && it.bluetoothAddress != null } 182 } 183 getPendingBroadcastnull184 private fun getPendingBroadcast(action: String): PendingIntent? { 185 return PendingIntent.getBroadcast( 186 context, 187 0, 188 Intent(action).setPackage(context.packageName), 189 PendingIntent.FLAG_IMMUTABLE, 190 ) 191 } 192 193 @VisibleForTesting 194 internal val receiver: BroadcastReceiver = 195 object : BroadcastReceiver() { onReceivenull196 override fun onReceive(context: Context, intent: Intent) { 197 when (intent.action) { 198 ACTION_DISMISSED_LOW_BATTERY -> { 199 debugLog { "USI low battery notification dismissed." } 200 logUiEvent(StylusUiEvent.STYLUS_LOW_BATTERY_NOTIFICATION_DISMISSED) 201 updateSuppression(true) 202 } 203 ACTION_CLICKED_LOW_BATTERY -> { 204 debugLog { "USI low battery notification clicked." } 205 logUiEvent(StylusUiEvent.STYLUS_LOW_BATTERY_NOTIFICATION_CLICKED) 206 updateSuppression(true) 207 if (inputDeviceId == null) return 208 209 val args = Bundle() 210 args.putInt(KEY_DEVICE_INPUT_ID, inputDeviceId!!) 211 try { 212 context.startActivity( 213 Intent(ACTION_STYLUS_USI_DETAILS) 214 .putExtra(KEY_SETTINGS_FRAGMENT_ARGS, args) 215 .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) 216 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 217 ) 218 } catch (e: ActivityNotFoundException) { 219 // In the rare scenario where the Settings app manifest doesn't contain 220 // the USI details activity, ignore the intent. 221 Log.e( 222 StylusUsiPowerUI::class.java.simpleName, 223 "Cannot open USI details page." 224 ) 225 } 226 } 227 } 228 } 229 } 230 231 /** 232 * Logs a stylus USI battery event with instance ID and battery level. The instance ID 233 * represents the notification instance, and is reset when a notification is cancelled. 234 */ logUiEventnull235 private fun logUiEvent(metricId: StylusUiEvent) { 236 uiEventLogger.logWithInstanceIdAndPosition( 237 metricId, 238 ActivityManager.getCurrentUser(), 239 context.packageName, 240 getInstanceId(), 241 (batteryCapacity * 100.0).toInt() 242 ) 243 } 244 245 @VisibleForTesting getInstanceIdnull246 fun getInstanceId(): InstanceId? { 247 if (instanceId == null) { 248 instanceId = instanceId ?: instanceIdSequence.newInstanceId() 249 } 250 return instanceId 251 } 252 253 companion object { 254 val TAG = StylusUsiPowerUI::class.simpleName.orEmpty() 255 256 // Low battery threshold matches CrOS, see: 257 // https://source.chromium.org/chromium/chromium/src/+/main:ash/system/power/peripheral_battery_notifier.cc;l=41 258 private const val LOW_BATTERY_THRESHOLD = 0.16f 259 260 private val USI_NOTIFICATION_ID = R.string.stylus_battery_low_percentage 261 262 @VisibleForTesting const val ACTION_DISMISSED_LOW_BATTERY = "StylusUsiPowerUI.dismiss" 263 264 @VisibleForTesting const val ACTION_CLICKED_LOW_BATTERY = "StylusUsiPowerUI.click" 265 266 @VisibleForTesting 267 const val ACTION_STYLUS_USI_DETAILS = "com.android.settings.STYLUS_USI_DETAILS_SETTINGS" 268 269 @VisibleForTesting const val KEY_DEVICE_INPUT_ID = "device_input_id" 270 271 @VisibleForTesting const val KEY_SETTINGS_FRAGMENT_ARGS = ":settings:show_fragment_args" 272 } 273 } 274