• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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