Add BluetoothGattHelper, BluetoothGattConnection.

Test: unit test

Bug: 200231384
Change-Id: Ia4c9dec00c81aa398fffc5af9477682f96682168
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattConnection.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattConnection.java
new file mode 100644
index 0000000..8e66ff2
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattConnection.java
@@ -0,0 +1,782 @@
+/*
+ * Copyright 2021 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.server.nearby.common.bluetooth.gatt;
+
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattService;
+import android.util.Log;
+
+import androidx.annotation.VisibleForTesting;
+
+import com.android.server.nearby.common.bluetooth.BluetoothConsts;
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.BluetoothGattException;
+import com.android.server.nearby.common.bluetooth.BluetoothTimeoutException;
+import com.android.server.nearby.common.bluetooth.ReservedUuids;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattHelper.ConnectionOptions;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattHelper.OperationType;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothDevice;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothGatt;
+import com.android.server.nearby.common.bluetooth.util.BluetoothGattUtils;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.Operation;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.SynchronousOperation;
+
+import com.google.common.base.Preconditions;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.BlockingDeque;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.LinkedBlockingDeque;
+import java.util.concurrent.TimeUnit;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
+
+/**
+ * Gatt connection to a Bluetooth device.
+ */
+public class BluetoothGattConnection implements AutoCloseable {
+
+    private static final String TAG = BluetoothGattConnection.class.getSimpleName();
+
+    @VisibleForTesting
+    static final long OPERATION_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(1);
+    @VisibleForTesting
+    static final long SLOW_OPERATION_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(10);
+
+    @VisibleForTesting
+    static final int GATT_INTERNAL_ERROR = 129;
+    @VisibleForTesting
+    static final int GATT_ERROR = 133;
+
+    private final BluetoothGatt mGatt;
+    private final BluetoothOperationExecutor mBluetoothOperationExecutor;
+    private final ConnectionOptions mConnectionOptions;
+
+    private volatile boolean mServicesDiscovered = false;
+
+    private volatile boolean mIsConnected = false;
+
+    private volatile int mMtu = BluetoothConsts.DEFAULT_MTU;
+
+    private final ConcurrentMap<BluetoothGattCharacteristic, ChangeObserver> mChangeObservers =
+            new ConcurrentHashMap<>();
+
+    private final List<ConnectionCloseListener> mCloseListeners = new ArrayList<>();
+
+    private long mOperationTimeoutMillis = OPERATION_TIMEOUT_MILLIS;
+
+    BluetoothGattConnection(
+            BluetoothGatt gatt,
+            BluetoothOperationExecutor bluetoothOperationExecutor,
+            ConnectionOptions connectionOptions) {
+        mGatt = gatt;
+        mBluetoothOperationExecutor = bluetoothOperationExecutor;
+        mConnectionOptions = connectionOptions;
+    }
+
+    /**
+     * Set operation timeout.
+     */
+    public void setOperationTimeout(long timeoutMillis) {
+        Preconditions.checkArgument(timeoutMillis > 0, "invalid time out value");
+        mOperationTimeoutMillis = timeoutMillis;
+    }
+
+    /**
+     * Returns connected device.
+     */
+    public BluetoothDevice getDevice() {
+        return mGatt.getDevice();
+    }
+
+    public ConnectionOptions getConnectionOptions() {
+        return mConnectionOptions;
+    }
+
+    public boolean isConnected() {
+        return mIsConnected;
+    }
+
+    /**
+     * Get service.
+     */
+    public BluetoothGattService getService(UUID uuid) throws BluetoothException {
+        Log.d(TAG, String.format("Getting service %s.", uuid));
+        if (!mServicesDiscovered) {
+            discoverServices();
+        }
+        BluetoothGattService match = null;
+        for (BluetoothGattService service : mGatt.getServices()) {
+            if (service.getUuid().equals(uuid)) {
+                if (match != null) {
+                    throw new BluetoothException(
+                            String.format("More than one service %s found on device %s.",
+                                    uuid,
+                                    mGatt.getDevice()));
+                }
+                match = service;
+            }
+        }
+        if (match == null) {
+            throw new BluetoothException(String.format("Service %s not found on device %s.",
+                    uuid,
+                    mGatt.getDevice()));
+        }
+        Log.d(TAG, "Service found.");
+        return match;
+    }
+
+    /**
+     * Returns a list of all characteristics under a given service UUID.
+     */
+    private List<BluetoothGattCharacteristic> getCharacteristics(UUID serviceUuid)
+            throws BluetoothException {
+        if (!mServicesDiscovered) {
+            discoverServices();
+        }
+        ArrayList<BluetoothGattCharacteristic> characteristics = new ArrayList<>();
+        for (BluetoothGattService service : mGatt.getServices()) {
+            // Add all characteristics under this service if its service UUID matches.
+            if (service.getUuid().equals(serviceUuid)) {
+                characteristics.addAll(service.getCharacteristics());
+            }
+        }
+        return characteristics;
+    }
+
+    /**
+     * Get characteristic.
+     */
+    public BluetoothGattCharacteristic getCharacteristic(UUID serviceUuid,
+            UUID characteristicUuid) throws BluetoothException {
+        Log.d(TAG, String.format("Getting characteristic %s on service %s.", characteristicUuid,
+                serviceUuid));
+        BluetoothGattCharacteristic match = null;
+        for (BluetoothGattCharacteristic characteristic : getCharacteristics(serviceUuid)) {
+            if (characteristic.getUuid().equals(characteristicUuid)) {
+                if (match != null) {
+                    throw new BluetoothException(String.format(
+                            "More than one characteristic %s found on service %s on device %s.",
+                            characteristicUuid,
+                            serviceUuid,
+                            mGatt.getDevice()));
+                }
+                match = characteristic;
+            }
+        }
+        if (match == null) {
+            throw new BluetoothException(String.format(
+                    "Characteristic %s not found on service %s of device %s.",
+                    characteristicUuid,
+                    serviceUuid,
+                    mGatt.getDevice()));
+        }
+        Log.d(TAG, "Characteristic found.");
+        return match;
+    }
+
+    /**
+     * Get descriptor.
+     */
+    public BluetoothGattDescriptor getDescriptor(UUID serviceUuid,
+            UUID characteristicUuid, UUID descriptorUuid) throws BluetoothException {
+        Log.d(TAG, String.format("Getting descriptor %s on characteristic %s on service %s.",
+                descriptorUuid, characteristicUuid, serviceUuid));
+        BluetoothGattDescriptor match = null;
+        for (BluetoothGattDescriptor descriptor :
+                getCharacteristic(serviceUuid, characteristicUuid).getDescriptors()) {
+            if (descriptor.getUuid().equals(descriptorUuid)) {
+                if (match != null) {
+                    throw new BluetoothException(String.format("More than one descriptor %s found "
+                                    + "on characteristic %s service %s on device %s.",
+                            descriptorUuid,
+                            characteristicUuid,
+                            serviceUuid,
+                            mGatt.getDevice()));
+                }
+                match = descriptor;
+            }
+        }
+        if (match == null) {
+            throw new BluetoothException(String.format(
+                    "Descriptor %s not found on characteristic %s on service %s of device %s.",
+                    descriptorUuid,
+                    characteristicUuid,
+                    serviceUuid,
+                    mGatt.getDevice()));
+        }
+        Log.d(TAG, "Descriptor found.");
+        return match;
+    }
+
+    /**
+     * Discover services.
+     */
+    public void discoverServices() throws BluetoothException {
+        mBluetoothOperationExecutor.execute(
+                new SynchronousOperation<Void>(OperationType.DISCOVER_SERVICES) {
+                    @Nullable
+                    @Override
+                    public Void call() throws BluetoothException {
+                        if (mServicesDiscovered) {
+                            return null;
+                        }
+                        boolean forceRefresh = false;
+                        try {
+                            discoverServicesInternal();
+                        } catch (BluetoothException e) {
+                            if (!(e instanceof BluetoothGattException)) {
+                                throw e;
+                            }
+                            int errorCode = ((BluetoothGattException) e).getGattErrorCode();
+                            if (errorCode != GATT_ERROR && errorCode != GATT_INTERNAL_ERROR) {
+                                throw e;
+                            }
+                            Log.e(TAG, e.getMessage()
+                                    + "\n Ignore the gatt error for post MNC apis and force "
+                                    + "a refresh");
+                            forceRefresh = true;
+                        }
+
+                        forceRefreshServiceCacheIfNeeded(forceRefresh);
+
+                        mServicesDiscovered = true;
+
+                        return null;
+                    }
+                });
+    }
+
+    private void discoverServicesInternal() throws BluetoothException {
+        Log.i(TAG, "Starting services discovery.");
+        long startTimeMillis = System.currentTimeMillis();
+        try {
+            mBluetoothOperationExecutor.execute(
+                    new Operation<Void>(OperationType.DISCOVER_SERVICES_INTERNAL, mGatt) {
+                        @Override
+                        public void run() throws BluetoothException {
+                            boolean success = mGatt.discoverServices();
+                            if (!success) {
+                                throw new BluetoothException(
+                                        "gatt.discoverServices returned false.");
+                            }
+                        }
+                    },
+                    SLOW_OPERATION_TIMEOUT_MILLIS);
+            Log.i(TAG, String.format("Services discovered successfully in %s ms.",
+                    System.currentTimeMillis() - startTimeMillis));
+        } catch (BluetoothException e) {
+            if (e instanceof BluetoothGattException) {
+                throw new BluetoothGattException(String.format(
+                        "Failed to discover services on device: %s.",
+                        mGatt.getDevice()), ((BluetoothGattException) e).getGattErrorCode(), e);
+            } else {
+                throw new BluetoothException(String.format(
+                        "Failed to discover services on device: %s.",
+                        mGatt.getDevice()), e);
+            }
+        }
+    }
+
+    private boolean hasDynamicServices() {
+        BluetoothGattService gattService =
+                mGatt.getService(ReservedUuids.Services.GENERIC_ATTRIBUTE);
+        if (gattService != null) {
+            BluetoothGattCharacteristic serviceChange =
+                    gattService.getCharacteristic(ReservedUuids.Characteristics.SERVICE_CHANGE);
+            if (serviceChange != null) {
+                return true;
+            }
+        }
+
+        // Check whether the server contains a self defined service dynamic characteristic.
+        gattService = mGatt.getService(BluetoothConsts.SERVICE_DYNAMIC_SERVICE);
+        if (gattService != null) {
+            BluetoothGattCharacteristic serviceChange =
+                    gattService.getCharacteristic(BluetoothConsts.SERVICE_DYNAMIC_CHARACTERISTIC);
+            if (serviceChange != null) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private void forceRefreshServiceCacheIfNeeded(boolean forceRefresh) throws BluetoothException {
+        if (mGatt.getDevice().getBondState() != BluetoothDevice.BOND_BONDED) {
+            // Device is not bonded, so services should not have been cached.
+            return;
+        }
+
+        if (!forceRefresh && !hasDynamicServices()) {
+            return;
+        }
+        Log.i(TAG, "Forcing a refresh of local cache of GATT services");
+        boolean success = mGatt.refresh();
+        if (!success) {
+            throw new BluetoothException("gatt.refresh returned false.");
+        }
+        discoverServicesInternal();
+    }
+
+    /**
+     * Read characteristic.
+     */
+    public byte[] readCharacteristic(UUID serviceUuid, UUID characteristicUuid)
+            throws BluetoothException {
+        return readCharacteristic(getCharacteristic(serviceUuid, characteristicUuid));
+    }
+
+    /**
+     * Read characteristic.
+     */
+    public byte[] readCharacteristic(final BluetoothGattCharacteristic characteristic)
+            throws BluetoothException {
+        try {
+            return mBluetoothOperationExecutor.executeNonnull(
+                    new Operation<byte[]>(OperationType.READ_CHARACTERISTIC, mGatt,
+                            characteristic) {
+                        @Override
+                        public void run() throws BluetoothException {
+                            boolean success = mGatt.readCharacteristic(characteristic);
+                            if (!success) {
+                                throw new BluetoothException(
+                                        "gatt.readCharacteristic returned false.");
+                            }
+                        }
+                    },
+                    mOperationTimeoutMillis);
+        } catch (BluetoothException e) {
+            throw new BluetoothException(String.format(
+                    "Failed to read %s on device %s.",
+                    BluetoothGattUtils.toString(characteristic),
+                    mGatt.getDevice()), e);
+        }
+    }
+
+    /**
+     * Writes Characteristic.
+     */
+    public void writeCharacteristic(UUID serviceUuid, UUID characteristicUuid, byte[] value)
+            throws BluetoothException {
+        writeCharacteristic(getCharacteristic(serviceUuid, characteristicUuid), value);
+    }
+
+    /**
+     * Writes Characteristic.
+     */
+    public void writeCharacteristic(final BluetoothGattCharacteristic characteristic,
+            final byte[] value) throws BluetoothException {
+        Log.d(TAG, String.format("Writing %d bytes on %s on device %s.",
+                value.length,
+                BluetoothGattUtils.toString(characteristic),
+                mGatt.getDevice()));
+        if ((characteristic.getProperties() & (BluetoothGattCharacteristic.PROPERTY_WRITE
+                | BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE)) == 0) {
+            throw new BluetoothException(String.format("%s is not writable!", characteristic));
+        }
+        try {
+            mBluetoothOperationExecutor.execute(
+                    new Operation<Void>(OperationType.WRITE_CHARACTERISTIC, mGatt, characteristic) {
+                        @Override
+                        public void run() throws BluetoothException {
+                            BluetoothGattCharacteristic characteristiClone =
+                                    BluetoothGattUtils.clone(characteristic);
+                            characteristiClone.setValue(value);
+                            boolean success = mGatt.writeCharacteristic(characteristiClone);
+                            if (!success) {
+                                throw new BluetoothException(
+                                        "gatt.writeCharacteristic returned false.");
+                            }
+                        }
+                    },
+                    mOperationTimeoutMillis);
+        } catch (BluetoothException e) {
+            throw new BluetoothException(String.format(
+                    "Failed to write %s on device %s.",
+                    BluetoothGattUtils.toString(characteristic),
+                    mGatt.getDevice()), e);
+        }
+        Log.d(TAG, "Writing characteristic done.");
+    }
+
+    /**
+     * Reads descriptor.
+     */
+    public byte[] readDescriptor(UUID serviceUuid, UUID characteristicUuid, UUID descriptorUuid)
+            throws BluetoothException {
+        return readDescriptor(getDescriptor(serviceUuid, characteristicUuid, descriptorUuid));
+    }
+
+    /**
+     * Reads descriptor.
+     */
+    public byte[] readDescriptor(final BluetoothGattDescriptor descriptor)
+            throws BluetoothException {
+        try {
+            return mBluetoothOperationExecutor.executeNonnull(
+                    new Operation<byte[]>(OperationType.READ_DESCRIPTOR, mGatt, descriptor) {
+                        @Override
+                        public void run() throws BluetoothException {
+                            boolean success = mGatt.readDescriptor(descriptor);
+                            if (!success) {
+                                throw new BluetoothException("gatt.readDescriptor returned false.");
+                            }
+                        }
+                    },
+                    mOperationTimeoutMillis);
+        } catch (BluetoothException e) {
+            throw new BluetoothException(String.format(
+                    "Failed to read %s on %s on device %s.",
+                    descriptor.getUuid(),
+                    BluetoothGattUtils.toString(descriptor),
+                    mGatt.getDevice()), e);
+        }
+    }
+
+    /**
+     * Writes descriptor.
+     */
+    public void writeDescriptor(UUID serviceUuid, UUID characteristicUuid, UUID descriptorUuid,
+            byte[] value) throws BluetoothException {
+        writeDescriptor(getDescriptor(serviceUuid, characteristicUuid, descriptorUuid), value);
+    }
+
+    /**
+     * Writes descriptor.
+     */
+    public void writeDescriptor(final BluetoothGattDescriptor descriptor, final byte[] value)
+            throws BluetoothException {
+        Log.d(TAG, String.format(
+                "Writing %d bytes on %s on device %s.",
+                value.length,
+                BluetoothGattUtils.toString(descriptor),
+                mGatt.getDevice()));
+        long startTimeMillis = System.currentTimeMillis();
+        try {
+            mBluetoothOperationExecutor.execute(
+                    new Operation<Void>(OperationType.WRITE_DESCRIPTOR, mGatt, descriptor) {
+                        @Override
+                        public void run() throws BluetoothException {
+                            BluetoothGattDescriptor descriptorClone =
+                                    BluetoothGattUtils.clone(descriptor);
+                            descriptorClone.setValue(value);
+                            boolean success = mGatt.writeDescriptor(descriptorClone);
+                            if (!success) {
+                                throw new BluetoothException(
+                                        "gatt.writeDescriptor returned false.");
+                            }
+                        }
+                    },
+                    mOperationTimeoutMillis);
+            Log.d(TAG, String.format("Writing descriptor done in %s ms.",
+                    System.currentTimeMillis() - startTimeMillis));
+        } catch (BluetoothException e) {
+            throw new BluetoothException(String.format(
+                    "Failed to write %s on device %s.",
+                    BluetoothGattUtils.toString(descriptor),
+                    mGatt.getDevice()), e);
+        }
+    }
+
+    /**
+     * Reads remote Rssi.
+     */
+    public int readRemoteRssi() throws BluetoothException {
+        try {
+            return mBluetoothOperationExecutor.executeNonnull(
+                    new Operation<Integer>(OperationType.READ_RSSI, mGatt) {
+                        @Override
+                        public void run() throws BluetoothException {
+                            boolean success = mGatt.readRemoteRssi();
+                            if (!success) {
+                                throw new BluetoothException("gatt.readRemoteRssi returned false.");
+                            }
+                        }
+                    },
+                    mOperationTimeoutMillis);
+        } catch (BluetoothException e) {
+            throw new BluetoothException(
+                    String.format("Failed to read rssi on device %s.", mGatt.getDevice()), e);
+        }
+    }
+
+    public int getMtu() {
+        return mMtu;
+    }
+
+    /**
+     * Get max data packet size.
+     */
+    public int getMaxDataPacketSize() {
+        // Per BT specs (3.2.9), only MTU - 3 bytes can be used to transmit data
+        return mMtu - 3;
+    }
+
+    /** Set notification enabled or disabled. */
+    @VisibleForTesting
+    public void setNotificationEnabled(BluetoothGattCharacteristic characteristic, boolean enabled)
+            throws BluetoothException {
+        boolean isIndication;
+        int properties = characteristic.getProperties();
+        if ((properties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) {
+            isIndication = false;
+        } else if ((properties & BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) {
+            isIndication = true;
+        } else {
+            throw new BluetoothException(String.format(
+                    "%s on device %s supports neither notifications nor indications.",
+                    BluetoothGattUtils.toString(characteristic),
+                    mGatt.getDevice()));
+        }
+        BluetoothGattDescriptor clientConfigDescriptor =
+                characteristic.getDescriptor(
+                        ReservedUuids.Descriptors.CLIENT_CHARACTERISTIC_CONFIGURATION);
+        if (clientConfigDescriptor == null) {
+            throw new BluetoothException(String.format(
+                    "%s on device %s is missing client config descriptor.",
+                    BluetoothGattUtils.toString(characteristic),
+                    mGatt.getDevice()));
+        }
+        long startTime = System.currentTimeMillis();
+        Log.d(TAG, String.format("%s %s on characteristic %s.", enabled ? "Enabling" : "Disabling",
+                isIndication ? "indication" : "notification", characteristic.getUuid()));
+
+        if (enabled) {
+            mGatt.setCharacteristicNotification(characteristic, enabled);
+        }
+        writeDescriptor(clientConfigDescriptor,
+                enabled
+                        ? (isIndication
+                        ? BluetoothGattDescriptor.ENABLE_INDICATION_VALUE :
+                        BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
+                        : BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE);
+        if (!enabled) {
+            mGatt.setCharacteristicNotification(characteristic, enabled);
+        }
+
+        Log.d(TAG, String.format("Done in %d ms.", System.currentTimeMillis() - startTime));
+    }
+
+    /**
+     * Enables notification.
+     */
+    public ChangeObserver enableNotification(UUID serviceUuid, UUID characteristicUuid)
+            throws BluetoothException {
+        return enableNotification(getCharacteristic(serviceUuid, characteristicUuid));
+    }
+
+    /**
+     * Enables notification.
+     */
+    public ChangeObserver enableNotification(final BluetoothGattCharacteristic characteristic)
+            throws BluetoothException {
+        return mBluetoothOperationExecutor.executeNonnull(
+                new SynchronousOperation<ChangeObserver>(
+                        OperationType.NOTIFICATION_CHANGE,
+                        characteristic) {
+                    @Override
+                    public ChangeObserver call() throws BluetoothException {
+                        ChangeObserver changeObserver = new ChangeObserver();
+                        mChangeObservers.put(characteristic, changeObserver);
+                        setNotificationEnabled(characteristic, true);
+                        return changeObserver;
+                    }
+                });
+    }
+
+    /**
+     * Disables notification.
+     */
+    public void disableNotification(UUID serviceUuid, UUID characteristicUuid)
+            throws BluetoothException {
+        disableNotification(getCharacteristic(serviceUuid, characteristicUuid));
+    }
+
+    /**
+     * Disables notification.
+     */
+    public void disableNotification(final BluetoothGattCharacteristic characteristic)
+            throws BluetoothException {
+        mBluetoothOperationExecutor.execute(
+                new SynchronousOperation<Void>(
+                        OperationType.NOTIFICATION_CHANGE,
+                        characteristic) {
+                    @Nullable
+                    @Override
+                    public Void call() throws BluetoothException {
+                        setNotificationEnabled(characteristic, false);
+                        mChangeObservers.remove(characteristic);
+                        return null;
+                    }
+                });
+    }
+
+    /**
+     * Adds a close listener.
+     */
+    public void addCloseListener(ConnectionCloseListener listener) {
+        mCloseListeners.add(listener);
+        if (!mIsConnected) {
+            listener.onClose();
+            return;
+        }
+    }
+
+    /**
+     * Removes a close listener.
+     */
+    public void removeCloseListener(ConnectionCloseListener listener) {
+        mCloseListeners.remove(listener);
+    }
+
+    /** onCharacteristicChanged callback. */
+    public void onCharacteristicChanged(BluetoothGattCharacteristic characteristic, byte[] value) {
+        ChangeObserver observer = mChangeObservers.get(characteristic);
+        if (observer == null) {
+            return;
+        }
+        observer.onValueChange(value);
+    }
+
+    @Override
+    public void close() throws BluetoothException {
+        Log.d(TAG, "close");
+        try {
+            if (!mIsConnected) {
+                // Don't call disconnect on a closed connection, since Android framework won't
+                // provide any feedback.
+                return;
+            }
+            mBluetoothOperationExecutor.execute(
+                    new Operation<Void>(OperationType.DISCONNECT, mGatt.getDevice()) {
+                        @Override
+                        public void run() throws BluetoothException {
+                            mGatt.disconnect();
+                        }
+                    }, mOperationTimeoutMillis);
+        } finally {
+            mGatt.close();
+        }
+    }
+
+    /** onConnected callback. */
+    public void onConnected() {
+        Log.d(TAG, "onConnected");
+        mIsConnected = true;
+    }
+
+    /** onClosed callback. */
+    public void onClosed() {
+        Log.d(TAG, "onClosed");
+        if (!mIsConnected) {
+            return;
+        }
+        mIsConnected = false;
+        for (ConnectionCloseListener listener : mCloseListeners) {
+            listener.onClose();
+        }
+        mGatt.close();
+    }
+
+    /**
+     * Observer to wait or be called back when value change.
+     */
+    public static class ChangeObserver {
+
+        private final BlockingDeque<byte[]> mValues = new LinkedBlockingDeque<byte[]>();
+
+        @GuardedBy("mValues")
+        @Nullable
+        private volatile CharacteristicChangeListener mListener;
+
+        /**
+         * Set listener.
+         */
+        public void setListener(@Nullable CharacteristicChangeListener listener) {
+            synchronized (mValues) {
+                mListener = listener;
+                if (listener != null) {
+                    byte[] value;
+                    while ((value = mValues.poll()) != null) {
+                        listener.onValueChange(value);
+                    }
+                }
+            }
+        }
+
+        /**
+         * onValueChange callback.
+         */
+        public void onValueChange(byte[] newValue) {
+            synchronized (mValues) {
+                CharacteristicChangeListener listener = mListener;
+                if (listener == null) {
+                    mValues.add(newValue);
+                } else {
+                    listener.onValueChange(newValue);
+                }
+            }
+        }
+
+        /**
+         * Waits for update for a given time.
+         */
+        public byte[] waitForUpdate(long timeoutMillis) throws BluetoothException {
+            byte[] result;
+            try {
+                result = mValues.poll(timeoutMillis, TimeUnit.MILLISECONDS);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new BluetoothException("Operation interrupted.");
+            }
+            if (result == null) {
+                throw new BluetoothTimeoutException(
+                        String.format("Operation timed out after %dms", timeoutMillis));
+            }
+            return result;
+        }
+    }
+
+    /**
+     * Listener for characteristic data changes over notifications or indications.
+     */
+    public interface CharacteristicChangeListener {
+
+        /**
+         * onValueChange callback.
+         */
+        void onValueChange(byte[] newValue);
+    }
+
+    /**
+     * Listener for connection close events.
+     */
+    public interface ConnectionCloseListener {
+
+        /**
+         * onClose callback.
+         */
+        void onClose();
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattHelper.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattHelper.java
new file mode 100644
index 0000000..8eb96ff
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattHelper.java
@@ -0,0 +1,598 @@
+/*
+ * Copyright 2021 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.server.nearby.common.bluetooth.gatt;
+
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanSettings;
+import android.content.Context;
+import android.os.ParcelUuid;
+import android.util.Log;
+
+import androidx.annotation.VisibleForTesting;
+
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothAdapter;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothDevice;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothGatt;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothGattCallback;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.le.BluetoothLeScanner;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.le.ScanCallback;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.le.ScanResult;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.BluetoothOperationTimeoutException;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.Operation;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Optional;
+import com.google.common.base.Preconditions;
+
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.TimeUnit;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
+
+/**
+ * Wrapper of {@link BluetoothGatt} that provides blocking methods, errors and timeout handling.
+ */
+@SuppressWarnings("Guava") // java.util.Optional is not available until API 24
+public class BluetoothGattHelper {
+
+    private static final String TAG = BluetoothGattHelper.class.getSimpleName();
+
+    @VisibleForTesting
+    static final long LOW_LATENCY_SCAN_MILLIS = TimeUnit.SECONDS.toMillis(5);
+    private static final long POLL_INTERVAL_MILLIS = 5L /* milliseconds */;
+
+    /**
+     * BT operation types that can be in flight.
+     */
+    public enum OperationType {
+        SCAN,
+        CONNECT,
+        DISCOVER_SERVICES,
+        DISCOVER_SERVICES_INTERNAL,
+        NOTIFICATION_CHANGE,
+        READ_CHARACTERISTIC,
+        WRITE_CHARACTERISTIC,
+        READ_DESCRIPTOR,
+        WRITE_DESCRIPTOR,
+        READ_RSSI,
+        WRITE_RELIABLE,
+        CHANGE_MTU,
+        DISCONNECT
+    }
+
+    @VisibleForTesting
+    final ScanCallback mScanCallback = new InternalScanCallback();
+    @VisibleForTesting
+    final BluetoothGattCallback mBluetoothGattCallback =
+            new InternalBluetoothGattCallback();
+    @VisibleForTesting
+    final ConcurrentMap<BluetoothGatt, BluetoothGattConnection> mConnections =
+            new ConcurrentHashMap<>();
+
+    private final Context mApplicationContext;
+    private final BluetoothAdapter mBluetoothAdapter;
+    private final BluetoothOperationExecutor mBluetoothOperationExecutor;
+
+    @VisibleForTesting
+    BluetoothGattHelper(
+            Context applicationContext,
+            BluetoothAdapter bluetoothAdapter,
+            BluetoothOperationExecutor bluetoothOperationExecutor) {
+        mApplicationContext = applicationContext;
+        mBluetoothAdapter = bluetoothAdapter;
+        mBluetoothOperationExecutor = bluetoothOperationExecutor;
+    }
+
+    public BluetoothGattHelper(Context applicationContext, BluetoothAdapter bluetoothAdapter) {
+        this(
+                Preconditions.checkNotNull(applicationContext),
+                Preconditions.checkNotNull(bluetoothAdapter),
+                new BluetoothOperationExecutor(5));
+    }
+
+    /**
+     * Auto-connects a serice Uuid.
+     */
+    public BluetoothGattConnection autoConnect(final UUID serviceUuid) throws BluetoothException {
+        Log.d(TAG, String.format("Starting autoconnection to a device advertising service %s.",
+                serviceUuid));
+        BluetoothDevice device = null;
+        int retries = 3;
+        final BluetoothLeScanner scanner = mBluetoothAdapter.getBluetoothLeScanner();
+        if (scanner == null) {
+            throw new BluetoothException("Bluetooth is disabled or LE is not supported.");
+        }
+        final ScanFilter serviceFilter = new ScanFilter.Builder()
+                .setServiceUuid(new ParcelUuid(serviceUuid))
+                .build();
+        ScanSettings.Builder scanSettingsBuilder = new ScanSettings.Builder()
+                .setReportDelay(0);
+        final ScanSettings scanSettingsLowLatency = scanSettingsBuilder
+                .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
+                .build();
+        final ScanSettings scanSettingsLowPower = scanSettingsBuilder
+                .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
+                .build();
+        while (true) {
+            long startTimeMillis = System.currentTimeMillis();
+            try {
+                Log.d(TAG, "Starting low latency scanning.");
+                device =
+                        mBluetoothOperationExecutor.executeNonnull(
+                                new Operation<BluetoothDevice>(OperationType.SCAN) {
+                                    @Override
+                                    public void run() throws BluetoothException {
+                                        scanner.startScan(Arrays.asList(serviceFilter),
+                                                scanSettingsLowLatency, mScanCallback);
+                                    }
+                                }, LOW_LATENCY_SCAN_MILLIS);
+            } catch (BluetoothOperationTimeoutException e) {
+                Log.d(TAG, String.format(
+                        "Cannot find a nearby device in low latency scanning after %s ms.",
+                        LOW_LATENCY_SCAN_MILLIS));
+            } finally {
+                scanner.stopScan(mScanCallback);
+            }
+            if (device == null) {
+                Log.d(TAG, "Starting low power scanning.");
+                try {
+                    device = mBluetoothOperationExecutor.executeNonnull(
+                            new Operation<BluetoothDevice>(OperationType.SCAN) {
+                                @Override
+                                public void run() throws BluetoothException {
+                                    scanner.startScan(Arrays.asList(serviceFilter),
+                                            scanSettingsLowPower, mScanCallback);
+                                }
+                            });
+                } finally {
+                    scanner.stopScan(mScanCallback);
+                }
+            }
+            Log.d(TAG, String.format("Scanning done in %d ms. Found device %s.",
+                    System.currentTimeMillis() - startTimeMillis, device));
+
+            try {
+                return connect(device);
+            } catch (BluetoothException e) {
+                retries--;
+                if (retries == 0) {
+                    throw e;
+                } else {
+                    Log.d(TAG, String.format(
+                            "Connection failed: %s. Retrying %d more times.", e, retries));
+                }
+            }
+        }
+    }
+
+    /**
+     * Connects to a device using default connection options.
+     */
+    public BluetoothGattConnection connect(BluetoothDevice bluetoothDevice)
+            throws BluetoothException {
+        return connect(bluetoothDevice, ConnectionOptions.builder().build());
+    }
+
+    /**
+     * Connects to a device using specifies connection options.
+     */
+    public BluetoothGattConnection connect(
+            BluetoothDevice bluetoothDevice, ConnectionOptions options) throws BluetoothException {
+        Log.d(TAG, String.format("Connecting to device %s.", bluetoothDevice));
+        long startTimeMillis = System.currentTimeMillis();
+
+        Operation<BluetoothGattConnection> connectOperation =
+                new Operation<BluetoothGattConnection>(OperationType.CONNECT, bluetoothDevice) {
+                    private final Object mLock = new Object();
+
+                    @GuardedBy("mLock")
+                    private boolean mIsCanceled = false;
+
+                    @GuardedBy("mLock")
+                    @Nullable(/* null before operation is executed */)
+                    private BluetoothGatt mBluetoothGatt;
+
+                    @Override
+                    public void run() throws BluetoothException {
+                        synchronized (mLock) {
+                            if (mIsCanceled) {
+                                return;
+                            }
+                            BluetoothGatt bluetoothGatt;
+                            Log.d(TAG, "Use LE transport");
+                            bluetoothGatt =
+                                    bluetoothDevice.connectGatt(
+                                            mApplicationContext,
+                                            options.autoConnect(),
+                                            mBluetoothGattCallback,
+                                            android.bluetooth.BluetoothDevice.TRANSPORT_LE);
+                            if (bluetoothGatt == null) {
+                                throw new BluetoothException("connectGatt() returned null.");
+                            }
+
+                            try {
+                                // Set connection priority without waiting for connection callback.
+                                // Per code, btif_gatt_client.c, when priority is set before
+                                // connection, this sets preferred connection parameters that will
+                                // be used during the connection establishment.
+                                Optional<Integer> connectionPriorityOption =
+                                        options.connectionPriority();
+                                if (connectionPriorityOption.isPresent()) {
+                                    // requestConnectionPriority can only be called when
+                                    // BluetoothGatt is connected to the system BluetoothGatt
+                                    // service (see android/bluetooth/BluetoothGatt.java code).
+                                    // However, there is no callback to the app to inform when this
+                                    // is done. requestConnectionPriority will returns false with no
+                                    // side-effect before the service is connected, so we just poll
+                                    // here until true is returned.
+                                    int connectionPriority = connectionPriorityOption.get();
+                                    long startTimeMillis = System.currentTimeMillis();
+                                    while (!bluetoothGatt.requestConnectionPriority(
+                                            connectionPriority)) {
+                                        if (System.currentTimeMillis() - startTimeMillis
+                                                > options.connectionTimeoutMillis()) {
+                                            throw new BluetoothException(
+                                                    String.format(
+                                                            Locale.US,
+                                                            "Failed to set connectionPriority "
+                                                                    + "after %dms.",
+                                                            options.connectionTimeoutMillis()));
+                                        }
+                                        try {
+                                            Thread.sleep(POLL_INTERVAL_MILLIS);
+                                        } catch (InterruptedException e) {
+                                            Thread.currentThread().interrupt();
+                                            throw new BluetoothException(
+                                                    "connect() operation interrupted.");
+                                        }
+                                    }
+                                }
+                            } catch (Exception e) {
+                                // Make sure to clean connection.
+                                bluetoothGatt.disconnect();
+                                bluetoothGatt.close();
+                                throw e;
+                            }
+
+                            BluetoothGattConnection connection = new BluetoothGattConnection(
+                                    bluetoothGatt, mBluetoothOperationExecutor, options);
+                            mConnections.put(bluetoothGatt, connection);
+                            mBluetoothGatt = bluetoothGatt;
+                        }
+                    }
+
+                    @Override
+                    public void cancel() {
+                        // Clean connection if connection times out.
+                        synchronized (mLock) {
+                            if (mIsCanceled) {
+                                return;
+                            }
+                            mIsCanceled = true;
+                            BluetoothGatt bluetoothGatt = mBluetoothGatt;
+                            if (bluetoothGatt == null) {
+                                return;
+                            }
+                            mConnections.remove(bluetoothGatt);
+                            bluetoothGatt.disconnect();
+                            bluetoothGatt.close();
+                        }
+                    }
+                };
+        BluetoothGattConnection result;
+        if (options.autoConnect()) {
+            result = mBluetoothOperationExecutor.executeNonnull(connectOperation);
+        } else {
+            result =
+                    mBluetoothOperationExecutor.executeNonnull(
+                            connectOperation, options.connectionTimeoutMillis());
+        }
+        Log.d(TAG, String.format("Connection success in %d ms.",
+                System.currentTimeMillis() - startTimeMillis));
+        return result;
+    }
+
+    private BluetoothGattConnection getConnectionByGatt(BluetoothGatt gatt)
+            throws BluetoothException {
+        BluetoothGattConnection connection = mConnections.get(gatt);
+        if (connection == null) {
+            throw new BluetoothException("Receive callback on unexpected device: " + gatt);
+        }
+        return connection;
+    }
+
+    private class InternalBluetoothGattCallback extends BluetoothGattCallback {
+
+        @Override
+        public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
+            BluetoothGattConnection connection;
+            BluetoothDevice device = gatt.getDevice();
+            switch (newState) {
+                case BluetoothGatt.STATE_CONNECTED: {
+                    connection = mConnections.get(gatt);
+                    if (connection == null) {
+                        Log.w(TAG, String.format(
+                                "Received unexpected successful connection for dev %s! Ignoring.",
+                                device));
+                        break;
+                    }
+
+                    Operation<BluetoothGattConnection> operation =
+                            new Operation<>(OperationType.CONNECT, device);
+                    if (status != BluetoothGatt.GATT_SUCCESS) {
+                        mConnections.remove(gatt);
+                        gatt.disconnect();
+                        gatt.close();
+                        mBluetoothOperationExecutor.notifyCompletion(operation, status, null);
+                        break;
+                    }
+
+                    // Process connection options
+                    ConnectionOptions options = connection.getConnectionOptions();
+                    Optional<Integer> mtuOption = options.mtu();
+                    if (mtuOption.isPresent()) {
+                        // Requesting MTU and waiting for MTU callback.
+                        boolean success = gatt.requestMtu(mtuOption.get());
+                        if (!success) {
+                            mBluetoothOperationExecutor.notifyFailure(operation,
+                                    new BluetoothException(String.format(Locale.US,
+                                            "Failed to request MTU of %d for dev %s: "
+                                                    + "returned false.",
+                                            mtuOption.get(), device)));
+                            // Make sure to clean connection.
+                            mConnections.remove(gatt);
+                            gatt.disconnect();
+                            gatt.close();
+                        }
+                        break;
+                    }
+
+                    // Connection successful
+                    connection.onConnected();
+                    mBluetoothOperationExecutor.notifyCompletion(operation, status, connection);
+                    break;
+                }
+                case BluetoothGatt.STATE_DISCONNECTED: {
+                    connection = mConnections.remove(gatt);
+                    if (connection == null) {
+                        Log.w(TAG, String.format("Received unexpected disconnection"
+                                + " for device %s! Ignoring.", device));
+                        break;
+                    }
+                    if (!connection.isConnected()) {
+                        // This is a failed connection attempt
+                        if (status == BluetoothGatt.GATT_SUCCESS) {
+                            // This is weird... considering this as a failure
+                            Log.w(TAG, String.format(
+                                    "Received a success for a failed connection "
+                                            + "attempt for device %s! Ignoring.", device));
+                            status = BluetoothGatt.GATT_FAILURE;
+                        }
+                        mBluetoothOperationExecutor
+                                .notifyCompletion(new Operation<BluetoothGattConnection>(
+                                        OperationType.CONNECT, device), status, null);
+                        // Clean Gatt object in every case.
+                        gatt.disconnect();
+                        gatt.close();
+                        break;
+                    }
+                    connection.onClosed();
+                    mBluetoothOperationExecutor.notifyCompletion(
+                            new Operation<>(OperationType.DISCONNECT, device), status);
+                    break;
+                }
+                default:
+                    Log.e(TAG, "Unexpected connection state: " + newState);
+            }
+        }
+
+        @Override
+        public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) {
+            BluetoothGattConnection connection = mConnections.get(gatt);
+            BluetoothDevice device = gatt.getDevice();
+            if (connection == null) {
+                Log.w(TAG, String.format(
+                        "Received unexpected MTU change for device %s! Ignoring.", device));
+                return;
+            }
+            if (connection.isConnected()) {
+                // This is the callback for the deprecated BluetoothGattConnection.requestMtu.
+                mBluetoothOperationExecutor.notifyCompletion(
+                        new Operation<>(OperationType.CHANGE_MTU, gatt), status, mtu);
+            } else {
+                // This is the callback when requesting MTU right after connecting.
+                connection.onConnected();
+                mBluetoothOperationExecutor.notifyCompletion(
+                        new Operation<>(OperationType.CONNECT, device), status, connection);
+                if (status != BluetoothGatt.GATT_SUCCESS) {
+                    Log.w(TAG, String.format(
+                            "%s responds MTU change failed, status %s.", device, status));
+                    // Clean connection if it's failed.
+                    mConnections.remove(gatt);
+                    gatt.disconnect();
+                    gatt.close();
+                    return;
+                }
+            }
+        }
+
+        @Override
+        public void onServicesDiscovered(BluetoothGatt gatt, int status) {
+            mBluetoothOperationExecutor.notifyCompletion(
+                    new Operation<Void>(OperationType.DISCOVER_SERVICES_INTERNAL, gatt), status);
+        }
+
+        @Override
+        public void onCharacteristicRead(BluetoothGatt gatt,
+                BluetoothGattCharacteristic characteristic, int status) {
+            mBluetoothOperationExecutor.notifyCompletion(
+                    new Operation<byte[]>(OperationType.READ_CHARACTERISTIC, gatt, characteristic),
+                    status, characteristic.getValue());
+        }
+
+        @Override
+        public void onCharacteristicWrite(BluetoothGatt gatt,
+                BluetoothGattCharacteristic characteristic, int status) {
+            mBluetoothOperationExecutor.notifyCompletion(new Operation<Void>(
+                    OperationType.WRITE_CHARACTERISTIC, gatt, characteristic), status);
+        }
+
+        @Override
+        public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor,
+                int status) {
+            mBluetoothOperationExecutor.notifyCompletion(
+                    new Operation<byte[]>(OperationType.READ_DESCRIPTOR, gatt, descriptor), status,
+                    descriptor.getValue());
+        }
+
+        @Override
+        public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor,
+                int status) {
+            Log.d(TAG, String.format("onDescriptorWrite %s, %s, %d",
+                    gatt.getDevice(), descriptor.getUuid(), status));
+            mBluetoothOperationExecutor.notifyCompletion(
+                    new Operation<Void>(OperationType.WRITE_DESCRIPTOR, gatt, descriptor), status);
+        }
+
+        @Override
+        public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) {
+            mBluetoothOperationExecutor.notifyCompletion(
+                    new Operation<Integer>(OperationType.READ_RSSI, gatt), status, rssi);
+        }
+
+        @Override
+        public void onReliableWriteCompleted(BluetoothGatt gatt, int status) {
+            mBluetoothOperationExecutor.notifyCompletion(
+                    new Operation<Void>(OperationType.WRITE_RELIABLE, gatt), status);
+        }
+
+        @Override
+        public void onCharacteristicChanged(BluetoothGatt gatt,
+                BluetoothGattCharacteristic characteristic) {
+            byte[] value = characteristic.getValue();
+            if (value == null) {
+                // Value is not supposed to be null, but just to be safe...
+                value = new byte[0];
+            }
+            Log.d(TAG, String.format("Characteristic %s changed, Gatt device: %s",
+                    characteristic.getUuid(), gatt.getDevice()));
+            try {
+                getConnectionByGatt(gatt).onCharacteristicChanged(characteristic, value);
+            } catch (BluetoothException e) {
+                Log.e(TAG, "Error in onCharacteristicChanged", e);
+            }
+        }
+    }
+
+    private class InternalScanCallback extends ScanCallback {
+
+        @Override
+        public void onScanFailed(int errorCode) {
+            String errorMessage;
+            switch (errorCode) {
+                case ScanCallback.SCAN_FAILED_ALREADY_STARTED:
+                    errorMessage = "SCAN_FAILED_ALREADY_STARTED";
+                    break;
+                case ScanCallback.SCAN_FAILED_APPLICATION_REGISTRATION_FAILED:
+                    errorMessage = "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED";
+                    break;
+                case ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED:
+                    errorMessage = "SCAN_FAILED_FEATURE_UNSUPPORTED";
+                    break;
+                case ScanCallback.SCAN_FAILED_INTERNAL_ERROR:
+                    errorMessage = "SCAN_FAILED_INTERNAL_ERROR";
+                    break;
+                default:
+                    errorMessage = "Unknown error code - " + errorCode;
+            }
+            mBluetoothOperationExecutor.notifyFailure(
+                    new Operation<BluetoothDevice>(OperationType.SCAN),
+                    new BluetoothException("Scan failed: " + errorMessage));
+        }
+
+        @Override
+        public void onScanResult(int callbackType, ScanResult result) {
+            mBluetoothOperationExecutor.notifySuccess(
+                    new Operation<BluetoothDevice>(OperationType.SCAN), result.getDevice());
+        }
+    }
+
+    /**
+     * Options for {@link #connect}.
+     */
+    @AutoValue
+    public abstract static class ConnectionOptions {
+
+        abstract boolean autoConnect();
+
+        abstract long connectionTimeoutMillis();
+
+        abstract Optional<Integer> connectionPriority();
+
+        abstract Optional<Integer> mtu();
+
+        /**
+         * Creates a builder of ConnectionOptions.
+         */
+        public static Builder builder() {
+            return new AutoValue_BluetoothGattHelper_ConnectionOptions.Builder()
+                    .setAutoConnect(false)
+                    .setConnectionTimeoutMillis(TimeUnit.SECONDS.toMillis(5));
+        }
+
+        /**
+         * Builder for {@link ConnectionOptions}.
+         */
+        @AutoValue.Builder
+        public abstract static class Builder {
+
+            /**
+             * See {@link android.bluetooth.BluetoothDevice#connectGatt}.
+             */
+            public abstract Builder setAutoConnect(boolean autoConnect);
+
+            /**
+             * See {@link android.bluetooth.BluetoothGatt#requestConnectionPriority(int)}.
+             */
+            public abstract Builder setConnectionPriority(int connectionPriority);
+
+            /**
+             * See {@link android.bluetooth.BluetoothGatt#requestMtu(int)}.
+             */
+            public abstract Builder setMtu(int mtu);
+
+            /**
+             * Sets the timeout for the GATT connection.
+             */
+            public abstract Builder setConnectionTimeoutMillis(long connectionTimeoutMillis);
+
+            /**
+             * Builds ConnectionOptions.
+             */
+            public abstract ConnectionOptions build();
+        }
+    }
+}
diff --git a/nearby/tests/src/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattConnectionTest.java b/nearby/tests/src/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattConnectionTest.java
new file mode 100644
index 0000000..9776836
--- /dev/null
+++ b/nearby/tests/src/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattConnectionTest.java
@@ -0,0 +1,796 @@
+/*
+ * Copyright (C) 2021 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.server.nearby.common.bluetooth.gatt;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isA;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+import static org.mockito.MockitoAnnotations.initMocks;
+
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattService;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+
+import com.android.server.nearby.common.bluetooth.BluetoothConsts;
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.BluetoothGattException;
+import com.android.server.nearby.common.bluetooth.ReservedUuids;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattConnection.ChangeObserver;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattHelper.OperationType;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothDevice;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothGatt;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.Operation;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.SynchronousOperation;
+
+import junit.framework.TestCase;
+
+import org.mockito.ArgumentCaptor;
+import org.mockito.ArgumentMatchers;
+import org.mockito.Captor;
+import org.mockito.Mock;
+
+import java.util.Arrays;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Unit tests for {@link BluetoothGattConnection}.
+ */
+public class BluetoothGattConnectionTest extends TestCase {
+
+    private static final UUID SERVICE_UUID = UUID.randomUUID();
+    private static final UUID CHARACTERISTIC_UUID = UUID.randomUUID();
+    private static final UUID DESCRIPTOR_UUID = UUID.randomUUID();
+    private static final byte[] DATA = "data".getBytes();
+    private static final int RSSI = -63;
+    private static final int CONNECTION_PRIORITY = 128;
+    private static final int MTU_REQUEST = 512;
+    private static final BluetoothGattHelper.ConnectionOptions CONNECTION_OPTIONS =
+            BluetoothGattHelper.ConnectionOptions.builder().build();
+
+    @Mock
+    private BluetoothGatt mMockBluetoothGatt;
+    @Mock
+    private BluetoothDevice mMockBluetoothDevice;
+    @Mock
+    private BluetoothOperationExecutor mMockBluetoothOperationExecutor;
+    @Mock
+    private BluetoothGattService mMockBluetoothGattService;
+    @Mock
+    private BluetoothGattService mMockBluetoothGattService2;
+    @Mock
+    private BluetoothGattCharacteristic mMockBluetoothGattCharacteristic;
+    @Mock
+    private BluetoothGattCharacteristic mMockBluetoothGattCharacteristic2;
+    @Mock
+    private BluetoothGattDescriptor mMockBluetoothGattDescriptor;
+    @Mock
+    private BluetoothGattConnection.CharacteristicChangeListener mMockCharChangeListener;
+    @Mock
+    private BluetoothGattConnection.ChangeObserver mMockChangeObserver;
+    @Mock
+    private BluetoothGattConnection.ConnectionCloseListener mMockConnectionCloseListener;
+
+    @Captor
+    private ArgumentCaptor<Operation<?>> mOperationCaptor;
+    @Captor
+    private ArgumentCaptor<SynchronousOperation<?>> mSynchronousOperationCaptor;
+    @Captor
+    private ArgumentCaptor<BluetoothGattCharacteristic> mCharacteristicCaptor;
+    @Captor
+    private ArgumentCaptor<BluetoothGattDescriptor> mDescriptorCaptor;
+
+    private BluetoothGattConnection mBluetoothGattConnection;
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+
+        initMocks(this);
+
+        mBluetoothGattConnection = new BluetoothGattConnection(
+                mMockBluetoothGatt,
+                mMockBluetoothOperationExecutor,
+                CONNECTION_OPTIONS);
+        mBluetoothGattConnection.onConnected();
+
+        when(mMockBluetoothGatt.getDevice()).thenReturn(mMockBluetoothDevice);
+        when(mMockBluetoothGatt.discoverServices()).thenReturn(true);
+        when(mMockBluetoothGatt.refresh()).thenReturn(true);
+        when(mMockBluetoothGatt.readCharacteristic(mMockBluetoothGattCharacteristic))
+                .thenReturn(true);
+        when(mMockBluetoothGatt
+                .writeCharacteristic(ArgumentMatchers.<BluetoothGattCharacteristic>any()))
+                .thenReturn(true);
+        when(mMockBluetoothGatt.readDescriptor(mMockBluetoothGattDescriptor)).thenReturn(true);
+        when(mMockBluetoothGatt.writeDescriptor(ArgumentMatchers.<BluetoothGattDescriptor>any()))
+                .thenReturn(true);
+        when(mMockBluetoothGatt.readRemoteRssi()).thenReturn(true);
+        when(mMockBluetoothGatt.requestConnectionPriority(CONNECTION_PRIORITY)).thenReturn(true);
+        when(mMockBluetoothGatt.requestMtu(MTU_REQUEST)).thenReturn(true);
+        when(mMockBluetoothGatt.getServices()).thenReturn(Arrays.asList(mMockBluetoothGattService));
+        when(mMockBluetoothGattService.getUuid()).thenReturn(SERVICE_UUID);
+        when(mMockBluetoothGattService.getCharacteristics())
+                .thenReturn(Arrays.asList(mMockBluetoothGattCharacteristic));
+        when(mMockBluetoothGattCharacteristic.getUuid()).thenReturn(CHARACTERISTIC_UUID);
+        when(mMockBluetoothGattCharacteristic.getProperties())
+                .thenReturn(
+                        BluetoothGattCharacteristic.PROPERTY_NOTIFY
+                                | BluetoothGattCharacteristic.PROPERTY_WRITE);
+        BluetoothGattDescriptor clientConfigDescriptor =
+                new BluetoothGattDescriptor(
+                        ReservedUuids.Descriptors.CLIENT_CHARACTERISTIC_CONFIGURATION,
+                        BluetoothGattDescriptor.PERMISSION_WRITE);
+        when(mMockBluetoothGattCharacteristic.getDescriptor(
+                ReservedUuids.Descriptors.CLIENT_CHARACTERISTIC_CONFIGURATION))
+                .thenReturn(clientConfigDescriptor);
+        when(mMockBluetoothGattCharacteristic.getDescriptors())
+                .thenReturn(Arrays.asList(mMockBluetoothGattDescriptor, clientConfigDescriptor));
+        when(mMockBluetoothGattDescriptor.getUuid()).thenReturn(DESCRIPTOR_UUID);
+        when(mMockBluetoothDevice.getBondState()).thenReturn(BluetoothDevice.BOND_BONDED);
+    }
+
+    public void test_getDevice() {
+        BluetoothDevice result = mBluetoothGattConnection.getDevice();
+
+        assertThat(result).isEqualTo(mMockBluetoothDevice);
+    }
+
+    public void test_getConnectionOptions() {
+        BluetoothGattHelper.ConnectionOptions result = mBluetoothGattConnection
+                .getConnectionOptions();
+
+        assertThat(result).isSameInstanceAs(CONNECTION_OPTIONS);
+    }
+
+    public void test_isConnected_false_beforeConnection() {
+        mBluetoothGattConnection = new BluetoothGattConnection(
+                mMockBluetoothGatt,
+                mMockBluetoothOperationExecutor,
+                CONNECTION_OPTIONS);
+
+        boolean result = mBluetoothGattConnection.isConnected();
+
+        assertThat(result).isFalse();
+    }
+
+    public void test_isConnected_true_afterConnection() {
+        boolean result = mBluetoothGattConnection.isConnected();
+
+        assertThat(result).isTrue();
+    }
+
+    public void test_isConnected_false_afterDisconnection() {
+        mBluetoothGattConnection.onClosed();
+
+        boolean result = mBluetoothGattConnection.isConnected();
+
+        assertThat(result).isFalse();
+    }
+
+    public void test_getService_notDiscovered() throws Exception {
+        BluetoothGattService result = mBluetoothGattConnection.getService(SERVICE_UUID);
+        verify(mMockBluetoothOperationExecutor).execute(mSynchronousOperationCaptor.capture());
+        mSynchronousOperationCaptor.getValue().call();
+        verify(mMockBluetoothOperationExecutor)
+                .execute(
+                        mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.SLOW_OPERATION_TIMEOUT_MILLIS));
+        mOperationCaptor.getValue().run();
+
+        assertThat(result).isEqualTo(mMockBluetoothGattService);
+        verify(mMockBluetoothGatt).discoverServices();
+    }
+
+    public void test_getService_alreadyDiscovered() throws Exception {
+        mBluetoothGattConnection.getService(SERVICE_UUID);
+        verify(mMockBluetoothOperationExecutor).execute(mSynchronousOperationCaptor.capture());
+        mSynchronousOperationCaptor.getValue().call();
+        reset(mMockBluetoothOperationExecutor);
+
+        BluetoothGattService result = mBluetoothGattConnection.getService(SERVICE_UUID);
+
+        assertThat(result).isEqualTo(mMockBluetoothGattService);
+        // Verify that service discovery has been done only once
+        verifyNoMoreInteractions(mMockBluetoothOperationExecutor);
+    }
+
+    public void test_getService_notFound() throws Exception {
+        when(mMockBluetoothGatt.getServices()).thenReturn(Arrays.<BluetoothGattService>asList());
+
+        try {
+            mBluetoothGattConnection.getService(SERVICE_UUID);
+            fail("Expected BluetoothException");
+        } catch (BluetoothException expected) {
+        }
+    }
+
+    public void test_getService_moreThanOne() throws Exception {
+        when(mMockBluetoothGatt.getServices())
+                .thenReturn(Arrays.asList(mMockBluetoothGattService, mMockBluetoothGattService));
+
+        try {
+            mBluetoothGattConnection.getService(SERVICE_UUID);
+            fail("Expected BluetoothException");
+        } catch (BluetoothException expected) {
+        }
+    }
+
+    public void test_getCharacteristic() throws Exception {
+        BluetoothGattCharacteristic result =
+                mBluetoothGattConnection.getCharacteristic(SERVICE_UUID, CHARACTERISTIC_UUID);
+
+        assertThat(result).isEqualTo(mMockBluetoothGattCharacteristic);
+    }
+
+    public void test_getCharacteristic_notFound() throws Exception {
+        when(mMockBluetoothGattService.getCharacteristics())
+                .thenReturn(Arrays.<BluetoothGattCharacteristic>asList());
+
+        try {
+            mBluetoothGattConnection.getCharacteristic(SERVICE_UUID, CHARACTERISTIC_UUID);
+            fail("Expected BluetoothException");
+        } catch (BluetoothException expected) {
+        }
+    }
+
+    public void test_getCharacteristic_moreThanOne() throws Exception {
+        when(mMockBluetoothGattService.getCharacteristics())
+                .thenReturn(
+                        Arrays.asList(mMockBluetoothGattCharacteristic,
+                                mMockBluetoothGattCharacteristic));
+
+        try {
+            mBluetoothGattConnection.getCharacteristic(SERVICE_UUID, CHARACTERISTIC_UUID);
+            fail("Expected BluetoothException");
+        } catch (BluetoothException expected) {
+        }
+    }
+
+    public void test_getCharacteristic_moreThanOneService() throws Exception {
+        // Add a new service with the same service UUID as our existing one, but add a different
+        // characteristic inside of it.
+        when(mMockBluetoothGatt.getServices())
+                .thenReturn(Arrays.asList(mMockBluetoothGattService, mMockBluetoothGattService2));
+        when(mMockBluetoothGattService2.getUuid()).thenReturn(SERVICE_UUID);
+        when(mMockBluetoothGattService2.getCharacteristics())
+                .thenReturn(Arrays.asList(mMockBluetoothGattCharacteristic2));
+        when(mMockBluetoothGattCharacteristic2.getUuid())
+                .thenReturn(
+                        new UUID(
+                                CHARACTERISTIC_UUID.getMostSignificantBits(),
+                                CHARACTERISTIC_UUID.getLeastSignificantBits() + 1));
+        when(mMockBluetoothGattCharacteristic2.getProperties())
+                .thenReturn(
+                        BluetoothGattCharacteristic.PROPERTY_NOTIFY
+                                | BluetoothGattCharacteristic.PROPERTY_WRITE);
+
+        mBluetoothGattConnection.getCharacteristic(SERVICE_UUID, CHARACTERISTIC_UUID);
+    }
+
+    public void test_getDescriptor() throws Exception {
+        when(mMockBluetoothGattCharacteristic.getDescriptors())
+                .thenReturn(Arrays.asList(mMockBluetoothGattDescriptor));
+
+        BluetoothGattDescriptor result =
+                mBluetoothGattConnection
+                        .getDescriptor(SERVICE_UUID, CHARACTERISTIC_UUID, DESCRIPTOR_UUID);
+
+        assertThat(result).isEqualTo(mMockBluetoothGattDescriptor);
+    }
+
+    public void test_getDescriptor_notFound() throws Exception {
+        when(mMockBluetoothGattCharacteristic.getDescriptors())
+                .thenReturn(Arrays.<BluetoothGattDescriptor>asList());
+
+        try {
+            mBluetoothGattConnection
+                    .getDescriptor(SERVICE_UUID, CHARACTERISTIC_UUID, DESCRIPTOR_UUID);
+            fail("Expected BluetoothException");
+        } catch (BluetoothException expected) {
+        }
+    }
+
+    public void test_getDescriptor_moreThanOne() throws Exception {
+        when(mMockBluetoothGattCharacteristic.getDescriptors())
+                .thenReturn(
+                        Arrays.asList(mMockBluetoothGattDescriptor, mMockBluetoothGattDescriptor));
+
+        try {
+            mBluetoothGattConnection
+                    .getDescriptor(SERVICE_UUID, CHARACTERISTIC_UUID, DESCRIPTOR_UUID);
+            fail("Expected BluetoothException");
+        } catch (BluetoothException expected) {
+        }
+    }
+
+    public void test_discoverServices() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new SynchronousOperation<>(
+                        mMockBluetoothOperationExecutor, OperationType.NOTIFICATION_CHANGE)))
+                .thenReturn(mMockChangeObserver);
+
+        mBluetoothGattConnection.discoverServices();
+
+        verify(mMockBluetoothOperationExecutor).execute(mSynchronousOperationCaptor.capture());
+        mSynchronousOperationCaptor.getValue().call();
+        verify(mMockBluetoothOperationExecutor)
+                .execute(
+                        mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.SLOW_OPERATION_TIMEOUT_MILLIS));
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGatt).discoverServices();
+        verify(mMockBluetoothGatt, never()).refresh();
+    }
+
+    public void test_discoverServices_serviceChange() throws Exception {
+        when(mMockBluetoothGatt.getService(ReservedUuids.Services.GENERIC_ATTRIBUTE))
+                .thenReturn(mMockBluetoothGattService);
+        when(mMockBluetoothGattService
+                .getCharacteristic(ReservedUuids.Characteristics.SERVICE_CHANGE))
+                .thenReturn(mMockBluetoothGattCharacteristic);
+
+        mBluetoothGattConnection.discoverServices();
+
+        verify(mMockBluetoothOperationExecutor).execute(mSynchronousOperationCaptor.capture());
+        mSynchronousOperationCaptor.getValue().call();
+        verify(mMockBluetoothOperationExecutor, times(2))
+                .execute(
+                        mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.SLOW_OPERATION_TIMEOUT_MILLIS));
+        verify(mMockBluetoothGatt).refresh();
+    }
+
+    public void test_discoverServices_SelfDefinedServiceDynamic() throws Exception {
+        when(mMockBluetoothGatt.getService(BluetoothConsts.SERVICE_DYNAMIC_SERVICE))
+                .thenReturn(mMockBluetoothGattService);
+        when(mMockBluetoothGattService
+                .getCharacteristic(BluetoothConsts.SERVICE_DYNAMIC_CHARACTERISTIC))
+                .thenReturn(mMockBluetoothGattCharacteristic);
+
+        mBluetoothGattConnection.discoverServices();
+
+        verify(mMockBluetoothOperationExecutor).execute(mSynchronousOperationCaptor.capture());
+        mSynchronousOperationCaptor.getValue().call();
+        verify(mMockBluetoothOperationExecutor, times(2))
+                .execute(
+                        mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.SLOW_OPERATION_TIMEOUT_MILLIS));
+        verify(mMockBluetoothGatt).refresh();
+    }
+
+    public void test_discoverServices_refreshWithGattErrorOnMncAbove() throws Exception {
+        if (VERSION.SDK_INT <= VERSION_CODES.LOLLIPOP_MR1) {
+            return;
+        }
+        mBluetoothGattConnection.discoverServices();
+        verify(mMockBluetoothOperationExecutor).execute(mSynchronousOperationCaptor.capture());
+
+        doThrow(new BluetoothGattException("fail", BluetoothGattConnection.GATT_ERROR))
+                .doReturn(null)
+                .when(mMockBluetoothOperationExecutor)
+                .execute(isA(Operation.class),
+                        eq(BluetoothGattConnection.SLOW_OPERATION_TIMEOUT_MILLIS));
+        mSynchronousOperationCaptor.getValue().call();
+        verify(mMockBluetoothOperationExecutor, times(2))
+                .execute(
+                        mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.SLOW_OPERATION_TIMEOUT_MILLIS));
+        verify(mMockBluetoothGatt).refresh();
+    }
+
+    public void test_discoverServices_refreshWithGattInternalErrorOnMncAbove() throws Exception {
+        if (VERSION.SDK_INT <= VERSION_CODES.LOLLIPOP_MR1) {
+            return;
+        }
+        mBluetoothGattConnection.discoverServices();
+        verify(mMockBluetoothOperationExecutor).execute(mSynchronousOperationCaptor.capture());
+
+        doThrow(new BluetoothGattException("fail", BluetoothGattConnection.GATT_INTERNAL_ERROR))
+                .doReturn(null)
+                .when(mMockBluetoothOperationExecutor)
+                .execute(isA(Operation.class),
+                        eq(BluetoothGattConnection.SLOW_OPERATION_TIMEOUT_MILLIS));
+        mSynchronousOperationCaptor.getValue().call();
+        verify(mMockBluetoothOperationExecutor, times(2))
+                .execute(
+                        mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.SLOW_OPERATION_TIMEOUT_MILLIS));
+        verify(mMockBluetoothGatt).refresh();
+    }
+
+    public void test_discoverServices_dynamicServices_notBonded() throws Exception {
+        when(mMockBluetoothGatt.getService(ReservedUuids.Services.GENERIC_ATTRIBUTE))
+                .thenReturn(mMockBluetoothGattService);
+        when(mMockBluetoothGattService
+                .getCharacteristic(ReservedUuids.Characteristics.SERVICE_CHANGE))
+                .thenReturn(mMockBluetoothGattCharacteristic);
+        when(mMockBluetoothDevice.getBondState()).thenReturn(BluetoothDevice.BOND_NONE);
+
+        mBluetoothGattConnection.discoverServices();
+
+        verify(mMockBluetoothGatt, never()).refresh();
+    }
+
+    public void test_readCharacteristic() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new Operation<byte[]>(
+                        OperationType.READ_CHARACTERISTIC,
+                        mMockBluetoothGatt,
+                        mMockBluetoothGattCharacteristic),
+                BluetoothGattConnection.OPERATION_TIMEOUT_MILLIS))
+                .thenReturn(DATA);
+
+        byte[] result = mBluetoothGattConnection
+                .readCharacteristic(mMockBluetoothGattCharacteristic);
+
+        assertThat(result).isEqualTo(DATA);
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(mOperationCaptor.capture(), anyLong());
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGatt).readCharacteristic(mMockBluetoothGattCharacteristic);
+    }
+
+    public void test_readCharacteristic_by_uuid() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new Operation<byte[]>(
+                        OperationType.READ_CHARACTERISTIC,
+                        mMockBluetoothGatt,
+                        mMockBluetoothGattCharacteristic),
+                BluetoothGattConnection.OPERATION_TIMEOUT_MILLIS))
+                .thenReturn(DATA);
+
+        byte[] result = mBluetoothGattConnection
+                .readCharacteristic(SERVICE_UUID, CHARACTERISTIC_UUID);
+
+        assertThat(result).isEqualTo(DATA);
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(mOperationCaptor.capture(), anyLong());
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGatt).readCharacteristic(mMockBluetoothGattCharacteristic);
+    }
+
+    public void test_writeCharacteristic() throws Exception {
+        BluetoothGattCharacteristic characteristic =
+                new BluetoothGattCharacteristic(
+                        CHARACTERISTIC_UUID, BluetoothGattCharacteristic.PROPERTY_WRITE, 0);
+        mBluetoothGattConnection.writeCharacteristic(characteristic, DATA);
+
+        verify(mMockBluetoothOperationExecutor)
+                .execute(mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.OPERATION_TIMEOUT_MILLIS));
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGatt).writeCharacteristic(mCharacteristicCaptor.capture());
+        BluetoothGattCharacteristic writtenCharacteristic = mCharacteristicCaptor.getValue();
+        assertThat(writtenCharacteristic.getValue()).isEqualTo(DATA);
+        assertThat(writtenCharacteristic.getUuid()).isEqualTo(CHARACTERISTIC_UUID);
+        assertThat(writtenCharacteristic).isNotEqualTo(characteristic);
+    }
+
+    public void test_writeCharacteristic_by_uuid() throws Exception {
+        mBluetoothGattConnection.writeCharacteristic(SERVICE_UUID, CHARACTERISTIC_UUID, DATA);
+
+        verify(mMockBluetoothOperationExecutor)
+                .execute(mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.OPERATION_TIMEOUT_MILLIS));
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGatt).writeCharacteristic(mCharacteristicCaptor.capture());
+        BluetoothGattCharacteristic writtenCharacteristic = mCharacteristicCaptor.getValue();
+        assertThat(writtenCharacteristic.getValue()).isEqualTo(DATA);
+        assertThat(writtenCharacteristic.getUuid()).isEqualTo(CHARACTERISTIC_UUID);
+    }
+
+    public void test_readDescriptor() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new Operation<byte[]>(
+                        OperationType.READ_DESCRIPTOR, mMockBluetoothGatt,
+                        mMockBluetoothGattDescriptor),
+                BluetoothGattConnection.OPERATION_TIMEOUT_MILLIS))
+                .thenReturn(DATA);
+
+        byte[] result = mBluetoothGattConnection.readDescriptor(mMockBluetoothGattDescriptor);
+
+        assertThat(result).isEqualTo(DATA);
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(mOperationCaptor.capture(), anyLong());
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGatt).readDescriptor(mMockBluetoothGattDescriptor);
+    }
+
+    public void test_readDescriptor_by_uuid() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new Operation<byte[]>(
+                        OperationType.READ_DESCRIPTOR, mMockBluetoothGatt,
+                        mMockBluetoothGattDescriptor),
+                BluetoothGattConnection.OPERATION_TIMEOUT_MILLIS))
+                .thenReturn(DATA);
+
+        byte[] result =
+                mBluetoothGattConnection
+                        .readDescriptor(SERVICE_UUID, CHARACTERISTIC_UUID, DESCRIPTOR_UUID);
+
+        assertThat(result).isEqualTo(DATA);
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(mOperationCaptor.capture(), anyLong());
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGatt).readDescriptor(mMockBluetoothGattDescriptor);
+    }
+
+    public void test_writeDescriptor() throws Exception {
+        BluetoothGattDescriptor descriptor = new BluetoothGattDescriptor(DESCRIPTOR_UUID, 0);
+        mBluetoothGattConnection.writeDescriptor(descriptor, DATA);
+
+        verify(mMockBluetoothOperationExecutor)
+                .execute(mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.OPERATION_TIMEOUT_MILLIS));
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGatt).writeDescriptor(mDescriptorCaptor.capture());
+        BluetoothGattDescriptor writtenDescriptor = mDescriptorCaptor.getValue();
+        assertThat(writtenDescriptor.getValue()).isEqualTo(DATA);
+        assertThat(writtenDescriptor.getUuid()).isEqualTo(DESCRIPTOR_UUID);
+        assertThat(writtenDescriptor).isNotEqualTo(descriptor);
+    }
+
+    public void test_writeDescriptor_by_uuid() throws Exception {
+        mBluetoothGattConnection.writeDescriptor(
+                SERVICE_UUID, CHARACTERISTIC_UUID, DESCRIPTOR_UUID, DATA);
+
+        verify(mMockBluetoothOperationExecutor)
+                .execute(mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.OPERATION_TIMEOUT_MILLIS));
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGatt).writeDescriptor(mDescriptorCaptor.capture());
+        BluetoothGattDescriptor writtenDescriptor = mDescriptorCaptor.getValue();
+        assertThat(writtenDescriptor.getValue()).isEqualTo(DATA);
+        assertThat(writtenDescriptor.getUuid()).isEqualTo(DESCRIPTOR_UUID);
+    }
+
+    public void test_readRemoteRssi() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new Operation<Integer>(OperationType.READ_RSSI, mMockBluetoothGatt),
+                BluetoothGattConnection.OPERATION_TIMEOUT_MILLIS))
+                .thenReturn(RSSI);
+
+        int result = mBluetoothGattConnection.readRemoteRssi();
+
+        assertThat(result).isEqualTo(RSSI);
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(
+                        mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.OPERATION_TIMEOUT_MILLIS));
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGatt).readRemoteRssi();
+    }
+
+    public void test_getMaxDataPacketSize() throws Exception {
+        int result = mBluetoothGattConnection.getMaxDataPacketSize();
+
+        assertThat(result).isEqualTo(mBluetoothGattConnection.getMtu() - 3);
+    }
+
+    public void testSetNotificationEnabled_indication_enable() throws Exception {
+        when(mMockBluetoothGattCharacteristic.getProperties())
+                .thenReturn(BluetoothGattCharacteristic.PROPERTY_INDICATE);
+
+        mBluetoothGattConnection.setNotificationEnabled(mMockBluetoothGattCharacteristic, true);
+
+        verify(mMockBluetoothGatt)
+                .setCharacteristicNotification(mMockBluetoothGattCharacteristic, true);
+        verify(mMockBluetoothOperationExecutor).execute(mOperationCaptor.capture(), anyLong());
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGatt).writeDescriptor(mDescriptorCaptor.capture());
+        BluetoothGattDescriptor writtenDescriptor = mDescriptorCaptor.getValue();
+        assertThat(writtenDescriptor.getValue())
+                .isEqualTo(BluetoothGattDescriptor.ENABLE_INDICATION_VALUE);
+        assertThat(writtenDescriptor.getUuid())
+                .isEqualTo(ReservedUuids.Descriptors.CLIENT_CHARACTERISTIC_CONFIGURATION);
+    }
+
+    public void test_getNotificationEnabled_notification_enable() throws Exception {
+        mBluetoothGattConnection.setNotificationEnabled(mMockBluetoothGattCharacteristic, true);
+
+        verify(mMockBluetoothGatt)
+                .setCharacteristicNotification(mMockBluetoothGattCharacteristic, true);
+        verify(mMockBluetoothOperationExecutor).execute(mOperationCaptor.capture(), anyLong());
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGatt).writeDescriptor(mDescriptorCaptor.capture());
+        BluetoothGattDescriptor writtenDescriptor = mDescriptorCaptor.getValue();
+        assertThat(writtenDescriptor.getValue())
+                .isEqualTo(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
+        assertThat(writtenDescriptor.getUuid())
+                .isEqualTo(ReservedUuids.Descriptors.CLIENT_CHARACTERISTIC_CONFIGURATION);
+    }
+
+    public void test_setNotificationEnabled_indication_disable() throws Exception {
+        when(mMockBluetoothGattCharacteristic.getProperties())
+                .thenReturn(BluetoothGattCharacteristic.PROPERTY_INDICATE);
+
+        mBluetoothGattConnection.setNotificationEnabled(mMockBluetoothGattCharacteristic, false);
+
+        verify(mMockBluetoothGatt)
+                .setCharacteristicNotification(mMockBluetoothGattCharacteristic, false);
+        verify(mMockBluetoothOperationExecutor).execute(mOperationCaptor.capture(), anyLong());
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGatt).writeDescriptor(mDescriptorCaptor.capture());
+        BluetoothGattDescriptor writtenDescriptor = mDescriptorCaptor.getValue();
+        assertThat(writtenDescriptor.getValue())
+                .isEqualTo(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE);
+        assertThat(writtenDescriptor.getUuid())
+                .isEqualTo(ReservedUuids.Descriptors.CLIENT_CHARACTERISTIC_CONFIGURATION);
+    }
+
+    public void test_setNotificationEnabled_notification_disable() throws Exception {
+        mBluetoothGattConnection.setNotificationEnabled(mMockBluetoothGattCharacteristic, false);
+
+        verify(mMockBluetoothGatt)
+                .setCharacteristicNotification(mMockBluetoothGattCharacteristic, false);
+        verify(mMockBluetoothOperationExecutor).execute(mOperationCaptor.capture(), anyLong());
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGatt).writeDescriptor(mDescriptorCaptor.capture());
+        BluetoothGattDescriptor writtenDescriptor = mDescriptorCaptor.getValue();
+        assertThat(writtenDescriptor.getValue())
+                .isEqualTo(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE);
+        assertThat(writtenDescriptor.getUuid())
+                .isEqualTo(ReservedUuids.Descriptors.CLIENT_CHARACTERISTIC_CONFIGURATION);
+    }
+
+    public void test_setNotificationEnabled_failure() throws Exception {
+        when(mMockBluetoothGattCharacteristic.getProperties())
+                .thenReturn(BluetoothGattCharacteristic.PROPERTY_READ);
+
+        try {
+            mBluetoothGattConnection.setNotificationEnabled(mMockBluetoothGattCharacteristic, true);
+            fail("BluetoothException was expected");
+        } catch (BluetoothException expected) {
+        }
+    }
+
+    public void test_enableNotification_Uuid() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new SynchronousOperation<>(
+                        mMockBluetoothOperationExecutor,
+                        OperationType.NOTIFICATION_CHANGE,
+                        mMockBluetoothGattCharacteristic)))
+                .thenReturn(mMockChangeObserver);
+        mBluetoothGattConnection.enableNotification(SERVICE_UUID, CHARACTERISTIC_UUID);
+
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(mSynchronousOperationCaptor.capture());
+        ((ChangeObserver) mSynchronousOperationCaptor.getValue().call())
+                .setListener(mMockCharChangeListener);
+        mBluetoothGattConnection.onCharacteristicChanged(mMockBluetoothGattCharacteristic, DATA);
+        verify(mMockCharChangeListener).onValueChange(DATA);
+    }
+
+    public void test_enableNotification() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new SynchronousOperation<>(
+                        mMockBluetoothOperationExecutor,
+                        OperationType.NOTIFICATION_CHANGE,
+                        mMockBluetoothGattCharacteristic)))
+                .thenReturn(mMockChangeObserver);
+        mBluetoothGattConnection.enableNotification(mMockBluetoothGattCharacteristic);
+
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(mSynchronousOperationCaptor.capture());
+        ((ChangeObserver) mSynchronousOperationCaptor.getValue().call())
+                .setListener(mMockCharChangeListener);
+
+        mBluetoothGattConnection.onCharacteristicChanged(mMockBluetoothGattCharacteristic, DATA);
+
+        verify(mMockCharChangeListener).onValueChange(DATA);
+    }
+
+    public void test_enableNotification_observe() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new SynchronousOperation<>(
+                        mMockBluetoothOperationExecutor,
+                        OperationType.NOTIFICATION_CHANGE,
+                        mMockBluetoothGattCharacteristic)))
+                .thenReturn(mMockChangeObserver);
+        mBluetoothGattConnection.enableNotification(mMockBluetoothGattCharacteristic);
+
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(mSynchronousOperationCaptor.capture());
+        ChangeObserver changeObserver = (ChangeObserver) mSynchronousOperationCaptor.getValue()
+                .call();
+        mBluetoothGattConnection.onCharacteristicChanged(mMockBluetoothGattCharacteristic, DATA);
+        assertThat(changeObserver.waitForUpdate(TimeUnit.SECONDS.toMillis(1))).isEqualTo(DATA);
+    }
+
+    public void test_disableNotification_Uuid() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new SynchronousOperation<>(
+                        OperationType.NOTIFICATION_CHANGE, mMockBluetoothGattCharacteristic)))
+                .thenReturn(mMockChangeObserver);
+        mBluetoothGattConnection
+                .enableNotification(SERVICE_UUID, CHARACTERISTIC_UUID)
+                .setListener(mMockCharChangeListener);
+
+        mBluetoothGattConnection.disableNotification(SERVICE_UUID, CHARACTERISTIC_UUID);
+
+        mBluetoothGattConnection.onCharacteristicChanged(mMockBluetoothGattCharacteristic, DATA);
+        verify(mMockCharChangeListener, never()).onValueChange(DATA);
+    }
+
+    public void test_disableNotification() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new SynchronousOperation<ChangeObserver>(
+                        OperationType.NOTIFICATION_CHANGE, mMockBluetoothGattCharacteristic)))
+                .thenReturn(mMockChangeObserver);
+        mBluetoothGattConnection
+                .enableNotification(mMockBluetoothGattCharacteristic)
+                .setListener(mMockCharChangeListener);
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(mSynchronousOperationCaptor.capture());
+        mSynchronousOperationCaptor.getValue().call();
+
+        mBluetoothGattConnection.disableNotification(mMockBluetoothGattCharacteristic);
+        verify(mMockBluetoothOperationExecutor).execute(mSynchronousOperationCaptor.capture());
+        mSynchronousOperationCaptor.getValue().call();
+
+        mBluetoothGattConnection.onCharacteristicChanged(mMockBluetoothGattCharacteristic, DATA);
+        verify(mMockCharChangeListener, never()).onValueChange(DATA);
+    }
+
+    public void test_addCloseListener() throws Exception {
+        mBluetoothGattConnection.addCloseListener(mMockConnectionCloseListener);
+
+        mBluetoothGattConnection.onClosed();
+        verify(mMockConnectionCloseListener).onClose();
+    }
+
+    public void test_removeCloseListener() throws Exception {
+        mBluetoothGattConnection.addCloseListener(mMockConnectionCloseListener);
+
+        mBluetoothGattConnection.removeCloseListener(mMockConnectionCloseListener);
+
+        mBluetoothGattConnection.onClosed();
+        verify(mMockConnectionCloseListener, never()).onClose();
+    }
+
+    public void test_close() throws Exception {
+        mBluetoothGattConnection.close();
+
+        verify(mMockBluetoothOperationExecutor)
+                .execute(mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.OPERATION_TIMEOUT_MILLIS));
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGatt).disconnect();
+        verify(mMockBluetoothGatt).close();
+    }
+
+    public void test_onClosed() throws Exception {
+        mBluetoothGattConnection.onClosed();
+
+        verify(mMockBluetoothOperationExecutor, never())
+                .execute(mOperationCaptor.capture(), anyLong());
+        verify(mMockBluetoothGatt).close();
+    }
+}
diff --git a/nearby/tests/src/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattHelperTest.java b/nearby/tests/src/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattHelperTest.java
new file mode 100644
index 0000000..a60f0b2
--- /dev/null
+++ b/nearby/tests/src/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattHelperTest.java
@@ -0,0 +1,613 @@
+/*
+ * Copyright (C) 2021 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.server.nearby.common.bluetooth.gatt;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isA;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
+import static org.mockito.MockitoAnnotations.initMocks;
+
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanSettings;
+import android.content.Context;
+import android.os.ParcelUuid;
+import android.test.mock.MockContext;
+
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattHelper.ConnectionOptions;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattHelper.OperationType;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothAdapter;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothDevice;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothGatt;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.le.BluetoothLeScanner;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.le.ScanCallback;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.le.ScanResult;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.BluetoothOperationTimeoutException;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.Operation;
+
+import junit.framework.TestCase;
+
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+
+import java.util.Arrays;
+import java.util.UUID;
+
+/**
+ * Unit tests for {@link BluetoothGattHelper}.
+ */
+public class BluetoothGattHelperTest extends TestCase {
+
+    private static final UUID SERVICE_UUID = UUID.randomUUID();
+    private static final int GATT_STATUS = 1234;
+    private static final Operation<BluetoothDevice> SCANNING_OPERATION =
+            new Operation<BluetoothDevice>(OperationType.SCAN);
+    private static final byte[] CHARACTERISTIC_VALUE = "characteristic_value".getBytes();
+    private static final byte[] DESCRIPTOR_VALUE = "descriptor_value".getBytes();
+    private static final int RSSI = -63;
+    private static final int MTU = 50;
+    private static final long CONNECT_TIMEOUT_MILLIS = 5000;
+
+    private Context mMockApplicationContext = new MockContext();
+    @Mock
+    private BluetoothAdapter mMockBluetoothAdapter;
+    @Mock
+    private BluetoothLeScanner mMockBluetoothLeScanner;
+    @Mock
+    private BluetoothOperationExecutor mMockBluetoothOperationExecutor;
+    @Mock
+    private BluetoothDevice mMockBluetoothDevice;
+    @Mock
+    private BluetoothGattConnection mMockBluetoothGattConnection;
+    @Mock
+    private BluetoothGatt mMockBluetoothGatt;
+    @Mock
+    private BluetoothGattCharacteristic mMockBluetoothGattCharacteristic;
+    @Mock
+    private BluetoothGattDescriptor mMockBluetoothGattDescriptor;
+    @Mock
+    private ScanResult mMockScanResult;
+
+    @Captor
+    private ArgumentCaptor<Operation<?>> mOperationCaptor;
+    @Captor
+    private ArgumentCaptor<ScanSettings> mScanSettingsCaptor;
+
+    private BluetoothGattHelper mBluetoothGattHelper;
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+
+        initMocks(this);
+
+        mBluetoothGattHelper = new BluetoothGattHelper(
+                mMockApplicationContext,
+                mMockBluetoothAdapter,
+                mMockBluetoothOperationExecutor);
+
+        when(mMockBluetoothAdapter.getBluetoothLeScanner()).thenReturn(mMockBluetoothLeScanner);
+        when(mMockBluetoothOperationExecutor.executeNonnull(SCANNING_OPERATION,
+                BluetoothGattHelper.LOW_LATENCY_SCAN_MILLIS)).thenReturn(mMockBluetoothDevice);
+        when(mMockBluetoothOperationExecutor.executeNonnull(SCANNING_OPERATION)).thenReturn(
+                mMockBluetoothDevice);
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new Operation<BluetoothGattConnection>(OperationType.CONNECT, mMockBluetoothDevice),
+                CONNECT_TIMEOUT_MILLIS))
+                .thenReturn(mMockBluetoothGattConnection);
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new Operation<BluetoothGattConnection>(OperationType.CONNECT,
+                        mMockBluetoothDevice)))
+                .thenReturn(mMockBluetoothGattConnection);
+        when(mMockBluetoothGattCharacteristic.getValue()).thenReturn(CHARACTERISTIC_VALUE);
+        when(mMockBluetoothGattDescriptor.getValue()).thenReturn(DESCRIPTOR_VALUE);
+        when(mMockScanResult.getDevice()).thenReturn(mMockBluetoothDevice);
+        when(mMockBluetoothGatt.getDevice()).thenReturn(mMockBluetoothDevice);
+        when(mMockBluetoothDevice.connectGatt(eq(mMockApplicationContext), anyBoolean(),
+                eq(mBluetoothGattHelper.mBluetoothGattCallback))).thenReturn(mMockBluetoothGatt);
+        when(mMockBluetoothDevice.connectGatt(eq(mMockApplicationContext), anyBoolean(),
+                eq(mBluetoothGattHelper.mBluetoothGattCallback), anyInt()))
+                .thenReturn(mMockBluetoothGatt);
+        when(mMockBluetoothGattConnection.getConnectionOptions())
+                .thenReturn(ConnectionOptions.builder().build());
+    }
+
+    public void test_autoConnect_uuid_success_lowLatency() throws Exception {
+        BluetoothGattConnection result = mBluetoothGattHelper.autoConnect(SERVICE_UUID);
+
+        assertThat(result).isEqualTo(mMockBluetoothGattConnection);
+        verify(mMockBluetoothOperationExecutor, atLeastOnce())
+                .executeNonnull(mOperationCaptor.capture(),
+                        anyLong());
+        for (Operation<?> operation : mOperationCaptor.getAllValues()) {
+            operation.run();
+        }
+        verify(mMockBluetoothLeScanner).startScan(eq(Arrays.asList(
+                new ScanFilter.Builder().setServiceUuid(new ParcelUuid(SERVICE_UUID)).build())),
+                mScanSettingsCaptor.capture(), eq(mBluetoothGattHelper.mScanCallback));
+        assertThat(mScanSettingsCaptor.getValue().getScanMode()).isEqualTo(
+                ScanSettings.SCAN_MODE_LOW_LATENCY);
+        verify(mMockBluetoothLeScanner).stopScan(mBluetoothGattHelper.mScanCallback);
+        verifyNoMoreInteractions(mMockBluetoothLeScanner);
+    }
+
+    public void test_autoConnect_uuid_success_lowPower() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(SCANNING_OPERATION,
+                BluetoothGattHelper.LOW_LATENCY_SCAN_MILLIS)).thenThrow(
+                new BluetoothOperationTimeoutException("Timeout"));
+
+        BluetoothGattConnection result = mBluetoothGattHelper.autoConnect(SERVICE_UUID);
+
+        assertThat(result).isEqualTo(mMockBluetoothGattConnection);
+        verify(mMockBluetoothOperationExecutor).executeNonnull(mOperationCaptor.capture());
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothLeScanner).startScan(eq(Arrays.asList(
+                new ScanFilter.Builder().setServiceUuid(new ParcelUuid(SERVICE_UUID)).build())),
+                mScanSettingsCaptor.capture(), eq(mBluetoothGattHelper.mScanCallback));
+        assertThat(mScanSettingsCaptor.getValue().getScanMode()).isEqualTo(
+                ScanSettings.SCAN_MODE_LOW_POWER);
+        verify(mMockBluetoothLeScanner, times(2)).stopScan(mBluetoothGattHelper.mScanCallback);
+        verifyNoMoreInteractions(mMockBluetoothLeScanner);
+    }
+
+    public void test_autoConnect_uuid_success_afterRetry() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new Operation<BluetoothGattConnection>(OperationType.CONNECT, mMockBluetoothDevice),
+                BluetoothGattHelper.LOW_LATENCY_SCAN_MILLIS))
+                .thenThrow(new BluetoothException("first attempt fails!"))
+                .thenReturn(mMockBluetoothGattConnection);
+
+        BluetoothGattConnection result = mBluetoothGattHelper.autoConnect(SERVICE_UUID);
+
+        assertThat(result).isEqualTo(mMockBluetoothGattConnection);
+    }
+
+    public void test_autoConnect_uuid_failure_scanning() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(SCANNING_OPERATION,
+                BluetoothGattHelper.LOW_LATENCY_SCAN_MILLIS)).thenThrow(
+                new BluetoothException("Scanning failed"));
+
+        try {
+            mBluetoothGattHelper.autoConnect(SERVICE_UUID);
+            fail("BluetoothException expected");
+        } catch (BluetoothException e) {
+            // expected
+        }
+    }
+
+    public void test_autoConnect_uuid_failure_connecting() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new Operation<BluetoothGattConnection>(OperationType.CONNECT, mMockBluetoothDevice),
+                CONNECT_TIMEOUT_MILLIS))
+                .thenThrow(new BluetoothException("Connect failed"));
+
+        try {
+            mBluetoothGattHelper.autoConnect(SERVICE_UUID);
+            fail("BluetoothException expected");
+        } catch (BluetoothException e) {
+            // expected
+        }
+        verify(mMockBluetoothOperationExecutor, times(3))
+                .executeNonnull(
+                        new Operation<BluetoothGattConnection>(OperationType.CONNECT,
+                                mMockBluetoothDevice),
+                        CONNECT_TIMEOUT_MILLIS);
+    }
+
+    public void test_autoConnect_uuid_failure_noBle() throws Exception {
+        when(mMockBluetoothAdapter.getBluetoothLeScanner()).thenReturn(null);
+
+        try {
+            mBluetoothGattHelper.autoConnect(SERVICE_UUID);
+            fail("BluetoothException expected");
+        } catch (BluetoothException e) {
+            // expected
+        }
+    }
+
+    public void test_connect() throws Exception {
+        BluetoothGattConnection result = mBluetoothGattHelper.connect(mMockBluetoothDevice);
+
+        assertThat(result).isEqualTo(mMockBluetoothGattConnection);
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(mOperationCaptor.capture(), eq(CONNECT_TIMEOUT_MILLIS));
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothDevice).connectGatt(mMockApplicationContext, false,
+                mBluetoothGattHelper.mBluetoothGattCallback,
+                android.bluetooth.BluetoothDevice.TRANSPORT_LE);
+        assertThat(mBluetoothGattHelper.mConnections.get(mMockBluetoothGatt).getDevice())
+                .isEqualTo(mMockBluetoothDevice);
+    }
+
+    public void test_connect_withOptionAutoConnect_success() throws Exception {
+        BluetoothGattConnection result = mBluetoothGattHelper
+                .connect(
+                        mMockBluetoothDevice,
+                        ConnectionOptions.builder()
+                                .setAutoConnect(true)
+                                .build());
+
+        assertThat(result).isEqualTo(mMockBluetoothGattConnection);
+        verify(mMockBluetoothOperationExecutor).executeNonnull(mOperationCaptor.capture());
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothDevice).connectGatt(mMockApplicationContext, true,
+                mBluetoothGattHelper.mBluetoothGattCallback,
+                android.bluetooth.BluetoothDevice.TRANSPORT_LE);
+        assertThat(mBluetoothGattHelper.mConnections.get(mMockBluetoothGatt).getConnectionOptions())
+                .isEqualTo(ConnectionOptions.builder()
+                        .setAutoConnect(true)
+                        .build());
+    }
+
+    public void test_connect_withOptionAutoConnect_failure_nullResult() throws Exception {
+        when(mMockBluetoothDevice.connectGatt(eq(mMockApplicationContext), anyBoolean(),
+                eq(mBluetoothGattHelper.mBluetoothGattCallback),
+                eq(android.bluetooth.BluetoothDevice.TRANSPORT_LE))).thenReturn(null);
+
+        try {
+            mBluetoothGattHelper.connect(
+                    mMockBluetoothDevice,
+                    ConnectionOptions.builder()
+                            .setAutoConnect(true)
+                            .build());
+            verify(mMockBluetoothOperationExecutor).executeNonnull(mOperationCaptor.capture());
+            mOperationCaptor.getValue().run();
+            fail("BluetoothException expected");
+        } catch (BluetoothException e) {
+            // expected
+        }
+    }
+
+    public void test_connect_withOptionRequestConnectionPriority_success() throws Exception {
+        // Operation succeeds on the 3rd try.
+        when(mMockBluetoothGatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH))
+                .thenReturn(false)
+                .thenReturn(false)
+                .thenReturn(true);
+
+        BluetoothGattConnection result = mBluetoothGattHelper
+                .connect(
+                        mMockBluetoothDevice,
+                        ConnectionOptions.builder()
+                                .setConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
+                                .build());
+
+        assertThat(result).isEqualTo(mMockBluetoothGattConnection);
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(mOperationCaptor.capture(), eq(CONNECT_TIMEOUT_MILLIS));
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothDevice).connectGatt(mMockApplicationContext, false,
+                mBluetoothGattHelper.mBluetoothGattCallback,
+                android.bluetooth.BluetoothDevice.TRANSPORT_LE);
+        assertThat(mBluetoothGattHelper.mConnections.get(mMockBluetoothGatt).getConnectionOptions())
+                .isEqualTo(ConnectionOptions.builder()
+                        .setConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
+                        .build());
+        verify(mMockBluetoothGatt, times(3))
+                .requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH);
+    }
+
+    public void test_connect_cancel() throws Exception {
+        mBluetoothGattHelper.connect(mMockBluetoothDevice);
+
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(mOperationCaptor.capture(), eq(CONNECT_TIMEOUT_MILLIS));
+        Operation<?> operation = mOperationCaptor.getValue();
+        operation.run();
+        operation.cancel();
+
+        verify(mMockBluetoothGatt).disconnect();
+        verify(mMockBluetoothGatt).close();
+        assertThat(mBluetoothGattHelper.mConnections.get(mMockBluetoothGatt)).isNull();
+    }
+
+    public void test_BluetoothGattCallback_onConnectionStateChange_connected_success()
+            throws Exception {
+        mBluetoothGattHelper.mConnections.put(mMockBluetoothGatt, mMockBluetoothGattConnection);
+
+        mBluetoothGattHelper.mBluetoothGattCallback.onConnectionStateChange(mMockBluetoothGatt,
+                BluetoothGatt.GATT_SUCCESS, BluetoothGatt.STATE_CONNECTED);
+
+        verify(mMockBluetoothOperationExecutor).notifyCompletion(
+                new Operation<>(OperationType.CONNECT, mMockBluetoothDevice),
+                BluetoothGatt.GATT_SUCCESS,
+                mMockBluetoothGattConnection);
+        verify(mMockBluetoothGattConnection).onConnected();
+    }
+
+    public void test_BluetoothGattCallback_onConnectionStateChange_connected_success_withMtuOption()
+            throws Exception {
+        mBluetoothGattHelper.mConnections.put(mMockBluetoothGatt, mMockBluetoothGattConnection);
+        when(mMockBluetoothGattConnection.getConnectionOptions())
+                .thenReturn(BluetoothGattHelper.ConnectionOptions.builder()
+                        .setMtu(MTU)
+                        .build());
+        when(mMockBluetoothGatt.requestMtu(MTU)).thenReturn(true);
+
+        mBluetoothGattHelper.mBluetoothGattCallback.onConnectionStateChange(mMockBluetoothGatt,
+                BluetoothGatt.GATT_SUCCESS, BluetoothGatt.STATE_CONNECTED);
+
+        verifyZeroInteractions(mMockBluetoothOperationExecutor);
+        verify(mMockBluetoothGattConnection, never()).onConnected();
+        verify(mMockBluetoothGatt).requestMtu(MTU);
+    }
+
+    public void test_BluetoothGattCallback_onConnectionStateChange_connected_success_failMtuOption()
+            throws Exception {
+        mBluetoothGattHelper.mConnections.put(mMockBluetoothGatt, mMockBluetoothGattConnection);
+        when(mMockBluetoothGattConnection.getConnectionOptions())
+                .thenReturn(BluetoothGattHelper.ConnectionOptions.builder()
+                        .setMtu(MTU)
+                        .build());
+        when(mMockBluetoothGatt.requestMtu(MTU)).thenReturn(false);
+
+        mBluetoothGattHelper.mBluetoothGattCallback.onConnectionStateChange(mMockBluetoothGatt,
+                BluetoothGatt.GATT_SUCCESS, BluetoothGatt.STATE_CONNECTED);
+
+        verify(mMockBluetoothOperationExecutor).notifyFailure(
+                eq(new Operation<>(OperationType.CONNECT, mMockBluetoothDevice)),
+                any(BluetoothException.class));
+        verify(mMockBluetoothGattConnection, never()).onConnected();
+        verify(mMockBluetoothGatt).disconnect();
+        verify(mMockBluetoothGatt).close();
+        assertThat(mBluetoothGattHelper.mConnections.get(mMockBluetoothGatt)).isNull();
+    }
+
+    public void test_BluetoothGattCallback_onConnectionStateChange_connected_unexpectedSuccess()
+            throws Exception {
+        mBluetoothGattHelper.mBluetoothGattCallback.onConnectionStateChange(mMockBluetoothGatt,
+                BluetoothGatt.GATT_SUCCESS, BluetoothGatt.STATE_CONNECTED);
+
+        verifyZeroInteractions(mMockBluetoothOperationExecutor);
+    }
+
+    public void test_BluetoothGattCallback_onConnectionStateChange_connected_failure()
+            throws Exception {
+        mBluetoothGattHelper.mConnections.put(mMockBluetoothGatt, mMockBluetoothGattConnection);
+
+        mBluetoothGattHelper.mBluetoothGattCallback
+                .onConnectionStateChange(
+                        mMockBluetoothGatt,
+                        BluetoothGatt.GATT_FAILURE,
+                        BluetoothGatt.STATE_CONNECTED);
+
+        verify(mMockBluetoothOperationExecutor)
+                .notifyCompletion(
+                        new Operation<>(OperationType.CONNECT, mMockBluetoothDevice),
+                        BluetoothGatt.GATT_FAILURE,
+                        null);
+        verify(mMockBluetoothGatt).disconnect();
+        verify(mMockBluetoothGatt).close();
+        assertThat(mBluetoothGattHelper.mConnections.get(mMockBluetoothGatt)).isNull();
+    }
+
+    public void test_BluetoothGattCallback_onConnectionStateChange_disconnected_unexpectedSuccess()
+            throws Exception {
+        mBluetoothGattHelper.mBluetoothGattCallback
+                .onConnectionStateChange(
+                        mMockBluetoothGatt,
+                        BluetoothGatt.GATT_SUCCESS,
+                        BluetoothGatt.STATE_DISCONNECTED);
+
+        verifyZeroInteractions(mMockBluetoothOperationExecutor);
+    }
+
+    public void test_BluetoothGattCallback_onConnectionStateChange_disconnected_notConnected()
+            throws Exception {
+        mBluetoothGattHelper.mConnections.put(mMockBluetoothGatt, mMockBluetoothGattConnection);
+        when(mMockBluetoothGattConnection.isConnected()).thenReturn(false);
+
+        mBluetoothGattHelper.mBluetoothGattCallback
+                .onConnectionStateChange(
+                        mMockBluetoothGatt,
+                        GATT_STATUS,
+                        BluetoothGatt.STATE_DISCONNECTED);
+
+        verify(mMockBluetoothOperationExecutor)
+                .notifyCompletion(
+                        new Operation<>(OperationType.CONNECT, mMockBluetoothDevice),
+                        GATT_STATUS,
+                        null);
+        verify(mMockBluetoothGatt).disconnect();
+        verify(mMockBluetoothGatt).close();
+        assertThat(mBluetoothGattHelper.mConnections.get(mMockBluetoothGatt)).isNull();
+    }
+
+    public void test_BluetoothGattCallback_onConnectionStateChange_disconnected_success()
+            throws Exception {
+        mBluetoothGattHelper.mConnections.put(mMockBluetoothGatt, mMockBluetoothGattConnection);
+        when(mMockBluetoothGattConnection.isConnected()).thenReturn(true);
+
+        mBluetoothGattHelper.mBluetoothGattCallback.onConnectionStateChange(mMockBluetoothGatt,
+                BluetoothGatt.GATT_SUCCESS, BluetoothGatt.STATE_DISCONNECTED);
+
+        verify(mMockBluetoothOperationExecutor).notifyCompletion(
+                new Operation<>(OperationType.DISCONNECT, mMockBluetoothDevice),
+                BluetoothGatt.GATT_SUCCESS);
+        verify(mMockBluetoothGattConnection).onClosed();
+        assertThat(mBluetoothGattHelper.mConnections.get(mMockBluetoothGatt)).isNull();
+    }
+
+    public void test_BluetoothGattCallback_onConnectionStateChange_disconnected_failure()
+            throws Exception {
+        mBluetoothGattHelper.mConnections.put(mMockBluetoothGatt, mMockBluetoothGattConnection);
+        when(mMockBluetoothGattConnection.isConnected()).thenReturn(true);
+
+        mBluetoothGattHelper.mBluetoothGattCallback.onConnectionStateChange(mMockBluetoothGatt,
+                BluetoothGatt.GATT_FAILURE, BluetoothGatt.STATE_DISCONNECTED);
+
+        verify(mMockBluetoothOperationExecutor).notifyCompletion(
+                new Operation<>(OperationType.DISCONNECT, mMockBluetoothDevice),
+                BluetoothGatt.GATT_FAILURE);
+        verify(mMockBluetoothGattConnection).onClosed();
+        assertThat(mBluetoothGattHelper.mConnections.get(mMockBluetoothGatt)).isNull();
+    }
+
+    public void test_BluetoothGattCallback_onServicesDiscovered() throws Exception {
+        mBluetoothGattHelper.mBluetoothGattCallback.onServicesDiscovered(mMockBluetoothGatt,
+                GATT_STATUS);
+
+        verify(mMockBluetoothOperationExecutor).notifyCompletion(
+                new Operation<Void>(OperationType.DISCOVER_SERVICES_INTERNAL, mMockBluetoothGatt),
+                GATT_STATUS);
+    }
+
+    public void test_BluetoothGattCallback_onCharacteristicRead() throws Exception {
+        mBluetoothGattHelper.mBluetoothGattCallback.onCharacteristicRead(mMockBluetoothGatt,
+                mMockBluetoothGattCharacteristic, GATT_STATUS);
+
+        verify(mMockBluetoothOperationExecutor).notifyCompletion(new Operation<byte[]>(
+                        OperationType.READ_CHARACTERISTIC, mMockBluetoothGatt,
+                        mMockBluetoothGattCharacteristic),
+                GATT_STATUS, CHARACTERISTIC_VALUE);
+    }
+
+    public void test_BluetoothGattCallback_onCharacteristicWrite() throws Exception {
+        mBluetoothGattHelper.mBluetoothGattCallback.onCharacteristicWrite(mMockBluetoothGatt,
+                mMockBluetoothGattCharacteristic, GATT_STATUS);
+
+        verify(mMockBluetoothOperationExecutor).notifyCompletion(new Operation<Void>(
+                        OperationType.WRITE_CHARACTERISTIC, mMockBluetoothGatt,
+                        mMockBluetoothGattCharacteristic),
+                GATT_STATUS);
+    }
+
+    public void test_BluetoothGattCallback_onDescriptorRead() throws Exception {
+        mBluetoothGattHelper.mBluetoothGattCallback.onDescriptorRead(mMockBluetoothGatt,
+                mMockBluetoothGattDescriptor, GATT_STATUS);
+
+        verify(mMockBluetoothOperationExecutor).notifyCompletion(new Operation<byte[]>(
+                        OperationType.READ_DESCRIPTOR, mMockBluetoothGatt,
+                        mMockBluetoothGattDescriptor),
+                GATT_STATUS,
+                DESCRIPTOR_VALUE);
+    }
+
+    public void test_BluetoothGattCallback_onDescriptorWrite() throws Exception {
+        mBluetoothGattHelper.mBluetoothGattCallback.onDescriptorWrite(mMockBluetoothGatt,
+                mMockBluetoothGattDescriptor, GATT_STATUS);
+
+        verify(mMockBluetoothOperationExecutor).notifyCompletion(new Operation<Void>(
+                        OperationType.WRITE_DESCRIPTOR, mMockBluetoothGatt,
+                        mMockBluetoothGattDescriptor),
+                GATT_STATUS);
+    }
+
+    public void test_BluetoothGattCallback_onReadRemoteRssi() throws Exception {
+        mBluetoothGattHelper.mBluetoothGattCallback.onReadRemoteRssi(mMockBluetoothGatt, RSSI,
+                GATT_STATUS);
+
+        verify(mMockBluetoothOperationExecutor).notifyCompletion(
+                new Operation<Integer>(OperationType.READ_RSSI, mMockBluetoothGatt), GATT_STATUS,
+                RSSI);
+    }
+
+    public void test_BluetoothGattCallback_onReliableWriteCompleted() throws Exception {
+        mBluetoothGattHelper.mBluetoothGattCallback.onReliableWriteCompleted(mMockBluetoothGatt,
+                GATT_STATUS);
+
+        verify(mMockBluetoothOperationExecutor).notifyCompletion(
+                new Operation<Void>(OperationType.WRITE_RELIABLE, mMockBluetoothGatt), GATT_STATUS);
+    }
+
+    public void test_BluetoothGattCallback_onMtuChanged() throws Exception {
+        mBluetoothGattHelper.mConnections.put(mMockBluetoothGatt, mMockBluetoothGattConnection);
+        when(mMockBluetoothGattConnection.isConnected()).thenReturn(true);
+
+        mBluetoothGattHelper.mBluetoothGattCallback
+                .onMtuChanged(mMockBluetoothGatt, MTU, GATT_STATUS);
+
+        verify(mMockBluetoothOperationExecutor).notifyCompletion(
+                new Operation<>(OperationType.CHANGE_MTU, mMockBluetoothGatt), GATT_STATUS, MTU);
+    }
+
+    public void testBluetoothGattCallback_onMtuChangedDuringConnection_success() throws Exception {
+        mBluetoothGattHelper.mConnections.put(mMockBluetoothGatt, mMockBluetoothGattConnection);
+        when(mMockBluetoothGattConnection.isConnected()).thenReturn(false);
+
+        mBluetoothGattHelper.mBluetoothGattCallback.onMtuChanged(
+                mMockBluetoothGatt, MTU, BluetoothGatt.GATT_SUCCESS);
+
+        verify(mMockBluetoothGattConnection).onConnected();
+        verify(mMockBluetoothOperationExecutor)
+                .notifyCompletion(
+                        new Operation<>(OperationType.CONNECT, mMockBluetoothDevice),
+                        BluetoothGatt.GATT_SUCCESS,
+                        mMockBluetoothGattConnection);
+    }
+
+    public void testBluetoothGattCallback_onMtuChangedDuringConnection_fail() throws Exception {
+        mBluetoothGattHelper.mConnections.put(mMockBluetoothGatt, mMockBluetoothGattConnection);
+        when(mMockBluetoothGattConnection.isConnected()).thenReturn(false);
+
+        mBluetoothGattHelper.mBluetoothGattCallback
+                .onMtuChanged(mMockBluetoothGatt, MTU, GATT_STATUS);
+
+        verify(mMockBluetoothGattConnection).onConnected();
+        verify(mMockBluetoothOperationExecutor)
+                .notifyCompletion(
+                        new Operation<>(OperationType.CONNECT, mMockBluetoothDevice),
+                        GATT_STATUS,
+                        mMockBluetoothGattConnection);
+        verify(mMockBluetoothGatt).disconnect();
+        verify(mMockBluetoothGatt).close();
+        assertThat(mBluetoothGattHelper.mConnections.get(mMockBluetoothGatt)).isNull();
+    }
+
+    public void test_BluetoothGattCallback_onCharacteristicChanged() throws Exception {
+        mBluetoothGattHelper.mConnections.put(mMockBluetoothGatt, mMockBluetoothGattConnection);
+
+        mBluetoothGattHelper.mBluetoothGattCallback.onCharacteristicChanged(mMockBluetoothGatt,
+                mMockBluetoothGattCharacteristic);
+
+        verify(mMockBluetoothGattConnection).onCharacteristicChanged(
+                mMockBluetoothGattCharacteristic,
+                CHARACTERISTIC_VALUE);
+    }
+
+    public void test_ScanCallback_onScanFailed() throws Exception {
+        mBluetoothGattHelper.mScanCallback.onScanFailed(ScanCallback.SCAN_FAILED_INTERNAL_ERROR);
+
+        verify(mMockBluetoothOperationExecutor).notifyFailure(
+                eq(new Operation<BluetoothDevice>(OperationType.SCAN)),
+                isA(BluetoothException.class));
+    }
+
+    public void test_ScanCallback_onScanResult() throws Exception {
+        mBluetoothGattHelper.mScanCallback.onScanResult(ScanSettings.CALLBACK_TYPE_ALL_MATCHES,
+                mMockScanResult);
+
+        verify(mMockBluetoothOperationExecutor).notifySuccess(
+                new Operation<BluetoothDevice>(OperationType.SCAN), mMockBluetoothDevice);
+    }
+}