blob: e8397164ea172ff949d0a45f9f816a48c11a84b6 [file] [log] [blame]
// 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.Manifest;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattServer;
import android.bluetooth.BluetoothGattServerCallback;
import android.bluetooth.BluetoothGattService;
import android.bluetooth.BluetoothManager;
import android.bluetooth.le.AdvertiseCallback;
import android.bluetooth.le.AdvertiseData;
import android.bluetooth.le.AdvertiseSettings;
import android.bluetooth.le.BluetoothLeAdvertiser;
import android.bluetooth.le.BluetoothLeScanner;
import android.bluetooth.le.ScanCallback;
import android.bluetooth.le.ScanFilter;
import android.bluetooth.le.ScanResult;
import android.bluetooth.le.ScanRecord;
import android.bluetooth.le.ScanSettings;
import android.content.Context;
import android.content.pm.PackageManager;
import android.support.v4.content.ContextCompat;
import android.util.Log;
import com.google.common.collect.ImmutableList;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.joda.time.Duration;
import io.v.android.v23.V;
import io.v.v23.context.VContext;
import io.v.v23.discovery.AdId;
import io.v.x.ref.lib.discovery.AdInfo;
import io.v.impl.google.lib.discovery.UUIDUtil;
import io.v.impl.google.lib.discovery.Plugin;
/**
* The discovery plugin interface for BLE.
*/
public class BlePlugin implements Plugin {
private static final String TAG = "BlePlugin";
// We are using a constant for the MTU because Android and paypal/gatt don't get along
// when the paypal gatt client sends a setMTU message. The Android server seems to send
// a malformed L2CAP message.
private static final int MTU = 23;
// Default device cache expiration timeout.
private static final Duration defaultCacheDuration = Duration.standardSeconds(90);
// Random generator for stamp.
private final SecureRandom random = new SecureRandom();
private final Context androidContext;
// Set of Ble objects that will be interacted with to perform operations.
private BluetoothLeAdvertiser bluetoothLeAdvertiser;
private BluetoothLeScanner bluetoothLeScanner;
private BluetoothGattServer bluetoothGattServer;
private Map<AdId, BluetoothGattService> advertisements;
private AdvertiseCallback advertiseCallback;
private Set<Plugin.ScanHandler> scanners;
private Set<String> pendingConnections;
private DeviceCache deviceCache;
private ScanCallback scanCallback;
private boolean hasPermission(String perm) {
return ContextCompat.checkSelfPermission(androidContext, perm)
== PackageManager.PERMISSION_GRANTED;
}
public BlePlugin(VContext ctx, String host) throws Exception {
androidContext = V.getAndroidContext(ctx);
if (androidContext == null) {
throw new IllegalStateException("androidContext not available");
}
BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) {
throw new IllegalStateException("BluetoothAdapter not available");
}
if (!hasPermission(Manifest.permission.ACCESS_COARSE_LOCATION)
&& !hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) {
throw new IllegalStateException("No permission on BluetoothAdapter");
}
bluetoothLeAdvertiser = bluetoothAdapter.getBluetoothLeAdvertiser();
bluetoothLeScanner = bluetoothAdapter.getBluetoothLeScanner();
BluetoothManager manager =
(BluetoothManager) androidContext.getSystemService(Context.BLUETOOTH_SERVICE);
bluetoothGattServer =
manager.openGattServer(
androidContext,
new BluetoothGattServerCallback() {
@Override
public void onConnectionStateChange(
BluetoothDevice device, int status, int newState) {
super.onConnectionStateChange(device, status, newState);
}
@Override
public void onCharacteristicReadRequest(
BluetoothDevice device,
int requestId,
int offset,
BluetoothGattCharacteristic characteristic) {
super.onCharacteristicReadRequest(
device, requestId, offset, characteristic);
byte[] total = characteristic.getValue();
byte[] res = {};
// Only send MTU - 1 bytes. The first byte of all packets is the op code.
if (offset < total.length) {
int finalByte = offset + MTU - 1;
if (finalByte > total.length) {
finalByte = total.length;
}
res = Arrays.copyOfRange(total, offset, finalByte);
bluetoothGattServer.sendResponse(
device, requestId, BluetoothGatt.GATT_SUCCESS, 0, res);
} else {
// This should probably be an error, but a bug in the paypal/gatt code causes an
// infinite loop if this returns an error rather than the empty value.
bluetoothGattServer.sendResponse(
device, requestId, BluetoothGatt.GATT_SUCCESS, 0, res);
}
}
});
advertisements = new HashMap<>();
scanners = new HashSet<>();
pendingConnections = new HashSet<>();
deviceCache = new DeviceCache(defaultCacheDuration);
}
public void startAdvertising(AdInfo adInfo) throws Exception {
BluetoothGattService service =
new BluetoothGattService(
UUIDUtil.serviceUUID(adInfo.getAd().getInterfaceName()),
BluetoothGattService.SERVICE_TYPE_PRIMARY);
for (Map.Entry<UUID, byte[]> entry : ConvertUtil.toGattAttrs(adInfo).entrySet()) {
BluetoothGattCharacteristic c =
new BluetoothGattCharacteristic(
entry.getKey(),
BluetoothGattCharacteristic.PROPERTY_READ,
BluetoothGattCharacteristic.PERMISSION_READ);
c.setValue(entry.getValue());
service.addCharacteristic(c);
}
synchronized (advertisements) {
advertisements.put(adInfo.getAd().getId(), service);
bluetoothGattServer.addService(service);
updateAdvertising();
}
}
public void stopAdvertising(AdInfo adInfo) {
synchronized (advertisements) {
BluetoothGattService service = advertisements.remove(adInfo.getAd().getId());
if (service != null) {
bluetoothGattServer.removeService(service);
updateAdvertising();
}
}
}
public void close() {
bluetoothGattServer.close();
}
private long genStamp() {
// We use 8-byte stamp to reflect the current services of the current device.
//
// TODO(bjornick): 8-byte random number might not be good enough for
// global uniqueness. We might want to consider a better way to generate
// stamp like using a unique device id with sequence number.
return new BigInteger(64, random).longValue();
}
private void updateAdvertising() {
if (advertiseCallback != null) {
bluetoothLeAdvertiser.stopAdvertising(advertiseCallback);
advertiseCallback = null;
}
if (advertisements.size() == 0) {
return;
}
AdvertiseData.Builder builder = new AdvertiseData.Builder();
ByteBuffer buf = ByteBuffer.allocate(9);
buf.put((byte) 8);
buf.putLong(genStamp());
builder.addManufacturerData(1001, buf.array());
AdvertiseSettings.Builder settingsBuilder = new AdvertiseSettings.Builder();
settingsBuilder.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY);
settingsBuilder.setConnectable(true);
advertiseCallback =
new AdvertiseCallback() {
@Override
public void onStartSuccess(AdvertiseSettings settingsInEffect) {
Log.d(TAG, "started " + settingsInEffect);
}
@Override
public void onStartFailure(int errorCode) {
Log.e(TAG, "failed to start advertising " + errorCode);
}
};
bluetoothLeAdvertiser.startAdvertising(
settingsBuilder.build(), builder.build(), advertiseCallback);
}
public void startScan(String interfaceName, Plugin.ScanHandler handler) throws Exception {
synchronized (scanners) {
if (!scanners.add(handler)) {
throw new IllegalArgumentException("handler already registered");
}
deviceCache.addScanner(interfaceName, handler);
updateScan();
}
}
public void stopScan(Plugin.ScanHandler handler) {
synchronized (scanners) {
if (!scanners.remove(handler)) {
return;
}
deviceCache.removeScanner(handler);
updateScan();
}
}
private void updateScan() {
// TODO(jhahn): Verify whether we need to stop scanning while connect to remote GATT servers.
if (scanners.isEmpty()) {
if (pendingConnections.isEmpty()) {
bluetoothLeScanner.stopScan(scanCallback);
scanCallback = null;
}
return;
}
if (scanCallback != null) {
return;
}
final List<ScanFilter> scanFilters =
ImmutableList.of(
new ScanFilter.Builder()
.setManufacturerData(1001, new byte[0], new byte[0])
.build());
final ScanSettings scanSettings =
new ScanSettings.Builder()
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
.setScanMode(ScanSettings.SCAN_MODE_BALANCED)
.build();
scanCallback =
new ScanCallback() {
@Override
public void onScanResult(int callbackType, ScanResult result) {
ScanRecord record = result.getScanRecord();
// Use 1001 to denote that this is a Vanadium device. We picked an id that is
// currently not in use.
byte[] data = record.getManufacturerSpecificData(1001);
ByteBuffer buffer = ByteBuffer.wrap(data);
final long stamp = buffer.getLong();
final String deviceId = result.getDevice().getAddress();
if (deviceCache.haveSeenStamp(stamp, deviceId)) {
return;
}
BluetoothGattReader.Handler handler =
new BluetoothGattReader.Handler() {
@Override
public void handle(Map<UUID, Map<UUID, byte[]>> services) {
if (services != null) {
List<AdInfo> adInfos = new ArrayList<>();
for (Map.Entry<UUID, Map<UUID, byte[]>> entry :
services.entrySet()) {
try {
AdInfo adInfo =
ConvertUtil.toAdInfo(entry.getValue());
adInfos.add(adInfo);
} catch (IOException e) {
Log.e(
TAG,
"failed to convert advertisement" + e);
}
}
deviceCache.saveDevice(stamp, deviceId, adInfos);
}
synchronized (scanners) {
pendingConnections.remove(deviceId);
if (pendingConnections.isEmpty()) {
if (scanners.isEmpty()) {
scanCallback = null;
return;
}
bluetoothLeScanner.startScan(
scanFilters, scanSettings, scanCallback);
}
}
}
};
BluetoothGattReader cb = new BluetoothGattReader(handler);
synchronized (scanners) {
if (scanners.isEmpty()) {
return;
}
if (!pendingConnections.add(deviceId)) {
return;
}
if (pendingConnections.size() == 1) {
bluetoothLeScanner.stopScan(scanCallback);
}
}
Log.d(TAG, "connecting to " + result.getDevice());
result.getDevice().connectGatt(androidContext, false, cb);
}
@Override
public void onBatchScanResults(List<ScanResult> results) {}
@Override
public void onScanFailed(int errorCode) {}
};
bluetoothLeScanner.startScan(scanFilters, scanSettings, scanCallback);
}
}