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