diff options
21 files changed, 2264 insertions, 26 deletions
diff --git a/android/app/AndroidManifest.xml b/android/app/AndroidManifest.xml index e55f2ee4a9..011ed6609e 100644 --- a/android/app/AndroidManifest.xml +++ b/android/app/AndroidManifest.xml @@ -497,5 +497,14 @@ <action android:name="android.telecom.InCallService"/> </intent-filter> </service> + <service + android:process="@string/process" + android:name=".bas.BatteryService" + android:enabled="@bool/profile_supported_battery" + android:exported = "true"> + <intent-filter> + <action android:name="android.bluetooth.IBluetoothBattery" /> + </intent-filter> + </service> </application> </manifest> diff --git a/android/app/res/values/config.xml b/android/app/res/values/config.xml index ea18d15133..092860759f 100644 --- a/android/app/res/values/config.xml +++ b/android/app/res/values/config.xml @@ -40,6 +40,7 @@ <bool name="profile_supported_le_call_control">true</bool> <bool name="profile_supported_hap_client">true</bool> <bool name="profile_supported_bass_client">false</bool> + <bool name="profile_supported_battery">true</bool> <!-- If true, we will require location to be enabled on the device to fire Bluetooth LE scan result callbacks in addition to having one diff --git a/android/app/src/com/android/bluetooth/bas/BatteryService.java b/android/app/src/com/android/bluetooth/bas/BatteryService.java new file mode 100644 index 0000000000..9b1df394c1 --- /dev/null +++ b/android/app/src/com/android/bluetooth/bas/BatteryService.java @@ -0,0 +1,652 @@ +/* + * Copyright 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.bluetooth.bas; + +import static android.Manifest.permission.BLUETOOTH_CONNECT; + +import static com.android.bluetooth.Utils.enforceBluetoothPrivilegedPermission; + +import android.annotation.RequiresPermission; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothProfile; +import android.bluetooth.BluetoothUuid; +import android.bluetooth.IBluetoothBattery; +import android.content.AttributionSource; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.HandlerThread; +import android.os.ParcelUuid; +import android.util.Log; + +import com.android.bluetooth.Utils; +import com.android.bluetooth.btservice.AdapterService; +import com.android.bluetooth.btservice.ProfileService; +import com.android.internal.annotations.VisibleForTesting; +import com.android.modules.utils.SynchronousResultReceiver; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * A profile service that connects to the Battery service (BAS) of BLE devices + */ +public class BatteryService extends ProfileService { + private static final boolean DBG = false; + private static final String TAG = "BatteryService"; + + // Timeout for state machine thread join, to prevent potential ANR. + private static final int SM_THREAD_JOIN_TIMEOUT_MS = 1_000; + + private static final int MAX_BATTERY_STATE_MACHINES = 10; + private static BatteryService sBatteryService; + + private AdapterService mAdapterService; + private HandlerThread mStateMachinesThread; + private final Map<BluetoothDevice, BatteryStateMachine> mStateMachines = new HashMap<>(); + + private BroadcastReceiver mBondStateChangedReceiver; + + @Override + protected IProfileServiceBinder initBinder() { + return new BluetoothBatteryBinder(this); + } + + @Override + protected void create() { + if (DBG) { + Log.d(TAG, "create()"); + } + } + + @Override + protected boolean start() { + if (DBG) { + Log.d(TAG, "start()"); + } + if (sBatteryService != null) { + throw new IllegalStateException("start() called twice"); + } + + mAdapterService = Objects.requireNonNull(AdapterService.getAdapterService(), + "AdapterService cannot be null when BatteryService starts"); + + mStateMachines.clear(); + mStateMachinesThread = new HandlerThread("BatteryService.StateMachines"); + mStateMachinesThread.start(); + + // Setup broadcast receivers + IntentFilter filter = new IntentFilter(); + filter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED); + mBondStateChangedReceiver = new BondStateChangedReceiver(); + registerReceiver(mBondStateChangedReceiver, filter); + + setBatteryService(this); + + return true; + } + + @Override + protected boolean stop() { + if (DBG) { + Log.d(TAG, "stop()"); + } + if (sBatteryService == null) { + Log.w(TAG, "stop() called before start()"); + return true; + } + + setBatteryService(null); + // Unregister broadcast receivers + unregisterReceiver(mBondStateChangedReceiver); + mBondStateChangedReceiver = null; + + // Destroy state machines and stop handler thread + synchronized (mStateMachines) { + for (BatteryStateMachine sm : mStateMachines.values()) { + sm.doQuit(); + sm.cleanup(); + } + mStateMachines.clear(); + } + + + if (mStateMachinesThread != null) { + try { + mStateMachinesThread.quitSafely(); + mStateMachinesThread.join(SM_THREAD_JOIN_TIMEOUT_MS); + mStateMachinesThread = null; + } catch (InterruptedException e) { + // Do not rethrow as we are shutting down anyway + } + } + + mAdapterService = null; + + return true; + } + + @Override + protected void cleanup() { + if (DBG) { + Log.d(TAG, "cleanup()"); + } + } + + /** + * Gets the BatteryService instance + */ + public static synchronized BatteryService getBatteryService() { + if (sBatteryService == null) { + Log.w(TAG, "getBatteryService(): service is NULL"); + return null; + } + + if (!sBatteryService.isAvailable()) { + Log.w(TAG, "getBatteryService(): service is not available"); + return null; + } + return sBatteryService; + } + + private static synchronized void setBatteryService(BatteryService instance) { + if (DBG) { + Log.d(TAG, "setBatteryService(): set to: " + instance); + } + sBatteryService = instance; + } + + /** + * Connects to the battery service of the given device. + */ + @RequiresPermission(android.Manifest.permission.BLUETOOTH_PRIVILEGED) + public boolean connect(BluetoothDevice device) { + enforceCallingOrSelfPermission(BLUETOOTH_PRIVILEGED, + "Need BLUETOOTH_PRIVILEGED permission"); + if (DBG) { + Log.d(TAG, "connect(): " + device); + } + if (device == null) { + Log.w(TAG, "Ignore connecting to null device"); + return false; + } + + if (getConnectionPolicy(device) == BluetoothProfile.CONNECTION_POLICY_FORBIDDEN) { + Log.w(TAG, "Cannot connect to " + device + " : policy forbidden"); + return false; + } + ParcelUuid[] featureUuids = mAdapterService.getRemoteUuids(device); + if (!Utils.arrayContains(featureUuids, BluetoothUuid.BATTERY)) { + Log.e(TAG, "Cannot connect to " + device + + " : Remote does not have Battery UUID"); + return false; + } + + synchronized (mStateMachines) { + BatteryStateMachine sm = getOrCreateStateMachine(device); + if (sm == null) { + Log.e(TAG, "Cannot connect to " + device + " : no state machine"); + } + sm.sendMessage(BatteryStateMachine.CONNECT); + } + + return true; + } + + /** + * Disconnects from the battery service of the given device. + */ + @RequiresPermission(android.Manifest.permission.BLUETOOTH_PRIVILEGED) + public boolean disconnect(BluetoothDevice device) { + enforceCallingOrSelfPermission(BLUETOOTH_PRIVILEGED, + "Need BLUETOOTH_PRIVILEGED permission"); + if (DBG) { + Log.d(TAG, "disconnect(): " + device); + } + if (device == null) { + Log.w(TAG, "Ignore disconnecting to null device"); + return false; + } + synchronized (mStateMachines) { + BatteryStateMachine sm = getOrCreateStateMachine(device); + if (sm != null) { + sm.sendMessage(BatteryStateMachine.DISCONNECT); + } + } + + return true; + } + + /** + * Gets devices that battery service is connected. + * @return + */ + @RequiresPermission(android.Manifest.permission.BLUETOOTH_PRIVILEGED) + public List<BluetoothDevice> getConnectedDevices() { + enforceCallingOrSelfPermission(BLUETOOTH_PRIVILEGED, + "Need BLUETOOTH_PRIVILEGED permission"); + synchronized (mStateMachines) { + List<BluetoothDevice> devices = new ArrayList<>(); + for (BatteryStateMachine sm : mStateMachines.values()) { + if (sm.isConnected()) { + devices.add(sm.getDevice()); + } + } + return devices; + } + } + + /** + * Check whether it can connect to a peer device. + * The check considers a number of factors during the evaluation. + * + * @param device the peer device to connect to + * @return true if connection is allowed, otherwise false + */ + @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) + public boolean canConnect(BluetoothDevice device) { + // Check connectionPolicy and accept or reject the connection. + int connectionPolicy = getConnectionPolicy(device); + int bondState = mAdapterService.getBondState(device); + // Allow this connection only if the device is bonded. Any attempt to connect while + // bonding would potentially lead to an unauthorized connection. + if (bondState != BluetoothDevice.BOND_BONDED) { + Log.w(TAG, "canConnect: return false, bondState=" + bondState); + return false; + } else if (connectionPolicy != BluetoothProfile.CONNECTION_POLICY_UNKNOWN + && connectionPolicy != BluetoothProfile.CONNECTION_POLICY_ALLOWED) { + // Otherwise, reject the connection if connectionPolicy is not valid. + Log.w(TAG, "canConnect: return false, connectionPolicy=" + connectionPolicy); + return false; + } + return true; + } + + /** + * Called when the connection state of a state machine is changed + */ + @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) + public void handleConnectionStateChanged(BatteryStateMachine sm, + int fromState, int toState) { + BluetoothDevice device = sm.getDevice(); + if ((sm == null) || (fromState == toState)) { + Log.e(TAG, "connectionStateChanged: unexpected invocation. device=" + device + + " fromState=" + fromState + " toState=" + toState); + return; + } + + // Check if the device is disconnected - if unbonded, remove the state machine + if (toState == BluetoothProfile.STATE_DISCONNECTED) { + int bondState = mAdapterService.getBondState(device); + if (bondState == BluetoothDevice.BOND_NONE) { + if (DBG) { + Log.d(TAG, device + " is unbonded. Remove state machine"); + } + removeStateMachine(device); + } + } + } + + @RequiresPermission(android.Manifest.permission.BLUETOOTH_PRIVILEGED) + List<BluetoothDevice> getDevicesMatchingConnectionStates(int[] states) { + enforceCallingOrSelfPermission(BLUETOOTH_PRIVILEGED, + "Need BLUETOOTH_PRIVILEGED permission"); + ArrayList<BluetoothDevice> devices = new ArrayList<>(); + if (states == null) { + return devices; + } + final BluetoothDevice[] bondedDevices = mAdapterService.getBondedDevices(); + if (bondedDevices == null) { + return devices; + } + synchronized (mStateMachines) { + for (BluetoothDevice device : bondedDevices) { + int connectionState = BluetoothProfile.STATE_DISCONNECTED; + BatteryStateMachine sm = mStateMachines.get(device); + if (sm != null) { + connectionState = sm.getConnectionState(); + } + for (int state : states) { + if (connectionState == state) { + devices.add(device); + break; + } + } + } + return devices; + } + } + + /** + * Get the list of devices that have state machines. + * + * @return the list of devices that have state machines + */ + @VisibleForTesting + List<BluetoothDevice> getDevices() { + List<BluetoothDevice> devices = new ArrayList<>(); + synchronized (mStateMachines) { + for (BatteryStateMachine sm : mStateMachines.values()) { + devices.add(sm.getDevice()); + } + return devices; + } + } + + /** + * Gets the connection state of the given device's battery service + */ + @RequiresPermission(android.Manifest.permission.BLUETOOTH_CONNECT) + public int getConnectionState(BluetoothDevice device) { + enforceCallingOrSelfPermission(BLUETOOTH_CONNECT, + "Need BLUETOOTH_CONNECT permission"); + synchronized (mStateMachines) { + BatteryStateMachine sm = mStateMachines.get(device); + if (sm == null) { + return BluetoothProfile.STATE_DISCONNECTED; + } + return sm.getConnectionState(); + } + } + + /** + * Set connection policy of the profile and connects it if connectionPolicy is + * {@link BluetoothProfile#CONNECTION_POLICY_ALLOWED} or disconnects if connectionPolicy is + * {@link BluetoothProfile#CONNECTION_POLICY_FORBIDDEN} + * + * <p> The device should already be paired. + * Connection policy can be one of: + * {@link BluetoothProfile#CONNECTION_POLICY_ALLOWED}, + * {@link BluetoothProfile#CONNECTION_POLICY_FORBIDDEN}, + * {@link BluetoothProfile#CONNECTION_POLICY_UNKNOWN} + * + * @param device the remote device + * @param connectionPolicy is the connection policy to set to for this profile + * @return true on success, otherwise false + */ + @RequiresPermission(android.Manifest.permission.BLUETOOTH_PRIVILEGED) + public boolean setConnectionPolicy(BluetoothDevice device, int connectionPolicy) { + enforceCallingOrSelfPermission(BLUETOOTH_PRIVILEGED, + "Need BLUETOOTH_PRIVILEGED permission"); + if (DBG) { + Log.d(TAG, "Saved connectionPolicy " + device + " = " + connectionPolicy); + } + mAdapterService.getDatabase() + .setProfileConnectionPolicy(device, BluetoothProfile.BATTERY, + connectionPolicy); + if (connectionPolicy == BluetoothProfile.CONNECTION_POLICY_ALLOWED) { + connect(device); + } else if (connectionPolicy == BluetoothProfile.CONNECTION_POLICY_FORBIDDEN) { + disconnect(device); + } + return true; + } + + /** + * Gets the connection policy for the battery service of the given device. + */ + @RequiresPermission(android.Manifest.permission.BLUETOOTH_PRIVILEGED) + public int getConnectionPolicy(BluetoothDevice device) { + enforceCallingOrSelfPermission(BLUETOOTH_PRIVILEGED, + "Need BLUETOOTH_PRIVILEGED permission"); + return mAdapterService.getDatabase() + .getProfileConnectionPolicy(device, BluetoothProfile.BATTERY); + } + + void handleBatteryChanged(BluetoothDevice device, int batteryLevel) { + mAdapterService.setBatteryLevel(device, batteryLevel); + } + + private BatteryStateMachine getOrCreateStateMachine(BluetoothDevice device) { + if (device == null) { + Log.e(TAG, "getOrCreateGatt failed: device cannot be null"); + return null; + } + synchronized (mStateMachines) { + BatteryStateMachine sm = mStateMachines.get(device); + if (sm != null) { + return sm; + } + // Limit the maximum number of state machines to avoid DoS attack + if (mStateMachines.size() >= MAX_BATTERY_STATE_MACHINES) { + Log.e(TAG, "Maximum number of Battery state machines reached: " + + MAX_BATTERY_STATE_MACHINES); + return null; + } + if (DBG) { + Log.d(TAG, "Creating a new state machine for " + device); + } + sm = BatteryStateMachine.make(device, this, mStateMachinesThread.getLooper()); + mStateMachines.put(device, sm); + return sm; + } + } + + // Remove state machine if the bonding for a device is removed + private class BondStateChangedReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (!BluetoothDevice.ACTION_BOND_STATE_CHANGED.equals(intent.getAction())) { + return; + } + int state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, + BluetoothDevice.ERROR); + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + Objects.requireNonNull(device, "ACTION_BOND_STATE_CHANGED with no EXTRA_DEVICE"); + handleBondStateChanged(device, state); + } + } + + /** + * Process a change in the bonding state for a device. + * + * @param device the device whose bonding state has changed + * @param bondState the new bond state for the device. Possible values are: + * {@link BluetoothDevice#BOND_NONE}, + * {@link BluetoothDevice#BOND_BONDING}, + * {@link BluetoothDevice#BOND_BONDED}, + * {@link BluetoothDevice#ERROR}. + */ + @VisibleForTesting + void handleBondStateChanged(BluetoothDevice device, int bondState) { + if (DBG) { + Log.d(TAG, "Bond state changed for device: " + device + " state: " + bondState); + } + // Remove state machine if the bonding for a device is removed + if (bondState != BluetoothDevice.BOND_NONE) { + return; + } + + synchronized (mStateMachines) { + BatteryStateMachine sm = mStateMachines.get(device); + if (sm == null) { + return; + } + if (sm.getConnectionState() != BluetoothProfile.STATE_DISCONNECTED) { + return; + } + removeStateMachine(device); + } + } + + private void removeStateMachine(BluetoothDevice device) { + if (device == null) { + Log.e(TAG, "removeStateMachine failed: device cannot be null"); + return; + } + synchronized (mStateMachines) { + BatteryStateMachine sm = mStateMachines.remove(device); + if (sm == null) { + Log.w(TAG, "removeStateMachine: device " + device + + " does not have a state machine"); + return; + } + Log.i(TAG, "removeGatt: removing bluetooth gatt for device: " + device); + sm.doQuit(); + sm.cleanup(); + } + } + + /** + * Binder object: must be a static class or memory leak may occur + */ + @VisibleForTesting + static class BluetoothBatteryBinder extends IBluetoothBattery.Stub + implements IProfileServiceBinder { + private final WeakReference<BatteryService> mServiceRef; + + @RequiresPermission(android.Manifest.permission.BLUETOOTH_CONNECT) + private BatteryService getService(AttributionSource source) { + BatteryService service = mServiceRef.get(); + + if (!Utils.checkCallerIsSystemOrActiveUser(TAG) + || !Utils.checkServiceAvailable(service, TAG) + || !Utils.checkConnectPermissionForDataDelivery(service, source, TAG)) { + return null; + } + return service; + } + + BluetoothBatteryBinder(BatteryService svc) { + mServiceRef = new WeakReference<>(svc); + } + + @Override + public void cleanup() { + mServiceRef.clear(); + } + + @Override + public void connect(BluetoothDevice device, AttributionSource source, + SynchronousResultReceiver receiver) { + try { + BatteryService service = getService(source); + boolean result = false; + if (service != null) { + result = service.connect(device); + } + receiver.send(result); + } catch (RuntimeException e) { + receiver.propagateException(e); + } + } + + @Override + public void disconnect(BluetoothDevice device, AttributionSource source, + SynchronousResultReceiver receiver) { + try { + BatteryService service = getService(source); + boolean result = false; + if (service != null) { + result = service.disconnect(device); + } + receiver.send(result); + } catch (RuntimeException e) { + receiver.propagateException(e); + } + } + + @Override + public void getConnectedDevices(AttributionSource source, + SynchronousResultReceiver receiver) { + try { + BatteryService service = getService(source); + List<BluetoothDevice> result = new ArrayList<>(); + if (service != null) { + enforceBluetoothPrivilegedPermission(service); + result = service.getConnectedDevices(); + } + receiver.send(result); + } catch (RuntimeException e) { + receiver.propagateException(e); + } + } + + @Override + public void getDevicesMatchingConnectionStates(int[] states, + AttributionSource source, SynchronousResultReceiver receiver) { + try { + BatteryService service = getService(source); + List<BluetoothDevice> result = new ArrayList<>(); + if (service != null) { + result = service.getDevicesMatchingConnectionStates(states); + } + receiver.send(result); + } catch (RuntimeException e) { + receiver.propagateException(e); + } + } + + @Override + public void getConnectionState(BluetoothDevice device, AttributionSource source, + SynchronousResultReceiver receiver) { + try { + BatteryService service = getService(source); + int result = BluetoothProfile.STATE_DISCONNECTED; + if (service != null) { + result = service.getConnectionState(device); + } + receiver.send(result); + } catch (RuntimeException e) { + receiver.propagateException(e); + } + } + + @Override + public void setConnectionPolicy(BluetoothDevice device, int connectionPolicy, + AttributionSource source, SynchronousResultReceiver receiver) { + try { + BatteryService service = getService(source); + boolean result = false; + if (service != null) { + result = service.setConnectionPolicy(device, connectionPolicy); + } + receiver.send(result); + } catch (RuntimeException e) { + receiver.propagateException(e); + } + } + + @Override + public void getConnectionPolicy(BluetoothDevice device, AttributionSource source, + SynchronousResultReceiver receiver) { + try { + BatteryService service = getService(source); + int result = BluetoothProfile.CONNECTION_POLICY_UNKNOWN; + if (service != null) { + result = service.getConnectionPolicy(device); + } + receiver.send(result); + } catch (RuntimeException e) { + receiver.propagateException(e); + } + } + } + + @Override + public void dump(StringBuilder sb) { + super.dump(sb); + for (BatteryStateMachine sm : mStateMachines.values()) { + sm.dump(sb); + } + } +} diff --git a/android/app/src/com/android/bluetooth/bas/BatteryStateMachine.java b/android/app/src/com/android/bluetooth/bas/BatteryStateMachine.java new file mode 100644 index 0000000000..afc6c5a195 --- /dev/null +++ b/android/app/src/com/android/bluetooth/bas/BatteryStateMachine.java @@ -0,0 +1,581 @@ +/* + * Copyright 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.bluetooth.bas; + +import static android.bluetooth.BluetoothDevice.PHY_LE_1M_MASK; +import static android.bluetooth.BluetoothDevice.PHY_LE_2M_MASK; +import static android.bluetooth.BluetoothDevice.TRANSPORT_AUTO; + +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCallback; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattService; +import android.bluetooth.BluetoothProfile; +import android.os.Looper; +import android.os.Message; +import android.util.Log; + +import com.android.bluetooth.btservice.ProfileService; +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.util.State; +import com.android.internal.util.StateMachine; + +import java.io.FileDescriptor; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.lang.ref.WeakReference; +import java.util.Scanner; +import java.util.UUID; + +/** + * It manages Battery service of a BLE device + */ +public class BatteryStateMachine extends StateMachine { + private static final boolean DBG = false; + private static final String TAG = "BatteryStateMachine"; + + static final UUID GATT_BATTERY_SERVICE_UUID = + UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb"); + + static final UUID GATT_BATTERY_LEVEL_CHARACTERISTIC_UUID = + UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb"); + + static final int CONNECT = 1; + static final int DISCONNECT = 2; + static final int CONNECTION_STATE_CHANGED = 3; + private static final int CONNECT_TIMEOUT = 201; + + // NOTE: the value is not "final" - it is modified in the unit tests + @VisibleForTesting + static int sConnectTimeoutMs = 30000; // 30s + + private Disconnected mDisconnected; + private Connecting mConnecting; + private Connected mConnected; + private Disconnecting mDisconnecting; + private int mLastConnectionState = BluetoothProfile.STATE_DISCONNECTED; + + WeakReference<BatteryService> mServiceRef; + + BluetoothGatt mBluetoothGatt; + BluetoothGattCallback mGattCallback; + final BluetoothDevice mDevice; + + BatteryStateMachine(BluetoothDevice device, BatteryService service, Looper looper) { + super(TAG, looper); + mDevice = device; + mServiceRef = new WeakReference<>(service); + + mDisconnected = new Disconnected(); + mConnecting = new Connecting(); + mConnected = new Connected(); + mDisconnecting = new Disconnecting(); + + addState(mDisconnected); + addState(mConnecting); + addState(mDisconnecting); + addState(mConnected); + + setInitialState(mDisconnected); + } + + static BatteryStateMachine make(BluetoothDevice device, BatteryService service, Looper looper) { + Log.i(TAG, "make for device " + device); + BatteryStateMachine sm = new BatteryStateMachine(device, service, looper); + sm.start(); + return sm; + } + + /** + * Quits the state machine + */ + public void doQuit() { + log("doQuit for device " + mDevice); + quitNow(); + } + + /** + * Cleans up the resources the state machine held. + */ + public void cleanup() { + log("cleanup for device " + mDevice); + if (mBluetoothGatt != null) { + mBluetoothGatt.close(); + mBluetoothGatt = null; + mGattCallback = null; + } + } + + BluetoothDevice getDevice() { + return mDevice; + } + + synchronized boolean isConnected() { + return getCurrentState() == mConnected; + } + + private static String messageWhatToString(int what) { + switch (what) { + case CONNECT: + return "CONNECT"; + case DISCONNECT: + return "DISCONNECT"; + case CONNECTION_STATE_CHANGED: + return "CONNECTION_STATE_CHANGED"; + case CONNECT_TIMEOUT: + return "CONNECT_TIMEOUT"; + default: + break; + } + return Integer.toString(what); + } + + private static String profileStateToString(int state) { + switch (state) { + case BluetoothProfile.STATE_DISCONNECTED: + return "DISCONNECTED"; + case BluetoothProfile.STATE_CONNECTING: + return "CONNECTING"; + case BluetoothProfile.STATE_CONNECTED: + return "CONNECTED"; + case BluetoothProfile.STATE_DISCONNECTING: + return "DISCONNECTING"; + default: + break; + } + return Integer.toString(state); + } + + /** + * Dumps battery state machine state. + */ + public void dump(StringBuilder sb) { + ProfileService.println(sb, "mDevice: " + mDevice); + ProfileService.println(sb, " StateMachine: " + this); + ProfileService.println(sb, " BluetoothGatt: " + mBluetoothGatt); + // Dump the state machine logs + StringWriter stringWriter = new StringWriter(); + PrintWriter printWriter = new PrintWriter(stringWriter); + super.dump(new FileDescriptor(), printWriter, new String[]{}); + printWriter.flush(); + stringWriter.flush(); + ProfileService.println(sb, " StateMachineLog:"); + Scanner scanner = new Scanner(stringWriter.toString()); + while (scanner.hasNextLine()) { + String line = scanner.nextLine(); + ProfileService.println(sb, " " + line); + } + scanner.close(); + } + + @BluetoothProfile.BtProfileState + int getConnectionState() { + String currentState = getCurrentState().getName(); + switch (currentState) { + case "Disconnected": + return BluetoothProfile.STATE_DISCONNECTED; + case "Connecting": + return BluetoothProfile.STATE_CONNECTING; + case "Connected": + return BluetoothProfile.STATE_CONNECTED; + case "Disconnecting": + return BluetoothProfile.STATE_DISCONNECTING; + default: + Log.e(TAG, "Bad currentState: " + currentState); + return BluetoothProfile.STATE_DISCONNECTED; + } + } + + void dispatchConnectionStateChanged(int fromState, int toState) { + log("Connection state " + mDevice + ": " + profileStateToString(fromState) + + "->" + profileStateToString(toState)); + + BatteryService service = mServiceRef.get(); + if (service != null) { + service.handleConnectionStateChanged(this, fromState, toState); + } + } + + /** + * Connects to the GATT server of the device. + * + * @return {@code true} if it successfully connects to the GATT server. + */ + @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) + public boolean connectGatt() { + BatteryService service = mServiceRef.get(); + if (service == null) { + return false; + } + + if (mBluetoothGatt != null) { + mBluetoothGatt.connect(); + } else { + mGattCallback = new GattCallback(); + mBluetoothGatt = mDevice.connectGatt(service, /*autoConnect=*/true, + mGattCallback, TRANSPORT_AUTO, /*opportunistic=*/true, + PHY_LE_1M_MASK | PHY_LE_2M_MASK, getHandler()); + } + return mBluetoothGatt != null; + } + + @Override + protected void log(String msg) { + if (DBG) { + super.log(msg); + } + } + + static void log(String tag, String msg) { + if (DBG) { + Log.d(tag, msg); + } + } + + @VisibleForTesting + class Disconnected extends State { + private static final String TAG = "BASM_Disconnected"; + + @Override + public void enter() { + log(TAG, "Enter (" + mDevice + "): " + messageWhatToString( + getCurrentMessage().what)); + + if (mLastConnectionState != BluetoothProfile.STATE_DISCONNECTED) { + // Don't broadcast during startup + dispatchConnectionStateChanged(mLastConnectionState, + BluetoothProfile.STATE_DISCONNECTED); + } + } + + @Override + public void exit() { + log(TAG, "Exit (" + mDevice + "): " + messageWhatToString( + getCurrentMessage().what)); + mLastConnectionState = BluetoothProfile.STATE_DISCONNECTED; + } + + @Override + public boolean processMessage(Message message) { + log(TAG, "Process message(" + mDevice + "): " + messageWhatToString( + message.what)); + + BatteryService service = mServiceRef.get(); + switch (message.what) { + case CONNECT: + log(TAG, "Connecting to " + mDevice); + if (service != null && service.canConnect(mDevice)) { + if (connectGatt()) { + transitionTo(mConnecting); + } else { + Log.w(TAG, "Battery connecting request rejected due to " + + "GATT connection rejection: " + mDevice); + } + } else { + // Reject the request and stay in Disconnected state + Log.w(TAG, "Battery connecting request rejected: " + + mDevice); + } + break; + case DISCONNECT: + Log.w(TAG, "DISCONNECT ignored: " + mDevice); + break; + case CONNECTION_STATE_CHANGED: + processConnectionEvent(message.arg1); + break; + default: + return NOT_HANDLED; + } + return HANDLED; + } + + // in Disconnected state + private void processConnectionEvent(int state) { + switch (state) { + case BluetoothGatt.STATE_DISCONNECTED: + Log.w(TAG, "Ignore Battery DISCONNECTED event: " + mDevice); + break; + default: + Log.e(TAG, "Incorrect state: " + state + " device: " + mDevice); + break; + } + } + } + + @VisibleForTesting + class Connecting extends State { + private static final String TAG = "BASM_Connecting"; + @Override + public void enter() { + log(TAG, "Enter (" + mDevice + "): " + + messageWhatToString(getCurrentMessage().what)); + sendMessageDelayed(CONNECT_TIMEOUT, sConnectTimeoutMs); + dispatchConnectionStateChanged(mLastConnectionState, BluetoothProfile.STATE_CONNECTING); + } + + @Override + public void exit() { + log(TAG, "Exit (" + mDevice + "): " + + messageWhatToString(getCurrentMessage().what)); + mLastConnectionState = BluetoothProfile.STATE_CONNECTING; + removeMessages(CONNECT_TIMEOUT); + } + + @Override + public boolean processMessage(Message message) { + log(TAG, "process message(" + mDevice + "): " + + messageWhatToString(message.what)); + + switch (message.what) { + case CONNECT: + Log.w(TAG, "CONNECT ignored: " + mDevice); + break; + case CONNECT_TIMEOUT: + Log.w(TAG, "Connection timeout: " + mDevice); + // fall through + case DISCONNECT: + log(TAG, "Connection canceled to " + mDevice); + if (mBluetoothGatt != null) { + mBluetoothGatt.disconnect(); + transitionTo(mDisconnecting); + } else { + transitionTo(mDisconnected); + } + break; + case CONNECTION_STATE_CHANGED: + processConnectionEvent(message.arg1); + break; + default: + return NOT_HANDLED; + } + return HANDLED; + } + + // in Connecting state + private void processConnectionEvent(int state) { + switch (state) { + case BluetoothGatt.STATE_DISCONNECTED: + Log.w(TAG, "Device disconnected: " + mDevice); + transitionTo(mDisconnected); + break; + case BluetoothGatt.STATE_CONNECTED: + transitionTo(mConnected); + break; + default: + Log.e(TAG, "Incorrect state: " + state); + break; + } + } + } + + @VisibleForTesting + class Disconnecting extends State { + private static final String TAG = "BASM_Disconnecting"; + @Override + public void enter() { + log(TAG, "Enter (" + mDevice + "): " + + messageWhatToString(getCurrentMessage().what)); + sendMessageDelayed(CONNECT_TIMEOUT, sConnectTimeoutMs); + dispatchConnectionStateChanged(mLastConnectionState, + BluetoothProfile.STATE_DISCONNECTING); + } + + @Override + public void exit() { + log(TAG, "Exit (" + mDevice + "): " + + messageWhatToString(getCurrentMessage().what)); + mLastConnectionState = BluetoothProfile.STATE_DISCONNECTING; + removeMessages(CONNECT_TIMEOUT); + } + + @Override + public boolean processMessage(Message message) { + log(TAG, "Process message(" + mDevice + "): " + + messageWhatToString(message.what)); + + switch (message.what) { + //TODO: Check if connect while disconnecting is okay. + // It is related to CONNECT_TIMEOUT as well. + case CONNECT: + Log.w(TAG, "CONNECT ignored: " + mDevice); + break; + case DISCONNECT: + Log.w(TAG, "DISCONNECT ignored: " + mDevice); + break; + case CONNECT_TIMEOUT: + Log.w(TAG, "Connection timeout: " + mDevice); + transitionTo(mDisconnected); + break; + case CONNECTION_STATE_CHANGED: + processConnectionEvent(message.arg1); + break; + default: + return NOT_HANDLED; + } + return HANDLED; + } + + // in Disconnecting state + private void processConnectionEvent(int state) { + switch (state) { + case BluetoothGatt.STATE_DISCONNECTED: + Log.i(TAG, "Disconnected: " + mDevice); + transitionTo(mDisconnected); + break; + case BluetoothGatt.STATE_CONNECTED: { + // Reject the connection and stay in Disconnecting state + Log.w(TAG, "Incoming Battery connected request rejected: " + + mDevice); + if (mBluetoothGatt != null) { + mBluetoothGatt.disconnect(); + } else { + transitionTo(mDisconnected); + } + break; + } + default: + Log.e(TAG, "Incorrect state: " + state); + break; + } + } + } + + @VisibleForTesting + class Connected extends State { + private static final String TAG = "BASM_Connected"; + @Override + public void enter() { + log(TAG, "Enter (" + mDevice + "): " + + messageWhatToString(getCurrentMessage().what)); + dispatchConnectionStateChanged(mLastConnectionState, BluetoothProfile.STATE_CONNECTED); + + if (mBluetoothGatt != null) { + mBluetoothGatt.discoverServices(); + } + } + + @Override + public void exit() { + log(TAG, "Exit (" + mDevice + "): " + + messageWhatToString(getCurrentMessage().what)); + mLastConnectionState = BluetoothProfile.STATE_CONNECTED; + } + + @Override + public boolean processMessage(Message message) { + log(TAG, "Process message(" + mDevice + "): " + + messageWhatToString(message.what)); + + switch (message.what) { + case CONNECT: + Log.w(TAG, "CONNECT ignored: " + mDevice); + break; + case DISCONNECT: + log(TAG, "Disconnecting from " + mDevice); + if (mBluetoothGatt != null) { + mBluetoothGatt.disconnect(); + transitionTo(mDisconnecting); + } else { + transitionTo(mDisconnected); + } + break; + case CONNECTION_STATE_CHANGED: + processConnectionEvent(message.arg1); + break; + default: + return NOT_HANDLED; + } + return HANDLED; + } + + // in Connected state + private void processConnectionEvent(int state) { + switch (state) { + case BluetoothGatt.STATE_DISCONNECTED: + Log.i(TAG, "Disconnected from " + mDevice); + transitionTo(mDisconnected); + break; + case BluetoothGatt.STATE_CONNECTED: + Log.w(TAG, "Ignore CONNECTED event: " + mDevice); + break; + default: + Log.e(TAG, "Connection State Device: " + mDevice + " bad state: " + state); + break; + } + } + } + + final class GattCallback extends BluetoothGattCallback { + @Override + public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { + sendMessage(CONNECTION_STATE_CHANGED, newState); + } + + @Override + public void onServicesDiscovered(BluetoothGatt gatt, int status) { + if (status != BluetoothGatt.GATT_SUCCESS) { + Log.e(TAG, "No gatt service"); + return; + } + + final BluetoothGattService batteryService = gatt.getService(GATT_BATTERY_SERVICE_UUID); + if (batteryService == null) { + Log.e(TAG, "No battery service"); + return; + } + + final BluetoothGattCharacteristic batteryLevel = + batteryService.getCharacteristic(GATT_BATTERY_LEVEL_CHARACTERISTIC_UUID); + if (batteryLevel == null) { + Log.e(TAG, "No battery level characteristic"); + return; + } + + gatt.setCharacteristicNotification(batteryLevel, /*enable=*/true); + gatt.readCharacteristic(batteryLevel); + } + + @Override + public void onCharacteristicChanged(BluetoothGatt gatt, + BluetoothGattCharacteristic characteristic, byte[] value) { + if (GATT_BATTERY_LEVEL_CHARACTERISTIC_UUID.equals(characteristic.getUuid())) { + updateBatteryLevel(value); + } + } + + @Override + public void onCharacteristicRead(BluetoothGatt gatt, + BluetoothGattCharacteristic characteristic, byte[] value, int status) { + if (status != BluetoothGatt.GATT_SUCCESS) { + Log.e(TAG, "Read characteristic failure on " + gatt + " " + characteristic); + return; + } + + if (GATT_BATTERY_LEVEL_CHARACTERISTIC_UUID.equals(characteristic.getUuid())) { + updateBatteryLevel(value); + } + } + + private void updateBatteryLevel(byte[] value) { + int batteryLevel = value[0] & 0xFF; + + BatteryService service = mServiceRef.get(); + service.handleBatteryChanged(mDevice, batteryLevel); + } + } +} diff --git a/android/app/src/com/android/bluetooth/btservice/AdapterService.java b/android/app/src/com/android/bluetooth/btservice/AdapterService.java index e68abd7052..503ae7c1e5 100644 --- a/android/app/src/com/android/bluetooth/btservice/AdapterService.java +++ b/android/app/src/com/android/bluetooth/btservice/AdapterService.java @@ -45,8 +45,6 @@ import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothAdapter.ActiveDeviceProfile; import android.bluetooth.BluetoothAdapter.ActiveDeviceUse; import android.bluetooth.BluetoothClass; -import android.bluetooth.BluetoothCodecConfig; -import android.bluetooth.BluetoothCodecStatus; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothProfile; import android.bluetooth.BluetoothProtoEnums; @@ -104,6 +102,7 @@ import com.android.bluetooth.R; import com.android.bluetooth.Utils; import com.android.bluetooth.a2dp.A2dpService; import com.android.bluetooth.a2dpsink.A2dpSinkService; +import com.android.bluetooth.bas.BatteryService; import com.android.bluetooth.bass_client.BassClientService; import com.android.bluetooth.btservice.RemoteDevices.DeviceProperties; import com.android.bluetooth.btservice.activityattribution.ActivityAttributionService; @@ -328,6 +327,7 @@ public class AdapterService extends Service { private CsipSetCoordinatorService mCsipSetCoordinatorService; private LeAudioService mLeAudioService; private BassClientService mBassClientService; + private BatteryService mBatteryService; private BinderCallsStats.SettingsObserver mBinderCallsSettingsObserver; @@ -372,7 +372,7 @@ public class AdapterService extends Service { /** * Confirm whether the ProfileService is started expectedly. * - * @param string the service simple name. + * @param serviceSampleName the service simple name. * @return true if the service is started expectedly, false otherwise. */ public boolean isStartedProfile(String serviceSampleName) { @@ -1102,6 +1102,9 @@ public class AdapterService extends Service { if (profile == BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT) { return Utils.arrayContains(remoteDeviceUuids, BluetoothUuid.BASS); } + if (profile == BluetoothProfile.BATTERY) { + return Utils.arrayContains(remoteDeviceUuids, BluetoothUuid.BATTERY); + } Log.e(TAG, "isSupported: Unexpected profile passed in to function: " + profile); return false; @@ -1115,7 +1118,6 @@ public class AdapterService extends Service { */ @RequiresPermission(android.Manifest.permission.BLUETOOTH_PRIVILEGED) boolean isAnyProfileEnabled(BluetoothDevice device) { - if (mA2dpService != null && mA2dpService.getConnectionPolicy(device) > BluetoothProfile.CONNECTION_POLICY_FORBIDDEN) { return true; @@ -1173,12 +1175,16 @@ public class AdapterService extends Service { > BluetoothProfile.CONNECTION_POLICY_FORBIDDEN) { return true; } + if (mBatteryService != null && mBatteryService.getConnectionPolicy(device) + > BluetoothProfile.CONNECTION_POLICY_FORBIDDEN) { + return true; + } return false; } /** * Connects only available profiles - * (those with {@link BluetoothProfile.CONNECTION_POLICY_ALLOWED}) + * (those with {@link BluetoothProfile#CONNECTION_POLICY_ALLOWED}) * * @param device is the device with which we are connecting the profiles * @return {@link BluetoothStatusCodes#SUCCESS} @@ -1274,17 +1280,25 @@ public class AdapterService extends Service { if (mLeAudioService != null && isSupported(localDeviceUuids, remoteDeviceUuids, BluetoothProfile.LE_AUDIO, device) && mLeAudioService.getConnectionPolicy(device) - > BluetoothProfile.CONNECTION_POLICY_FORBIDDEN) { + > BluetoothProfile.CONNECTION_POLICY_FORBIDDEN) { Log.i(TAG, "connectEnabledProfiles: Connecting LeAudio profile (BAP)"); mLeAudioService.connect(device); } if (mBassClientService != null && isSupported(localDeviceUuids, remoteDeviceUuids, BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT, device) && mBassClientService.getConnectionPolicy(device) - > BluetoothProfile.CONNECTION_POLICY_FORBIDDEN) { + > BluetoothProfile.CONNECTION_POLICY_FORBIDDEN) { Log.i(TAG, "connectEnabledProfiles: Connecting LE Broadcast Assistant Profile"); mBassClientService.connect(device); } + if (mBatteryService != null + && isSupported( + localDeviceUuids, remoteDeviceUuids, BluetoothProfile.BATTERY, device) + && mBatteryService.getConnectionPolicy(device) + > BluetoothProfile.CONNECTION_POLICY_FORBIDDEN) { + Log.i(TAG, "connectEnabledProfiles: Connecting Battery Service"); + mBatteryService.connect(device); + } return BluetoothStatusCodes.SUCCESS; } @@ -1326,6 +1340,7 @@ public class AdapterService extends Service { mCsipSetCoordinatorService = CsipSetCoordinatorService.getCsipSetCoordinatorService(); mLeAudioService = LeAudioService.getLeAudioService(); mBassClientService = BassClientService.getBassClientService(); + mBatteryService = BatteryService.getBatteryService(); } @BluetoothAdapter.RfcommListenerResult @@ -3903,7 +3918,7 @@ public class AdapterService extends Service { * Fetches the local OOB data to give out to remote. * * @param transport - specify data transport. - * @param callback - callback used to receive the requested {@link Oobdata}; null will be + * @param callback - callback used to receive the requested {@link OobData}; null will be * ignored silently. * * @hide @@ -5270,6 +5285,13 @@ public class AdapterService extends Service { return allowLowLatencyAudioNative(allowed, Utils.getByteAddress(device)); } + /** + * Sets the battery level of the remote device + */ + public void setBatteryLevel(BluetoothDevice device, int batteryLevel) { + mRemoteDevices.updateBatteryLevel(device, batteryLevel); + } + static native void classInitNative(); native boolean initNative(boolean startRestricted, boolean isCommonCriteriaMode, diff --git a/android/app/src/com/android/bluetooth/btservice/Config.java b/android/app/src/com/android/bluetooth/btservice/Config.java index 9ddd9ab9e0..d7bbafbc40 100644 --- a/android/app/src/com/android/bluetooth/btservice/Config.java +++ b/android/app/src/com/android/bluetooth/btservice/Config.java @@ -33,6 +33,7 @@ import com.android.bluetooth.a2dp.A2dpService; import com.android.bluetooth.a2dpsink.A2dpSinkService; import com.android.bluetooth.avrcp.AvrcpTargetService; import com.android.bluetooth.avrcpcontroller.AvrcpControllerService; +import com.android.bluetooth.bas.BatteryService; import com.android.bluetooth.bass_client.BassClientService; import com.android.bluetooth.csip.CsipSetCoordinatorService; import com.android.bluetooth.gatt.GattService; @@ -63,6 +64,9 @@ import java.util.List; public class Config { private static final String TAG = "AdapterServiceConfig"; + private static final String FEATURE_HEARING_AID = "settings_bluetooth_hearing_aid"; + private static final String FEATURE_BATTERY = "settings_bluetooth_battery"; + private static class ProfileConfig { Class mClass; int mSupported; @@ -140,6 +144,8 @@ public class Config { new ProfileConfig(BassClientService.class, R.bool.profile_supported_bass_client, (1 << BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT)), + new ProfileConfig(BatteryService.class, R.bool.profile_supported_battery, + (1 << BluetoothProfile.BATTERY)), }; private static Class[] sSupportedProfiles = new Class[0]; @@ -163,22 +169,28 @@ public class Config { supported = BluetoothProperties.isProfileAshaCentralEnabled().orElse(false); } else { - supported = resources.getBoolean(config.mSupported); + supported = config.mSupported > 0 ? resources.getBoolean(config.mSupported) : false; } - if (!supported && (config.mClass == HearingAidService.class) && isHearingAidSettingsEnabled(ctx)) { - Log.v(TAG, "Feature Flag enables support for HearingAidService"); + if (!supported && (config.mClass == HearingAidService.class) + && isFeatureEnabled(ctx, FEATURE_HEARING_AID, /*defaultValue=*/false)) { + Log.i(TAG, "Feature Flag enables support for HearingAidService"); supported = true; } + if (config.mClass == BatteryService.class) { + // It could be overridden by flag. + supported = isFeatureEnabled(ctx, FEATURE_BATTERY, /*defaultValue=*/ supported); + } + if (enabledProfiles != null && enabledProfiles.contains(config.mClass.getName())) { supported = true; - Log.v(TAG, config.mClass.getSimpleName() + " Feature Flag set to " + supported + Log.i(TAG, config.mClass.getSimpleName() + " Feature Flag set to " + supported + " by components configuration"); } if (supported && !isProfileDisabled(ctx, config.mMask)) { - Log.v(TAG, "Adding " + config.mClass.getSimpleName()); + Log.i(TAG, "Adding " + config.mClass.getSimpleName()); profiles.add(config.mClass); } } @@ -247,26 +259,31 @@ public class Config { final String flagOverridePrefix = "sys.fflag.override."; final String hearingAidSettings = "settings_bluetooth_hearing_aid"; + return isFeatureEnabled(context, hearingAidSettings, /*defaultValue=*/false); + } + + private static boolean isFeatureEnabled(Context context, String feature, boolean defaultValue) { + final String flagOverridePrefix = "sys.fflag.override."; // Override precedence: // Settings.Global -> sys.fflag.override.* -> static list - // Step 1: check if hearing aid flag is set in Settings.Global. + // Step 1: check if feature flag is set in Settings.Global. String value; if (context != null) { - value = Settings.Global.getString(context.getContentResolver(), hearingAidSettings); + value = Settings.Global.getString(context.getContentResolver(), feature); if (!TextUtils.isEmpty(value)) { return Boolean.parseBoolean(value); } } - // Step 2: check if hearing aid flag has any override. - value = SystemProperties.get(flagOverridePrefix + hearingAidSettings); + // Step 2: check if feature flag has any override. + // Flag name: sys.fflag.override.<feature> + value = SystemProperties.get(flagOverridePrefix + feature); if (!TextUtils.isEmpty(value)) { return Boolean.parseBoolean(value); } - - // Step 3: return default value. - return false; + // Step 3: return default value + return defaultValue; } private static List<String> getSystemConfigEnabledProfilesForPackage(Context ctx) { diff --git a/android/app/src/com/android/bluetooth/btservice/PhonePolicy.java b/android/app/src/com/android/bluetooth/btservice/PhonePolicy.java index 451bd4c887..9b83ccf01b 100644 --- a/android/app/src/com/android/bluetooth/btservice/PhonePolicy.java +++ b/android/app/src/com/android/bluetooth/btservice/PhonePolicy.java @@ -41,6 +41,7 @@ import android.util.Log; import com.android.bluetooth.R; import com.android.bluetooth.Utils; import com.android.bluetooth.a2dp.A2dpService; +import com.android.bluetooth.bas.BatteryService; import com.android.bluetooth.bass_client.BassClientService; import com.android.bluetooth.btservice.storage.DatabaseManager; import com.android.bluetooth.csip.CsipSetCoordinatorService; @@ -300,6 +301,7 @@ class PhonePolicy { mFactory.getVolumeControlService(); HapClientService hapClientService = mFactory.getHapClientService(); BassClientService bcService = mFactory.getBassClientService(); + BatteryService batteryService = mFactory.getBatteryService(); // Set profile priorities only for the profiles discovered on the remote device. // This avoids needless auto-connect attempts to profiles non-existent on the remote device @@ -385,6 +387,13 @@ class PhonePolicy { BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT, BluetoothProfile.CONNECTION_POLICY_ALLOWED); } + if ((batteryService != null) && Utils.arrayContains(uuids, + BluetoothUuid.BATTERY) && (batteryService.getConnectionPolicy(device) + == BluetoothProfile.CONNECTION_POLICY_UNKNOWN)) { + debugLog("setting battery profile priority for device " + device); + mAdapterService.getDatabase().setProfileConnectionPolicy(device, + BluetoothProfile.BATTERY, BluetoothProfile.CONNECTION_POLICY_ALLOWED); + } } @RequiresPermission(android.Manifest.permission.BLUETOOTH_PRIVILEGED) @@ -594,6 +603,7 @@ class PhonePolicy { mFactory.getCsipSetCoordinatorService(); VolumeControlService volumeControlService = mFactory.getVolumeControlService(); + BatteryService batteryService = mFactory.getBatteryService(); if (hsService != null) { if (!mHeadsetRetrySet.contains(device) && (hsService.getConnectionPolicy(device) @@ -653,10 +663,20 @@ class PhonePolicy { == BluetoothProfile.CONNECTION_POLICY_ALLOWED) && (volumeControlService.getConnectionState(device) == BluetoothProfile.STATE_DISCONNECTED)) { - debugLog("Retrying connection to CSIP with device " + device); + debugLog("Retrying connection to VCP with device " + device); volumeControlService.connect(device); } } + if (batteryService != null) { + List<BluetoothDevice> connectedDevices = batteryService.getConnectedDevices(); + if (!connectedDevices.contains(device) && (batteryService.getConnectionPolicy(device) + == BluetoothProfile.CONNECTION_POLICY_ALLOWED) + && (batteryService.getConnectionState(device) + == BluetoothProfile.STATE_DISCONNECTED)) { + debugLog("Retrying connection to BAS with device " + device); + batteryService.connect(device); + } + } } private static void debugLog(String msg) { diff --git a/android/app/src/com/android/bluetooth/btservice/RemoteDevices.java b/android/app/src/com/android/bluetooth/btservice/RemoteDevices.java index c9d8426dcd..122e59ae43 100644 --- a/android/app/src/com/android/bluetooth/btservice/RemoteDevices.java +++ b/android/app/src/com/android/bluetooth/btservice/RemoteDevices.java @@ -41,6 +41,7 @@ import android.util.Log; import com.android.bluetooth.BluetoothStatsLog; import com.android.bluetooth.R; import com.android.bluetooth.Utils; +import com.android.bluetooth.bas.BatteryService; import com.android.bluetooth.hfp.HeadsetHalConstants; import com.android.internal.annotations.VisibleForTesting; @@ -765,6 +766,10 @@ final class RemoteDevices { || state == BluetoothAdapter.STATE_BLE_TURNING_ON) { intent = new Intent(BluetoothAdapter.ACTION_BLE_ACL_CONNECTED); } + BatteryService batteryService = BatteryService.getBatteryService(); + if (batteryService != null) { + batteryService.connect(device); + } debugLog( "aclStateChangeCallback: Adapter State: " + BluetoothAdapter.nameForState(state) + " Connected: " + device); @@ -786,6 +791,10 @@ final class RemoteDevices { } // Reset battery level on complete disconnection if (sAdapterService.getConnectionState(device) == 0) { + BatteryService batteryService = BatteryService.getBatteryService(); + if (batteryService != null) { + batteryService.disconnect(device); + } resetBatteryLevel(device); } if (!sAdapterService.isAnyProfileEnabled(device)) { diff --git a/android/app/src/com/android/bluetooth/btservice/ServiceFactory.java b/android/app/src/com/android/bluetooth/btservice/ServiceFactory.java index 98d37e96a3..e8a624eb95 100644 --- a/android/app/src/com/android/bluetooth/btservice/ServiceFactory.java +++ b/android/app/src/com/android/bluetooth/btservice/ServiceFactory.java @@ -18,6 +18,7 @@ package com.android.bluetooth.btservice; import com.android.bluetooth.a2dp.A2dpService; import com.android.bluetooth.avrcp.AvrcpTargetService; +import com.android.bluetooth.bas.BatteryService; import com.android.bluetooth.bass_client.BassClientService; import com.android.bluetooth.csip.CsipSetCoordinatorService; import com.android.bluetooth.hap.HapClientService; @@ -83,4 +84,8 @@ public class ServiceFactory { public BassClientService getBassClientService() { return BassClientService.getBassClientService(); } + + public BatteryService getBatteryService() { + return BatteryService.getBatteryService(); + } } diff --git a/android/app/src/com/android/bluetooth/btservice/storage/Metadata.java b/android/app/src/com/android/bluetooth/btservice/storage/Metadata.java index 6d917393a3..36d6b43cae 100644 --- a/android/app/src/com/android/bluetooth/btservice/storage/Metadata.java +++ b/android/app/src/com/android/bluetooth/btservice/storage/Metadata.java @@ -128,6 +128,9 @@ class Metadata { case BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT: profileConnectionPolicies.bass_client_connection_policy = connectionPolicy; break; + case BluetoothProfile.BATTERY: + profileConnectionPolicies.battery_connection_policy = connectionPolicy; + break; default: throw new IllegalArgumentException("invalid profile " + profile); } @@ -171,6 +174,8 @@ class Metadata { return profileConnectionPolicies.le_call_control_connection_policy; case BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT: return profileConnectionPolicies.bass_client_connection_policy; + case BluetoothProfile.BATTERY: + return profileConnectionPolicies.battery_connection_policy; } return BluetoothProfile.CONNECTION_POLICY_UNKNOWN; } diff --git a/android/app/src/com/android/bluetooth/btservice/storage/MetadataDatabase.java b/android/app/src/com/android/bluetooth/btservice/storage/MetadataDatabase.java index c008ef95da..91338179f2 100644 --- a/android/app/src/com/android/bluetooth/btservice/storage/MetadataDatabase.java +++ b/android/app/src/com/android/bluetooth/btservice/storage/MetadataDatabase.java @@ -33,7 +33,7 @@ import java.util.List; /** * MetadataDatabase is a Room database stores Bluetooth persistence data */ -@Database(entities = {Metadata.class}, version = 111) +@Database(entities = {Metadata.class}, version = 112) public abstract class MetadataDatabase extends RoomDatabase { /** * The metadata database file name @@ -64,6 +64,7 @@ public abstract class MetadataDatabase extends RoomDatabase { .addMigrations(MIGRATION_108_109) .addMigrations(MIGRATION_109_110) .addMigrations(MIGRATION_110_111) + .addMigrations(MIGRATION_111_112) .allowMainThreadQueries() .build(); } @@ -435,7 +436,7 @@ public abstract class MetadataDatabase extends RoomDatabase { try { database.execSQL( "ALTER TABLE metadata ADD COLUMN `bass_client_connection_policy` " - + "INTEGER DEFAULT 100"); + + "INTEGER DEFAULT 100"); } catch (SQLException ex) { // Check if user has new schema, but is just missing the version update Cursor cursor = database.query("SELECT * FROM metadata"); @@ -446,4 +447,22 @@ public abstract class MetadataDatabase extends RoomDatabase { } } }; + + @VisibleForTesting + static final Migration MIGRATION_111_112 = new Migration(111, 112) { + @Override + public void migrate(SupportSQLiteDatabase database) { + try { + database.execSQL( + "ALTER TABLE metadata ADD COLUMN `battery_connection_policy` " + + "INTEGER DEFAULT 100"); + } catch (SQLException ex) { + // Check if user has new schema, but is just missing the version update + Cursor cursor = database.query("SELECT * FROM metadata"); + if (cursor == null || cursor.getColumnIndex("battery_connection_policy") == -1) { + throw ex; + } + } + } + }; } diff --git a/android/app/src/com/android/bluetooth/btservice/storage/ProfilePrioritiesEntity.java b/android/app/src/com/android/bluetooth/btservice/storage/ProfilePrioritiesEntity.java index 4f81f9eeb5..6825adc97a 100644 --- a/android/app/src/com/android/bluetooth/btservice/storage/ProfilePrioritiesEntity.java +++ b/android/app/src/com/android/bluetooth/btservice/storage/ProfilePrioritiesEntity.java @@ -41,6 +41,7 @@ class ProfilePrioritiesEntity { public int csip_set_coordinator_connection_policy; public int le_call_control_connection_policy; public int bass_client_connection_policy; + public int battery_connection_policy; ProfilePrioritiesEntity() { a2dp_connection_policy = BluetoothProfile.CONNECTION_POLICY_UNKNOWN; @@ -61,6 +62,7 @@ class ProfilePrioritiesEntity { csip_set_coordinator_connection_policy = BluetoothProfile.CONNECTION_POLICY_UNKNOWN; le_call_control_connection_policy = BluetoothProfile.CONNECTION_POLICY_UNKNOWN; bass_client_connection_policy = BluetoothProfile.CONNECTION_POLICY_UNKNOWN; + battery_connection_policy = BluetoothProfile.CONNECTION_POLICY_UNKNOWN; } public String toString() { @@ -82,7 +84,8 @@ class ProfilePrioritiesEntity { .append("|LE_AUDIO=").append(le_audio_connection_policy) .append("|VOLUME_CONTROL=").append(volume_control_connection_policy) .append("|LE_CALL_CONTROL=").append(le_call_control_connection_policy) - .append("|LE_BROADCAST_ASSISTANT=").append(bass_client_connection_policy); + .append("|LE_BROADCAST_ASSISTANT=").append(bass_client_connection_policy) + .append("|BATTERY=").append(battery_connection_policy); return builder.toString(); } diff --git a/android/app/tests/unit/src/com/android/bluetooth/bas/BatteryServiceTest.java b/android/app/tests/unit/src/com/android/bluetooth/bas/BatteryServiceTest.java new file mode 100644 index 0000000000..c8b4e89e2b --- /dev/null +++ b/android/app/tests/unit/src/com/android/bluetooth/bas/BatteryServiceTest.java @@ -0,0 +1,248 @@ +/* + * Copyright 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.bluetooth.bas; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.spy; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothProfile; +import android.bluetooth.BluetoothUuid; +import android.content.Context; +import android.os.Looper; +import android.os.ParcelUuid; + +import androidx.test.InstrumentationRegistry; +import androidx.test.filters.LargeTest; +import androidx.test.rule.ServiceTestRule; + +import com.android.bluetooth.R; +import com.android.bluetooth.TestUtils; +import com.android.bluetooth.btservice.AdapterService; +import com.android.bluetooth.btservice.storage.DatabaseManager; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +import java.util.concurrent.TimeoutException; + +@LargeTest +@RunWith(JUnit4.class) +public class BatteryServiceTest { + private BluetoothAdapter mAdapter; + private Context mTargetContext; + private BatteryService mService; + private BluetoothDevice mDevice; + private static final int CONNECTION_TIMEOUT_MS = 1000; + + @Mock private AdapterService mAdapterService; + @Mock private DatabaseManager mDatabaseManager; + + @Rule public final ServiceTestRule mServiceRule = new ServiceTestRule(); + @Rule + public final MockitoRule mockito = MockitoJUnit.rule(); + + @Before + public void setUp() throws Exception { + mTargetContext = InstrumentationRegistry.getTargetContext(); + Assume.assumeTrue("Ignore test when Battery service is not enabled", + mTargetContext.getResources().getBoolean(R.bool.profile_supported_battery)); + + if (Looper.myLooper() == null) { + Looper.prepare(); + } + + TestUtils.setAdapterService(mAdapterService); + doReturn(mDatabaseManager).when(mAdapterService).getDatabase(); + doReturn(true, false).when(mAdapterService).isStartedProfile(anyString()); + + mAdapter = BluetoothAdapter.getDefaultAdapter(); + + startService(); + + // Override the timeout value to speed up the test + BatteryStateMachine.sConnectTimeoutMs = CONNECTION_TIMEOUT_MS; // 1s + + // Get a device for testing + mDevice = TestUtils.getTestDevice(mAdapter, 0); + doReturn(BluetoothDevice.BOND_BONDED).when(mAdapterService) + .getBondState(any(BluetoothDevice.class)); + } + + @After + public void tearDown() throws Exception { + if (!mTargetContext.getResources().getBoolean( + R.bool.profile_supported_battery)) { + return; + } + stopService(); + TestUtils.clearAdapterService(mAdapterService); + } + + private void startService() throws TimeoutException { + TestUtils.startService(mServiceRule, BatteryService.class); + mService = BatteryService.getBatteryService(); + Assert.assertNotNull(mService); + } + + private void stopService() throws TimeoutException { + TestUtils.stopService(mServiceRule, BatteryService.class); + mService = BatteryService.getBatteryService(); + Assert.assertNull(mService); + } + + /** + * Test get Battery Service + */ + @Test + public void testGetBatteryService() { + Assert.assertEquals(mService, BatteryService.getBatteryService()); + } + + /** + * Test get/set policy for BluetoothDevice + */ + @Test + public void testGetSetPolicy() { + when(mAdapterService.getDatabase()).thenReturn(mDatabaseManager); + when(mDatabaseManager + .getProfileConnectionPolicy(mDevice, BluetoothProfile.BATTERY)) + .thenReturn(BluetoothProfile.CONNECTION_POLICY_UNKNOWN); + Assert.assertEquals("Initial device policy", + BluetoothProfile.CONNECTION_POLICY_UNKNOWN, + mService.getConnectionPolicy(mDevice)); + + when(mAdapterService.getDatabase()).thenReturn(mDatabaseManager); + when(mDatabaseManager + .getProfileConnectionPolicy(mDevice, BluetoothProfile.BATTERY)) + .thenReturn(BluetoothProfile.CONNECTION_POLICY_FORBIDDEN); + Assert.assertEquals("Setting device policy to POLICY_FORBIDDEN", + BluetoothProfile.CONNECTION_POLICY_FORBIDDEN, + mService.getConnectionPolicy(mDevice)); + + when(mAdapterService.getDatabase()).thenReturn(mDatabaseManager); + when(mDatabaseManager + .getProfileConnectionPolicy(mDevice, BluetoothProfile.BATTERY)) + .thenReturn(BluetoothProfile.CONNECTION_POLICY_ALLOWED); + Assert.assertEquals("Setting device policy to POLICY_ALLOWED", + BluetoothProfile.CONNECTION_POLICY_ALLOWED, + mService.getConnectionPolicy(mDevice)); + } + + /** + * Test okToConnect method using various test cases + */ + @Test + public void testCanConnect() { + int badPolicyValue = 1024; + int badBondState = 42; + testCanConnectCase(mDevice, + BluetoothDevice.BOND_NONE, BluetoothProfile.CONNECTION_POLICY_UNKNOWN, false); + testCanConnectCase(mDevice, + BluetoothDevice.BOND_NONE, BluetoothProfile.CONNECTION_POLICY_FORBIDDEN, false); + testCanConnectCase(mDevice, + BluetoothDevice.BOND_NONE, BluetoothProfile.CONNECTION_POLICY_ALLOWED, false); + testCanConnectCase(mDevice, + BluetoothDevice.BOND_NONE, badPolicyValue, false); + testCanConnectCase(mDevice, + BluetoothDevice.BOND_BONDING, BluetoothProfile.CONNECTION_POLICY_UNKNOWN, false); + testCanConnectCase(mDevice, + BluetoothDevice.BOND_BONDING, BluetoothProfile.CONNECTION_POLICY_FORBIDDEN, false); + testCanConnectCase(mDevice, + BluetoothDevice.BOND_BONDING, BluetoothProfile.CONNECTION_POLICY_ALLOWED, false); + testCanConnectCase(mDevice, + BluetoothDevice.BOND_BONDING, badPolicyValue, false); + testCanConnectCase(mDevice, + BluetoothDevice.BOND_BONDED, BluetoothProfile.CONNECTION_POLICY_UNKNOWN, true); + testCanConnectCase(mDevice, + BluetoothDevice.BOND_BONDED, BluetoothProfile.CONNECTION_POLICY_FORBIDDEN, false); + testCanConnectCase(mDevice, + BluetoothDevice.BOND_BONDED, BluetoothProfile.CONNECTION_POLICY_ALLOWED, true); + testCanConnectCase(mDevice, + BluetoothDevice.BOND_BONDED, badPolicyValue, false); + testCanConnectCase(mDevice, + badBondState, BluetoothProfile.CONNECTION_POLICY_UNKNOWN, false); + testCanConnectCase(mDevice, + badBondState, BluetoothProfile.CONNECTION_POLICY_FORBIDDEN, false); + testCanConnectCase(mDevice, + badBondState, BluetoothProfile.CONNECTION_POLICY_ALLOWED, false); + testCanConnectCase(mDevice, + badBondState, badPolicyValue, false); + } + + /** + * Test that an outgoing connection to device + */ + @Test + public void testConnect() { + // Update the device policy so okToConnect() returns true + when(mAdapterService.getDatabase()).thenReturn(mDatabaseManager); + when(mDatabaseManager + .getProfileConnectionPolicy(mDevice, BluetoothProfile.BATTERY)) + .thenReturn(BluetoothProfile.CONNECTION_POLICY_ALLOWED); + // Return Battery UUID + doReturn(new ParcelUuid[]{BluetoothUuid.BATTERY}).when(mAdapterService) + .getRemoteUuids(any(BluetoothDevice.class)); + // Send a connect request + Assert.assertTrue("Connect expected to succeed", mService.connect(mDevice)); + } + + /** + * Test that an outgoing connection to device with POLICY_FORBIDDEN is rejected + */ + @Test + public void testForbiddenPolicy_FailsToConnect() { + // Set the device policy to POLICY_FORBIDDEN so connect() should fail + when(mAdapterService.getDatabase()).thenReturn(mDatabaseManager); + when(mDatabaseManager + .getProfileConnectionPolicy(mDevice, BluetoothProfile.BATTERY)) + .thenReturn(BluetoothProfile.CONNECTION_POLICY_FORBIDDEN); + + // Send a connect request + Assert.assertFalse("Connect expected to fail", mService.connect(mDevice)); + } + + /** + * Helper function to test okToConnect() method + * + * @param device test device + * @param bondState bond state value, could be invalid + * @param policy value, could be invalid + * @param expected expected result from okToConnect() + */ + private void testCanConnectCase(BluetoothDevice device, int bondState, int policy, + boolean expected) { + doReturn(bondState).when(mAdapterService).getBondState(device); + when(mAdapterService.getDatabase()).thenReturn(mDatabaseManager); + when(mDatabaseManager.getProfileConnectionPolicy(device, BluetoothProfile.BATTERY)) + .thenReturn(policy); + Assert.assertEquals(expected, mService.canConnect(device)); + } +} diff --git a/android/app/tests/unit/src/com/android/bluetooth/bas/BatteryStateMachineTest.java b/android/app/tests/unit/src/com/android/bluetooth/bas/BatteryStateMachineTest.java new file mode 100644 index 0000000000..2f9c48e052 --- /dev/null +++ b/android/app/tests/unit/src/com/android/bluetooth/bas/BatteryStateMachineTest.java @@ -0,0 +1,243 @@ +/* + * Copyright 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.bluetooth.bas; + +import static android.bluetooth.BluetoothGatt.GATT_SUCCESS; + +import static org.junit.Assert.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.after; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.reset; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothProfile; +import android.content.Context; +import android.os.HandlerThread; +import android.os.Looper; + +import androidx.test.InstrumentationRegistry; +import androidx.test.filters.LargeTest; +import androidx.test.filters.MediumTest; + +import com.android.bluetooth.R; +import com.android.bluetooth.TestUtils; +import com.android.bluetooth.btservice.AdapterService; + +import org.hamcrest.core.IsInstanceOf; +import org.junit.After; +import org.junit.Assert; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +@LargeTest +@RunWith(JUnit4.class) +public class BatteryStateMachineTest { + @Rule + public final MockitoRule mockito = MockitoJUnit.rule(); + + private BluetoothAdapter mAdapter; + private Context mTargetContext; + private HandlerThread mHandlerThread; + private StubBatteryStateMachine mBatteryStateMachine; + private static final int CONNECTION_TIMEOUT_MS = 1_000; + private static final int TIMEOUT_MS = 2_000; + private static final int WAIT_MS = 1_000; + + private BluetoothDevice mTestDevice; + @Mock private AdapterService mAdapterService; + @Mock private BatteryService mBatteryService; + + @Before + public void setUp() throws Exception { + mTargetContext = InstrumentationRegistry.getTargetContext(); + //TODO: check flag or override it + Assume.assumeTrue("Ignore test when Battery service is not enabled", + mTargetContext.getResources().getBoolean(R.bool.profile_supported_battery)); + TestUtils.setAdapterService(mAdapterService); + + mAdapter = BluetoothAdapter.getDefaultAdapter(); + + // Get a device for testing + mTestDevice = mAdapter.getRemoteDevice("00:01:02:03:04:05"); + + // Set up thread and looper + mHandlerThread = new HandlerThread("BatteryStateMachineTestHandlerThread"); + mHandlerThread.start(); + mBatteryStateMachine = new StubBatteryStateMachine(mTestDevice, + mBatteryService, mHandlerThread.getLooper()); + // Override the timeout value to speed up the test + mBatteryStateMachine.sConnectTimeoutMs = CONNECTION_TIMEOUT_MS; + mBatteryStateMachine.start(); + } + + @After + public void tearDown() throws Exception { + if (!mTargetContext.getResources().getBoolean(R.bool.profile_supported_battery)) { + return; + } + mBatteryStateMachine.doQuit(); + mHandlerThread.quit(); + TestUtils.clearAdapterService(mAdapterService); + reset(mBatteryService); + } + + /** + * Test that default state is disconnected + */ + @Test + public void testDefaultDisconnectedState() { + Assert.assertEquals(BluetoothProfile.STATE_DISCONNECTED, + mBatteryStateMachine.getConnectionState()); + } + + /** + * Allow/disallow connection to any device. + * + * @param allow if true, connection is allowed + */ + private void allowConnection(boolean allow) { + when(mBatteryService.canConnect(any(BluetoothDevice.class))).thenReturn(allow); + } + + private void allowConnectGatt(boolean allow) { + mBatteryStateMachine.mShouldAllowGatt = allow; + } + + /** + * Test that an incoming connection with policy forbidding connection is rejected + */ + @Test + public void testOkToConnectFails() { + allowConnection(false); + allowConnectGatt(true); + + // Inject an event for when incoming connection is requested + mBatteryStateMachine.sendMessage(BatteryStateMachine.CONNECT); + + verify(mBatteryService, after(WAIT_MS).never()) + .handleConnectionStateChanged(any(BatteryStateMachine.class), anyInt(), anyInt()); + + // Check that we are in Disconnected state + Assert.assertThat(mBatteryStateMachine.getCurrentState(), + IsInstanceOf.instanceOf(BatteryStateMachine.Disconnected.class)); + } + + @Test + public void testFailToConnectGatt() { + allowConnection(true); + allowConnectGatt(false); + + // Inject an event for when incoming connection is requested + mBatteryStateMachine.sendMessage(BatteryStateMachine.CONNECT); + + verify(mBatteryService, after(WAIT_MS).never()) + .handleConnectionStateChanged(any(BatteryStateMachine.class), anyInt(), anyInt()); + + // Check that we are in Disconnected state + Assert.assertThat(mBatteryStateMachine.getCurrentState(), + IsInstanceOf.instanceOf(BatteryStateMachine.Disconnected.class)); + } + + @Test + public void testSuccessfullyConnected() { + allowConnection(true); + allowConnectGatt(true); + + // Inject an event for when incoming connection is requested + mBatteryStateMachine.sendMessage(BatteryStateMachine.CONNECT); + + verify(mBatteryService, timeout(TIMEOUT_MS)) + .handleConnectionStateChanged(any(BatteryStateMachine.class), + eq(BluetoothProfile.STATE_DISCONNECTED), + eq(BluetoothProfile.STATE_CONNECTING)); + + Assert.assertThat(mBatteryStateMachine.getCurrentState(), + IsInstanceOf.instanceOf(BatteryStateMachine.Connecting.class)); + + assertNotNull(mBatteryStateMachine.mGattCallback); + mBatteryStateMachine.notifyConnectionStateChanged( + GATT_SUCCESS, BluetoothProfile.STATE_CONNECTED); + + verify(mBatteryService, timeout(TIMEOUT_MS)) + .handleConnectionStateChanged(any(BatteryStateMachine.class), + eq(BluetoothProfile.STATE_CONNECTING), + eq(BluetoothProfile.STATE_CONNECTED)); + + Assert.assertThat(mBatteryStateMachine.getCurrentState(), + IsInstanceOf.instanceOf(BatteryStateMachine.Connected.class)); + } + + @Test + public void testConnectGattTimeout() { + allowConnection(true); + allowConnectGatt(true); + + // Inject an event for when incoming connection is requested + mBatteryStateMachine.sendMessage(BatteryStateMachine.CONNECT); + + verify(mBatteryService, timeout(TIMEOUT_MS)) + .handleConnectionStateChanged(any(BatteryStateMachine.class), + eq(BluetoothProfile.STATE_DISCONNECTED), + eq(BluetoothProfile.STATE_CONNECTING)); + + Assert.assertThat(mBatteryStateMachine.getCurrentState(), + IsInstanceOf.instanceOf(BatteryStateMachine.Connecting.class)); + + verify(mBatteryService, timeout(TIMEOUT_MS)) + .handleConnectionStateChanged(any(BatteryStateMachine.class), + eq(BluetoothProfile.STATE_CONNECTING), + eq(BluetoothProfile.STATE_DISCONNECTED)); + + Assert.assertThat(mBatteryStateMachine.getCurrentState(), + IsInstanceOf.instanceOf(BatteryStateMachine.Disconnected.class)); + } + + // It simulates GATT connection for testing. + public class StubBatteryStateMachine extends BatteryStateMachine { + boolean mShouldAllowGatt = true; + + StubBatteryStateMachine(BluetoothDevice device, BatteryService service, Looper looper) { + super(device, service, looper); + } + + @Override + public boolean connectGatt() { + mGattCallback = new GattCallback(); + return mShouldAllowGatt; + } + + public void notifyConnectionStateChanged(int status, int newState) { + if (mGattCallback != null) { + mGattCallback.onConnectionStateChange(mBluetoothGatt, status, newState); + } + } + } +} + diff --git a/android/app/tests/unit/src/com/android/bluetooth/btservice/storage/DatabaseManagerTest.java b/android/app/tests/unit/src/com/android/bluetooth/btservice/storage/DatabaseManagerTest.java index 57eed65e5f..eb046d832e 100644 --- a/android/app/tests/unit/src/com/android/bluetooth/btservice/storage/DatabaseManagerTest.java +++ b/android/app/tests/unit/src/com/android/bluetooth/btservice/storage/DatabaseManagerTest.java @@ -1128,6 +1128,29 @@ public final class DatabaseManagerTest { } } + @Test + public void testDatabaseMigration_111_112() throws IOException { + String testString = "TEST STRING"; + // Create a database with version 109 + SupportSQLiteDatabase db = testHelper.createDatabase(DB_NAME, 111); + // insert a device to the database + ContentValues device = new ContentValues(); + device.put("address", TEST_BT_ADDR); + device.put("migrated", false); + assertThat(db.insert("metadata", SQLiteDatabase.CONFLICT_IGNORE, device), + CoreMatchers.not(-1)); + // Migrate database from 111 to 112 + db.close(); + db = testHelper.runMigrationsAndValidate(DB_NAME, 112, true, + MetadataDatabase.MIGRATION_111_112); + Cursor cursor = db.query("SELECT * FROM metadata"); + assertHasColumn(cursor, "battery_connection_policy", true); + while (cursor.moveToNext()) { + // Check the new columns was added with default value + assertColumnIntData(cursor, "battery_connection_policy", 100); + } + } + /** * Helper function to check whether the database has the expected column */ diff --git a/android/app/tests/unit/src/com/android/bluetooth/btservice/storage/schemas/com.android.bluetooth.btservice.storage.MetadataDatabase/112.json b/android/app/tests/unit/src/com/android/bluetooth/btservice/storage/schemas/com.android.bluetooth.btservice.storage.MetadataDatabase/112.json new file mode 100644 index 0000000000..a0228ea0f7 --- /dev/null +++ b/android/app/tests/unit/src/com/android/bluetooth/btservice/storage/schemas/com.android.bluetooth.btservice.storage.MetadataDatabase/112.json @@ -0,0 +1,322 @@ +{ + "formatVersion": 1, + "database": { + "version": 112, + "identityHash": "43c4197a0566aabd64121fba2d08407c", + "entities": [ + { + "tableName": "metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `migrated` INTEGER NOT NULL, `a2dpSupportsOptionalCodecs` INTEGER NOT NULL, `a2dpOptionalCodecsEnabled` INTEGER NOT NULL, `last_active_time` INTEGER NOT NULL, `is_active_a2dp_device` INTEGER NOT NULL, `a2dp_connection_policy` INTEGER, `a2dp_sink_connection_policy` INTEGER, `hfp_connection_policy` INTEGER, `hfp_client_connection_policy` INTEGER, `hid_host_connection_policy` INTEGER, `pan_connection_policy` INTEGER, `pbap_connection_policy` INTEGER, `pbap_client_connection_policy` INTEGER, `map_connection_policy` INTEGER, `sap_connection_policy` INTEGER, `hearing_aid_connection_policy` INTEGER, `hap_client_connection_policy` INTEGER, `map_client_connection_policy` INTEGER, `le_audio_connection_policy` INTEGER, `volume_control_connection_policy` INTEGER, `csip_set_coordinator_connection_policy` INTEGER, `le_call_control_connection_policy` INTEGER, `bass_client_connection_policy` INTEGER, `battery_connection_policy` INTEGER, `manufacturer_name` BLOB, `model_name` BLOB, `software_version` BLOB, `hardware_version` BLOB, `companion_app` BLOB, `main_icon` BLOB, `is_untethered_headset` BLOB, `untethered_left_icon` BLOB, `untethered_right_icon` BLOB, `untethered_case_icon` BLOB, `untethered_left_battery` BLOB, `untethered_right_battery` BLOB, `untethered_case_battery` BLOB, `untethered_left_charging` BLOB, `untethered_right_charging` BLOB, `untethered_case_charging` BLOB, `enhanced_settings_ui_uri` BLOB, `device_type` BLOB, `main_battery` BLOB, `main_charging` BLOB, `main_low_battery_threshold` BLOB, `untethered_left_low_battery_threshold` BLOB, `untethered_right_low_battery_threshold` BLOB, `untethered_case_low_battery_threshold` BLOB, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "migrated", + "columnName": "migrated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "a2dpSupportsOptionalCodecs", + "columnName": "a2dpSupportsOptionalCodecs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "a2dpOptionalCodecsEnabled", + "columnName": "a2dpOptionalCodecsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "last_active_time", + "columnName": "last_active_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "is_active_a2dp_device", + "columnName": "is_active_a2dp_device", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "profileConnectionPolicies.a2dp_connection_policy", + "columnName": "a2dp_connection_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "profileConnectionPolicies.a2dp_sink_connection_policy", + "columnName": "a2dp_sink_connection_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "profileConnectionPolicies.hfp_connection_policy", + "columnName": "hfp_connection_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "profileConnectionPolicies.hfp_client_connection_policy", + "columnName": "hfp_client_connection_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "profileConnectionPolicies.hid_host_connection_policy", + "columnName": "hid_host_connection_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "profileConnectionPolicies.pan_connection_policy", + "columnName": "pan_connection_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "profileConnectionPolicies.pbap_connection_policy", + "columnName": "pbap_connection_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "profileConnectionPolicies.pbap_client_connection_policy", + "columnName": "pbap_client_connection_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "profileConnectionPolicies.map_connection_policy", + "columnName": "map_connection_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "profileConnectionPolicies.sap_connection_policy", + "columnName": "sap_connection_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "profileConnectionPolicies.hearing_aid_connection_policy", + "columnName": "hearing_aid_connection_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "profileConnectionPolicies.hap_client_connection_policy", + "columnName": "hap_client_connection_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "profileConnectionPolicies.map_client_connection_policy", + "columnName": "map_client_connection_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "profileConnectionPolicies.le_audio_connection_policy", + "columnName": "le_audio_connection_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "profileConnectionPolicies.volume_control_connection_policy", + "columnName": "volume_control_connection_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "profileConnectionPolicies.csip_set_coordinator_connection_policy", + "columnName": "csip_set_coordinator_connection_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "profileConnectionPolicies.le_call_control_connection_policy", + "columnName": "le_call_control_connection_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "profileConnectionPolicies.bass_client_connection_policy", + "columnName": "bass_client_connection_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "profileConnectionPolicies.battery_connection_policy", + "columnName": "battery_connection_policy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "publicMetadata.manufacturer_name", + "columnName": "manufacturer_name", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.model_name", + "columnName": "model_name", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.software_version", + "columnName": "software_version", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.hardware_version", + "columnName": "hardware_version", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.companion_app", + "columnName": "companion_app", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.main_icon", + "columnName": "main_icon", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.is_untethered_headset", + "columnName": "is_untethered_headset", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.untethered_left_icon", + "columnName": "untethered_left_icon", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.untethered_right_icon", + "columnName": "untethered_right_icon", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.untethered_case_icon", + "columnName": "untethered_case_icon", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.untethered_left_battery", + "columnName": "untethered_left_battery", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.untethered_right_battery", + "columnName": "untethered_right_battery", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.untethered_case_battery", + "columnName": "untethered_case_battery", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.untethered_left_charging", + "columnName": "untethered_left_charging", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.untethered_right_charging", + "columnName": "untethered_right_charging", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.untethered_case_charging", + "columnName": "untethered_case_charging", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.enhanced_settings_ui_uri", + "columnName": "enhanced_settings_ui_uri", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.device_type", + "columnName": "device_type", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.main_battery", + "columnName": "main_battery", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.main_charging", + "columnName": "main_charging", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.main_low_battery_threshold", + "columnName": "main_low_battery_threshold", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.untethered_left_low_battery_threshold", + "columnName": "untethered_left_low_battery_threshold", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.untethered_right_low_battery_threshold", + "columnName": "untethered_right_low_battery_threshold", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "publicMetadata.untethered_case_low_battery_threshold", + "columnName": "untethered_case_low_battery_threshold", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "address" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '43c4197a0566aabd64121fba2d08407c')" + ] + } +}
\ No newline at end of file diff --git a/framework/java/android/bluetooth/BluetoothProfile.java b/framework/java/android/bluetooth/BluetoothProfile.java index 190cbf7f9c..78d0e136cf 100644 --- a/framework/java/android/bluetooth/BluetoothProfile.java +++ b/framework/java/android/bluetooth/BluetoothProfile.java @@ -267,12 +267,19 @@ public interface BluetoothProfile { int LE_AUDIO_BROADCAST_ASSISTANT = 29; /** + * Battery Service + * + * @hide + */ + int BATTERY = 30; + + /** * Max profile ID. This value should be updated whenever a new profile is added to match * the largest value assigned to a profile. * * @hide */ - int MAX_PROFILE_ID = 29; + int MAX_PROFILE_ID = 30; /** * Default priority for devices that we try to auto-connect to and diff --git a/framework/java/android/bluetooth/BluetoothUuid.java b/framework/java/android/bluetooth/BluetoothUuid.java index 7847718c5d..c77b981d35 100644 --- a/framework/java/android/bluetooth/BluetoothUuid.java +++ b/framework/java/android/bluetooth/BluetoothUuid.java @@ -197,6 +197,10 @@ public final class BluetoothUuid { ParcelUuid.fromString("00001853-0000-1000-8000-00805F9B34FB"); /** @hide */ @NonNull + public static final ParcelUuid BATTERY = + ParcelUuid.fromString("0000180F-0000-1000-8000-00805F9B34FB"); + /** @hide */ + @NonNull @SystemApi public static final ParcelUuid BASS = ParcelUuid.fromString("0000184F-0000-1000-8000-00805F9B34FB"); diff --git a/system/binder/Android.bp b/system/binder/Android.bp index 72e11e7202..d82a4dc234 100644 --- a/system/binder/Android.bp +++ b/system/binder/Android.bp @@ -17,6 +17,7 @@ filegroup { "android/bluetooth/IBluetoothA2dpSink.aidl", "android/bluetooth/IBluetoothAvrcpController.aidl", "android/bluetooth/IBluetoothAvrcpTarget.aidl", + "android/bluetooth/IBluetoothBattery.aidl", "android/bluetooth/IBluetoothCallback.aidl", "android/bluetooth/IBluetoothCsipSetCoordinator.aidl", "android/bluetooth/IBluetoothCsipSetCoordinatorCallback.aidl", diff --git a/system/binder/android/bluetooth/IBluetoothBattery.aidl b/system/binder/android/bluetooth/IBluetoothBattery.aidl new file mode 100644 index 0000000000..dbb5cfcad5 --- /dev/null +++ b/system/binder/android/bluetooth/IBluetoothBattery.aidl @@ -0,0 +1,45 @@ +/* + * Copyright 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 android.bluetooth; + +import android.bluetooth.BluetoothDevice; +import android.content.AttributionSource; + +import com.android.modules.utils.SynchronousResultReceiver; + +/** + * APIs for Bluetooth Battery service + * + * @hide + */ +oneway interface IBluetoothBattery { + /* Public API */ + @JavaPassthrough(annotation="@android.annotation.RequiresPermission(android.Manifest.permission.BLUETOOTH_CONNECT)") + void connect(in BluetoothDevice device, in AttributionSource attributionSource, in SynchronousResultReceiver receiver); + @JavaPassthrough(annotation="@android.annotation.RequiresPermission(android.Manifest.permission.BLUETOOTH_CONNECT)") + void disconnect(in BluetoothDevice device, in AttributionSource attributionSource, in SynchronousResultReceiver receiver); + @JavaPassthrough(annotation="@android.annotation.RequiresPermission(android.Manifest.permission.BLUETOOTH_CONNECT)") + void getConnectedDevices(in AttributionSource attributionSource, in SynchronousResultReceiver receiver); + @JavaPassthrough(annotation="@android.annotation.RequiresPermission(android.Manifest.permission.BLUETOOTH_CONNECT)") + void getDevicesMatchingConnectionStates(in int[] states, in AttributionSource attributionSource, in SynchronousResultReceiver receiver); + @JavaPassthrough(annotation="@android.annotation.RequiresPermission(android.Manifest.permission.BLUETOOTH_CONNECT)") + void getConnectionState(in BluetoothDevice device, in AttributionSource attributionSource, in SynchronousResultReceiver receiver); + @JavaPassthrough(annotation="@android.annotation.RequiresPermission(allOf={android.Manifest.permission.BLUETOOTH_CONNECT,android.Manifest.permission.BLUETOOTH_PRIVILEGED})") + void setConnectionPolicy(in BluetoothDevice device, int connectionPolicy, in AttributionSource attributionSource, in SynchronousResultReceiver receiver); + @JavaPassthrough(annotation="@android.annotation.RequiresPermission(allOf={android.Manifest.permission.BLUETOOTH_CONNECT,android.Manifest.permission.BLUETOOTH_PRIVILEGED})") + void getConnectionPolicy(in BluetoothDevice device, in AttributionSource attributionSource, in SynchronousResultReceiver receiver); +} diff --git a/system/btif/src/btif_dm.cc b/system/btif/src/btif_dm.cc index 2b836bc31b..b54a275100 100644 --- a/system/btif/src/btif_dm.cc +++ b/system/btif/src/btif_dm.cc @@ -97,6 +97,7 @@ const Uuid UUID_LE_AUDIO = Uuid::FromString("184E"); const Uuid UUID_LE_MIDI = Uuid::FromString("03B80E5A-EDE8-4B33-A751-6CE34EC4C700"); const Uuid UUID_HAS = Uuid::FromString("1854"); const Uuid UUID_BASS = Uuid::FromString("184F"); +const Uuid UUID_BATTERY = Uuid::FromString("180F"); const bool enable_address_consolidate = true; // TODO remove #define COD_MASK 0x07FF @@ -1336,7 +1337,8 @@ static void btif_dm_search_devices_evt(tBTA_DM_SEARCH_EVT event, static bool btif_is_interesting_le_service(bluetooth::Uuid uuid) { return (uuid.As16Bit() == UUID_SERVCLASS_LE_HID || uuid == UUID_HEARING_AID || uuid == UUID_VC || uuid == UUID_CSIS || uuid == UUID_LE_AUDIO || - uuid == UUID_LE_MIDI || uuid == UUID_HAS || uuid == UUID_BASS); + uuid == UUID_LE_MIDI || uuid == UUID_HAS || uuid == UUID_BASS || + uuid == UUID_BATTERY); } /******************************************************************************* |