Merge "Low battery notifications for USI styluses." into tm-qpr-dev am: 206392b411

Original change: https://googleplex-android-review.googlesource.com/c/platform/frameworks/base/+/20608953

Change-Id: Ic61d520a905d37a6def6771c2f27162e5e9d59d7
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
This commit is contained in:
Vania Januar
2022-12-15 10:31:13 +00:00
committed by Automerger Merge Worker
7 changed files with 646 additions and 0 deletions

View File

@@ -2762,4 +2762,7 @@
<string name="rear_display_accessibility_folded_animation">Foldable device being unfolded</string>
<!-- Text for education page content description for unfolded animation. [CHAR_LIMIT=NONE] -->
<string name="rear_display_accessibility_unfolded_animation">Foldable device being flipped around</string>
<!-- Title for notification of low stylus battery. [CHAR_LIMIT=NONE] -->
<string name="stylus_battery_low">Stylus battery low</string>
</resources>

View File

@@ -93,6 +93,8 @@ import android.view.accessibility.AccessibilityManager;
import android.view.accessibility.CaptioningManager;
import android.view.inputmethod.InputMethodManager;
import androidx.core.app.NotificationManagerCompat;
import com.android.internal.app.IBatteryStats;
import com.android.internal.appwidget.IAppWidgetService;
import com.android.internal.jank.InteractionJankMonitor;
@@ -389,6 +391,12 @@ public class FrameworkServicesModule {
return context.getSystemService(NotificationManager.class);
}
@Provides
@Singleton
static NotificationManagerCompat provideNotificationManagerCompat(Context context) {
return NotificationManagerCompat.from(context);
}
/** */
@Provides
@Singleton

View File

@@ -42,6 +42,7 @@ import com.android.systemui.settings.dagger.MultiUserUtilsModule
import com.android.systemui.shortcut.ShortcutKeyDispatcher
import com.android.systemui.statusbar.notification.InstantAppNotifier
import com.android.systemui.statusbar.phone.KeyguardLiftController
import com.android.systemui.stylus.StylusUsiPowerStartable
import com.android.systemui.temporarydisplay.chipbar.ChipbarCoordinator
import com.android.systemui.theme.ThemeOverlayController
import com.android.systemui.toast.ToastUI
@@ -251,4 +252,10 @@ abstract class SystemUICoreStartableModule {
@IntoMap
@ClassKey(RearDisplayDialogController::class)
abstract fun bindRearDisplayDialogController(sysui: RearDisplayDialogController): CoreStartable
/** Inject into StylusUsiPowerStartable) */
@Binds
@IntoMap
@ClassKey(StylusUsiPowerStartable::class)
abstract fun bindStylusUsiPowerStartable(sysui: StylusUsiPowerStartable): CoreStartable
}

View File

