Loading packages/SystemUI/res/values/strings.xml +3 −0 Original line number Diff line number Diff line Loading @@ -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> packages/SystemUI/src/com/android/systemui/dagger/FrameworkServicesModule.java +8 −0 Original line number Diff line number Diff line Loading @@ -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; Loading Loading @@ -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 Loading packages/SystemUI/src/com/android/systemui/dagger/SystemUICoreStartableModule.kt +7 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 } packages/SystemUI/src/com/android/systemui/stylus/StylusUsiPowerStartable.kt 0 → 100644 +118 −0 Original line number Diff line number Diff line /* * 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() } } packages/SystemUI/src/com/android/systemui/stylus/StylusUsiPowerUI.kt 0 → 100644 +185 −0 Original line number Diff line number Diff line /* * 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" } } Loading
packages/SystemUI/res/values/strings.xml +3 −0 Original line number Diff line number Diff line Loading @@ -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>
packages/SystemUI/src/com/android/systemui/dagger/FrameworkServicesModule.java +8 −0 Original line number Diff line number Diff line Loading @@ -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; Loading Loading @@ -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 Loading
packages/SystemUI/src/com/android/systemui/dagger/SystemUICoreStartableModule.kt +7 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 }
packages/SystemUI/src/com/android/systemui/stylus/StylusUsiPowerStartable.kt 0 → 100644 +118 −0 Original line number Diff line number Diff line /* * 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() } }
packages/SystemUI/src/com/android/systemui/stylus/StylusUsiPowerUI.kt 0 → 100644 +185 −0 Original line number Diff line number Diff line /* * 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" } }