discovery: android ble driver

  * Updated GattServer/Reader to handle Gatt over BR/EDR so that
    we can do Gatt operations while having Bluetooth socket.
  * Updated GattReader to read all requested services in one Gatt
    connection instead of having a separate Gatt connection per service.
  * Added Bluetooth classic discovery
    - This doesn't work well yet.

Change-Id: I18e96fe45acfd914925c918f847cb73b37200561
diff --git a/android-lib/src/main/java/io/v/android/impl/google/discovery/plugins/ble/BluetoothAdvertiser.java b/android-lib/src/main/java/io/v/android/impl/google/discovery/plugins/ble/BluetoothAdvertiser.java
new file mode 100644
index 0000000..4ec1b24
--- /dev/null
+++ b/android-lib/src/main/java/io/v/android/impl/google/discovery/plugins/ble/BluetoothAdvertiser.java
@@ -0,0 +1,141 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.android.impl.google.discovery.plugins.ble;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothServerSocket;
+import android.bluetooth.BluetoothSocket;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * An advertiser that broadcasts Vanadium services through Bluetooth.
+ */
+class BluetoothAdvertiser {
+    private static final String TAG = Driver.TAG;
+
+    private final Context mContext;
+
+    private final BluetoothAdapter mBluetoothAdapter;
+    private RfcommListener mRfcommListener;
+
+    private final Set<UUID> mServices;
+
+    private class RfcommListener extends Thread {
+        private BluetoothServerSocket mServerSocket;
+
+        public void run() {
+            try {
+                mServerSocket =
+                        mBluetoothAdapter.listenUsingInsecureRfcommWithServiceRecord(
+                                Constants.SDP_NAME, Constants.SDP_UUID);
+            } catch (IOException e) {
+                Log.e(TAG, "rfcomm listen failed", e);
+                return;
+            }
+
+            for (; ; ) {
+                try (BluetoothSocket socket = mServerSocket.accept();
+                        ObjectInputStream in = new ObjectInputStream(socket.getInputStream());
+                        ObjectOutputStream out = new ObjectOutputStream(socket.getOutputStream())) {
+                    Set<UUID> uuids = (Set<UUID>) in.readObject();
+
+                    boolean found;
+                    synchronized (BluetoothAdvertiser.this) {
+                        if (uuids.isEmpty()) {
+                            found = !mServices.isEmpty();
+                        } else {
+                            found = !Collections.disjoint(mServices, uuids);
+                        }
+                    }
+
+                    out.writeBoolean(found);
+                    out.flush();
+                } catch (Exception e) {
+                    Log.e(TAG, "rfcomm accept failed", e);
+                    break;
+                }
+            }
+        }
+
+        void close() {
+            if (mServerSocket == null) {
+                return;
+            }
+
+            try {
+                mServerSocket.close();
+            } catch (IOException e) {
+                Log.e(TAG, "close failed", e);
+            }
+        }
+    }
+
+    BluetoothAdvertiser(Context context, BluetoothAdapter bluetoothAdapter) {
+        mContext = context;
+        mBluetoothAdapter = bluetoothAdapter;
+        mServices = new HashSet<>();
+    }
+
+    /**
+     * Add a service to the Bluetooth advertiser.
+     */
+    synchronized void addService(UUID uuid, int discoverableDurationInSec) {
+        mServices.add(uuid);
+
+        if (discoverableDurationInSec <= 0) {
+            return;
+        }
+
+        if (mRfcommListener == null) {
+            mRfcommListener = new RfcommListener();
+            mRfcommListener.start();
+        }
+
+        // Make it discoverable if not already in discoverable mode.
+        if (mBluetoothAdapter.getScanMode()
+                == BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE) {
+            Log.w(TAG, "already in discoverable mode");
+            return;
+        }
+
+        Intent discoverableIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE);
+        discoverableIntent.putExtra(
+                BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, discoverableDurationInSec);
+        discoverableIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+        mContext.startActivity(discoverableIntent);
+    }
+
+    /**
+     * Removes a service from the Bluetooth advertiser.
+     */
+    synchronized void removeService(UUID uuid) {
+        mServices.remove(uuid);
+        if (mServices.isEmpty() && mRfcommListener != null) {
+            mRfcommListener.close();
+            mRfcommListener = null;
+        }
+    }
+
+    /**
+     * Closes the BT advertiser.
+     */
+    synchronized void close() {
+        mServices.clear();
+        if (mRfcommListener != null) {
+            mRfcommListener.close();
+            mRfcommListener = null;
+        }
+    }
+}
diff --git a/android-lib/src/main/java/io/v/android/impl/google/discovery/plugins/ble/BluetoothScanner.java b/android-lib/src/main/java/io/v/android/impl/google/discovery/plugins/ble/BluetoothScanner.java
new file mode 100644
index 0000000..764738c
--- /dev/null
+++ b/android-lib/src/main/java/io/v/android/impl/google/discovery/plugins/ble/BluetoothScanner.java
@@ -0,0 +1,156 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.android.impl.google.discovery.plugins.ble;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothSocket;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * An advertiser that broadcasts Vanadium services through Bluetooth.
+ */
+class BluetoothScanner {
+    private static final String TAG = Driver.TAG;
+
+    // A handler that will get called when a GATT service is read.
+    interface Handler {
+        void onBluetoothDiscoveryFinished(Map<BluetoothDevice, Integer> found);
+    }
+
+    private final Context mContext;
+
+    private final BluetoothAdapter mBluetoothAdapter;
+    private BluetoothScanReceiver mBluetoothScanReceiver;
+
+    private Set<UUID> mScanUuids;
+    private Handler mHandler;
+
+    private final class BluetoothScanReceiver extends BroadcastReceiver {
+        // A map of devices that have been discovered with RSSI.
+        private final Map<BluetoothDevice, Integer> mScanSeens = new HashMap<>();
+
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            // We try to connect each discovered device once discovery finishes,
+            // since discovery will slow down connections and unstable.
+            switch (intent.getAction()) {
+                case BluetoothDevice.ACTION_FOUND:
+                    BluetoothDevice device =
+                            intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+                    if (device.getType() == BluetoothDevice.DEVICE_TYPE_CLASSIC) {
+                        mScanSeens.put(
+                                device,
+                                (int) intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, (short) 0));
+                    }
+                    break;
+                case BluetoothAdapter.ACTION_DISCOVERY_FINISHED:
+                    // Note that discovery can be finished when bluetooth is turned off.
+                    if (mBluetoothAdapter.isEnabled()) {
+                        new RfcommConnector(mScanSeens).start();
+                    }
+                    break;
+            }
+        }
+    }
+
+    private class RfcommConnector extends Thread {
+        private final Map<BluetoothDevice, Integer> mScanSeens;
+
+        RfcommConnector(Map<BluetoothDevice, Integer> scanSeens) {
+            mScanSeens = scanSeens;
+        }
+
+        public void run() {
+            Map<BluetoothDevice, Integer> found = new HashMap<>();
+
+            for (Map.Entry<BluetoothDevice, Integer> seen : mScanSeens.entrySet()) {
+                BluetoothDevice device = seen.getKey();
+
+                try (BluetoothSocket socket =
+                        device.createInsecureRfcommSocketToServiceRecord(Constants.SDP_UUID)) {
+                    socket.connect();
+
+                    try (ObjectOutputStream out = new ObjectOutputStream(socket.getOutputStream());
+                            ObjectInputStream in = new ObjectInputStream(socket.getInputStream())) {
+                        synchronized (BluetoothScanner.this) {
+                            if (mScanUuids == null) {
+                                // Scan already finished.
+                                return;
+                            }
+                            out.writeObject(mScanUuids);
+                        }
+                        out.flush();
+
+                        if (in.readBoolean()) {
+                            found.put(device, seen.getValue());
+                        }
+                    }
+                } catch (Exception e) {
+                }
+            }
+
+            synchronized (BluetoothScanner.this) {
+                if (mHandler != null) {
+                    mHandler.onBluetoothDiscoveryFinished(found);
+                }
+            }
+        }
+    }
+
+    BluetoothScanner(Context context, BluetoothAdapter bluetoothAdapter, Handler handler) {
+        mContext = context;
+        mBluetoothAdapter = bluetoothAdapter;
+        mHandler = handler;
+    }
+
+    synchronized void startScan(Set<UUID> uuids) {
+        mScanUuids = uuids;
+
+        if (mBluetoothAdapter.isDiscovering()) {
+            mBluetoothAdapter.cancelDiscovery();
+        }
+
+        mBluetoothScanReceiver = new BluetoothScanReceiver();
+        IntentFilter intentFilter = new IntentFilter();
+        intentFilter.addAction(BluetoothDevice.ACTION_FOUND);
+        intentFilter.addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED);
+        mContext.registerReceiver(mBluetoothScanReceiver, intentFilter);
+
+        if (!mBluetoothAdapter.startDiscovery()) {
+            mHandler.onBluetoothDiscoveryFinished(Collections.<BluetoothDevice, Integer>emptyMap());
+        }
+    }
+
+    synchronized void stopScan() {
+        if (mScanUuids == null) {
+            return;
+        }
+
+        if (mBluetoothAdapter.isDiscovering()) {
+            mBluetoothAdapter.cancelDiscovery();
+        }
+
+        mContext.unregisterReceiver(mBluetoothScanReceiver);
+        mBluetoothScanReceiver = null;
+        mScanUuids = null;
+    }
+
+    synchronized void close() {
+        stopScan();
+        mHandler = null;
+    }
+}
diff --git a/android-lib/src/main/java/io/v/android/impl/google/discovery/plugins/ble/Constants.java b/android-lib/src/main/java/io/v/android/impl/google/discovery/plugins/ble/Constants.java
new file mode 100644
index 0000000..cba1b8e
--- /dev/null
+++ b/android-lib/src/main/java/io/v/android/impl/google/discovery/plugins/ble/Constants.java
@@ -0,0 +1,16 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.android.impl.google.discovery.plugins.ble;
+
+import java.util.UUID;
+
+/**
+ * Constants for use in the Bluetooth Advertisements.
+ */
+class Constants {
+    // Name and UUID for SDP record.
+    static final String SDP_NAME = "v23";
+    static final UUID SDP_UUID = UUID.fromString("62e59f86-22b8-572b-82bf-2ee0ea877259");
+}
diff --git a/android-lib/src/main/java/io/v/android/impl/google/discovery/plugins/ble/Driver.java b/android-lib/src/main/java/io/v/android/impl/google/discovery/plugins/ble/Driver.java
index 83073b4..1a8a741 100644
--- a/android-lib/src/main/java/io/v/android/impl/google/discovery/plugins/ble/Driver.java
+++ b/android-lib/src/main/java/io/v/android/impl/google/discovery/plugins/ble/Driver.java
@@ -4,6 +4,7 @@
 
 package io.v.android.impl.google.discovery.plugins.ble;
 