@@ -0,0 +1,118 @@
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.stylus
import android.hardware.BatteryState
import android.hardware.input.InputManager
import android.util.Log
import android.view.InputDevice
import com.android.systemui.CoreStartable
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.flags.FeatureFlags
import com.android.systemui.flags.Flags
import java.util.concurrent.Executor
import javax.inject.Inject
/**
* A [CoreStartable] that listens to USI stylus battery events, to manage the [StylusUsiPowerUI]
* notification controller.
*/
@SysUISingleton
class StylusUsiPowerStartable
@Inject
constructor(
private val stylusManager: StylusManager,
private val inputManager: InputManager,
private val stylusUsiPowerUi: StylusUsiPowerUI,
private val featureFlags: FeatureFlags,
@Background private val executor: Executor,
) : CoreStartable, StylusManager.StylusCallback, InputManager.InputDeviceBatteryListener {
override fun onStylusAdded(deviceId: Int) {
val device = inputManager.getInputDevice(deviceId) ?: return
if (!device.isExternal) {
registerBatteryListener(deviceId)
}
}
override fun onStylusBluetoothConnected(deviceId: Int, btAddress: String) {
stylusUsiPowerUi.refresh()
}
override fun onStylusBluetoothDisconnected(deviceId: Int, btAddress: String) {
stylusUsiPowerUi.refresh()
}
override fun onStylusRemoved(deviceId: Int) {
val device = inputManager.getInputDevice(deviceId) ?: return
if (!device.isExternal) {
unregisterBatteryListener(deviceId)
}
}
override fun onBatteryStateChanged(
deviceId: Int,
eventTimeMillis: Long,
batteryState: BatteryState
) {
if (batteryState.isPresent) {
stylusUsiPowerUi.updateBatteryState(batteryState)
}
}
private fun registerBatteryListener(deviceId: Int) {
try {
inputManager.addInputDeviceBatteryListener(deviceId, executor, this)
} catch (e: SecurityException) {
Log.e(TAG, "$e: Failed to register battery listener for $deviceId.")
}
}
private fun unregisterBatteryListener(deviceId: Int) {
try {
inputManager.removeInputDeviceBatteryListener(deviceId, this)
} catch (e: SecurityException) {
Log.e(TAG, "$e: Failed to unregister battery listener for $deviceId.")
}
}
override fun start() {
if (!featureFlags.isEnabled(Flags.ENABLE_USI_BATTERY_NOTIFICATIONS)) return
addBatteryListenerForInternalStyluses()
stylusManager.registerCallback(this)
stylusManager.startListener()
}
private fun addBatteryListenerForInternalStyluses() {
// For most devices, an active stylus is represented by an internal InputDevice.
// This InputDevice will be present in InputManager before CoreStartables run,
// and will not be removed. In many cases, it reports the battery level of the stylus.
inputManager.inputDeviceIds
.asSequence()
.mapNotNull { inputManager.getInputDevice(it) }
.filter { it.supportsSource(InputDevice.SOURCE_STYLUS) }
.forEach { onStylusAdded(it.id) }
}
companion object {
private val TAG = StylusUsiPowerStartable::class.simpleName.orEmpty()
}
}

View File

@@ -0,0 +1,185 @@
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.stylus
import android.Manifest
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.BatteryState
import android.hardware.input.InputManager
import android.os.Handler
import android.os.UserHandle
import android.view.InputDevice
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.android.systemui.R
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.util.NotificationChannels
import java.text.NumberFormat
import javax.inject.Inject
/**
* UI controller for the notification that shows when a USI stylus battery is low. The
* [StylusUsiPowerStartable], which listens to battery events, uses this controller.
*/
@SysUISingleton
class StylusUsiPowerUI
@Inject
constructor(
private val context: Context,
private val notificationManager: NotificationManagerCompat,
private val inputManager: InputManager,
@Background private val handler: Handler,
) {
// These values must only be accessed on the handler.
private var batteryCapacity = 1.0f
private var suppressed = false
fun init() {
val filter =
IntentFilter().also {
it.addAction(ACTION_DISMISSED_LOW_BATTERY)
it.addAction(ACTION_CLICKED_LOW_BATTERY)
}
context.registerReceiverAsUser(
receiver,
UserHandle.ALL,
filter,
Manifest.permission.DEVICE_POWER,
handler,
Context.RECEIVER_NOT_EXPORTED,
)
}
fun refresh() {
handler.post refreshNotification@{
if (!suppressed && !hasConnectedBluetoothStylus() && isBatteryBelowThreshold()) {
showOrUpdateNotification()
return@refreshNotification
}
if (!isBatteryBelowThreshold()) {
// Reset suppression when stylus battery is recharged, so that the next time
// it reaches a low battery, the notification will show again.
suppressed = false
}
hideNotification()
}
}
fun updateBatteryState(batteryState: BatteryState) {
handler.post updateBattery@{
if (batteryState.capacity == batteryCapacity) return@updateBattery
batteryCapacity = batteryState.capacity
refresh()
}
}
/**
* Suppression happens when the notification is dismissed by the user. This is to prevent
* further battery events with capacities below the threshold from reopening the suppressed
* notification.
*
* Suppression can only be removed when the battery has been recharged - thus restarting the
* notification cycle (i.e. next low battery event, notification should show).
*/
fun updateSuppression(suppress: Boolean) {
handler.post updateSuppressed@{
if (suppressed == suppress) return@updateSuppressed
suppressed = suppress
refresh()
}
}
private fun hideNotification() {
notificationManager.cancel(USI_NOTIFICATION_ID)
}
private fun showOrUpdateNotification() {
val notification =
NotificationCompat.Builder(context, NotificationChannels.BATTERY)
.setSmallIcon(R.drawable.ic_power_low)
.setDeleteIntent(getPendingBroadcast(ACTION_DISMISSED_LOW_BATTERY))
.setContentIntent(getPendingBroadcast(ACTION_CLICKED_LOW_BATTERY))
.setContentTitle(context.getString(R.string.stylus_battery_low))
.setContentText(
context.getString(
R.string.battery_low_percent_format,
NumberFormat.getPercentInstance().format(batteryCapacity)
)
)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setLocalOnly(true)
.setAutoCancel(true)
.build()
notificationManager.notify(USI_NOTIFICATION_ID, notification)
}
private fun isBatteryBelowThreshold(): Boolean {
return batteryCapacity <= LOW_BATTERY_THRESHOLD
}
private fun hasConnectedBluetoothStylus(): Boolean {
// TODO(b/257936830): get bt address once input api available
return inputManager.inputDeviceIds.any { deviceId ->
inputManager.getInputDevice(deviceId).supportsSource(InputDevice.SOURCE_STYLUS)
}
}
private fun getPendingBroadcast(action: String): PendingIntent? {
return PendingIntent.getBroadcastAsUser(
context,
0,
Intent(action),
PendingIntent.FLAG_IMMUTABLE,
UserHandle.CURRENT
)
}
private val receiver: BroadcastReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_DISMISSED_LOW_BATTERY -> updateSuppression(true)
ACTION_CLICKED_LOW_BATTERY -> {
updateSuppression(true)
// TODO(b/261584943): open USI device details page
}
}
}
}
companion object {
// Low battery threshold matches CrOS, see:
// https://source.chromium.org/chromium/chromium/src/+/main:ash/system/power/peripheral_battery_notifier.cc;l=41
private const val LOW_BATTERY_THRESHOLD = 0.16f
private val USI_NOTIFICATION_ID = R.string.stylus_battery_low
private const val ACTION_DISMISSED_LOW_BATTERY = "StylusUsiPowerUI.dismiss"
private const val ACTION_CLICKED_LOW_BATTERY = "StylusUsiPowerUI.click"
}
}

