xref: /aosp_15_r20/frameworks/base/packages/SystemUI/src/com/android/systemui/stylus/StylusUsiPowerUI.kt (revision d57664e9bc4670b3ecf6748a746a57c557b6bc9e)
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