1 /* <lambda>null2 * 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.PendingIntent 21 import android.content.ActivityNotFoundException 22 import android.content.BroadcastReceiver 23 import android.content.Context 24 import android.content.Intent 25 import android.content.IntentFilter 26 import android.hardware.BatteryState 27 import android.hardware.input.InputManager 28 import android.os.Bundle 29 import android.os.Handler 30 import android.os.UserHandle 31 import android.util.Log 32 import android.view.InputDevice 33 import androidx.core.app.NotificationCompat 34 import androidx.core.app.NotificationManagerCompat 35 import com.android.internal.annotations.VisibleForTesting 36 import com.android.systemui.R 37 import com.android.systemui.dagger.SysUISingleton 38 import com.android.systemui.dagger.qualifiers.Background 39 import com.android.systemui.util.NotificationChannels 40 import java.text.NumberFormat 41 import javax.inject.Inject 42 43 /** 44 * UI controller for the notification that shows when a USI stylus battery is low. The 45 * [StylusUsiPowerStartable], which listens to battery events, uses this controller. 46 */ 47 @SysUISingleton 48 class StylusUsiPowerUI 49 @Inject 50 constructor( 51 private val context: Context, 52 private val notificationManager: NotificationManagerCompat, 53 private val inputManager: InputManager, 54 @Background private val handler: Handler, 55 ) { 56 57 // These values must only be accessed on the handler. 58 private var batteryCapacity = 1.0f 59 private var suppressed = false 60 private var inputDeviceId: Int? = null 61 62 fun init() { 63 val filter = 64 IntentFilter().also { 65 it.addAction(ACTION_DISMISSED_LOW_BATTERY) 66 it.addAction(ACTION_CLICKED_LOW_BATTERY) 67 } 68 69 context.registerReceiverAsUser( 70 receiver, 71 UserHandle.ALL, 72 filter, 73 Manifest.permission.DEVICE_POWER, 74 handler, 75 Context.RECEIVER_NOT_EXPORTED, 76 ) 77 } 78 79 fun refresh() { 80 handler.post refreshNotification@{ 81 if (!suppressed && !hasConnectedBluetoothStylus() && isBatteryBelowThreshold()) { 82 showOrUpdateNotification() 83 return@refreshNotification 84 } 85 86 if (!isBatteryBelowThreshold()) { 87 // Reset suppression when stylus battery is recharged, so that the next time 88 // it reaches a low battery, the notification will show again. 89 suppressed = false 90 } 91 hideNotification() 92 } 93 } 94 95 fun updateBatteryState(deviceId: Int, batteryState: BatteryState) { 96 handler.post updateBattery@{ 97 if (batteryState.capacity == batteryCapacity || batteryState.capacity <= 0f) 98 return@updateBattery 99 100 inputDeviceId = deviceId 101 batteryCapacity = batteryState.capacity 102 refresh() 103 } 104 } 105 106 /** 107 * Suppression happens when the notification is dismissed by the user. This is to prevent 108 * further battery events with capacities below the threshold from reopening the suppressed 109 * notification. 110 * 111 * Suppression can only be removed when the battery has been recharged - thus restarting the 112 * notification cycle (i.e. next low battery event, notification should show). 113 */ 114 fun updateSuppression(suppress: Boolean) { 115 handler.post updateSuppressed@{ 116 if (suppressed == suppress) return@updateSuppressed 117 118 suppressed = suppress 119 refresh() 120 } 121 } 122 123 private fun hideNotification() { 124 notificationManager.cancel(USI_NOTIFICATION_ID) 125 } 126 127 private fun showOrUpdateNotification() { 128 val notification = 129 NotificationCompat.Builder(context, NotificationChannels.BATTERY) 130 .setSmallIcon(R.drawable.ic_power_low) 131 .setDeleteIntent(getPendingBroadcast(ACTION_DISMISSED_LOW_BATTERY)) 132 .setContentIntent(getPendingBroadcast(ACTION_CLICKED_LOW_BATTERY)) 133 .setContentTitle( 134 context.getString( 135 R.string.stylus_battery_low_percentage, 136 NumberFormat.getPercentInstance().format(batteryCapacity) 137 ) 138 ) 139 .setContentText(context.getString(R.string.stylus_battery_low_subtitle)) 140 .setPriority(NotificationCompat.PRIORITY_DEFAULT) 141 .setLocalOnly(true) 142 .setAutoCancel(true) 143 .build() 144 145 notificationManager.notify(USI_NOTIFICATION_ID, notification) 146 } 147 148 private fun isBatteryBelowThreshold(): Boolean { 149 return batteryCapacity <= LOW_BATTERY_THRESHOLD 150 } 151 152 private fun hasConnectedBluetoothStylus(): Boolean { 153 // TODO(b/257936830): get bt address once input api available 154 return inputManager.inputDeviceIds.any { deviceId -> 155 inputManager.getInputDevice(deviceId).supportsSource(InputDevice.SOURCE_STYLUS) 156 } 157 } 158 159 private fun getPendingBroadcast(action: String): PendingIntent? { 160 return PendingIntent.getBroadcast( 161 context, 162 0, 163 Intent(action).setPackage(context.packageName), 164 PendingIntent.FLAG_IMMUTABLE, 165 ) 166 } 167 168 @VisibleForTesting 169 internal val receiver: BroadcastReceiver = 170 object : BroadcastReceiver() { 171 override fun onReceive(context: Context, intent: Intent) { 172 when (intent.action) { 173 ACTION_DISMISSED_LOW_BATTERY -> updateSuppression(true) 174 ACTION_CLICKED_LOW_BATTERY -> { 175 updateSuppression(true) 176 if (inputDeviceId == null) return 177 178 val args = Bundle() 179 args.putInt(KEY_DEVICE_INPUT_ID, inputDeviceId!!) 180 try { 181 context.startActivity( 182 Intent(ACTION_STYLUS_USI_DETAILS) 183 .putExtra(KEY_SETTINGS_FRAGMENT_ARGS, args) 184 .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) 185 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 186 ) 187 } catch (e: ActivityNotFoundException) { 188 // In the rare scenario where the Settings app manifest doesn't contain 189 // the USI details activity, ignore the intent. 190 Log.e( 191 StylusUsiPowerUI::class.java.simpleName, 192 "Cannot open USI details page." 193 ) 194 } 195 } 196 } 197 } 198 } 199 200 companion object { 201 // Low battery threshold matches CrOS, see: 202 // https://source.chromium.org/chromium/chromium/src/+/main:ash/system/power/peripheral_battery_notifier.cc;l=41 203 private const val LOW_BATTERY_THRESHOLD = 0.16f 204 205 private val USI_NOTIFICATION_ID = R.string.stylus_battery_low_percentage 206 207 @VisibleForTesting const val ACTION_DISMISSED_LOW_BATTERY = "StylusUsiPowerUI.dismiss" 208 @VisibleForTesting const val ACTION_CLICKED_LOW_BATTERY = "StylusUsiPowerUI.click" 209 @VisibleForTesting 210 const val ACTION_STYLUS_USI_DETAILS = "com.android.settings.STYLUS_USI_DETAILS_SETTINGS" 211 @VisibleForTesting const val KEY_DEVICE_INPUT_ID = "device_input_id" 212 @VisibleForTesting const val KEY_SETTINGS_FRAGMENT_ARGS = ":settings:show_fragment_args" 213 } 214 } 215