View File

@@ -0,0 +1,158 @@
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.stylus
import android.hardware.BatteryState
import android.hardware.input.InputManager
import android.testing.AndroidTestingRunner
import android.view.InputDevice
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.flags.FeatureFlags
import com.android.systemui.flags.Flags
import com.android.systemui.util.mockito.whenever
import java.util.concurrent.Executor
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito.inOrder
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoMoreInteractions
import org.mockito.MockitoAnnotations
@RunWith(AndroidTestingRunner::class)
@SmallTest
class StylusUsiPowerStartableTest : SysuiTestCase() {
@Mock lateinit var inputManager: InputManager
@Mock lateinit var stylusManager: StylusManager
@Mock lateinit var stylusDevice: InputDevice
@Mock lateinit var externalDevice: InputDevice
@Mock lateinit var featureFlags: FeatureFlags
@Mock lateinit var stylusUsiPowerUi: StylusUsiPowerUI
lateinit var startable: StylusUsiPowerStartable
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
startable =
StylusUsiPowerStartable(
stylusManager,
inputManager,
stylusUsiPowerUi,
featureFlags,
DIRECT_EXECUTOR,
)
whenever(featureFlags.isEnabled(Flags.ENABLE_USI_BATTERY_NOTIFICATIONS)).thenReturn(true)
whenever(inputManager.getInputDevice(EXTERNAL_DEVICE_ID)).thenReturn(externalDevice)
whenever(inputManager.getInputDevice(STYLUS_DEVICE_ID)).thenReturn(stylusDevice)
whenever(inputManager.inputDeviceIds)
.thenReturn(intArrayOf(EXTERNAL_DEVICE_ID, STYLUS_DEVICE_ID))
whenever(stylusDevice.supportsSource(InputDevice.SOURCE_STYLUS)).thenReturn(true)
whenever(stylusDevice.isExternal).thenReturn(false)
whenever(stylusDevice.id).thenReturn(STYLUS_DEVICE_ID)
whenever(externalDevice.supportsSource(InputDevice.SOURCE_STYLUS)).thenReturn(true)
whenever(externalDevice.isExternal).thenReturn(true)
whenever(externalDevice.id).thenReturn(EXTERNAL_DEVICE_ID)
}
@Test
fun start_addsBatteryListenerForInternalStylus() {
startable.start()
verify(inputManager, times(1))
.addInputDeviceBatteryListener(STYLUS_DEVICE_ID, DIRECT_EXECUTOR, startable)
}
@Test
fun onStylusAdded_internalStylus_addsBatteryListener() {
startable.onStylusAdded(STYLUS_DEVICE_ID)
verify(inputManager, times(1))
.addInputDeviceBatteryListener(STYLUS_DEVICE_ID, DIRECT_EXECUTOR, startable)
}
@Test
fun onStylusAdded_externalStylus_doesNotAddBatteryListener() {
startable.onStylusAdded(EXTERNAL_DEVICE_ID)
verify(inputManager, never())
.addInputDeviceBatteryListener(EXTERNAL_DEVICE_ID, DIRECT_EXECUTOR, startable)
}
@Test
fun onStylusRemoved_registeredStylus_removesBatteryListener() {
startable.onStylusAdded(STYLUS_DEVICE_ID)
startable.onStylusRemoved(STYLUS_DEVICE_ID)
inOrder(inputManager).let {
it.verify(inputManager, times(1))
.addInputDeviceBatteryListener(STYLUS_DEVICE_ID, DIRECT_EXECUTOR, startable)
it.verify(inputManager, times(1))
.removeInputDeviceBatteryListener(STYLUS_DEVICE_ID, startable)
}
}
@Test
fun onStylusBluetoothConnected_refreshesNotification() {
startable.onStylusBluetoothConnected(STYLUS_DEVICE_ID, "ANY")
verify(stylusUsiPowerUi, times(1)).refresh()
}
@Test
fun onStylusBluetoothDisconnected_refreshesNotification() {
startable.onStylusBluetoothDisconnected(STYLUS_DEVICE_ID, "ANY")
verify(stylusUsiPowerUi, times(1)).refresh()
}
@Test
fun onBatteryStateChanged_batteryPresent_refreshesNotification() {
val batteryState = mock(BatteryState::class.java)
whenever(batteryState.isPresent).thenReturn(true)
startable.onBatteryStateChanged(STYLUS_DEVICE_ID, 123, batteryState)
verify(stylusUsiPowerUi, times(1)).updateBatteryState(batteryState)
}
@Test
fun onBatteryStateChanged_batteryNotPresent_noop() {
val batteryState = mock(BatteryState::class.java)
whenever(batteryState.isPresent).thenReturn(false)
startable.onBatteryStateChanged(STYLUS_DEVICE_ID, 123, batteryState)
verifyNoMoreInteractions(stylusUsiPowerUi)
}
companion object {
private val DIRECT_EXECUTOR = Executor { r -> r.run() }
private const val EXTERNAL_DEVICE_ID = 0
private const val STYLUS_DEVICE_ID = 1
}
}

