/* * Copyright (c) 2019 Martijn van Welie * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. * */ package com.welie.blessed; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothGatt; import android.bluetooth.BluetoothGattCallback; import android.bluetooth.BluetoothGattCharacteristic; import android.bluetooth.BluetoothGattDescriptor; import android.bluetooth.BluetoothGattService; import android.bluetooth.BluetoothProfile; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.os.Build; import android.os.Handler; import android.os.Looper; import android.os.SystemClock; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Queue; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentLinkedQueue; import timber.log.Timber; import static android.bluetooth.BluetoothDevice.TRANSPORT_LE; import static android.bluetooth.BluetoothGattCharacteristic.PROPERTY_INDICATE; import static android.bluetooth.BluetoothGattCharacteristic.PROPERTY_NOTIFY; import static android.bluetooth.BluetoothGattCharacteristic.PROPERTY_READ; import static android.bluetooth.BluetoothGattCharacteristic.PROPERTY_SIGNED_WRITE; import static android.bluetooth.BluetoothGattCharacteristic.PROPERTY_WRITE; import static android.bluetooth.BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE; import static android.bluetooth.BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT; import static android.bluetooth.BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE; import static android.bluetooth.BluetoothGattCharacteristic.WRITE_TYPE_SIGNED; /** * Represents a remote Bluetooth peripheral and replaces BluetoothDevice and BluetoothGatt * * <p>A {@link BluetoothPeripheral} lets you create a connection with the peripheral or query information about it. * This class is a wrapper around the {@link BluetoothDevice} and takes care of operation queueing, some Android bugs, and provides several convenience functions. */ @SuppressWarnings({"SpellCheckingInspection", "unused", "UnusedReturnValue"}) public class BluetoothPeripheral { // CCC descriptor UUID private static final String CCC_DESCRIPTOR_UUID = "00002902-0000-1000-8000-00805f9b34fb"; // Gatt status values taken from Android source code: // https://android.googlesource.com/platform/external/bluetooth/bluedroid/+/android-4.4.4_r2.0.1/stack/include/gatt_api.h /** * A GATT operation completed successfully */ @SuppressWarnings("WeakerAccess") public static final int GATT_SUCCESS = 0; /** * The connection was terminated because of a L2C failure */ @SuppressWarnings("WeakerAccess") public static final int GATT_CONN_L2C_FAILURE = 1; /** * The connection has timed out */ @SuppressWarnings("WeakerAccess") public static final int GATT_CONN_TIMEOUT = 8; /** * GATT read operation is not permitted */ @SuppressWarnings("WeakerAccess") public static final int GATT_READ_NOT_PERMITTED = 2; /** * GATT write operation is not permitted */ @SuppressWarnings("WeakerAccess") public static final int GATT_WRITE_NOT_PERMITTED = 3; /** * Insufficient authentication for a given operation */ @SuppressWarnings("WeakerAccess") public static final int GATT_INSUFFICIENT_AUTHENTICATION = 5; /** * The given request is not supported */ @SuppressWarnings("WeakerAccess") public static final int GATT_REQUEST_NOT_SUPPORTED = 6; /** * Insufficient encryption for a given operation */ @SuppressWarnings("WeakerAccess") public static final int GATT_INSUFFICIENT_ENCRYPTION = 15; /** * The connection was terminated by the peripheral */ @SuppressWarnings("WeakerAccess") public static final int GATT_CONN_TERMINATE_PEER_USER = 19; /** * The connection was terminated by the local host */ @SuppressWarnings("WeakerAccess") public static final int GATT_CONN_TERMINATE_LOCAL_HOST = 22; /** * The connection lost because of LMP timeout */ @SuppressWarnings("WeakerAccess") public static final int GATT_CONN_LMP_TIMEOUT = 34; /** * The connection was terminated due to MIC failure */ @SuppressWarnings("WeakerAccess") public static final int BLE_HCI_CONN_TERMINATED_DUE_TO_MIC_FAILURE = 61; /** * The connection cannot be established */ @SuppressWarnings("WeakerAccess") public static final int GATT_CONN_FAIL_ESTABLISH = 62; /** * The peripheral has no resources to complete the request */ @SuppressWarnings("WeakerAccess") public static final int GATT_NO_RESOURCES = 128; /** * Something went wrong in the bluetooth stack */ @SuppressWarnings("WeakerAccess") public static final int GATT_INTERNAL_ERROR = 129; /** * The GATT operation could not be executed because the stack is busy */ @SuppressWarnings("WeakerAccess") public static final int GATT_BUSY = 132; /** * Generic error, could be anything */ @SuppressWarnings("WeakerAccess") public static final int GATT_ERROR = 133; /** * Authentication failed */ @SuppressWarnings("WeakerAccess") public static final int GATT_AUTH_FAIL = 137; /** * The connection was cancelled */ @SuppressWarnings("WeakerAccess") public static final int GATT_CONN_CANCEL = 256; /** * Bluetooth device type, Unknown */ @SuppressWarnings("WeakerAccess") public static final int DEVICE_TYPE_UNKNOWN = 0; /** * Bluetooth device type, Classic - BR/EDR devices */ @SuppressWarnings("WeakerAccess") public static final int DEVICE_TYPE_CLASSIC = 1; /** * Bluetooth device type, Low Energy - LE-only */ @SuppressWarnings("WeakerAccess") public static final int DEVICE_TYPE_LE = 2; /** * Bluetooth device type, Dual Mode - BR/EDR/LE */ @SuppressWarnings("WeakerAccess") public static final int DEVICE_TYPE_DUAL = 3; /** * Indicates the remote device is not bonded (paired). * <p>There is no shared link key with the remote device, so communication * (if it is allowed at all) will be unauthenticated and unencrypted. */ @SuppressWarnings("WeakerAccess") public static final int BOND_NONE = 10; /** * Indicates bonding (pairing) is in progress with the remote device. */ @SuppressWarnings("WeakerAccess") public static final int BOND_BONDING = 11; /** * Indicates the remote device is bonded (paired). * <p>A shared link keys exists locally for the remote device, so * communication can be authenticated and encrypted. * <p><i>Being bonded (paired) with a remote device does not necessarily * mean the device is currently connected. It just means that the pending * procedure was completed at some earlier time, and the link key is still * stored locally, ready to use on the next connection. * </i> */ @SuppressWarnings("WeakerAccess") public static final int BOND_BONDED = 12; /** * The profile is in disconnected state */ @SuppressWarnings("WeakerAccess") public static final int STATE_DISCONNECTED = 0; /** * The profile is in connecting state */ @SuppressWarnings("WeakerAccess") public static final int STATE_CONNECTING = 1; /** * The profile is in connected state */ @SuppressWarnings("WeakerAccess") public static final int STATE_CONNECTED = 2; /** * The profile is in disconnecting state */ @SuppressWarnings("WeakerAccess") public static final int STATE_DISCONNECTING = 3; // Maximum number of retries of commands private static final int MAX_TRIES = 2; // Delay to use when doing a connect private static final int DIRECT_CONNECTION_DELAY_IN_MS = 100; // Timeout to use if no callback on onConnectionStateChange happens private static final int CONNECTION_TIMEOUT_IN_MS = 35000; // Samsung phones time out after 5 seconds while most other phone time out after 30 seconds private static final int TIMEOUT_THRESHOLD_SAMSUNG = 4500; // Most other phone time out after 30 seconds private static final int TIMEOUT_THRESHOLD_DEFAULT = 25000; // When a bond is lost, the bluetooth stack needs some time to update its internal state private static final long DELAY_AFTER_BOND_LOST = 1000L; // The maximum number of enabled notifications Android supports (BTA_GATTC_NOTIF_REG_MAX) private static final int MAX_NOTIFYING_CHARACTERISTICS = 15; // Member variables private final Context context; private final Handler callbackHandler; private final BluetoothDevice device; private final InternalCallback listener; private BluetoothPeripheralCallback peripheralCallback; private final Queue<Runnable> commandQueue = new ConcurrentLinkedQueue<>(); private boolean commandQueueBusy; private boolean isRetrying; private boolean bondLost = false; private boolean manuallyBonding = false; private volatile BluetoothGatt bluetoothGatt; private int state; private int nrTries; private byte[] currentWriteBytes; private final Set<UUID> notifyingCharacteristics = new HashSet<>(); private final Handler mainHandler = new Handler(Looper.getMainLooper()); private Runnable timeoutRunnable; private Runnable discoverServicesRunnable; private long connectTimestamp; private String cachedName; /** * This abstract class is used to implement BluetoothGatt callbacks. */ private final BluetoothGattCallback bluetoothGattCallback = new BluetoothGattCallback() { @Override public void onConnectionStateChange(final BluetoothGatt gatt, final int status, final int newState) { long timePassed = SystemClock.elapsedRealtime() - connectTimestamp; cancelConnectionTimer(); final int previousState = state; state = newState; if (status == GATT_SUCCESS) { switch (newState) { case BluetoothProfile.STATE_CONNECTED: successfullyConnected(device.getBondState(), timePassed); break; case BluetoothProfile.STATE_DISCONNECTED: successfullyDisconnected(previousState); break; case BluetoothProfile.STATE_DISCONNECTING: Timber.i("peripheral is disconnecting"); break; case BluetoothProfile.STATE_CONNECTING: Timber.i("peripheral is connecting"); default: Timber.e("unknown state received"); break; } } else { connectionStateChangeUnsuccessful(status, previousState, newState, timePassed); } } @Override public void onServicesDiscovered(BluetoothGatt gatt, int status) { if (status != GATT_SUCCESS) { Timber.e("service discovery failed due to internal error '%s', disconnecting", statusToString(status)); disconnect(); return; } final List<BluetoothGattService> services = gatt.getServices(); Timber.i("discovered %d services for '%s'", services.size(), getName()); if (listener != null) { listener.connected(BluetoothPeripheral.this); } callbackHandler.post(new Runnable() { @Override public void run() { peripheralCallback.onServicesDiscovered(BluetoothPeripheral.this); } }); } @Override public void onDescriptorWrite(BluetoothGatt gatt, final BluetoothGattDescriptor descriptor, final int status) { final BluetoothGattCharacteristic parentCharacteristic = descriptor.getCharacteristic(); if (status != GATT_SUCCESS) { Timber.e("failed to write <%s> to descriptor of characteristic: <%s> for device: '%s', ", bytes2String(currentWriteBytes), parentCharacteristic.getUuid(), getAddress()); } // Check if this was the Client Configuration Descriptor if (descriptor.getUuid().equals(UUID.fromString(CCC_DESCRIPTOR_UUID))) { if (status == GATT_SUCCESS) { byte[] value = descriptor.getValue(); if (value != null) { if (Arrays.equals(value, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) || Arrays.equals(value, BluetoothGattDescriptor.ENABLE_INDICATION_VALUE)){ // Notify set to on, add it to the set of notifying characteristics notifyingCharacteristics.add(parentCharacteristic.getUuid()); if (notifyingCharacteristics.size() > MAX_NOTIFYING_CHARACTERISTICS) { Timber.e("too many (%d) notifying characteristics. The maximum Android can handle is %d", notifyingCharacteristics.size(), MAX_NOTIFYING_CHARACTERISTICS); } } else if (Arrays.equals(value, BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE)){ // Notify was turned off, so remove it from the set of notifying characteristics notifyingCharacteristics.remove(parentCharacteristic.getUuid()); } else { Timber.e("unexpected CCC descriptor value"); } } } callbackHandler.post(new Runnable() { @Override public void run() { peripheralCallback.onNotificationStateUpdate(BluetoothPeripheral.this, parentCharacteristic, status); } }); } else { callbackHandler.post(new Runnable() { @Override public void run() { peripheralCallback.onDescriptorWrite(BluetoothPeripheral.this, currentWriteBytes, descriptor, status); } }); } completedCommand(); } @Override public void onDescriptorRead(BluetoothGatt gatt, final BluetoothGattDescriptor descriptor, final int status) { if (status != GATT_SUCCESS) { Timber.e("reading descriptor <%s> failed for device '%s'", descriptor.getUuid(), getAddress()); } final byte[] value = copyOf(descriptor.getValue()); callbackHandler.post(new Runnable() { @Override public void run() { peripheralCallback.onDescriptorRead(BluetoothPeripheral.this, value, descriptor, status); } }); completedCommand(); } @Override public void onCharacteristicChanged(BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic) { final byte[] value = copyOf(characteristic.getValue()); callbackHandler.post(new Runnable() { @Override public void run() { peripheralCallback.onCharacteristicUpdate(BluetoothPeripheral.this, value, characteristic, GATT_SUCCESS); } }); } @Override public void onCharacteristicRead(BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic, final int status) { if (status != GATT_SUCCESS) { if (status == GATT_AUTH_FAIL || status == GATT_INSUFFICIENT_AUTHENTICATION) { // Characteristic encrypted and needs bonding, // So retry operation after bonding completes // This only seems to happen on Android 5/6/7 Timber.w("read needs bonding, bonding in progress"); return; } else { Timber.e("read failed for characteristic: %s, status %d", characteristic.getUuid(), status); completedCommand(); return; } } final byte[] value = copyOf(characteristic.getValue()); callbackHandler.post(new Runnable() { @Override public void run() { peripheralCallback.onCharacteristicUpdate(BluetoothPeripheral.this, value, characteristic, status); } }); completedCommand(); } @Override public void onCharacteristicWrite(BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic, final int status) { if (status != GATT_SUCCESS) { if (status == GATT_AUTH_FAIL || status == GATT_INSUFFICIENT_AUTHENTICATION) { // Characteristic encrypted and needs bonding, // So retry operation after bonding completes // This only seems to happen on Android 5/6/7 Timber.i("write needs bonding, bonding in progress"); return; } else { Timber.e("writing <%s> to characteristic <%s> failed, status %s", bytes2String(currentWriteBytes), characteristic.getUuid(), statusToString(status)); } } final byte[] value = copyOf(currentWriteBytes); currentWriteBytes = null; callbackHandler.post(new Runnable() { @Override public void run() { peripheralCallback.onCharacteristicWrite(BluetoothPeripheral.this, value, characteristic, status); } }); completedCommand(); } @Override public void onReadRemoteRssi(BluetoothGatt gatt, final int rssi, final int status) { callbackHandler.post(new Runnable() { @Override public void run() { peripheralCallback.onReadRemoteRssi(BluetoothPeripheral.this, rssi, status); } }); completedCommand(); } @Override public void onMtuChanged(BluetoothGatt gatt, final int mtu, final int status) { callbackHandler.post(new Runnable() { @Override public void run() { peripheralCallback.onMtuChanged(BluetoothPeripheral.this, mtu, status); } }); completedCommand(); } }; private void successfullyConnected(int bondstate, long timePassed) { Timber.i("connected to '%s' (%s) in %.1fs", getName(), bondStateToString(bondstate), timePassed / 1000.0f); if (bondstate == BOND_NONE || bondstate == BOND_BONDED) { delayedDiscoverServices(getServiceDiscoveryDelay(bondstate)); } else if (bondstate == BOND_BONDING) { // Apparently the bonding process has already started, so let it complete. We'll do discoverServices once bonding finished Timber.i("waiting for bonding to complete"); } } private void delayedDiscoverServices(final long delay) { discoverServicesRunnable = new Runnable() { @Override public void run() { Timber.d("discovering services of '%s' with delay of %d ms", getName(), delay); if (!bluetoothGatt.discoverServices()) { Timber.e("discoverServices failed to start"); } discoverServicesRunnable = null; } }; mainHandler.postDelayed(discoverServicesRunnable, delay); } private long getServiceDiscoveryDelay(int bondstate) { long delayWhenBonded = 0; if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N) { // It seems delays when bonded are only needed in versions Nougat or lower // This issue was observed on a Nexus 5 (M) and Sony Xperia L1 (N) when connecting to a A&D UA-651BLE // The delay is needed when devices have the Service Changed Characteristic. // If they don't have it the delay isn't needed but we do it anyway to keep code simple delayWhenBonded = 1000L; } return bondstate == BOND_BONDED ? delayWhenBonded : 0; } private void successfullyDisconnected(int previousState) { if (previousState == BluetoothProfile.STATE_CONNECTED || previousState == BluetoothProfile.STATE_DISCONNECTING) { Timber.i("disconnected '%s' on request", getName()); } else if (previousState == BluetoothProfile.STATE_CONNECTING) { Timber.i("cancelling connect attempt"); } if (bondLost) { completeDisconnect(false, GATT_SUCCESS); if (listener != null) { // Consider the loss of the bond a connection failure so that a connection retry will take place callbackHandler.postDelayed(new Runnable() { @Override public void run() { listener.connectFailed(BluetoothPeripheral.this, GATT_SUCCESS); } }, DELAY_AFTER_BOND_LOST); // Give the stack some time to register the bond loss internally. This is needed on most phones... } } else { completeDisconnect(true, GATT_SUCCESS); } } private void connectionStateChangeUnsuccessful(int status, int previousState, int newState, long timePassed) { // Check if service discovery completed if (discoverServicesRunnable != null) { // Service discovery is still pending so cancel it mainHandler.removeCallbacks(discoverServicesRunnable); discoverServicesRunnable = null; } boolean servicesDiscovered = !getServices().isEmpty(); // See if the initial connection failed if (previousState == BluetoothProfile.STATE_CONNECTING) { boolean isTimeout = timePassed > getTimoutThreshold(); Timber.i("connection failed with status '%s' (%s)", statusToString(status), isTimeout ? "TIMEOUT" : "ERROR"); final int adjustedStatus = (status == GATT_ERROR && isTimeout) ? GATT_CONN_TIMEOUT : status; completeDisconnect(false, adjustedStatus); if (listener != null) { listener.connectFailed(BluetoothPeripheral.this, adjustedStatus); } } else if (previousState == BluetoothProfile.STATE_CONNECTED && newState == BluetoothProfile.STATE_DISCONNECTED && !servicesDiscovered) { // We got a disconnection before the services were even discovered Timber.i("peripheral '%s' disconnected with status '%s' before completing service discovery", getName(), statusToString(status)); completeDisconnect(false, status); if (listener != null) { listener.connectFailed(BluetoothPeripheral.this, status); } } else { // See if we got connection drop if (newState == BluetoothProfile.STATE_DISCONNECTED) { Timber.i("peripheral '%s' disconnected with status '%s'", getName(), statusToString(status)); } else { Timber.i("unexpected connection state change for '%s' status '%s'", getName(), statusToString(status)); } completeDisconnect(true, status); } } private final BroadcastReceiver bondStateReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { final String action = intent.getAction(); if (action == null) return; final BluetoothDevice receivedDevice = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); if (receivedDevice == null) return; // Ignore updates for other devices if (!receivedDevice.getAddress().equalsIgnoreCase(getAddress())) return; if (action.equals(BluetoothDevice.ACTION_BOND_STATE_CHANGED)) { final int bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.ERROR); final int previousBondState = intent.getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, BluetoothDevice.ERROR); handleBondStateChange(bondState, previousBondState); } } }; private void handleBondStateChange(int bondState, int previousBondState) { switch (bondState) { case BOND_BONDING: Timber.d("starting bonding with '%s' (%s)", getName(), getAddress()); callbackHandler.post(new Runnable() { @Override public void run() { peripheralCallback.onBondingStarted(BluetoothPeripheral.this); } }); break; case BOND_BONDED: // Bonding succeeded Timber.d("bonded with '%s' (%s)", getName(), getAddress()); callbackHandler.post(new Runnable() { @Override public void run() { peripheralCallback.onBondingSucceeded(BluetoothPeripheral.this); } }); // If bonding was started at connection time, we may still have to discover the services if (bluetoothGatt.getServices().isEmpty()) { delayedDiscoverServices(0); } // If bonding was triggered by a read/write, we must retry it if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { if (commandQueueBusy && !manuallyBonding) { mainHandler.postDelayed(new Runnable() { @Override public void run() { Timber.d("retrying command after bonding"); retryCommand(); } }, 50); } } // If we are doing a manual bond, complete the command if (manuallyBonding) { manuallyBonding = false; completedCommand(); } break; case BOND_NONE: if (previousBondState == BOND_BONDING) { Timber.e("bonding failed for '%s', disconnecting device", getName()); callbackHandler.post(new Runnable() { @Override public void run() { peripheralCallback.onBondingFailed(BluetoothPeripheral.this); } }); } else { Timber.e("bond lost for '%s'", getName()); bondLost = true; // Cancel the discoverServiceRunnable if it is still pending if (discoverServicesRunnable != null) { mainHandler.removeCallbacks(discoverServicesRunnable); discoverServicesRunnable = null; } callbackHandler.post(new Runnable() { @Override public void run() { peripheralCallback.onBondLost(BluetoothPeripheral.this); } }); } disconnect(); break; } } private final BroadcastReceiver pairingRequestBroadcastReceiver = new BroadcastReceiver() { @Override public void onReceive(final Context context, final Intent intent) { final BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); if (device == null) return; // Skip other devices if (!device.getAddress().equalsIgnoreCase(getAddress())) return; final int variant = intent.getIntExtra(BluetoothDevice.EXTRA_PAIRING_VARIANT, BluetoothDevice.ERROR); Timber.d("pairing request received " + ", pairing variant: " + pairingVariantToString(variant) + " (" + variant + ")"); if (variant == PAIRING_VARIANT_PIN) { String pin = listener.getPincode(BluetoothPeripheral.this); if (pin != null) { Timber.d("Setting PIN code for this peripheral using '%s'", pin); device.setPin(pin.getBytes()); abortBroadcast(); } } } }; /** * Constructs a new device wrapper around {@code device}. * * @param context Android application environment. * @param device Wrapped Android bluetooth device. * @param listener Callback to {@link BluetoothCentral}. */ BluetoothPeripheral(Context context, BluetoothDevice device, InternalCallback listener, BluetoothPeripheralCallback peripheralCallback, Handler callbackHandler) { if (context == null || device == null || listener == null) { Timber.e("cannot create BluetoothPeripheral because of null values"); } this.context = context; this.device = device; this.peripheralCallback = peripheralCallback; this.listener = listener; this.callbackHandler = (callbackHandler != null) ? callbackHandler : new Handler(Looper.getMainLooper()); this.state = BluetoothProfile.STATE_DISCONNECTED; this.commandQueueBusy = false; } void setPeripheralCallback(BluetoothPeripheralCallback peripheralCallback) { this.peripheralCallback = peripheralCallback; } /** * Connect directly with the bluetooth device. This call will timeout in max 30 seconds (5 seconds on Samsung phones) */ void connect() { // Make sure we are disconnected before we start making a connection if (state == BluetoothProfile.STATE_DISCONNECTED) { mainHandler.postDelayed(new Runnable() { @Override public void run() { // Connect to device with autoConnect = false Timber.i("connect to '%s' (%s) using TRANSPORT_LE", getName(), getAddress()); registerBondingBroadcastReceivers(); state = BluetoothProfile.STATE_CONNECTING; bluetoothGatt = connectGattHelper(device, false, bluetoothGattCallback); connectTimestamp = SystemClock.elapsedRealtime(); startConnectionTimer(BluetoothPeripheral.this); } }, DIRECT_CONNECTION_DELAY_IN_MS); } else { Timber.e("peripheral '%s' not yet disconnected, will not connect", getName()); } } /** * Try to connect to a device whenever it is found by the OS. This call never times out. * Connecting to a device will take longer than when using connect() */ void autoConnect() { // Note that this will only work for devices that are known! After turning BT on/off Android doesn't know the device anymore! // https://stackoverflow.com/questions/43476369/android-save-ble-device-to-reconnect-after-app-close if (state == BluetoothProfile.STATE_DISCONNECTED) { mainHandler.post(new Runnable() { @Override public void run() { // Connect to device with autoConnect = true Timber.i("autoConnect to '%s' (%s) using TRANSPORT_LE", getName(), getAddress()); registerBondingBroadcastReceivers(); state = BluetoothProfile.STATE_CONNECTING; bluetoothGatt = connectGattHelper(device, true, bluetoothGattCallback); connectTimestamp = SystemClock.elapsedRealtime(); } }); } else { Timber.e("peripheral '%s' not yet disconnected, will not connect", getName()); } } private void registerBondingBroadcastReceivers() { context.registerReceiver(bondStateReceiver, new IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED)); context.registerReceiver(pairingRequestBroadcastReceiver, new IntentFilter(BluetoothDevice.ACTION_PAIRING_REQUEST)); } /** * Create a bond with the peripheral. * * <p>If a (auto)connect has been issued, the bonding command will be enqueued and you will * receive updates via the {@link BluetoothPeripheralCallback}. Otherwise the bonding will * be done immediately and no updates via the callback will happen. * * @return true if bonding was started/enqueued, false if not */ public boolean createBond() { // Check if we have a Gatt object if (bluetoothGatt == null) { // No gatt object so no connection issued, do create bond immediately return device.createBond(); } // Enqueue the bond command because a connection has been issued or we are already connected boolean result = commandQueue.add(new Runnable() { @Override public void run() { manuallyBonding = true; if (!device.createBond()) { Timber.e("bonding failed for %s", getAddress()); completedCommand(); } else { Timber.d("manually bonding %s", getAddress()); nrTries++; } } }); if (result) { nextCommand(); } else { Timber.e("could not enqueue bonding command"); } return result; } /** * Request a different connection priority. * <p> * Use the standard parameters for Android: CONNECTION_PRIORITY_BALANCED, CONNECTION_PRIORITY_HIGH, or CONNECTION_PRIORITY_LOW_POWER. There is no callback for this function. * * @param priority the requested connection priority * @return true if request was enqueued, false if not */ public boolean requestConnectionPriority(final int priority) { // Enqueue the request connection priority command and complete is immediately as there is no callback for it boolean result = commandQueue.add(new Runnable() { @Override public void run() { if (isConnected()) { if (!bluetoothGatt.requestConnectionPriority(priority)) { Timber.e("could not set connection priority"); } else { Timber.d("requesting connection priority %d", priority); } completedCommand(); } } }); if (result) { nextCommand(); } else { Timber.e("could not enqueue request connection priority command"); } return result; } /** * Version of createBond with transport parameter. * May use in the future if needed as I never encountered an issue */ private boolean createBond(int transport) { Timber.d("bonding using TRANSPORT_LE"); boolean result = false; try { Method bondMethod = device.getClass().getMethod("createBond", int.class); if (bondMethod != null) { result = (boolean) bondMethod.invoke(device, transport); } } catch (Exception e) { Timber.e("could not invoke createBond method"); } return result; } /** * Cancel an active or pending connection. * <p> * This operation is asynchronous and you will receive a callback on onDisconnectedPeripheral. */ public void cancelConnection() { // Check if we have a Gatt object if (bluetoothGatt == null) { return; } // Check if we are not already disconnected or disconnecting if (state == BluetoothProfile.STATE_DISCONNECTED || state == BluetoothProfile.STATE_DISCONNECTING) { return; } // Cancel the connection timer cancelConnectionTimer(); // Check if we are in the process of connecting if (state == BluetoothProfile.STATE_CONNECTING) { // Cancel the connection by calling disconnect disconnect(); // Since we will not get a callback on onConnectionStateChange for this, we complete the disconnect ourselves mainHandler.postDelayed(new Runnable() { @Override public void run() { completeDisconnect(true, GATT_SUCCESS); } }, 50); } else { // Cancel active connection disconnect(); } } /** * Disconnect the bluetooth peripheral. * * <p>When the disconnection has been completed {@link BluetoothCentralCallback#onDisconnectedPeripheral(BluetoothPeripheral, int)} will be called. */ private void disconnect() { if (state == BluetoothProfile.STATE_CONNECTED || state == BluetoothProfile.STATE_CONNECTING) { this.state = BluetoothProfile.STATE_DISCONNECTING; mainHandler.post(new Runnable() { @Override public void run() { if (bluetoothGatt != null) { Timber.i("force disconnect '%s' (%s)", getName(), getAddress()); bluetoothGatt.disconnect(); } } }); } else { if (listener != null) { listener.disconnected(BluetoothPeripheral.this, GATT_CONN_TERMINATE_LOCAL_HOST); } } } void disconnectWhenBluetoothOff() { bluetoothGatt = null; completeDisconnect(true, GATT_SUCCESS); } /** * Complete the disconnect after getting connectionstate = disconnected */ private void completeDisconnect(boolean notify, final int status) { if (bluetoothGatt != null) { bluetoothGatt.close(); bluetoothGatt = null; } commandQueue.clear(); commandQueueBusy = false; try { context.unregisterReceiver(bondStateReceiver); context.unregisterReceiver(pairingRequestBroadcastReceiver); } catch (IllegalArgumentException e) { // In case bluetooth is off, unregisering broadcast receivers may fail } bondLost = false; if (listener != null && notify) { listener.disconnected(BluetoothPeripheral.this, status); } } /** * Get the mac address of the bluetooth peripheral. * * @return Address of the bluetooth peripheral */ public String getAddress() { return device.getAddress(); } /** * Get the type of the peripheral. * * @return the device type {@link #DEVICE_TYPE_CLASSIC}, {@link #DEVICE_TYPE_LE} {@link #DEVICE_TYPE_DUAL}. {@link #DEVICE_TYPE_UNKNOWN} if it's not available */ public int getType() { return device.getType(); } /** * Get the name of the bluetooth peripheral. * * @return name of the bluetooth peripheral */ public String getName() { String name = device.getName(); if (name != null) { // Cache the name so that we even know it when bluetooth is switched off cachedName = name; } return cachedName; } /** * Get the bond state of the bluetooth peripheral. * * <p>Possible values for the bond state are: * {@link #BOND_NONE}, * {@link #BOND_BONDING}, * {@link #BOND_BONDED}. * * @return returns the bond state */ public int getBondState() { return device.getBondState(); } /** * Get the services supported by the connected bluetooth peripheral. * Only services that are also supported by {@link BluetoothCentral} are included. * * @return Supported services. */ @SuppressWarnings("WeakerAccess") public List<BluetoothGattService> getServices() { return bluetoothGatt.getServices(); } /** * Get the BluetoothGattService object for a service UUID. * * @param serviceUUID the UUID of the service * @return the BluetoothGattService object for the service UUID or null if the peripheral does not have a service with the specified UUID */ public BluetoothGattService getService(UUID serviceUUID) { if (bluetoothGatt != null) { return bluetoothGatt.getService(serviceUUID); } else { return null; } } /** * Get the BluetoothGattCharacteristic object for a characteristic UUID. * * @param serviceUUID the service UUID the characteristic is part of * @param characteristicUUID the UUID of the chararacteristic * @return the BluetoothGattCharacteristic object for the characteristic UUID or null if the peripheral does not have a characteristic with the specified UUID */ public BluetoothGattCharacteristic getCharacteristic(UUID serviceUUID, UUID characteristicUUID) { BluetoothGattService service = getService(serviceUUID); if (service != null) { return service.getCharacteristic(characteristicUUID); } else { return null; } } /** * Returns the connection state of the peripheral. * * <p>Possible values for the connection state are: * {@link #STATE_CONNECTED}, * {@link #STATE_CONNECTING}, * {@link #STATE_DISCONNECTED}, * {@link #STATE_DISCONNECTING}. * * @return the connection state. */ public int getState() { return state; } /** * Boolean to indicate if the specified characteristic is currently notifying or indicating. * * @param characteristic the characteristic to check * @return true if the characteristic is notifying or indicating, false if it is not */ public boolean isNotifying(BluetoothGattCharacteristic characteristic) { return notifyingCharacteristics.contains(characteristic.getUuid()); } private boolean isConnected() { return bluetoothGatt != null && state == BluetoothProfile.STATE_CONNECTED; } /** * Read the value of a characteristic. * * <p>The characteristic must support reading it, otherwise the operation will not be enqueued. * * <p>{@link BluetoothPeripheralCallback#onCharacteristicUpdate(BluetoothPeripheral, byte[], BluetoothGattCharacteristic, int)} will be triggered as a result of this call. * * @param characteristic Specifies the characteristic to read. * @return true if the operation was enqueued, false if the characteristic does not support reading or the characteristic was invalid */ public boolean readCharacteristic(final BluetoothGattCharacteristic characteristic) { // Check if gatt object is valid if (bluetoothGatt == null) { Timber.e("gatt is 'null', ignoring read request"); return false; } // Check if characteristic is valid if (characteristic == null) { Timber.e("characteristic is 'null', ignoring read request"); return false; } // Check if this characteristic actually has READ property if ((characteristic.getProperties() & PROPERTY_READ) == 0) { Timber.e("characteristic does not have read property"); return false; } // Enqueue the read command now that all checks have been passed boolean result = commandQueue.add(new Runnable() { @Override public void run() { if (isConnected()) { if (!bluetoothGatt.readCharacteristic(characteristic)) { Timber.e("readCharacteristic failed for characteristic: %s", characteristic.getUuid()); completedCommand(); } else { Timber.d("reading characteristic <%s>", characteristic.getUuid()); nrTries++; } } else { completedCommand(); } } }); if (result) { nextCommand(); } else { Timber.e("could not enqueue read characteristic command"); } return result; } /** * Write a value to a characteristic using the specified write type. * * <p>All parameters must have a valid value in order for the operation * to be enqueued. If the characteristic does not support writing with the specified writeType, the operation will not be enqueued. * * <p>{@link BluetoothPeripheralCallback#onCharacteristicWrite(BluetoothPeripheral, byte[], BluetoothGattCharacteristic, int)} will be triggered as a result of this call. * * @param characteristic the characteristic to write to * @param value the byte array to write * @param writeType the write type to use when writing. Must be WRITE_TYPE_DEFAULT, WRITE_TYPE_NO_RESPONSE or WRITE_TYPE_SIGNED * @return true if a write operation was succesfully enqueued, otherwise false */ public boolean writeCharacteristic(final BluetoothGattCharacteristic characteristic, final byte[] value, final int writeType) { // Check if gatt object is valid if (bluetoothGatt == null) { Timber.e("gatt is 'null', ignoring read request"); return false; } // Check if characteristic is valid if (characteristic == null) { Timber.e("characteristic is 'null', ignoring write request"); return false; } // Check if byte array is valid if (value == null) { Timber.e("value to write is 'null', ignoring write request"); return false; } // Copy the value to avoid race conditions final byte[] bytesToWrite = copyOf(value); // Check if this characteristic actually supports this writeType int writeProperty; switch (writeType) { case WRITE_TYPE_DEFAULT: writeProperty = PROPERTY_WRITE; break; case WRITE_TYPE_NO_RESPONSE: writeProperty = PROPERTY_WRITE_NO_RESPONSE; break; case WRITE_TYPE_SIGNED: writeProperty = PROPERTY_SIGNED_WRITE; break; default: writeProperty = 0; break; } if ((characteristic.getProperties() & writeProperty) == 0) { Timber.e("characteristic <%s> does not support writeType '%s'", characteristic.getUuid(), writeTypeToString(writeType)); return false; } // Enqueue the write command now that all checks have been passed boolean result = commandQueue.add(new Runnable() { @Override public void run() { if (isConnected()) { currentWriteBytes = bytesToWrite; characteristic.setValue(bytesToWrite); characteristic.setWriteType(writeType); if (!bluetoothGatt.writeCharacteristic(characteristic)) { Timber.e("writeCharacteristic failed for characteristic: %s", characteristic.getUuid()); completedCommand(); } else { Timber.d("writing <%s> to characteristic <%s>", bytes2String(bytesToWrite), characteristic.getUuid()); nrTries++; } } else { completedCommand(); } } }); if (result) { nextCommand(); } else { Timber.e("could not enqueue write characteristic command"); } return result; } /** * Read the value of a descriptor. * * @param descriptor the descriptor to read * @return true if a write operation was succesfully enqueued, otherwise false */ public boolean readDescriptor(final BluetoothGattDescriptor descriptor) { // Check if gatt object is valid if (bluetoothGatt == null) { Timber.e("gatt is 'null', ignoring read request"); return false; } // Check if descriptor is valid if (descriptor == null) { Timber.e("descriptor is 'null', ignoring read request"); return false; } // Enqueue the read command now that all checks have been passed boolean result = commandQueue.add(new Runnable() { @Override public void run() { if (isConnected()) { if (!bluetoothGatt.readDescriptor(descriptor)) { Timber.e("readDescriptor failed for characteristic: %s", descriptor.getUuid()); completedCommand(); } else { nrTries++; } } else { completedCommand(); } } }); if (result) { nextCommand(); } else { Timber.e("could not enqueue read descriptor command"); } return result; } /** * Write a value to a descriptor. * * <p>For turning on/off notifications use {@link BluetoothPeripheral#setNotify(BluetoothGattCharacteristic, boolean)} instead. * * @param descriptor the descriptor to write to * @param value the value to write * @return true if a write operation was succesfully enqueued, otherwise false */ public boolean writeDescriptor(final BluetoothGattDescriptor descriptor, final byte[] value) { // Check if gatt object is valid if (bluetoothGatt == null) { Timber.e("gatt is 'null', ignoring write descriptor request"); return false; } // Check if characteristic is valid if (descriptor == null) { Timber.e("descriptor is 'null', ignoring write request"); return false; } // Check if byte array is valid if (value == null) { Timber.e("value to write is 'null', ignoring write request"); return false; } // Copy the value to avoid race conditions final byte[] bytesToWrite = copyOf(value); // Enqueue the write command now that all checks have been passed boolean result = commandQueue.add(new Runnable() { @Override public void run() { if (isConnected()) { currentWriteBytes = bytesToWrite; descriptor.setValue(bytesToWrite); if (!bluetoothGatt.writeDescriptor(descriptor)) { Timber.e("writeDescriptor failed for descriptor: %s", descriptor.getUuid()); completedCommand(); } else { Timber.d("writing <%s> to descriptor <%s>", bytes2String(bytesToWrite), descriptor.getUuid()); nrTries++; } } else { completedCommand(); } } }); if (result) { nextCommand(); } else { Timber.e("could not enqueue write descriptor command"); } return result; } /** * Set the notification state of a characteristic to 'on' or 'off'. The characteristic must support notifications or indications. * * <p>{@link BluetoothPeripheralCallback#onNotificationStateUpdate(BluetoothPeripheral, BluetoothGattCharacteristic, int)} will be triggered as a result of this call. * * @param characteristic the characteristic to turn notification on/off for * @param enable true for setting notification on, false for turning it off * @return true if the operation was enqueued, false if the characteristic doesn't support notification or indications or */ public boolean setNotify(final BluetoothGattCharacteristic characteristic, final boolean enable) { // Check if gatt object is valid if (bluetoothGatt == null) { Timber.e("gatt is 'null', ignoring set notify request"); return false; } // Check if characteristic is valid if (characteristic == null) { Timber.e("characteristic is 'null', ignoring setNotify request"); return false; } // Get the Client Configuration Descriptor for the characteristic final BluetoothGattDescriptor descriptor = characteristic.getDescriptor(UUID.fromString(CCC_DESCRIPTOR_UUID)); if (descriptor == null) { Timber.e("could not get CCC descriptor for characteristic %s", characteristic.getUuid()); return false; } // Check if characteristic has NOTIFY or INDICATE properties and set the correct byte value to be written byte[] value; int properties = characteristic.getProperties(); if ((properties & PROPERTY_NOTIFY) > 0) { value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE; } else if ((properties & PROPERTY_INDICATE) > 0) { value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE; } else { Timber.e("characteristic %s does not have notify or indicate property", characteristic.getUuid()); return false; } final byte[] finalValue = enable ? value : BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE; // Queue Runnable to turn on/off the notification now that all checks have been passed boolean result = commandQueue.add(new Runnable() { @Override public void run() { if (!isConnected()) { completedCommand(); return; } // First set notification for Gatt object if (!bluetoothGatt.setCharacteristicNotification(characteristic, enable)) { Timber.e("setCharacteristicNotification failed for characteristic: %s", characteristic.getUuid()); } // Then write to descriptor currentWriteBytes = finalValue; descriptor.setValue(finalValue); boolean result; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { result = bluetoothGatt.writeDescriptor(descriptor); } else { // Up to Android 6 there is a bug where Android takes the writeType of the parent characteristic instead of always WRITE_TYPE_DEFAULT // See: https://android.googlesource.com/platform/frameworks/base/+/942aebc95924ab1e7ea1e92aaf4e7fc45f695a6c%5E%21/#F0 final BluetoothGattCharacteristic parentCharacteristic = descriptor.getCharacteristic(); final int originalWriteType = parentCharacteristic.getWriteType(); parentCharacteristic.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT); result = bluetoothGatt.writeDescriptor(descriptor); parentCharacteristic.setWriteType(originalWriteType); } if (!result) { Timber.e("writeDescriptor failed for descriptor: %s", descriptor.getUuid()); completedCommand(); } else { nrTries++; } } }); if (result) { nextCommand(); } else { Timber.e("could not enqueue setNotify command"); } return result; } /** * Asynchronous method to clear the services cache. Make sure to add a delay when using this! * * @return true if the method was executed, false if not executed */ public boolean clearServicesCache() { boolean result = false; try { Method refreshMethod = bluetoothGatt.getClass().getMethod("refresh"); if (refreshMethod != null) { result = (boolean) refreshMethod.invoke(bluetoothGatt); } } catch (Exception e) { Timber.e("could not invoke refresh method"); } return result; } /** * Read the RSSI for a connected remote peripheral. * * <p>{@link BluetoothPeripheralCallback#onReadRemoteRssi(BluetoothPeripheral, int, int)} will be triggered as a result of this call. * * @return true if the operation was enqueued, false otherwise */ public boolean readRemoteRssi() { boolean result = commandQueue.add(new Runnable() { @Override public void run() { if (isConnected()) { if (!bluetoothGatt.readRemoteRssi()) { Timber.e("readRemoteRssi failed"); completedCommand(); } } else { Timber.e("cannot get rssi, peripheral not connected"); completedCommand(); } } }); if (result) { nextCommand(); } else { Timber.e("could not enqueue setNotify command"); } return result; } /** * Request an MTU size used for a given connection. * * <p>When performing a write request operation (write without response), * the data sent is truncated to the MTU size. This function may be used * to request a larger MTU size to be able to send more data at once. * * <p>{@link BluetoothPeripheralCallback#onMtuChanged(BluetoothPeripheral, int, int)} will be triggered as a result of this call. * * @param mtu the desired MTU size * @return true if the operation was enqueued, false otherwise */ public boolean requestMtu(final int mtu) { boolean result = commandQueue.add(new Runnable() { @Override public void run() { if (isConnected()) { if (!bluetoothGatt.requestMtu(mtu)) { Timber.e("requestMtu failed"); completedCommand(); } } else { Timber.e("cannot request MTU, peripheral not connected"); completedCommand(); } } }); if (result) { nextCommand(); } else { Timber.e("could not enqueue setNotify command"); } return result; } /** * The current command has been completed, move to the next command in the queue (if any) */ private void completedCommand() { isRetrying = false; commandQueue.poll(); commandQueueBusy = false; nextCommand(); } /** * Retry the current command. Typically used when a read/write fails and triggers a bonding procedure */ private void retryCommand() { commandQueueBusy = false; Runnable currentCommand = commandQueue.peek(); if (currentCommand != null) { if (nrTries >= MAX_TRIES) { // Max retries reached, give up on this one and proceed Timber.d("max number of tries reached, not retrying operation anymore"); commandQueue.poll(); } else { isRetrying = true; } } nextCommand(); } /** * Execute the next command in the subscribe queue. * A queue is used because the calls have to be executed sequentially. * If the read or write fails, the next command in the queue is executed. */ private void nextCommand() { synchronized (this) { // If there is still a command being executed, then bail out if (commandQueueBusy) return; // Check if there is something to do at all final Runnable bluetoothCommand = commandQueue.peek(); if (bluetoothCommand == null) return; // Check if we still have a valid gatt object if (bluetoothGatt == null) { Timber.e("gatt is 'null' for peripheral '%s', clearing command queue", getAddress()); commandQueue.clear(); commandQueueBusy = false; return; } // Execute the next command in the queue commandQueueBusy = true; if (!isRetrying) { nrTries = 0; } mainHandler.post(new Runnable() { @Override public void run() { try { bluetoothCommand.run(); } catch (Exception ex) { Timber.e(ex, "command exception for device '%s'", getName()); completedCommand(); } } }); } } private String bondStateToString(final int state) { switch (state) { case BOND_NONE: return "BOND_NONE"; case BOND_BONDING: return "BOND_BONDING"; case BOND_BONDED: return "BOND_BONDED"; default: return "UNKNOWN"; } } private String stateToString(final int state) { switch (state) { case BluetoothProfile.STATE_CONNECTED: return "CONNECTED"; case BluetoothProfile.STATE_CONNECTING: return "CONNECTING"; case BluetoothProfile.STATE_DISCONNECTING: return "DISCONNECTING"; default: return "DISCONNECTED"; } } private String writeTypeToString(final int writeType) { switch (writeType) { case WRITE_TYPE_DEFAULT: return "WRITE_TYPE_DEFAULT"; case WRITE_TYPE_NO_RESPONSE: return "WRITE_TYPE_NO_RESPONSE"; case WRITE_TYPE_SIGNED: return "WRITE_TYPE_SIGNED"; default: return "unknown writeType"; } } private static String statusToString(final int error) { switch (error) { case GATT_SUCCESS: return "SUCCESS"; case GATT_CONN_L2C_FAILURE: return "GATT CONN L2C FAILURE"; case GATT_CONN_TIMEOUT: return "GATT CONN TIMEOUT"; // Connection timed out case GATT_CONN_TERMINATE_PEER_USER: return "GATT CONN TERMINATE PEER USER"; case GATT_CONN_TERMINATE_LOCAL_HOST: return "GATT CONN TERMINATE LOCAL HOST"; case BLE_HCI_CONN_TERMINATED_DUE_TO_MIC_FAILURE: return "BLE HCI CONN TERMINATED DUE TO MIC FAILURE"; case GATT_CONN_FAIL_ESTABLISH: return "GATT CONN FAIL ESTABLISH"; case GATT_CONN_LMP_TIMEOUT: return "GATT CONN LMP TIMEOUT"; case GATT_CONN_CANCEL: return "GATT CONN CANCEL "; case GATT_BUSY: return "GATT BUSY"; case GATT_ERROR: return "GATT ERROR"; // Device not reachable case GATT_AUTH_FAIL: return "GATT AUTH FAIL"; // Device needs to be bonded case GATT_NO_RESOURCES: return "GATT NO RESOURCES"; case GATT_INTERNAL_ERROR: return "GATT INTERNAL ERROR"; default: return "UNKNOWN (" + error + ")"; } } private static final int PAIRING_VARIANT_PIN = 0; private static final int PAIRING_VARIANT_PASSKEY = 1; private static final int PAIRING_VARIANT_PASSKEY_CONFIRMATION = 2; private static final int PAIRING_VARIANT_CONSENT = 3; private static final int PAIRING_VARIANT_DISPLAY_PASSKEY = 4; private static final int PAIRING_VARIANT_DISPLAY_PIN = 5; private static final int PAIRING_VARIANT_OOB_CONSENT = 6; private String pairingVariantToString(final int variant) { switch (variant) { case PAIRING_VARIANT_PIN: return "PAIRING_VARIANT_PIN"; case PAIRING_VARIANT_PASSKEY: return "PAIRING_VARIANT_PASSKEY"; case PAIRING_VARIANT_PASSKEY_CONFIRMATION: return "PAIRING_VARIANT_PASSKEY_CONFIRMATION"; case PAIRING_VARIANT_CONSENT: return "PAIRING_VARIANT_CONSENT"; case PAIRING_VARIANT_DISPLAY_PASSKEY: return "PAIRING_VARIANT_DISPLAY_PASSKEY"; case PAIRING_VARIANT_DISPLAY_PIN: return "PAIRING_VARIANT_DISPLAY_PIN"; case PAIRING_VARIANT_OOB_CONSENT: return "PAIRING_VARIANT_OOB_CONSENT"; default: return "UNKNOWN"; } } /** * Converts byte array to hex string * * @param bytes the byte array to convert * @return String representing the byte array as a HEX string */ private static String bytes2String(final byte[] bytes) { StringBuilder sb = new StringBuilder(); for (byte b : bytes) { sb.append(String.format("%02x", b & 0xff)); } return sb.toString(); } interface InternalCallback { /** * {@link BluetoothPeripheral} has successfully connected. * * @param device {@link BluetoothPeripheral} that connected. */ void connected(BluetoothPeripheral device); /** * Connecting with {@link BluetoothPeripheral} has failed. * * @param device {@link BluetoothPeripheral} of which connect failed. */ void connectFailed(BluetoothPeripheral device, final int status); /** * {@link BluetoothPeripheral} has disconnected. * * @param device {@link BluetoothPeripheral} that disconnected. */ void disconnected(BluetoothPeripheral device, final int status); String getPincode(BluetoothPeripheral device); } ///////////////// private BluetoothGatt connectGattHelper(BluetoothDevice remoteDevice, boolean autoConnect, BluetoothGattCallback bluetoothGattCallback) { if (remoteDevice == null) { return null; } /* This bug workaround was taken from the Polidea RxAndroidBle Issue that caused a race condition mentioned below was fixed in 7.0.0_r1 https://android.googlesource.com/platform/frameworks/base/+/android-7.0.0_r1/core/java/android/bluetooth/BluetoothGatt.java#649 compared to https://android.googlesource.com/platform/frameworks/base/+/android-6.0.1_r72/core/java/android/bluetooth/BluetoothGatt.java#739 issue: https://android.googlesource.com/platform/frameworks/base/+/d35167adcaa40cb54df8e392379dfdfe98bcdba2%5E%21/#F0 */ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N || !autoConnect) { return connectGattCompat(bluetoothGattCallback, remoteDevice, autoConnect); } try { Object iBluetoothGatt = getIBluetoothGatt(getIBluetoothManager()); if (iBluetoothGatt == null) { Timber.e("could not get iBluetoothGatt object"); return connectGattCompat(bluetoothGattCallback, remoteDevice, true); } BluetoothGatt bluetoothGatt = createBluetoothGatt(iBluetoothGatt, remoteDevice); if (bluetoothGatt == null) { Timber.e("could not create BluetoothGatt object"); return connectGattCompat(bluetoothGattCallback, remoteDevice, true); } boolean connectedSuccessfully = connectUsingReflection(remoteDevice, bluetoothGatt, bluetoothGattCallback, true); if (!connectedSuccessfully) { Timber.i("connection using reflection failed, closing gatt"); bluetoothGatt.close(); } return bluetoothGatt; } catch (NoSuchMethodException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | InstantiationException | NoSuchFieldException exception) { Timber.e("error during reflection"); return connectGattCompat(bluetoothGattCallback, remoteDevice, true); } } private BluetoothGatt connectGattCompat(BluetoothGattCallback bluetoothGattCallback, BluetoothDevice device, boolean autoConnect) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { return device.connectGatt(context, autoConnect, bluetoothGattCallback, TRANSPORT_LE); } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { // Try to call connectGatt with TRANSPORT_LE parameter using reflection try { Method connectGattMethod = device.getClass().getMethod("connectGatt", Context.class, boolean.class, BluetoothGattCallback.class, int.class); try { return (BluetoothGatt) connectGattMethod.invoke(device, context, autoConnect, bluetoothGattCallback, TRANSPORT_LE); } catch (IllegalAccessException e) { e.printStackTrace(); } catch (InvocationTargetException e) { e.printStackTrace(); } } catch (NoSuchMethodException e) { e.printStackTrace(); } } // Fallback on connectGatt without TRANSPORT_LE parameter return device.connectGatt(context, autoConnect, bluetoothGattCallback); } @SuppressWarnings("SameParameterValue") private boolean connectUsingReflection(BluetoothDevice device, BluetoothGatt bluetoothGatt, BluetoothGattCallback bluetoothGattCallback, boolean autoConnect) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException, NoSuchFieldException { setAutoConnectValue(bluetoothGatt, autoConnect); Method connectMethod = bluetoothGatt.getClass().getDeclaredMethod("connect", Boolean.class, BluetoothGattCallback.class); connectMethod.setAccessible(true); return (Boolean) (connectMethod.invoke(bluetoothGatt, true, bluetoothGattCallback)); } private BluetoothGatt createBluetoothGatt(Object iBluetoothGatt, BluetoothDevice remoteDevice) throws IllegalAccessException, InvocationTargetException, InstantiationException { Constructor bluetoothGattConstructor = BluetoothGatt.class.getDeclaredConstructors()[0]; bluetoothGattConstructor.setAccessible(true); if (bluetoothGattConstructor.getParameterTypes().length == 4) { return (BluetoothGatt) (bluetoothGattConstructor.newInstance(context, iBluetoothGatt, remoteDevice, TRANSPORT_LE)); } else { return (BluetoothGatt) (bluetoothGattConstructor.newInstance(context, iBluetoothGatt, remoteDevice)); } } private Object getIBluetoothGatt(Object iBluetoothManager) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { if (iBluetoothManager == null) { return null; } Method getBluetoothGattMethod = getMethodFromClass(iBluetoothManager.getClass(), "getBluetoothGatt"); return getBluetoothGattMethod.invoke(iBluetoothManager); } private Object getIBluetoothManager() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); if (bluetoothAdapter == null) { return null; } Method getBluetoothManagerMethod = getMethodFromClass(bluetoothAdapter.getClass(), "getBluetoothManager"); return getBluetoothManagerMethod.invoke(bluetoothAdapter); } private Method getMethodFromClass(Class<?> cls, String methodName) throws NoSuchMethodException { Method method = cls.getDeclaredMethod(methodName); method.setAccessible(true); return method; } private void setAutoConnectValue(BluetoothGatt bluetoothGatt, boolean autoConnect) throws NoSuchFieldException, IllegalAccessException { Field autoConnectField = bluetoothGatt.getClass().getDeclaredField("mAutoConnect"); autoConnectField.setAccessible(true); autoConnectField.setBoolean(bluetoothGatt, autoConnect); } private void startConnectionTimer(final BluetoothPeripheral peripheral) { cancelConnectionTimer(); timeoutRunnable = new Runnable() { @Override public void run() { Timber.e("connection timout, disconnecting '%s'", peripheral.getName()); disconnect(); completeDisconnect(true, GATT_CONN_TIMEOUT); timeoutRunnable = null; } }; mainHandler.postDelayed(timeoutRunnable, CONNECTION_TIMEOUT_IN_MS); } private void cancelConnectionTimer() { if (timeoutRunnable != null) { mainHandler.removeCallbacks(timeoutRunnable); timeoutRunnable = null; } } private int getTimoutThreshold() { String manufacturer = Build.MANUFACTURER; if (manufacturer.equals("samsung")) { return TIMEOUT_THRESHOLD_SAMSUNG; } else { return TIMEOUT_THRESHOLD_DEFAULT; } } private byte[] copyOf(byte[] source) { if (source == null) return new byte[0]; final int sourceLength = source.length; final byte[] copy = new byte[sourceLength]; System.arraycopy(source, 0, copy, 0, sourceLength); return copy; } }