blob: 2459932b862f2d85cae842f349860bdea3a9a991 [file] [log] [blame]
/*
* Copyright (C) 2020 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.google.android.connecteddevice.connection;
import static com.google.android.connecteddevice.model.Errors.DEVICE_ERROR_INVALID_HANDSHAKE;
import static com.google.android.connecteddevice.util.SafeLog.logd;
import static com.google.android.connecteddevice.util.SafeLog.loge;
import static com.google.android.connecteddevice.util.SafeLog.logw;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import androidx.annotation.CallSuper;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.connecteddevice.model.AssociatedDevice;
import com.google.android.connecteddevice.oob.OobChannel;
import com.google.android.connecteddevice.storage.ConnectedDeviceStorage;
import com.google.android.connecteddevice.util.ThreadSafeCallbacks;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.Executor;
/**
* Generic manager for a car that keeps track of connected devices and their associated callbacks.
*/
public abstract class CarBluetoothManager {
private static final String TAG = "CarBluetoothManager";
protected final ConnectedDeviceStorage storage;
protected final CopyOnWriteArraySet<ConnectedRemoteDevice> connectedDevices =
new CopyOnWriteArraySet<>();
protected final ThreadSafeCallbacks<Callback> callbacks = new ThreadSafeCallbacks<>();
private final boolean isCompressionEnabled;
private String clientDeviceName;
private String clientDeviceAddress;
protected CarBluetoothManager(
@NonNull ConnectedDeviceStorage connectedDeviceStorage, boolean enableCompression) {
storage = connectedDeviceStorage;
isCompressionEnabled = enableCompression;
}
/** Attempt to connect to device with provided id. */
public void connectToDevice(@NonNull UUID deviceId) {
for (ConnectedRemoteDevice device : connectedDevices) {
if (UUID.fromString(device.deviceId).equals(deviceId)) {
logd(TAG, "Already connected to device " + deviceId + ".");
// Already connected to this device. Ignore requests to connect again.
return;
}
}
// Clear any previous session before starting a new one.
reset();
initiateConnectionToDevice(deviceId);
}
/** Start to connect to associated devices */
public abstract void initiateConnectionToDevice(@NonNull UUID deviceId);
/** Start the association with a new device */
public abstract void startAssociation(
@NonNull String nameForAssociation, @NonNull AssociationCallback callback);
/** Start the association with a new device using out of band verification code exchange */
public abstract void startOutOfBandAssociation(
@NonNull String nameForAssociation,
@NonNull OobChannel oobChannel,
@NonNull AssociationCallback callback);
/** Disconnect the provided device from this manager. */
public abstract void disconnectDevice(@NonNull String deviceId);
/** Get current {@link AssociationCallback}. */
@Nullable
public abstract AssociationCallback getAssociationCallback();
/** Set current {@link AssociationCallback}. */
public abstract void setAssociationCallback(@Nullable AssociationCallback callback);
/** Set the value of the client device name */
public void setClientDeviceName(String deviceName) {
clientDeviceName = deviceName;
}
/** Set the value of client device's mac address */
public void setClientDeviceAddress(String macAddress) {
clientDeviceAddress = macAddress;
}
/** Initialize and start the manager. */
@CallSuper
public void start() {}
/** Stop the manager and clean up. */
public void stop() {
for (ConnectedRemoteDevice device : connectedDevices) {
if (device.gatt != null) {
device.gatt.close();
}
}
connectedDevices.clear();
}
/** Stop the association process with any device. */
public void stopAssociation() {
if (!isAssociating()) {
return;
}
reset();
}
/** Register a {@link Callback} to be notified on the {@link Executor}. */
public void registerCallback(@NonNull Callback callback, @NonNull Executor executor) {
callbacks.add(callback, executor);
}
/**
* Unregister a callback.
*
* @param callback The {@link Callback} to unregister.
*/
public void unregisterCallback(@NonNull Callback callback) {
callbacks.remove(callback);
}
/**
* Send a message to a connected device.
*
* @param deviceId Id of connected device.
* @param message {@link DeviceMessage} to send.
*/
public void sendMessage(@NonNull String deviceId, @NonNull DeviceMessage message) {
ConnectedRemoteDevice device = getConnectedDevice(deviceId);
if (device == null) {
logw(TAG, "Attempted to send message to unknown device $deviceId. Ignored.");
return;
}
sendMessage(device, message);
}
/**
* Send a message to a connected device.
*
* @param device The connected {@link ConnectedRemoteDevice}.
* @param message {@link DeviceMessage} to send.
*/
public void sendMessage(@NonNull ConnectedRemoteDevice device, @NonNull DeviceMessage message) {
String deviceId = device.deviceId;
if (deviceId == null) {
deviceId = "Unidentified device";
}
logd(TAG, "Writing " + message.getMessage().length + " bytes to " + deviceId + ".");
device.secureChannel.sendClientMessage(message);
}
/** Clean manager status and callbacks. */
@CallSuper
public void reset() {
clientDeviceAddress = null;
clientDeviceName = null;
connectedDevices.clear();
}
/** Notify that the user has accepted a pairing code or other out-of-band confirmation. */
public void notifyOutOfBandAccepted() {
if (getConnectedDevice() == null) {
disconnectWithError(
"Null connected device found when out-of-band confirmation " + "received.");
return;
}
AssociationSecureChannel secureChannel =
(AssociationSecureChannel) getConnectedDevice().secureChannel;
if (secureChannel == null) {
disconnectWithError(
"Null SecureBleChannel found for the current connected device "
+ "when out-of-band confirmation received.");
return;
}
secureChannel.notifyOutOfBandAccepted();
}
/** Returns the secure channel of current connected device. */
@Nullable
public SecureChannel getConnectedDeviceChannel() {
ConnectedRemoteDevice connectedDevice = getConnectedDevice();
if (connectedDevice == null) {
return null;
}
return connectedDevice.secureChannel;
}
/** Return the current connected device. */
@Nullable
protected final ConnectedRemoteDevice getConnectedDevice() {
if (connectedDevices.isEmpty()) {
return null;
}
// Directly return the next because there will only be one device connected at one time.
return connectedDevices.iterator().next();
}
/**
* Get the {@link ConnectedRemoteDevice} with matching {@link BluetoothGatt} if available. Returns
* {@code null} if no matches are found.
*/
@Nullable
protected final ConnectedRemoteDevice getConnectedDevice(@NonNull BluetoothGatt gatt) {
for (ConnectedRemoteDevice device : connectedDevices) {
if (device.gatt == gatt) {
return device;
}
}
return null;
}
/**
* Get the {@link ConnectedRemoteDevice} with matching {@link BluetoothDevice} if available.
* Returns {@code null} if no matches are found.
*/
@Nullable
protected final ConnectedRemoteDevice getConnectedDevice(@NonNull BluetoothDevice device) {
for (ConnectedRemoteDevice connectedDevice : connectedDevices) {
if (device.equals(connectedDevice.device)) {
return connectedDevice;
}
}
return null;
}
/**
* Get the {@link ConnectedRemoteDevice} with matching device id if available. Returns {@code
* null} if no matches are found.
*/
@Nullable
protected final ConnectedRemoteDevice getConnectedDevice(@NonNull String deviceId) {
for (ConnectedRemoteDevice device : connectedDevices) {
if (deviceId.equals(device.deviceId)) {
return device;
}
}
return null;
}
/** Add the {@link ConnectedRemoteDevice} that has connected. */
protected final void addConnectedDevice(@NonNull ConnectedRemoteDevice device) {
device.secureChannel.setCompressionEnabled(isCompressionEnabled);
connectedDevices.add(device);
}
/** Return the number of devices currently connected. */
protected final int getConnectedDevicesCount() {
return connectedDevices.size();
}
/** Remove [@link BleDevice} that has been disconnected. */
protected final void removeConnectedDevice(@NonNull ConnectedRemoteDevice device) {
connectedDevices.remove(device);
}
/** Return [@code true} if the manager is currently in an association process. */
protected final boolean isAssociating() {
return getAssociationCallback() != null;
}
/**
* Set the device id of {@link ConnectedRemoteDevice} and then notify device connected callback.
*
* @param deviceId The device id received from remote device.
*/
protected final void setDeviceIdAndNotifyCallbacks(@NonNull String deviceId) {
logd(TAG, "Setting device id: " + deviceId);
ConnectedRemoteDevice connectedDevice = getConnectedDevice();
if (connectedDevice == null) {
disconnectWithError("Null connected device found when device id received.");
return;
}
connectedDevice.deviceId = deviceId;
callbacks.invoke(callback -> callback.onDeviceConnected(deviceId));
}
/** Log error which causes the disconnect with {@link Exception} and notify callbacks. */
protected final void disconnectWithError(@NonNull String errorMessage, @Nullable Exception e) {
loge(TAG, errorMessage, e);
if (isAssociating()) {
getAssociationCallback().onAssociationError(DEVICE_ERROR_INVALID_HANDSHAKE);
}
reset();
}
/** Log error which cause the disconnection and notify callbacks. */
protected final void disconnectWithError(@NonNull String errorMessage) {
disconnectWithError(errorMessage, null);
}
protected final SecureChannel.Callback secureChannelCallback =
new SecureChannel.Callback() {
@Override
public void onSecureChannelEstablished() {
ConnectedRemoteDevice connectedDevice = getConnectedDevice();
if (connectedDevice == null || connectedDevice.deviceId == null) {
disconnectWithError("Null device id found when secure channel " + "established.");
return;
}
String deviceId = connectedDevice.deviceId;
if (clientDeviceAddress == null) {
disconnectWithError("Null device address found when secure channel " + "established.");
return;
}
if (isAssociating()) {
logd(
TAG,
"Secure channel established for un-associated device. Saving "
+ "association of that device for current user.");
storage.addAssociatedDeviceForActiveUser(
new AssociatedDevice(
deviceId,
clientDeviceAddress,
clientDeviceName,
/* isConnectionEnabled= */ true));
AssociationCallback callback = getAssociationCallback();
if (callback != null) {
callback.onAssociationCompleted(deviceId);
setAssociationCallback(null);
}
}
callbacks.invoke(callback -> callback.onSecureChannelEstablished(deviceId));
}
@Override
public void onEstablishSecureChannelFailure(int error) {
ConnectedRemoteDevice connectedDevice = getConnectedDevice();
if (connectedDevice == null || connectedDevice.deviceId == null) {
disconnectWithError(
"Null device id found when secure channel " + "failed to establish.");
return;
}
String deviceId = connectedDevice.deviceId;
callbacks.invoke(callback -> callback.onSecureChannelError(deviceId));
if (isAssociating()) {
getAssociationCallback().onAssociationError(error);
}
disconnectWithError("Error while establishing secure connection.");
}
@Override
public void onMessageReceived(DeviceMessage deviceMessage) {
ConnectedRemoteDevice connectedDevice = getConnectedDevice();
if (connectedDevice == null || connectedDevice.deviceId == null) {
disconnectWithError("Null device id found when message received.");
return;
}
logd(
TAG,
"Received new message from "
+ connectedDevice.deviceId
+ " with "
+ deviceMessage.getMessage().length
+ " bytes in its "
+ "payload. Notifying "
+ callbacks.size()
+ " callbacks.");
callbacks.invoke(
callback -> callback.onMessageReceived(connectedDevice.deviceId, deviceMessage));
}
@Override
public void onMessageReceivedError(Exception exception) {
disconnectWithError("Error while receiving message.");
}
@Override
public void onDeviceIdReceived(String deviceId) {
setDeviceIdAndNotifyCallbacks(deviceId);
}
};
/** State for a connected device. */
public enum ConnectedDeviceState {
CONNECTING,
PENDING_VERIFICATION,
CONNECTED,
UNKNOWN
}
/** Container class to hold information about a connected device. */
public static class ConnectedRemoteDevice {
@NonNull public BluetoothDevice device;
@Nullable public BluetoothGatt gatt;
@NonNull public ConnectedDeviceState state;
@Nullable public String deviceId;
@Nullable public SecureChannel secureChannel;
public ConnectedRemoteDevice(@NonNull BluetoothDevice device, @Nullable BluetoothGatt gatt) {
this.device = device;
this.gatt = gatt;
state = ConnectedDeviceState.UNKNOWN;
}
}
/** Callback for triggered events from {@link CarBluetoothManager}. */
public interface Callback {
/**
* Triggered when device is connected and device id retrieved. Device is now ready to receive
* messages.
*
* @param deviceId Id of device that has connected.
*/
void onDeviceConnected(@NonNull String deviceId);
/**
* Triggered when device is disconnected.
*
* @param deviceId Id of device that has disconnected.
*/
void onDeviceDisconnected(@NonNull String deviceId);
/**
* Triggered when device has established encryption for secure communication.
*
* @param deviceId Id of device that has established encryption.
*/
void onSecureChannelEstablished(@NonNull String deviceId);
/**
* Triggered when a new message is received.
*
* @param deviceId Id of the device that sent the message.
* @param message {@link DeviceMessage} received.
*/
void onMessageReceived(@NonNull String deviceId, @NonNull DeviceMessage message);
/**
* Triggered when an error when establishing the secure channel.
*
* @param deviceId Id of the device that experienced the error.
*/
void onSecureChannelError(@NonNull String deviceId);
}
}