View File

@@ -0,0 +1,167 @@
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.stylus
import android.hardware.BatteryState
import android.hardware.input.InputManager
import android.os.Handler
import android.testing.AndroidTestingRunner
import android.view.InputDevice
import androidx.core.app.NotificationManagerCompat
import androidx.test.filters.SmallTest
import com.android.systemui.R
import com.android.systemui.SysuiTestCase
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.eq
import com.android.systemui.util.mockito.whenever
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito.inOrder
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoMoreInteractions
import org.mockito.MockitoAnnotations
@RunWith(AndroidTestingRunner::class)
@SmallTest
class StylusUsiPowerUiTest : SysuiTestCase() {
@Mock lateinit var notificationManager: NotificationManagerCompat
@Mock lateinit var inputManager: InputManager
@Mock lateinit var handler: Handler
@Mock lateinit var btStylusDevice: InputDevice
private lateinit var stylusUsiPowerUi: StylusUsiPowerUI
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
whenever(handler.post(any())).thenAnswer {
(it.arguments[0] as Runnable).run()
true
}
whenever(inputManager.inputDeviceIds).thenReturn(intArrayOf())
whenever(inputManager.getInputDevice(0)).thenReturn(btStylusDevice)
whenever(btStylusDevice.supportsSource(InputDevice.SOURCE_STYLUS)).thenReturn(true)
// whenever(btStylusDevice.bluetoothAddress).thenReturn("SO:ME:AD:DR:ES")
stylusUsiPowerUi = StylusUsiPowerUI(mContext, notificationManager, inputManager, handler)
}
@Test
fun updateBatteryState_capacityBelowThreshold_notifies() {
stylusUsiPowerUi.updateBatteryState(FixedCapacityBatteryState(0.1f))
verify(notificationManager, times(1)).notify(eq(R.string.stylus_battery_low), any())
verifyNoMoreInteractions(notificationManager)
}
@Test
fun updateBatteryState_capacityAboveThreshold_cancelsNotificattion() {
stylusUsiPowerUi.updateBatteryState(FixedCapacityBatteryState(0.8f))
verify(notificationManager, times(1)).cancel(R.string.stylus_battery_low)
verifyNoMoreInteractions(notificationManager)
}
@Test
fun updateBatteryState_existingNotification_capacityAboveThreshold_cancelsNotification() {
stylusUsiPowerUi.updateBatteryState(FixedCapacityBatteryState(0.1f))
stylusUsiPowerUi.updateBatteryState(FixedCapacityBatteryState(0.8f))
inOrder(notificationManager).let {
it.verify(notificationManager, times(1)).notify(eq(R.string.stylus_battery_low), any())
it.verify(notificationManager, times(1)).cancel(R.string.stylus_battery_low)
it.verifyNoMoreInteractions()
}
}
@Test
fun updateBatteryState_existingNotification_capacityBelowThreshold_updatesNotification() {
stylusUsiPowerUi.updateBatteryState(FixedCapacityBatteryState(0.1f))
stylusUsiPowerUi.updateBatteryState(FixedCapacityBatteryState(0.15f))
verify(notificationManager, times(2)).notify(eq(R.string.stylus_battery_low), any())
verifyNoMoreInteractions(notificationManager)
}
@Test
fun updateBatteryState_capacityAboveThenBelowThreshold_hidesThenShowsNotification() {
stylusUsiPowerUi.updateBatteryState(FixedCapacityBatteryState(0.1f))
stylusUsiPowerUi.updateBatteryState(FixedCapacityBatteryState(0.5f))
stylusUsiPowerUi.updateBatteryState(FixedCapacityBatteryState(0.1f))
inOrder(notificationManager).let {
it.verify(notificationManager, times(1)).notify(eq(R.string.stylus_battery_low), any())
it.verify(notificationManager, times(1)).cancel(R.string.stylus_battery_low)
it.verify(notificationManager, times(1)).notify(eq(R.string.stylus_battery_low), any())
it.verifyNoMoreInteractions()
}
}
@Test
fun updateSuppression_noExistingNotification_cancelsNotification() {
stylusUsiPowerUi.updateSuppression(true)
verify(notificationManager, times(1)).cancel(R.string.stylus_battery_low)
verifyNoMoreInteractions(notificationManager)
}
@Test
fun updateSuppression_existingNotification_cancelsNotification() {
stylusUsiPowerUi.updateBatteryState(FixedCapacityBatteryState(0.1f))
stylusUsiPowerUi.updateSuppression(true)
inOrder(notificationManager).let {
it.verify(notificationManager, times(1)).notify(eq(R.string.stylus_battery_low), any())
it.verify(notificationManager, times(1)).cancel(R.string.stylus_battery_low)
it.verifyNoMoreInteractions()
}
}
@Test
@Ignore("TODO(b/257936830): get bt address once input api available")
fun refresh_hasConnectedBluetoothStylus_doesNotNotify() {
whenever(inputManager.inputDeviceIds).thenReturn(intArrayOf(0))
stylusUsiPowerUi.refresh()
verifyNoMoreInteractions(notificationManager)
}
@Test
@Ignore("TODO(b/257936830): get bt address once input api available")
fun refresh_hasConnectedBluetoothStylus_existingNotification_cancelsNotification() {
stylusUsiPowerUi.updateBatteryState(FixedCapacityBatteryState(0.1f))
whenever(inputManager.inputDeviceIds).thenReturn(intArrayOf(0))
stylusUsiPowerUi.refresh()
verify(notificationManager).cancel(R.string.stylus_battery_low)
}
class FixedCapacityBatteryState(private val capacity: Float) : BatteryState() {
override fun getCapacity() = capacity
override fun getStatus() = 0
override fun isPresent() = true
}
}