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.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 72 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 89 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 111 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 */ 134 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 144 private fun hideNotification() { 145 debugLog { "Cancelling USI low battery notification." } 146 instanceId = null 147 notificationManager.cancel(USI_NOTIFICATION_ID) 148 } 149 150 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 .setAutoCancel(true) 166 .build() 167 168 debugLog { "Show or update USI low battery notification at $batteryCapacity." } 169 logUiEvent(StylusUiEvent.STYLUS_LOW_BATTERY_NOTIFICATION_SHOWN) 170 notificationManager.notify(USI_NOTIFICATION_ID, notification) 171 } 172 173 private fun isBatteryBelowThreshold(): Boolean { 174 return batteryCapacity <= LOW_BATTERY_THRESHOLD 175 } 176 177 private fun hasConnectedBluetoothStylus(): Boolean { 178 return inputManager.hasInputDevice { it.isAnyStylusSource && it.bluetoothAddress != null } 179 } 180 181 private fun getPendingBroadcast(action: String): PendingIntent? { 182 return PendingIntent.getBroadcast( 183 context, 184 0, 185 Intent(action).setPackage(context.packageName), 186 PendingIntent.FLAG_IMMUTABLE, 187 ) 188 } 189 190 @VisibleForTesting 191 internal val receiver: BroadcastReceiver = 192 object : BroadcastReceiver() { 193 override fun onReceive(context: Context, intent: Intent) { 194 when (intent.action) { 195 ACTION_DISMISSED_LOW_BATTERY -> { 196 debugLog { "USI low battery notification dismissed." } 197 logUiEvent(StylusUiEvent.STYLUS_LOW_BATTERY_NOTIFICATION_DISMISSED) 198 updateSuppression(true) 199 } 200 ACTION_CLICKED_LOW_BATTERY -> { 201 debugLog { "USI low battery notification clicked." } 202 logUiEvent(StylusUiEvent.STYLUS_LOW_BATTERY_NOTIFICATION_CLICKED) 203 updateSuppression(true) 204 if (inputDeviceId == null) return 205 206 val args = Bundle() 207 args.putInt(KEY_DEVICE_INPUT_ID, inputDeviceId!!) 208 try { 209 context.startActivity( 210 Intent(ACTION_STYLUS_USI_DETAILS) 211 .putExtra(KEY_SETTINGS_FRAGMENT_ARGS, args) 212 .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) 213 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 214 ) 215 } catch (e: ActivityNotFoundException) { 216 // In the rare scenario where the Settings app manifest doesn't contain 217 // the USI details activity, ignore the intent. 218 Log.e( 219 StylusUsiPowerUI::class.java.simpleName, 220 "Cannot open USI details page." 221 ) 222 } 223 } 224 } 225 } 226 } 227 228 /** 229 * Logs a stylus USI battery event with instance ID and battery level. The instance ID 230 * represents the notification instance, and is reset when a notification is cancelled. 231 */ 232 private fun logUiEvent(metricId: StylusUiEvent) { 233 uiEventLogger.logWithInstanceIdAndPosition( 234 metricId, 235 ActivityManager.getCurrentUser(), 236 context.packageName, 237 getInstanceId(), 238 (batteryCapacity * 100.0).toInt() 239 ) 240 } 241 242 @VisibleForTesting 243 fun getInstanceId(): InstanceId? { 244 if (instanceId == null) { 245 instanceId = instanceId ?: instanceIdSequence.newInstanceId() 246 } 247 return instanceId 248 } 249 250 companion object { 251 val TAG = StylusUsiPowerUI::class.simpleName.orEmpty() 252 253 // Low battery threshold matches CrOS, see: 254 // https://source.chromium.org/chromium/chromium/src/+/main:ash/system/power/peripheral_battery_notifier.cc;l=41 255 private const val LOW_BATTERY_THRESHOLD = 0.16f 256 257 private val USI_NOTIFICATION_ID = R.string.stylus_battery_low_percentage 258 259 @VisibleForTesting const val ACTION_DISMISSED_LOW_BATTERY = "StylusUsiPowerUI.dismiss" 260 261 @VisibleForTesting const val ACTION_CLICKED_LOW_BATTERY = "StylusUsiPowerUI.click" 262 263 @VisibleForTesting 264 const val ACTION_STYLUS_USI_DETAILS = "com.android.settings.STYLUS_USI_DETAILS_SETTINGS" 265 266 @VisibleForTesting const val KEY_DEVICE_INPUT_ID = "device_input_id" 267 268 @VisibleForTesting const val KEY_SETTINGS_FRAGMENT_ARGS = ":settings:show_fragment_args" 269 } 270 } 271