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