Merge "BatteryController: Add battery monitoring from Bluetooth"

This commit is contained in:
Prabir Pradhan
2022-11-28 15:24:57 +00:00
committed by Android (Google) Code Review
3 changed files with 352 additions and 24 deletions

View File

@@ -19,7 +19,13 @@ package com.android.server.input;
import android.annotation.BinderThread;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothManager;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.hardware.BatteryState;
import android.hardware.input.IInputDeviceBatteryListener;
import android.hardware.input.IInputDeviceBatteryState;
@@ -46,6 +52,7 @@ import java.util.Arrays;
import java.util.Objects;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
/**
@@ -74,6 +81,7 @@ final class BatteryController {
private final NativeInputManagerService mNative;
private final Handler mHandler;
private final UEventManager mUEventManager;
private final BluetoothBatteryManager mBluetoothBatteryManager;
// Maps a pid to the registered listener record for that process. There can only be one battery
// listener per process.
@@ -88,18 +96,23 @@ final class BatteryController {
private boolean mIsPolling = false;
@GuardedBy("mLock")
private boolean mIsInteractive = true;
@Nullable
@GuardedBy("mLock")
private BluetoothBatteryManager.BluetoothBatteryListener mBluetoothBatteryListener;
BatteryController(Context context, NativeInputManagerService nativeService, Looper looper) {
this(context, nativeService, looper, new UEventManager() {});
this(context, nativeService, looper, new UEventManager() {},
new LocalBluetoothBatteryManager(context));
}
@VisibleForTesting
BatteryController(Context context, NativeInputManagerService nativeService, Looper looper,
UEventManager uEventManager) {
UEventManager uEventManager, BluetoothBatteryManager bbm) {
mContext = context;
mNative = nativeService;
mHandler = new Handler(looper);
mUEventManager = uEventManager;
mBluetoothBatteryManager = bbm;
}
public void systemRunning() {
@@ -150,6 +163,7 @@ final class BatteryController {
// This is the first listener that is monitoring this device.
monitor = new DeviceMonitor(deviceId);
mDeviceMonitors.put(deviceId, monitor);
updateBluetoothMonitoring();
}
if (DEBUG) {
@@ -202,25 +216,39 @@ final class BatteryController {
mHandler.postDelayed(this::handlePollEvent, delayStart ? POLLING_PERIOD_MILLIS : 0);
}
private String getInputDeviceName(int deviceId) {
private <R> R processInputDevice(int deviceId, R defaultValue, Function<InputDevice, R> func) {
final InputDevice device =
Objects.requireNonNull(mContext.getSystemService(InputManager.class))
.getInputDevice(deviceId);
return device != null ? device.getName() : "<none>";
return device == null ? defaultValue : func.apply(device);
}
private String getInputDeviceName(int deviceId) {
return processInputDevice(deviceId, "<none>" /*defaultValue*/, InputDevice::getName);
}
private boolean hasBattery(int deviceId) {
final InputDevice device =
Objects.requireNonNull(mContext.getSystemService(InputManager.class))
.getInputDevice(deviceId);
return device != null && device.hasBattery();
return processInputDevice(deviceId, false /*defaultValue*/, InputDevice::hasBattery);
}
private boolean isUsiDevice(int deviceId) {
final InputDevice device =
Objects.requireNonNull(mContext.getSystemService(InputManager.class))
.getInputDevice(deviceId);
return device != null && device.supportsUsi();
return processInputDevice(deviceId, false /*defaultValue*/, InputDevice::supportsUsi);
}
@Nullable
private BluetoothDevice getBluetoothDevice(int inputDeviceId) {
return getBluetoothDevice(mContext,
processInputDevice(inputDeviceId, null /*defaultValue*/,
InputDevice::getBluetoothAddress));
}
@Nullable
private static BluetoothDevice getBluetoothDevice(Context context, String address) {
if (address == null) return null;
final BluetoothAdapter adapter =
Objects.requireNonNull(context.getSystemService(BluetoothManager.class))
.getAdapter();
return adapter.getRemoteDevice(address);
}
@GuardedBy("mLock")
@@ -350,6 +378,17 @@ final class BatteryController {
}
}
private void handleBluetoothBatteryLevelChange(long eventTime, String address) {
synchronized (mLock) {
final DeviceMonitor monitor = findIf(mDeviceMonitors, (m) ->
(m.mBluetoothDevice != null
&& address.equals(m.mBluetoothDevice.getAddress())));
if (monitor != null) {
monitor.onBluetoothBatteryChanged(eventTime);
}
}
}
/** Gets the current battery state of an input device. */
public IInputDeviceBatteryState getBatteryState(int deviceId) {
synchronized (mLock) {
@@ -475,17 +514,52 @@ final class BatteryController {
isPresent ? mNative.getBatteryCapacity(deviceId) / 100.f : Float.NaN);
}
// Queries the battery state of an input device from Bluetooth.
private State queryBatteryStateFromBluetooth(int deviceId, long updateTime,
@NonNull BluetoothDevice bluetoothDevice) {
final int level = mBluetoothBatteryManager.getBatteryLevel(bluetoothDevice.getAddress());
if (level == BluetoothDevice.BATTERY_LEVEL_BLUETOOTH_OFF
|| level == BluetoothDevice.BATTERY_LEVEL_UNKNOWN) {
return new State(deviceId);
}
return new State(deviceId, updateTime, true /*isPresent*/, BatteryState.STATUS_UNKNOWN,
level / 100.f);
}
private void updateBluetoothMonitoring() {
synchronized (mLock) {
if (anyOf(mDeviceMonitors, (m) -> m.mBluetoothDevice != null)) {
// At least one input device being monitored is connected over Bluetooth.
if (mBluetoothBatteryListener == null) {
if (DEBUG) Slog.d(TAG, "Registering bluetooth battery listener");
mBluetoothBatteryListener = this::handleBluetoothBatteryLevelChange;
mBluetoothBatteryManager.addListener(mBluetoothBatteryListener);
}
} else if (mBluetoothBatteryListener != null) {
// No Bluetooth input devices are monitored, so remove the registered listener.
if (DEBUG) Slog.d(TAG, "Unregistering bluetooth battery listener");
mBluetoothBatteryManager.removeListener(mBluetoothBatteryListener);
mBluetoothBatteryListener = null;
}
}
}
// Holds the state of an InputDevice for which battery changes are currently being monitored.
private class DeviceMonitor {
protected final State mState;
// Represents whether the input device has a sysfs battery node.
protected boolean mHasBattery = false;
protected final State mBluetoothState;
@Nullable
private BluetoothDevice mBluetoothDevice;
@Nullable
private UEventBatteryListener mUEventBatteryListener;
DeviceMonitor(int deviceId) {
mState = new State(deviceId);
mBluetoothState = new State(deviceId);
// Load the initial battery state and start monitoring.
final long eventTime = SystemClock.uptimeMillis();
@@ -506,18 +580,31 @@ final class BatteryController {
}
private void configureDeviceMonitor(long eventTime) {
final int deviceId = mState.deviceId;
if (mHasBattery != hasBattery(mState.deviceId)) {
mHasBattery = !mHasBattery;
if (mHasBattery) {
startMonitoring();
startNativeMonitoring();
} else {
stopMonitoring();
stopNativeMonitoring();
}
updateBatteryStateFromNative(eventTime);
}
final BluetoothDevice bluetoothDevice = getBluetoothDevice(deviceId);
if (!Objects.equals(mBluetoothDevice, bluetoothDevice)) {
if (DEBUG) {
Slog.d(TAG, "Bluetooth device "
+ ((bluetoothDevice != null) ? "is" : "is not")
+ " now present for deviceId " + deviceId);
}
mBluetoothDevice = bluetoothDevice;
updateBluetoothMonitoring();
updateBatteryStateFromBluetooth(eventTime);
}
}
private void startMonitoring() {
private void startNativeMonitoring() {
final String batteryPath = mNative.getBatteryDevicePath(mState.deviceId);
if (batteryPath == null) {
return;
@@ -538,7 +625,7 @@ final class BatteryController {
return path.startsWith("/sys") ? path.substring(4) : path;
}
private void stopMonitoring() {
private void stopNativeMonitoring() {
if (mUEventBatteryListener != null) {
mUEventManager.removeListener(mUEventBatteryListener);
mUEventBatteryListener = null;
@@ -547,7 +634,9 @@ final class BatteryController {
// This must be called when the device is no longer being monitored.
public void onMonitorDestroy() {
stopMonitoring();
stopNativeMonitoring();
mBluetoothDevice = null;
updateBluetoothMonitoring();
}
protected void updateBatteryStateFromNative(long eventTime) {
@@ -555,6 +644,13 @@ final class BatteryController {
queryBatteryStateFromNative(mState.deviceId, eventTime, mHasBattery));
}
protected void updateBatteryStateFromBluetooth(long eventTime) {
final State bluetoothState = mBluetoothDevice == null ? new State(mState.deviceId)
: queryBatteryStateFromBluetooth(mState.deviceId, eventTime,
mBluetoothDevice);
mBluetoothState.updateIfChanged(bluetoothState);
}
public void onPoll(long eventTime) {
processChangesAndNotify(eventTime, this::updateBatteryStateFromNative);
}
@@ -563,6 +659,10 @@ final class BatteryController {
processChangesAndNotify(eventTime, this::updateBatteryStateFromNative);
}
public void onBluetoothBatteryChanged(long eventTime) {
processChangesAndNotify(eventTime, this::updateBatteryStateFromBluetooth);
}
public boolean requiresPolling() {
return true;
}
@@ -577,6 +677,10 @@ final class BatteryController {
// Returns the current battery state that can be used to notify listeners BatteryController.
public State getBatteryStateForReporting() {
// Give precedence to the Bluetooth battery state if it's present.
if (mBluetoothState.isPresent) {
return new State(mBluetoothState);
}
return new State(mState);
}
@@ -585,7 +689,8 @@ final class BatteryController {
return "DeviceId=" + mState.deviceId
+ ", Name='" + getInputDeviceName(mState.deviceId) + "'"
+ ", NativeBattery=" + mState
+ ", UEventListener=" + (mUEventBatteryListener != null ? "added" : "none");
+ ", UEventListener=" + (mUEventBatteryListener != null ? "added" : "none")
+ ", BluetoothBattery=" + mBluetoothState;
}
}
@@ -670,6 +775,10 @@ final class BatteryController {
@Override
public State getBatteryStateForReporting() {
// Give precedence to the Bluetooth battery state if it's present.
if (mBluetoothState.isPresent) {
return new State(mBluetoothState);
}
return mValidityTimeoutCallback != null
? new State(mState) : new State(mState.deviceId);
}
@@ -729,6 +838,82 @@ final class BatteryController {
}
}
// An interface used to change the API of adding a bluetooth battery listener to a more
// test-friendly format.
@VisibleForTesting
interface BluetoothBatteryManager {
@VisibleForTesting
interface BluetoothBatteryListener {
void onBluetoothBatteryChanged(long eventTime, String address);
}
void addListener(BluetoothBatteryListener listener);
void removeListener(BluetoothBatteryListener listener);
int getBatteryLevel(String address);
}
private static class LocalBluetoothBatteryManager implements BluetoothBatteryManager {
private final Context mContext;
@Nullable
@GuardedBy("mBroadcastReceiver")
private BluetoothBatteryListener mRegisteredListener;
@GuardedBy("mBroadcastReceiver")
private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (!BluetoothDevice.ACTION_BATTERY_LEVEL_CHANGED.equals(intent.getAction())) {
return;
}
final BluetoothDevice bluetoothDevice = intent.getParcelableExtra(
BluetoothDevice.EXTRA_DEVICE, BluetoothDevice.class);
if (bluetoothDevice == null) {
return;
}
// We do not use the EXTRA_LEVEL value. Instead, the battery level will be queried
// from BluetoothDevice later so that we use a single source for the battery level.
synchronized (mBroadcastReceiver) {
if (mRegisteredListener != null) {
final long eventTime = SystemClock.uptimeMillis();
mRegisteredListener.onBluetoothBatteryChanged(
eventTime, bluetoothDevice.getAddress());
}
}
}
};
LocalBluetoothBatteryManager(Context context) {
mContext = context;
}
@Override
public void addListener(BluetoothBatteryListener listener) {
synchronized (mBroadcastReceiver) {
if (mRegisteredListener != null) {
throw new IllegalStateException(
"Only one bluetooth battery listener can be registered at once.");
}
mRegisteredListener = listener;
mContext.registerReceiver(mBroadcastReceiver,
new IntentFilter(BluetoothDevice.ACTION_BATTERY_LEVEL_CHANGED));
}
}
@Override
public void removeListener(BluetoothBatteryListener listener) {
synchronized (mBroadcastReceiver) {
if (!listener.equals(mRegisteredListener)) {
throw new IllegalStateException("Listener is not registered.");
}
mRegisteredListener = null;
mContext.unregisterReceiver(mBroadcastReceiver);
}
}
@Override
public int getBatteryLevel(String address) {
return getBluetoothDevice(mContext, address).getBatteryLevel();
}
}
// Helper class that adds copying and printing functionality to IInputDeviceBatteryState.
private static class State extends IInputDeviceBatteryState {
@@ -792,11 +977,17 @@ final class BatteryController {
// Check if any value in an ArrayMap matches the predicate in an optimized way.
private static <K, V> boolean anyOf(ArrayMap<K, V> arrayMap, Predicate<V> test) {
return findIf(arrayMap, test) != null;
}
// Find the first value in an ArrayMap that matches the predicate in an optimized way.
private static <K, V> V findIf(ArrayMap<K, V> arrayMap, Predicate<V> test) {
for (int i = 0; i < arrayMap.size(); i++) {
if (test.test(arrayMap.valueAt(i))) {
return true;
final V value = arrayMap.valueAt(i);
if (test.test(value)) {
return value;
}
}
return false;
return null;
}
}

View File

@@ -2126,7 +2126,13 @@ public class InputManagerService extends IInputManager.Stub
public String getInputDeviceBluetoothAddress(int deviceId) {
super.getInputDeviceBluetoothAddress_enforcePermission();
return mNative.getBluetoothAddress(deviceId);
final String address = mNative.getBluetoothAddress(deviceId);
if (address == null) return null;
if (!BluetoothAdapter.checkBluetoothAddress(address)) {
throw new IllegalStateException("The Bluetooth address of input device " + deviceId
+ " should not be invalid: address=" + address);
}
return address;
}
@EnforcePermission(Manifest.permission.MONITOR_INPUT)

View File

@@ -16,6 +16,7 @@
package com.android.server.input
import android.bluetooth.BluetoothDevice
import android.content.Context
import android.content.ContextWrapper
import android.hardware.BatteryState.STATUS_CHARGING
@@ -33,6 +34,8 @@ import android.os.test.TestLooper
import android.platform.test.annotations.Presubmit
import android.view.InputDevice
import androidx.test.InstrumentationRegistry
import com.android.server.input.BatteryController.BluetoothBatteryManager
import com.android.server.input.BatteryController.BluetoothBatteryManager.BluetoothBatteryListener
import com.android.server.input.BatteryController.POLLING_PERIOD_MILLIS
import com.android.server.input.BatteryController.UEventManager
import com.android.server.input.BatteryController.UEventManager.UEventBatteryListener
@@ -52,6 +55,7 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.notNull
import org.mockito.Mock
import org.mockito.Mockito.anyInt
@@ -172,6 +176,8 @@ class BatteryControllerTests {
const val SECOND_DEVICE_ID = 11
const val USI_DEVICE_ID = 101
const val SECOND_USI_DEVICE_ID = 102
const val BT_DEVICE_ID = 100001
const val SECOND_BT_DEVICE_ID = 100002
const val TIMESTAMP = 123456789L
}
@@ -184,6 +190,8 @@ class BatteryControllerTests {
private lateinit var iInputManager: IInputManager
@Mock
private lateinit var uEventManager: UEventManager
@Mock
private lateinit var bluetoothBatteryManager: BluetoothBatteryManager
private lateinit var batteryController: BatteryController
private lateinit var context: Context
@@ -203,11 +211,13 @@ class BatteryControllerTests {
addInputDevice(DEVICE_ID)
addInputDevice(SECOND_DEVICE_ID)
batteryController = BatteryController(context, native, testLooper.looper, uEventManager)
batteryController = BatteryController(context, native, testLooper.looper, uEventManager,
bluetoothBatteryManager)
batteryController.systemRunning()
val listenerCaptor = ArgumentCaptor.forClass(IInputDevicesChangedListener::class.java)
verify(iInputManager).registerInputDevicesChangedListener(listenerCaptor.capture())
devicesChangedListener = listenerCaptor.value
testLooper.dispatchAll()
}
private fun notifyDeviceChanged(
@@ -230,7 +240,7 @@ class BatteryControllerTests {
private fun addInputDevice(
deviceId: Int,
hasBattery: Boolean = true,
supportsUsi: Boolean = false
supportsUsi: Boolean = false,
) {
deviceGenerationMap[deviceId] = 0
notifyDeviceChanged(deviceId, hasBattery, supportsUsi)
@@ -634,4 +644,125 @@ class BatteryControllerTests {
assertThat("battery state matches", batteryController.getBatteryState(USI_DEVICE_ID),
matchesState(USI_DEVICE_ID, status = STATUS_UNKNOWN, capacity = 0f))
}
@Test
fun testRegisterBluetoothListenerForMonitoredBluetoothDevices() {
`when`(iInputManager.getInputDeviceBluetoothAddress(BT_DEVICE_ID))
.thenReturn("AA:BB:CC:DD:EE:FF")
`when`(iInputManager.getInputDeviceBluetoothAddress(SECOND_BT_DEVICE_ID))
.thenReturn("11:22:33:44:55:66")
addInputDevice(BT_DEVICE_ID)
testLooper.dispatchNext()
addInputDevice(SECOND_BT_DEVICE_ID)
testLooper.dispatchNext()
// Ensure that a BT battery listener is not added when there are no monitored BT devices.
verify(bluetoothBatteryManager, never()).addListener(any())
val bluetoothListener = ArgumentCaptor.forClass(BluetoothBatteryListener::class.java)
val listener = createMockListener()
// The BT battery listener is added when the first BT input device is monitored.
batteryController.registerBatteryListener(BT_DEVICE_ID, listener, PID)
verify(bluetoothBatteryManager).addListener(bluetoothListener.capture())
// The BT listener is only added once for all BT devices.
batteryController.registerBatteryListener(SECOND_BT_DEVICE_ID, listener, PID)
verify(bluetoothBatteryManager, times(1)).addListener(any())
// The BT listener is only removed when there are no monitored BT devices.
batteryController.unregisterBatteryListener(BT_DEVICE_ID, listener, PID)
verify(bluetoothBatteryManager, never()).removeListener(any())
`when`(iInputManager.getInputDeviceBluetoothAddress(SECOND_BT_DEVICE_ID))
.thenReturn(null)
notifyDeviceChanged(SECOND_BT_DEVICE_ID)
testLooper.dispatchNext()
verify(bluetoothBatteryManager).removeListener(bluetoothListener.value)
}
@Test
fun testNotifiesBluetoothBatteryChanges() {
`when`(iInputManager.getInputDeviceBluetoothAddress(BT_DEVICE_ID))
.thenReturn("AA:BB:CC:DD:EE:FF")
`when`(bluetoothBatteryManager.getBatteryLevel(eq("AA:BB:CC:DD:EE:FF"))).thenReturn(21)
addInputDevice(BT_DEVICE_ID)
val bluetoothListener = ArgumentCaptor.forClass(BluetoothBatteryListener::class.java)
val listener = createMockListener()
batteryController.registerBatteryListener(BT_DEVICE_ID, listener, PID)
verify(bluetoothBatteryManager).addListener(bluetoothListener.capture())
listener.verifyNotified(BT_DEVICE_ID, capacity = 0.21f)
// When the state has not changed, the listener is not notified again.
bluetoothListener.value!!.onBluetoothBatteryChanged(TIMESTAMP, "AA:BB:CC:DD:EE:FF")
listener.verifyNotified(BT_DEVICE_ID, mode = times(1), capacity = 0.21f)
`when`(bluetoothBatteryManager.getBatteryLevel(eq("AA:BB:CC:DD:EE:FF"))).thenReturn(25)
bluetoothListener.value!!.onBluetoothBatteryChanged(TIMESTAMP, "AA:BB:CC:DD:EE:FF")
listener.verifyNotified(BT_DEVICE_ID, capacity = 0.25f)
}
@Test
fun testBluetoothBatteryIsPrioritized() {
`when`(native.getBatteryDevicePath(BT_DEVICE_ID)).thenReturn("/sys/dev/bt_device")
`when`(iInputManager.getInputDeviceBluetoothAddress(BT_DEVICE_ID))
.thenReturn("AA:BB:CC:DD:EE:FF")
`when`(bluetoothBatteryManager.getBatteryLevel(eq("AA:BB:CC:DD:EE:FF"))).thenReturn(21)
`when`(native.getBatteryCapacity(BT_DEVICE_ID)).thenReturn(98)
addInputDevice(BT_DEVICE_ID)
val bluetoothListener = ArgumentCaptor.forClass(BluetoothBatteryListener::class.java)
val listener = createMockListener()
val uEventListener = ArgumentCaptor.forClass(UEventBatteryListener::class.java)
// When the device is first monitored and both native and BT battery is available,
// the latter is used.
batteryController.registerBatteryListener(BT_DEVICE_ID, listener, PID)
verify(bluetoothBatteryManager).addListener(bluetoothListener.capture())
verify(uEventManager).addListener(uEventListener.capture(), any())
listener.verifyNotified(BT_DEVICE_ID, capacity = 0.21f)
assertThat("battery state matches", batteryController.getBatteryState(BT_DEVICE_ID),
matchesState(BT_DEVICE_ID, capacity = 0.21f))
// If only the native battery state changes the listener is not notified.
`when`(native.getBatteryCapacity(BT_DEVICE_ID)).thenReturn(97)
uEventListener.value!!.onBatteryUEvent(TIMESTAMP)
listener.verifyNotified(BT_DEVICE_ID, mode = times(1), capacity = 0.21f)
assertThat("battery state matches", batteryController.getBatteryState(BT_DEVICE_ID),
matchesState(BT_DEVICE_ID, capacity = 0.21f))
}
@Test
fun testFallBackToNativeBatteryStateWhenBluetoothStateInvalid() {
`when`(native.getBatteryDevicePath(BT_DEVICE_ID)).thenReturn("/sys/dev/bt_device")
`when`(iInputManager.getInputDeviceBluetoothAddress(BT_DEVICE_ID))
.thenReturn("AA:BB:CC:DD:EE:FF")
`when`(bluetoothBatteryManager.getBatteryLevel(eq("AA:BB:CC:DD:EE:FF"))).thenReturn(21)
`when`(native.getBatteryCapacity(BT_DEVICE_ID)).thenReturn(98)
addInputDevice(BT_DEVICE_ID)
val bluetoothListener = ArgumentCaptor.forClass(BluetoothBatteryListener::class.java)
val listener = createMockListener()
val uEventListener = ArgumentCaptor.forClass(UEventBatteryListener::class.java)
batteryController.registerBatteryListener(BT_DEVICE_ID, listener, PID)
verify(bluetoothBatteryManager).addListener(bluetoothListener.capture())
verify(uEventManager).addListener(uEventListener.capture(), any())
listener.verifyNotified(BT_DEVICE_ID, capacity = 0.21f)
// Fall back to the native state when BT is off.
`when`(bluetoothBatteryManager.getBatteryLevel(eq("AA:BB:CC:DD:EE:FF")))
.thenReturn(BluetoothDevice.BATTERY_LEVEL_BLUETOOTH_OFF)
bluetoothListener.value!!.onBluetoothBatteryChanged(TIMESTAMP, "AA:BB:CC:DD:EE:FF")
listener.verifyNotified(BT_DEVICE_ID, capacity = 0.98f)
`when`(bluetoothBatteryManager.getBatteryLevel(eq("AA:BB:CC:DD:EE:FF"))).thenReturn(22)
bluetoothListener.value!!.onBluetoothBatteryChanged(TIMESTAMP, "AA:BB:CC:DD:EE:FF")
verify(bluetoothBatteryManager).addListener(bluetoothListener.capture())
listener.verifyNotified(BT_DEVICE_ID, capacity = 0.22f)
// Fall back to the native state when BT battery is unknown.
`when`(bluetoothBatteryManager.getBatteryLevel(eq("AA:BB:CC:DD:EE:FF")))
.thenReturn(BluetoothDevice.BATTERY_LEVEL_UNKNOWN)
bluetoothListener.value!!.onBluetoothBatteryChanged(TIMESTAMP, "AA:BB:CC:DD:EE:FF")
listener.verifyNotified(BT_DEVICE_ID, mode = times(2), capacity = 0.98f)
}
}