+import android.Manifest;
 import android.bluetooth.BluetoothAdapter;
 import android.bluetooth.BluetoothDevice;
 import android.bluetooth.BluetoothGattCharacteristic;
@@ -16,23 +17,26 @@
 import android.bluetooth.le.BluetoothLeScanner;
 import android.bluetooth.le.ScanCallback;
 import android.bluetooth.le.ScanFilter;
-import android.bluetooth.le.ScanRecord;
 import android.bluetooth.le.ScanResult;
 import android.bluetooth.le.ScanSettings;
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
+import android.content.pm.PackageManager;
 import android.os.ParcelUuid;
+import android.support.v4.app.ActivityCompat;
+import android.support.v4.content.ContextCompat;
 import android.util.Log;
-import android.util.Pair;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.UUID;
 
 import io.v.android.v23.V;
@@ -40,8 +44,15 @@
 
 /**
  * A BLE Driver for Android.
+ *
+ * This Driver also support discovery over Bluetooth classic by
+ *    - Each peripheral makes the device discoverable for a specified duration.
+ *    - A central device discovers near-by devices through Bluetooth classic,
+ *      tries to connect and check each device whether the device has any
+ *      services that the central is looking for. A central device will fetch
+ *      services through Gatt over BR/EDR.
  */
-public class Driver implements GattReader.Handler {
+public class Driver implements BluetoothScanner.Handler, GattReader.Handler {
     static final String TAG = "BleDriver";
 
     /**
@@ -54,24 +65,28 @@
         void onDiscovered(String uuid, Map<String, byte[]> characteristics, int rssi);
     }
 
-    private Context mContext;
+    private final Context mContext;
 
     private final BluetoothAdapter mBluetoothAdapter;
 
-    private BluetoothLeAdvertiser mAdvertiser;
-    private Map<UUID, AdvertiseCallback> mAdvertiseCallbacks;
+    private BluetoothAdvertiser mClassicAdvertiser;
+    private static int sClassicDiscoverableDurationInSec;
+    private BluetoothLeAdvertiser mLeAdvertiser;
+    private Map<UUID, AdvertiseCallback> mLeAdvertiseCallbacks;
     private GattServer mGattServer;
 
-    private final Map<String, BluetoothGattService> mGattServices;
+    private final Map<String, BluetoothGattService> mServices;
 
-    private BluetoothLeScanner mScanner;
-    private ScanCallback mScanCallback;
+    private BluetoothScanner mClassicScanner;
+    private static boolean sClassicScanEnabled;
+    private BluetoothLeScanner mLeScanner;
+    private ScanCallback mLeScanCallback;
     private GattReader mGattReader;
 
-    private String[] mScanUuids;
-    private String mScanBaseUuid, mScanMaskUuid;
+    private Set<UUID> mScanUuids;
+    private ParcelUuid mScanBaseUuid, mScanMaskUuid;
     private ScanHandler mScanHandler;
-    private Map<Pair<BluetoothDevice, UUID>, Integer> mScanSeens;
+    private Map<BluetoothDevice, Integer> mScanSeens;
 
     private boolean mEnabled;
     private int mOnServiceReadCallbacks;
@@ -103,7 +118,7 @@
         if (mContext == null) {
             throw new IllegalStateException("AndroidContext not available");
         }
-        mGattServices = new HashMap<>();
+        mServices = new HashMap<>();
 
         BluetoothManager manager =
                 ((BluetoothManager) mContext.getSystemService(Context.BLUETOOTH_SERVICE));
@@ -113,6 +128,18 @@
             return;
         }
 
+        if (ContextCompat.checkSelfPermission(mContext, Manifest.permission.ACCESS_FINE_LOCATION)
+                        != PackageManager.PERMISSION_GRANTED
+                && ContextCompat.checkSelfPermission(
+                                mContext, Manifest.permission.ACCESS_COARSE_LOCATION)
+                        != PackageManager.PERMISSION_GRANTED) {
+            Log.w(
+                    TAG,
+                    "ACCESS_FINE_LOCATION or ACCESS_COARSE_LOCATION not granted, "
+                            + "Bluetooth discovery will not be happening");
+            return;
+        }
+
         mContext.registerReceiver(
                 new BluetoothAdapterStatusReceiver(),
                 new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED));
@@ -135,8 +162,8 @@
         }
         mEnabled = true;
 
-        resumeAdvertisingSynchronized();
-        resumeScanningSynchronized();
+        resumeAdvertising();
+        resumeScanning();
 
         Log.i(TAG, "started");
     }
@@ -147,8 +174,8 @@
         }
         mEnabled = false;
 
-        pauseAdvertisingSynchronized();
-        pauseScanningSynchronized();
+        pauseAdvertising();
+        pauseScanning();
 
         Log.i(TAG, "stopped");
     }
@@ -168,84 +195,95 @@
         }
 
         synchronized (this) {
-            if (mGattServices.put(uuid, service) != null) {
+            if (mServices.put(uuid, service) != null) {
                 throw new IllegalStateException("already being advertised: " + uuid);
             }
-            if (mEnabled && mBluetoothAdapter.isMultipleAdvertisementSupported()) {
-                startAdvertisingSynchronized(service);
+            if (mEnabled) {
+                startAdvertising(service);
             }
         }
     }
 
     public synchronized void removeService(String uuid) {
-        BluetoothGattService service = mGattServices.remove(uuid);
+        BluetoothGattService service = mServices.remove(uuid);
         if (service == null) {
             return;
         }
-        if (mEnabled && mBluetoothAdapter.isMultipleAdvertisementSupported()) {
-            stopAdvertisingSynchronized(service);
+        if (mEnabled) {
+            stopAdvertising(service);
         }
     }
 
-    private void startAdvertisingSynchronized(BluetoothGattService service) {
+    private synchronized void startAdvertising(BluetoothGattService service) {
         mGattServer.addService(service);
-
-        final UUID uuid = service.getUuid();
-        AdvertiseSettings settings =
-                new AdvertiseSettings.Builder()
-                        .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED)
-                        .setConnectable(true)
-                        .build();
-        AdvertiseData data =
-                new AdvertiseData.Builder()
-                        .addServiceUuid(new ParcelUuid(uuid))
-                        .setIncludeTxPowerLevel(true)
-                        .build();
-        AdvertiseCallback callback =
-                new AdvertiseCallback() {
-                    @Override
-                    public void onStartFailure(int errorCode) {
-                        Log.e(TAG, "startAdvertising failed: " + uuid + ", errorCode:" + errorCode);
-                    }
-                };
-        // TODO(jhahn): The maximum number of simultaneous advertisements is limited by the chipset.
-        // Rotate active advertisements periodically if the total number of advertisement exceeds
-        // the limit.
-        mAdvertiser.startAdvertising(settings, data, callback);
-        mAdvertiseCallbacks.put(uuid, callback);
+        synchronized (Driver.class) {
+            mClassicAdvertiser.addService(service.getUuid(), sClassicDiscoverableDurationInSec);
+            sClassicDiscoverableDurationInSec = 0;
+        }
+        if (mLeAdvertiser != null) {
+            final UUID uuid = service.getUuid();
+            AdvertiseSettings settings =
+                    new AdvertiseSettings.Builder()
+                            .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED)
+                            .setConnectable(true)
+                            .build();
+            AdvertiseData data =
+                    new AdvertiseData.Builder()
+                            .addServiceUuid(new ParcelUuid(uuid))
+                            .setIncludeTxPowerLevel(true)
+                            .build();
+            AdvertiseCallback callback =
+                    new AdvertiseCallback() {
+                        @Override
+                        public void onStartFailure(int errorCode) {
+                            Log.e(
+                                    TAG,
+                                    "startAdvertising failed: "
+                                            + uuid
+                                            + ", errorCode:"
+                                            + errorCode);
+                        }
+                    };
+            // TODO(jhahn): The maximum number of simultaneous advertisements is limited by the chipset.
+            // Rotate active advertisements periodically if the total number of advertisement exceeds
+            // the limit.
+            mLeAdvertiser.startAdvertising(settings, data, callback);
+            mLeAdvertiseCallbacks.put(uuid, callback);
+        }
     }
 
-    private void stopAdvertisingSynchronized(BluetoothGattService service) {
+    private synchronized void stopAdvertising(BluetoothGattService service) {
         mGattServer.removeService(service);
-
-        AdvertiseCallback callback = mAdvertiseCallbacks.remove(service.getUuid());
-        mAdvertiser.stopAdvertising(callback);
+        mClassicAdvertiser.removeService(service.getUuid());
+        if (mLeAdvertiser != null) {
+            AdvertiseCallback callback = mLeAdvertiseCallbacks.remove(service.getUuid());
+            mLeAdvertiser.stopAdvertising(callback);
+        }
     }
 
-    private void resumeAdvertisingSynchronized() {
-        if (!mBluetoothAdapter.isMultipleAdvertisementSupported()) {
-            Log.w(TAG, "advertisement is not supported by this device");
-            return;
-        }
-
-        mAdvertiser = mBluetoothAdapter.getBluetoothLeAdvertiser();
-        mAdvertiseCallbacks = new HashMap<>();
+    private synchronized void resumeAdvertising() {
         mGattServer = new GattServer(mContext);
-        for (BluetoothGattService service : mGattServices.values()) {
-            startAdvertisingSynchronized(service);
+        mClassicAdvertiser = new BluetoothAdvertiser(mContext, mBluetoothAdapter);
+        if (mBluetoothAdapter.isMultipleAdvertisementSupported()) {
+            mLeAdvertiser = mBluetoothAdapter.getBluetoothLeAdvertiser();
+            mLeAdvertiseCallbacks = new HashMap<>();
+        }
+
+        for (BluetoothGattService service : mServices.values()) {
+            startAdvertising(service);
         }
     }
 
-    private void pauseAdvertisingSynchronized() {
-        if (mGattServer != null) {
-            mGattServer.close();
-            mGattServer = null;
-        }
+    private synchronized void pauseAdvertising() {
+        mGattServer.close();
+        mGattServer = null;
+        mClassicAdvertiser.close();
+        mClassicAdvertiser = null;
 
-        // mAdvertiser is invalidated when BluetoothAdapter is turned off.
+        // mLeAdvertiser is invalidated when BluetoothAdapter is turned off.
         // We don't need to stop any active advertising.
-        mAdvertiser = null;
-        mAdvertiseCallbacks = null;
+        mLeAdvertiser = null;
+        mLeAdvertiseCallbacks = null;
     }
 
     public synchronized void startScan(
@@ -254,12 +292,18 @@
             throw new IllegalStateException("scan already started");
         }
 
-        mScanUuids = uuids;
-        mScanBaseUuid = baseUuid;
-        mScanMaskUuid = maskUuid;
+        ImmutableSet.Builder<UUID> builder = ImmutableSet.builder();
+        if (uuids != null) {
+            for (String uuid : uuids) {
+                builder.add(UUID.fromString(uuid));
+            }
+        }
+        mScanUuids = builder.build();
+        mScanBaseUuid = ParcelUuid.fromString(baseUuid);
+        mScanMaskUuid = ParcelUuid.fromString(maskUuid);
         mScanHandler = handler;
         if (mEnabled) {
-            startScanningSynchronized();
+            startScanning();
         }
     }
 
@@ -268,30 +312,40 @@
             return;
         }
 
+        if (mEnabled) {
+            stopScanning();
+        }
         mScanUuids = null;
         mScanBaseUuid = null;
         mScanMaskUuid = null;
         mScanHandler = null;
-        if (mEnabled) {
-            stopScanningSynchronized();
+    }
+
+    private synchronized void startScanning() {
+        mScanSeens = new HashMap<>();
+        mGattReader = new GattReader(mContext, mScanUuids, this);
+        synchronized (Driver.class) {
+            if (sClassicScanEnabled) {
+                // Note that BluetoothLeScan will be started when BluetoothScan finishes.
+                mClassicScanner.startScan(mScanUuids);
+                sClassicScanEnabled = false;
+            } else {
+                startBluetoothLeScanner();
+            }
         }
     }
 
-    private void startScanningSynchronized() {
-        mGattReader = new GattReader(mContext, this);
-        mScanSeens = new HashMap();
-
-        List<ScanFilter> filters = null;
-        if (mScanUuids != null) {
-            ImmutableList.Builder<ScanFilter> builder = new ImmutableList.Builder();
-            for (String uuid : mScanUuids) {
-                builder.add(
-                        new ScanFilter.Builder()
-                                .setServiceUuid(ParcelUuid.fromString(uuid))
-                                .build());
-            }
-            filters = builder.build();
+    private synchronized void startBluetoothLeScanner() {
+        if (mLeScanner == null) {
+            return;
         }
+
+        ImmutableList.Builder<ScanFilter> builder = new ImmutableList.Builder();
+        for (UUID uuid : mScanUuids) {
+            builder.add(new ScanFilter.Builder().setServiceUuid(new ParcelUuid(uuid)).build());
+        }
+        List<ScanFilter> filters = builder.build();
+
         ScanSettings settings =
                 new ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_BALANCED).build();
 
@@ -299,13 +353,9 @@
         // bits. So we call startScan() without a scan filter for base/mask uuids and match scan results
         // against it.
         final ScanFilter matcher =
-                new ScanFilter.Builder()
-                        .setServiceUuid(
-                                ParcelUuid.fromString(mScanBaseUuid),
-                                ParcelUuid.fromString(mScanMaskUuid))
-                        .build();
+                new ScanFilter.Builder().setServiceUuid(mScanBaseUuid, mScanMaskUuid).build();
 
-        mScanCallback =
+        mLeScanCallback =
                 new ScanCallback() {
                     @Override
                     public void onScanResult(int callbackType, ScanResult result) {
@@ -317,16 +367,11 @@
                         if (!matcher.matches(result)) {
                             return;
                         }
-                        ScanRecord scanRecord = result.getScanRecord();
-                        if (scanRecord.getServiceUuids().size() != 1) {
-                            // This shouldn't happen since we advertise only one uuid in each advertisement.
-                            return;
-                        }
-                        UUID uuid = scanRecord.getServiceUuids().get(0).getUuid();
-                        Pair<BluetoothDevice, UUID> seen = Pair.create(result.getDevice(), uuid);
+                        BluetoothDevice device = result.getDevice();
                         synchronized (Driver.this) {
-                            if (mEnabled && mScanSeens.put(seen, result.getRssi()) == null) {
-                                mGattReader.readService(result.getDevice(), uuid);
+                            if (mScanSeens != null
+                                    && mScanSeens.put(device, result.getRssi()) == null) {
+                                mGattReader.readDevice(device);
                             }
                         }
                     }
@@ -336,39 +381,63 @@
                         Log.e(TAG, "startScan failed: " + errorCode);
                     }
                 };
-        mScanner.startScan(filters, settings, mScanCallback);
+
+        mLeScanner.startScan(filters, settings, mLeScanCallback);
     }
 
-    private void stopScanningSynchronized() {
-        mScanner.stopScan(mScanCallback);
-        mScanCallback = null;
-        mScanSeens = null;
-
+    private synchronized void stopScanning() {
+        mClassicScanner.stopScan();
+        if (mLeScanCallback != null) {
+            mLeScanner.stopScan(mLeScanCallback);
+            mLeScanCallback = null;
+        }
         mGattReader.close();
         mGattReader = null;
+        mScanSeens = null;
     }
 
-    private void resumeScanningSynchronized() {
-        mScanner = mBluetoothAdapter.getBluetoothLeScanner();
+    private synchronized void resumeScanning() {
+        mClassicScanner = new BluetoothScanner(mContext, mBluetoothAdapter, this);
+        mLeScanner = mBluetoothAdapter.getBluetoothLeScanner();
         if (mScanHandler != null) {
-            startScanningSynchronized();
+            startScanning();
         }
     }
 
-    private void pauseScanningSynchronized() {
+    private synchronized void pauseScanning() {
+        mClassicScanner.close();
+        mClassicScanner = null;
         if (mScanHandler != null) {
             mGattReader.close();
             mGattReader = null;
 
-            // mScanner is invalidated when BluetoothAdapter is turned off.
+            // mLeScanner is invalidated when BluetoothAdapter is turned off.
             // We don't need to stop any active scan.
-            mScanner = null;
-            mScanCallback = null;
+            mLeScanner = null;
+            mLeScanCallback = null;
             mScanSeens = null;
         }
     }
 
-    public void onServiceRead(BluetoothDevice device, BluetoothGattService service) {
+    public synchronized void onBluetoothDiscoveryFinished(Map<BluetoothDevice, Integer> found) {
+        if (mScanSeens == null) {
+            return;
+        }
+
+        // Start to read services through Gatt.
+        //
+        // TODO(jhahn): Do we need to retry when Gatt read fails?
+        for (Map.Entry<BluetoothDevice, Integer> e : found.entrySet()) {
+
+            mScanSeens.put(e.getKey(), e.getValue());
+            mGattReader.readDevice(e.getKey());
+        }
+
+        // Now start BluetoothLeScan.
+        startBluetoothLeScanner();
+    }
+
+    public void onGattRead(BluetoothDevice device, BluetoothGattService service) {
         Map<String, byte[]> characteristics;
         ImmutableMap.Builder<String, byte[]> builder = new ImmutableMap.Builder();
         for (BluetoothGattCharacteristic c : service.getCharacteristics()) {
@@ -377,22 +446,44 @@
         characteristics = builder.build();
 
         synchronized (this) {
-            mOnServiceReadCallbacks++;
-            if (mScanHandler == null) {
+            if (mScanSeens == null) {
                 return;
             }
-            Integer rssi = mScanSeens.get(Pair.create(device, service.getUuid()));
+            Integer rssi = mScanSeens.get(device);
             if (rssi == null) {
                 return;
             }
-            mScanHandler.onDiscovered(
-                    service.getUuid().toString(), characteristics, rssi);
+            mScanHandler.onDiscovered(service.getUuid().toString(), characteristics, rssi);
+            mOnServiceReadCallbacks++;
         }
     }
 
-    public synchronized void onServiceReadFailed(BluetoothDevice device, UUID uuid) {
-        // Remove the seen record to retry to read the service.
-        mScanSeens.remove(Pair.create(device, uuid));
+    public synchronized void onGattReadFailed(BluetoothDevice device) {
+        if (mScanSeens == null) {
+            return;
+        }
+
+        // Remove the seen record to retry to read the device.
+        mScanSeens.remove(device);
+    }
+
+    /**
+     * Set the Duration of Bluetooth discoverability in seconds. This will be applied for
+     * the next addService() only one time.
+     *
+     * TODO(jhahn): Find a better API to set Bluetooth discovery options.
+     */
+    public static synchronized void setBluetoothDiscoverableDuration(int durationInSec) {
+        sClassicDiscoverableDurationInSec = durationInSec;
+    }
+
+    /**
+     * Enable Bluetooth scan. This will be applied for the next startScan() only one time.
+     *
+     * TODO(jhahn): Find a better API to set Bluetooth discovery options.
+     */
+    public static synchronized void setBluetoothScanEnabled(boolean enabled) {
+        sClassicScanEnabled = enabled;
     }
 
     public synchronized String debugString() {
@@ -419,10 +510,10 @@
         }
         b.append("\n");
         b.append("ENABLED: ").append(mEnabled).append("\n");
-        if (mGattServices.size() > 0) {
-            b.append("ADVERTISING ").append(mGattServices.size()).append(" services\n");
+        if (mServices.size() > 0) {
+            b.append("ADVERTISING ").append(mServices.size()).append(" services\n");
         }
-        if (mScanCallback != null) {
+        if (mLeScanCallback != null) {
             b.append("SCANNING\n");
         }
         b.append("OnServiceReadCallbacks: ").append(mOnServiceReadCallbacks).append("\n");
diff --git a/android-lib/src/main/java/io/v/android/impl/google/discovery/plugins/ble/GattReader.java b/android-lib/src/main/java/io/v/android/impl/google/discovery/plugins/ble/GattReader.java
index 800ba35..0099475 100644
--- a/android-lib/src/main/java/io/v/android/impl/google/discovery/plugins/ble/GattReader.java
+++ b/android-lib/src/main/java/io/v/android/impl/google/discovery/plugins/ble/GattReader.java
@@ -11,17 +11,16 @@
 import android.bluetooth.BluetoothGattService;
 import android.content.Context;
 import android.util.Log;
-import android.util.Pair;
 
 import com.google.common.collect.Queues;
 
 import java.util.ArrayDeque;
 import java.util.Iterator;
-import java.util.Timer;
-import java.util.TimerTask;
+import java.util.Set;
 import java.util.UUID;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
 
 /**
  * A reader that reads Vanadium Gatt services from remote Gatt servers.
@@ -31,9 +30,9 @@
 
     // A handler that will get called when a GATT service is read.
     interface Handler {
-        void onServiceRead(BluetoothDevice device, BluetoothGattService service);
+        void onGattRead(BluetoothDevice device, BluetoothGattService service);
 
-        void onServiceReadFailed(BluetoothDevice device, UUID uuid);
+        void onGattReadFailed(BluetoothDevice device);
     }
 
     // TODO(jhahn): What's the maximum MTU size in Android?
@@ -48,36 +47,48 @@
 
     private final Context mContext;
 
-    private final ExecutorService mExecutor;
-    private final Timer mTimer;
+    private final ScheduledThreadPoolExecutor mExecutor;
+
+    private final Set<UUID> mScanUuids;
     private final Handler mHandler;
 
-    private final ArrayDeque<Pair<BluetoothDevice, UUID>> mPendingReads;
+    private final ArrayDeque<BluetoothDevice> mPendingReads;
 
-    private Pair<BluetoothDevice, UUID> mCurrentRead;
+    private final Runnable mCancelTask;
+
+    private BluetoothDevice mCurrentDevice;
     private BluetoothGatt mCurrentGatt;
-    private TimerTask mCurrentGattTimeout;
+    private ScheduledFuture mCurrentGattTimeout;
     private BluetoothGattService mCurrentService;
+    private Iterator<BluetoothGattService> mCurrentServiceIterator;
     private Iterator<BluetoothGattCharacteristic> mCurrentCharacteristicIterator;
 
-    GattReader(Context context, Handler handler) {
+    GattReader(Context context, Set<UUID> scanUuids, Handler handler) {
         mContext = context;
-        mExecutor = Executors.newSingleThreadExecutor();
-        mTimer = new Timer();
+        mExecutor = new ScheduledThreadPoolExecutor(1);
+        mScanUuids = scanUuids;
         mHandler = handler;
         mPendingReads = Queues.newArrayDeque();
+        mCancelTask =
+                new Runnable() {
+                    @Override
+                    public void run() {
+                        Log.e(TAG, "gatt operation timed out: " + mCurrentDevice);
+                        cancelAndMaybeReadNextDevice();
+                    }
+                };
     }
 
     /**
      * Reads a specified service from a remote device as well as their characteristics.
      * <p/>
-     * This is an asynchronous operation. Once service read is completed, the onServiceRead() or
+     * This is an asynchronous operation. Once service read is completed, the onGattRead() or
      * onServiceReadFailed() callback is triggered.
      */
-    synchronized void readService(BluetoothDevice device, UUID uuid) {
-        mPendingReads.add(Pair.create(device, uuid));
-        if (mCurrentRead == null) {
-            maybeReadNextService();
+    synchronized void readDevice(BluetoothDevice device) {
+        mPendingReads.add(device);
+        if (mCurrentDevice == null) {
+            maybeReadNextDevice();
         }
     }
 
@@ -86,69 +97,54 @@
      */
     synchronized void close() {
         if (mCurrentGatt != null) {
-            mCurrentGatt.disconnect();
+            mCurrentGatt.close();
         }
-        mTimer.cancel();
+        mExecutor.shutdown();
         mPendingReads.clear();
     }
 
-    private synchronized void maybeReadNextService() {
+    private synchronized void maybeReadNextDevice() {
         mCurrentGatt = null;
         mCurrentGattTimeout = null;
         mCurrentService = null;
+        mCurrentServiceIterator = null;
         mCurrentCharacteristicIterator = null;
 
-        mCurrentRead = mPendingReads.poll();
-        if (mCurrentRead == null) {
+        mCurrentDevice = mPendingReads.poll();
+        if (mCurrentDevice == null) {
             return;
         }
-        mCurrentGatt = mCurrentRead.first.connectGatt(mContext, false, this);
+
+        mCurrentGatt = mCurrentDevice.connectGatt(mContext, false, this);
         mCurrentGattTimeout =
-                new TimerTask() {
-                    @Override
-                    public void run() {
-                        Log.e(TAG, "gatt operation timed out: " + mCurrentRead.first);
-                        cancelAndMaybeReadNextService();
-                    }
-                };
-        mTimer.schedule(mCurrentGattTimeout, GATT_TIMEOUT_MS);
+                mExecutor.schedule(mCancelTask, GATT_TIMEOUT_MS, TimeUnit.MILLISECONDS);
     }
 
-    private synchronized void finishAndMaybeReadNextService() {
-        mCurrentGattTimeout.cancel();
+    private synchronized void finishAndMaybeReadNextDevice() {
+        mCurrentGattTimeout.cancel(false);
         mCurrentGatt.disconnect();
 
-        final BluetoothDevice currentDevice = mCurrentRead.first;
-        final BluetoothGattService currentService = mCurrentService;
+        maybeReadNextDevice();
+    }
+
+    private synchronized void cancelAndMaybeReadNextDevice() {
+        mCurrentGattTimeout.cancel(false);
+        mCurrentGatt.close();
+
+        final BluetoothDevice device = mCurrentDevice;
         mExecutor.submit(
                 new Runnable() {
                     @Override
                     public void run() {
-                        mHandler.onServiceRead(currentDevice, currentService);
+                        mHandler.onGattReadFailed(device);
                     }
                 });
-        maybeReadNextService();
-    }
-
-    private synchronized void cancelAndMaybeReadNextService() {
-        mCurrentGattTimeout.cancel();
-        mCurrentGatt.disconnect();
-
-        final Pair<BluetoothDevice, UUID> currentRead = mCurrentRead;
-        mExecutor.submit(
-                new Runnable() {
-                    @Override
-                    public void run() {
-                        mHandler.onServiceReadFailed(currentRead.first, currentRead.second);
-                    }
-                });
-        maybeReadNextService();
+        maybeReadNextDevice();
     }
 
     @Override
     public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
         super.onConnectionStateChange(gatt, status, newState);
-
         if (newState != BluetoothGatt.STATE_CONNECTED) {
             // Connection is disconnected. Release it.
             gatt.close();
@@ -156,84 +152,118 @@
         }
 
         if (status != BluetoothGatt.GATT_SUCCESS) {
-            Log.e(TAG, "connectGatt failed: " + mCurrentRead.first + " , status: " + status);
-            cancelAndMaybeReadNextService();
+            Log.e(TAG, "connectGatt failed: " + mCurrentDevice + " , status: " + status);
+            cancelAndMaybeReadNextDevice();
             return;
         }
 
-        if (!gatt.requestMtu(MTU)) {
-            Log.e(TAG, "requestMtu failed: " + mCurrentRead.first);
-
-            // Try to discover services although requesting MTU fails.
+        // MTU exchange is not allowed on a BR/EDR physical link.
+        // (Bluetooth Core Specification Volume 3, Part G, 4.3.1)
+        //
+        // There is no way to get the actual link type. So we use the device type for it.
+        // It is not clear whether DEVICE_TYPE_DUAL is on a BR/EDR physical link, but
+        // it is safe to not exchange MTU for that type too.
+        int deviceType = mCurrentDevice.getType();
+        if (deviceType != BluetoothDevice.DEVICE_TYPE_CLASSIC
+                && deviceType != BluetoothDevice.DEVICE_TYPE_DUAL) {
+            if (!gatt.requestMtu(MTU)) {
+                Log.e(TAG, "requestMtu failed: " + mCurrentDevice);
+                cancelAndMaybeReadNextDevice();
+            }
+        } else {
             if (!gatt.discoverServices()) {
-                Log.e(TAG, "discoverServices failed: " + mCurrentRead.first);
-                cancelAndMaybeReadNextService();
+                Log.e(TAG, "discoverServices failed: " + mCurrentDevice);
+                cancelAndMaybeReadNextDevice();
             }
         }
     }
 
     @Override
-    public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) {
+    public synchronized void onMtuChanged(BluetoothGatt gatt, int mtu, int status) {
         super.onMtuChanged(gatt, mtu, status);
 
         if (status != BluetoothGatt.GATT_SUCCESS) {
-            Log.w(TAG, "requestMtu failed: " + mCurrentRead.first + ", status: " + status);
+            Log.w(TAG, "requestMtu failed: " + mCurrentDevice + ", status: " + status);
+            cancelAndMaybeReadNextDevice();
+            return;
         }
 
         if (!gatt.discoverServices()) {
-            Log.e(TAG, "discoverServices failed: " + mCurrentRead.first);
-            cancelAndMaybeReadNextService();
+            Log.e(TAG, "discoverServices failed: " + mCurrentDevice);
+            cancelAndMaybeReadNextDevice();
         }
     }
 
     @Override
-    public void onServicesDiscovered(BluetoothGatt gatt, int status) {
+    public synchronized void onServicesDiscovered(BluetoothGatt gatt, int status) {
         super.onServicesDiscovered(gatt, status);
 
         if (status != BluetoothGatt.GATT_SUCCESS) {
-            Log.e(TAG, "discoverServices failed: " + mCurrentRead.first + ", status: " + status);
-            cancelAndMaybeReadNextService();
+            Log.e(TAG, "discoverServices failed: " + mCurrentDevice + ", status: " + status);
+            cancelAndMaybeReadNextDevice();
             return;
         }
 
-        mCurrentService = gatt.getService(mCurrentRead.second);
-        if (mCurrentService == null) {
-            Log.e(
-                    TAG,
-                    "service not found: " + mCurrentRead.first + ", uuid: " + mCurrentRead.second);
-            cancelAndMaybeReadNextService();
+        mCurrentServiceIterator = gatt.getServices().iterator();
+        maybeReadNextService();
+    }
+
+    private void maybeReadNextService() {
+        while (mCurrentServiceIterator.hasNext()) {
+            mCurrentService = mCurrentServiceIterator.next();
+            if (mScanUuids.isEmpty() || mScanUuids.contains(mCurrentService.getUuid())) {
+                // Reset the timer.
+                if (!mCurrentGattTimeout.cancel(false)) {
+                    // Already cancelled.
+                    return;
+                }
+                mCurrentGattTimeout =
+                        mExecutor.schedule(mCancelTask, GATT_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+
+                mCurrentCharacteristicIterator = mCurrentService.getCharacteristics().iterator();
+                maybeReadNextCharacteristic();
+                return;
+            }
+        }
+
+        // All services have been read. Finish the current device read.
+        finishAndMaybeReadNextDevice();
+    }
+
+    @Override
+    public synchronized void onCharacteristicRead(
+            BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
+        super.onCharacteristicRead(gatt, characteristic, status);
+
+        if (status != BluetoothGatt.GATT_SUCCESS) {
+            Log.e(TAG, "readCharacteristic failed: " + mCurrentDevice + ", status: " + status);
+            cancelAndMaybeReadNextDevice();
             return;
         }
 
-        mCurrentCharacteristicIterator = mCurrentService.getCharacteristics().iterator();
         maybeReadNextCharacteristic();
     }
 
     private void maybeReadNextCharacteristic() {
         if (!mCurrentCharacteristicIterator.hasNext()) {
-            // All characteristics have been read. Finish the current read.
-            finishAndMaybeReadNextService();
+            // All characteristics have been read. Finish the current service read.
+            final BluetoothDevice device = mCurrentDevice;
+            final BluetoothGattService service = mCurrentService;
+            mExecutor.submit(
+                    new Runnable() {
+                        @Override
+                        public void run() {
+                            mHandler.onGattRead(device, service);
+                        }
+                    });
+            maybeReadNextService();
             return;
         }
 
         BluetoothGattCharacteristic characteristic = mCurrentCharacteristicIterator.next();
         if (!mCurrentGatt.readCharacteristic(characteristic)) {
-            Log.e(TAG, "readCharacteristic failed: " + mCurrentRead.first);
-            cancelAndMaybeReadNextService();
+            Log.e(TAG, "readCharacteristic failed: " + mCurrentDevice);
+            cancelAndMaybeReadNextDevice();
         }
     }
-
-    @Override
-    public void onCharacteristicRead(
-            BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
-        super.onCharacteristicRead(gatt, characteristic, status);
-
-        if (status != BluetoothGatt.GATT_SUCCESS) {
-            Log.e(TAG, "readCharacteristic failed: " + mCurrentRead.first + ", status: " + status);
-            cancelAndMaybeReadNextService();
-            return;
-        }
-
-        maybeReadNextCharacteristic();
-    }
 }
diff --git a/android-lib/src/main/java/io/v/android/impl/google/discovery/plugins/ble/GattServer.java b/android-lib/src/main/java/io/v/android/impl/google/discovery/plugins/ble/GattServer.java
index 45683a8..7195102 100644
--- a/android-lib/src/main/java/io/v/android/impl/google/discovery/plugins/ble/GattServer.java
+++ b/android-lib/src/main/java/io/v/android/impl/google/discovery/plugins/ble/GattServer.java
@@ -71,7 +71,7 @@
     public void onMtuChanged(BluetoothDevice device, int mtu) {
         super.onMtuChanged(device, mtu);
 
-        mMtus.put(device, new Integer(mtu));
+        mMtus.put(device, mtu);
     }
 
     @Override
@@ -86,16 +86,27 @@
         if (offset >= data.length) {
             data = null;
         } else {
-            int mtu = DEFAULT_MTU;
-            if (mMtus.containsKey(device)) {
-                mtu = mMtus.get(device).intValue();
+            // MTU exchange is not allowed on a BR/EDR physical link. Send all requested data in that link.
+            int deviceType = device.getType();
+            if (deviceType == BluetoothDevice.DEVICE_TYPE_CLASSIC
+                    || deviceType == BluetoothDevice.DEVICE_TYPE_DUAL) {
+                if (offset > 0) {
+                    data = Arrays.copyOfRange(data, offset, data.length);
+                }
+            } else {
+                int mtu = DEFAULT_MTU;
+                if (mMtus.containsKey(device)) {
+                    mtu = mMtus.get(device);
+                }
+                // We can send data up to MTU - 1 bytes.
+                int to = offset + mtu - 1;
+                if (to > data.length) {
+                    to = data.length;
+                }
+                if (offset > 0 || to < data.length) {
+                    data = Arrays.copyOfRange(data, offset, to);
+                }
             }
-            // We can send data up to MTU - 1 bytes.
-            int to = offset + mtu - 1;
-            if (to > data.length) {
-                to = data.length;
-            }
-            data = Arrays.copyOfRange(data, offset, to);
         }
         mBluetoothGattServer.sendResponse(
                 device, requestId, BluetoothGatt.GATT_SUCCESS, offset, data);