DO NOT MERGE Add connected-device-lib
Bug: 148110165
Test: Manual
Change-Id: I11fd13a74b0ddecda2e5b848ec5fc2975acd597d
diff --git a/connected-device-lib/Android.bp b/connected-device-lib/Android.bp
new file mode 100644
index 0000000..85490be
--- /dev/null
+++ b/connected-device-lib/Android.bp
@@ -0,0 +1,43 @@
+//
+// Copyright (C) 2019 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.
+//
+
+android_library {
+ name: "connected-device-lib",
+
+ srcs: ["src/**/*.java"],
+
+ manifest: "AndroidManifest.xml",
+
+ resource_dirs: ["res"],
+
+ optimize: {
+ enabled: false,
+ },
+
+ libs: ["android.car"],
+
+ static_libs: [
+ "EncryptionRunner-lib",
+ "androidx.room_room-runtime",
+ "connected-device-protos",
+ ],
+
+ plugins: [
+ "car-androidx-room-compiler",
+ ],
+
+ platform_apis: true,
+}
diff --git a/connected-device-lib/AndroidManifest.xml b/connected-device-lib/AndroidManifest.xml
new file mode 100644
index 0000000..d02ffce
--- /dev/null
+++ b/connected-device-lib/AndroidManifest.xml
@@ -0,0 +1,28 @@
+<!--
+ ~ Copyright (C) 2019 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.
+ -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.android.car.connecteddevice">
+
+ <!-- Needed for BLE scanning/advertising -->
+ <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
+ <uses-permission android:name="android.permission.BLUETOOTH"/>
+ <uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
+
+ <!-- Needed for detecting foreground user -->
+ <uses-permission android:name="android.permission.INTERACT_ACROSS_USERS"/>
+ <uses-permission android:name="android.permission.MANAGE_USERS" />
+</manifest>
diff --git a/connected-device-lib/OWNERS b/connected-device-lib/OWNERS
new file mode 100644
index 0000000..108da4e
--- /dev/null
+++ b/connected-device-lib/OWNERS
@@ -0,0 +1,5 @@
+# People who can approve changes for submission.
+nicksauer@google.com
+ramperry@google.com
+ajchen@google.com
+danharms@google.com
diff --git a/connected-device-lib/lib/kotlin-reflect-sources.jar b/connected-device-lib/lib/kotlin-reflect-sources.jar
new file mode 100644
index 0000000..917a722
--- /dev/null
+++ b/connected-device-lib/lib/kotlin-reflect-sources.jar
Binary files differ
diff --git a/connected-device-lib/lib/kotlin-reflect.jar b/connected-device-lib/lib/kotlin-reflect.jar
new file mode 100644
index 0000000..e872351
--- /dev/null
+++ b/connected-device-lib/lib/kotlin-reflect.jar
Binary files differ
diff --git a/connected-device-lib/lib/kotlin-stdlib-jdk7-sources.jar b/connected-device-lib/lib/kotlin-stdlib-jdk7-sources.jar
new file mode 100644
index 0000000..551568d
--- /dev/null
+++ b/connected-device-lib/lib/kotlin-stdlib-jdk7-sources.jar
Binary files differ
diff --git a/connected-device-lib/lib/kotlin-stdlib-jdk7.jar b/connected-device-lib/lib/kotlin-stdlib-jdk7.jar
new file mode 100644
index 0000000..d80ae96
--- /dev/null
+++ b/connected-device-lib/lib/kotlin-stdlib-jdk7.jar
Binary files differ
diff --git a/connected-device-lib/lib/kotlin-stdlib-jdk8-sources.jar b/connected-device-lib/lib/kotlin-stdlib-jdk8-sources.jar
new file mode 100644
index 0000000..3538660
--- /dev/null
+++ b/connected-device-lib/lib/kotlin-stdlib-jdk8-sources.jar
Binary files differ
diff --git a/connected-device-lib/lib/kotlin-stdlib-jdk8.jar b/connected-device-lib/lib/kotlin-stdlib-jdk8.jar
new file mode 100644
index 0000000..08101a3
--- /dev/null
+++ b/connected-device-lib/lib/kotlin-stdlib-jdk8.jar
Binary files differ
diff --git a/connected-device-lib/lib/kotlin-stdlib-sources.jar b/connected-device-lib/lib/kotlin-stdlib-sources.jar
new file mode 100644
index 0000000..2bdaf9e
--- /dev/null
+++ b/connected-device-lib/lib/kotlin-stdlib-sources.jar
Binary files differ
diff --git a/connected-device-lib/lib/kotlin-stdlib.jar b/connected-device-lib/lib/kotlin-stdlib.jar
new file mode 100644
index 0000000..2bd7644
--- /dev/null
+++ b/connected-device-lib/lib/kotlin-stdlib.jar
Binary files differ
diff --git a/connected-device-lib/lib/kotlin-test-sources.jar b/connected-device-lib/lib/kotlin-test-sources.jar
new file mode 100644
index 0000000..7bd21ce
--- /dev/null
+++ b/connected-device-lib/lib/kotlin-test-sources.jar
Binary files differ
diff --git a/connected-device-lib/lib/kotlin-test.jar b/connected-device-lib/lib/kotlin-test.jar
new file mode 100644
index 0000000..ede1d8b
--- /dev/null
+++ b/connected-device-lib/lib/kotlin-test.jar
Binary files differ
diff --git a/connected-device-lib/proto/Android.bp b/connected-device-lib/proto/Android.bp
new file mode 100644
index 0000000..c9dcb73
--- /dev/null
+++ b/connected-device-lib/proto/Android.bp
@@ -0,0 +1,26 @@
+//
+// Copyright (C) 2019 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.
+//
+
+java_library_static {
+ name: "connected-device-protos",
+ host_supported: true,
+ proto: {
+ type: "lite",
+ },
+ srcs: ["*.proto"],
+ jarjar_rules: "jarjar-rules.txt",
+ sdk_version: "28",
+}
diff --git a/connected-device-lib/proto/ble_device_message.proto b/connected-device-lib/proto/ble_device_message.proto
new file mode 100644
index 0000000..581d6a0
--- /dev/null
+++ b/connected-device-lib/proto/ble_device_message.proto
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2019 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.
+ */
+
+syntax = "proto3";
+
+package com.android.car.connecteddevice.proto;
+
+import "packages/apps/Car/libs/connected-device-lib/proto/operation_type.proto";
+
+option java_package = "com.android.car.connecteddevice.BleStreamProtos";
+option java_outer_classname = "BleDeviceMessageProto";
+
+// A message between devices.
+message BleDeviceMessage {
+ // The operation that this message represents.
+ OperationType operation = 1;
+
+ // Whether the payload field is encrypted.
+ bool is_payload_encrypted = 2;
+
+ // Identifier of the intended recipient.
+ bytes recipient = 3;
+
+ // The bytes that represent the content for this message.
+ bytes payload = 4;
+}
\ No newline at end of file
diff --git a/connected-device-lib/proto/ble_packet.proto b/connected-device-lib/proto/ble_packet.proto
new file mode 100644
index 0000000..c2ce262
--- /dev/null
+++ b/connected-device-lib/proto/ble_packet.proto
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2019 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.
+ */
+
+syntax = "proto3";
+
+package com.android.car.connecteddevice.proto;
+
+option java_package = "com.android.car.connecteddevice.BleStreamProtos";
+option java_outer_classname = "BlePacketProto";
+
+// A packet across a BLE channel.
+message BlePacket {
+ // A 1-based packet number. The first message will have a value of "1" rather
+ // than "0".
+ fixed32 packet_number = 1;
+
+ // The total number of packets in the message stream.
+ int32 total_packets = 2;
+
+ // Id of message for reassembly on other side
+ int32 message_id = 3;
+
+ // The bytes that represent the message content for this packet.
+ bytes payload = 4;
+}
diff --git a/connected-device-lib/proto/ble_version_exchange.proto b/connected-device-lib/proto/ble_version_exchange.proto
new file mode 100644
index 0000000..a7e8021
--- /dev/null
+++ b/connected-device-lib/proto/ble_version_exchange.proto
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2019 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.
+ */
+
+syntax = "proto3";
+
+package com.android.car.connecteddevice.proto;
+
+option java_package = "com.android.car.connecteddevice.BleStreamProtos";
+option java_outer_classname = "VersionExchangeProto";
+
+message BleVersionExchange {
+ // Minimum supported protobuf version.
+ int32 minSupportedMessagingVersion = 1;
+
+ // Maximum supported protobuf version.
+ int32 maxSupportedMessagingVersion = 2;
+
+ // Minimum supported version of the encryption engine.
+ int32 minSupportedSecurityVersion = 3;
+
+ // Maximum supported version of the encryption engine.
+ int32 maxSupportedSecurityVersion = 4;
+}
diff --git a/connected-device-lib/proto/jarjar-rules.txt b/connected-device-lib/proto/jarjar-rules.txt
new file mode 100644
index 0000000..d27aecb
--- /dev/null
+++ b/connected-device-lib/proto/jarjar-rules.txt
@@ -0,0 +1 @@
+rule com.google.protobuf.** com.android.car.protobuf.@1
diff --git a/connected-device-lib/proto/operation_type.proto b/connected-device-lib/proto/operation_type.proto
new file mode 100644
index 0000000..d447ccc
--- /dev/null
+++ b/connected-device-lib/proto/operation_type.proto
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2019 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.
+ */
+
+syntax = "proto3";
+
+package com.android.car.connecteddevice.proto;
+
+option java_package = "com.android.car.connecteddevice.BleStreamProtos";
+option java_outer_classname = "BleOperationProto";
+
+// The different message types that indicate the content of the payload.
+//
+// Ensure that these values are positive to reduce incurring too many bytes
+// to encode.
+enum OperationType {
+ // The contents of the payload are unknown.
+ //
+ // Note, this enum name is prefixed. See
+ // go/proto-best-practices-checkers#enum-default-value-name-conflict
+ OPERATION_TYPE_UNKNOWN = 0;
+
+ // The payload contains handshake messages needed to set up encryption.
+ ENCRYPTION_HANDSHAKE = 2;
+
+ // The message is an acknowledgment of a previously received message. The
+ // payload for this type should be empty.
+ ACK = 3;
+
+ // The payload contains a client-specific message.
+ CLIENT_MESSAGE = 4;
+}
diff --git a/connected-device-lib/res/values/config.xml b/connected-device-lib/res/values/config.xml
new file mode 100644
index 0000000..c052264
--- /dev/null
+++ b/connected-device-lib/res/values/config.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ ~ Copyright (C) 2019 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.
+ -->
+
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+
+ <string name="car_service_uuid" translatable="false">5e2a68a8-27be-43f9-8d1e-4546976fabd7</string>
+ <string name="car_association_service_uuid" translatable="false">5e2a68a4-27be-43f9-8d1e-4546976fabd7</string>
+ <string name="car_bg_mask" translatable="false">00000000000000000000000000000000</string>
+
+ <string name="car_secure_read_uuid" translatable="false">5e2a68a6-27be-43f9-8d1e-4546976fabd7</string>
+ <string name="car_secure_write_uuid" translatable="false">5e2a68a5-27be-43f9-8d1e-4546976fabd7</string>
+
+ <string name="connected_device_shared_preferences" translatable="false">com.android.car.connecteddevice</string>
+</resources>
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/AssociationCallback.java b/connected-device-lib/src/com/android/car/connecteddevice/AssociationCallback.java
new file mode 100644
index 0000000..fb7000b
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/AssociationCallback.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2019 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.car.connecteddevice;
+
+import android.annotation.NonNull;
+
+/** Callbacks that will be invoked during associating a new client. */
+public interface AssociationCallback {
+
+ /**
+ * Invoked when IHU starts advertising with its device name for association successfully.
+ *
+ * @param deviceName The device name to identify the car.
+ */
+ void onAssociationStartSuccess(@NonNull String deviceName);
+
+ /** Invoked when IHU failed to start advertising for association. */
+ void onAssociationStartFailure();
+
+ /**
+ * Invoked when a {@link ConnectedDeviceManager.DeviceError} has been encountered in attempting
+ * to associate a new device.
+ *
+ * @param error The failure indication.
+ */
+ void onAssociationError(@ConnectedDeviceManager.DeviceError int error);
+
+ /**
+ * Invoked when a verification code needs to be displayed. The user needs to confirm, and
+ * then call {@link ConnectedDeviceManager#notifyOutOfBandAccepted()}.
+ *
+ * @param code The verification code.
+ */
+ void onVerificationCodeAvailable(@NonNull String code);
+
+ /**
+ * Invoked when the association has completed.
+ *
+ * @param deviceId The id of the newly associated device.
+ */
+ void onAssociationCompleted(@NonNull String deviceId);
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/ConnectedDeviceManager.java b/connected-device-lib/src/com/android/car/connecteddevice/ConnectedDeviceManager.java
new file mode 100644
index 0000000..20a86ac
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/ConnectedDeviceManager.java
@@ -0,0 +1,851 @@
+/*
+ * Copyright (C) 2019 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.car.connecteddevice;
+
+import static com.android.car.connecteddevice.util.SafeLog.logd;
+import static com.android.car.connecteddevice.util.SafeLog.loge;
+import static com.android.car.connecteddevice.util.SafeLog.logw;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import android.annotation.CallbackExecutor;
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.content.Context;
+
+import com.android.car.connecteddevice.ble.BleCentralManager;
+import com.android.car.connecteddevice.ble.BlePeripheralManager;
+import com.android.car.connecteddevice.ble.CarBleCentralManager;
+import com.android.car.connecteddevice.ble.CarBleManager;
+import com.android.car.connecteddevice.ble.CarBlePeripheralManager;
+import com.android.car.connecteddevice.ble.DeviceMessage;
+import com.android.car.connecteddevice.model.AssociatedDevice;
+import com.android.car.connecteddevice.model.ConnectedDevice;
+import com.android.car.connecteddevice.storage.ConnectedDeviceStorage;
+import com.android.car.connecteddevice.storage.ConnectedDeviceStorage.AssociatedDeviceCallback;
+import com.android.car.connecteddevice.util.ByteUtils;
+import com.android.car.connecteddevice.util.ThreadSafeCallbacks;
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.lang.annotation.Retention;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArraySet;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+
+/** Manager of devices connected to the car. */
+public class ConnectedDeviceManager {
+
+ private static final String TAG = "ConnectedDeviceManager";
+
+ // Device name length is limited by available bytes in BLE advertisement data packet.
+ //
+ // BLE advertisement limits data packet length to 31
+ // Currently we send:
+ // - 18 bytes for 16 chars UUID: 16 bytes + 2 bytes for header;
+ // - 3 bytes for advertisement being connectable;
+ // which leaves 10 bytes.
+ // Subtracting 2 bytes used by header, we have 8 bytes for device name.
+ private static final int DEVICE_NAME_LENGTH_LIMIT = 8;
+
+ private final ConnectedDeviceStorage mStorage;
+
+ private final CarBleCentralManager mCentralManager;
+
+ private final CarBlePeripheralManager mPeripheralManager;
+
+ private final ThreadSafeCallbacks<DeviceAssociationCallback> mDeviceAssociationCallbacks =
+ new ThreadSafeCallbacks<>();
+
+ private final ThreadSafeCallbacks<ConnectionCallback> mActiveUserConnectionCallbacks =
+ new ThreadSafeCallbacks<>();
+
+ private final ThreadSafeCallbacks<ConnectionCallback> mAllUserConnectionCallbacks =
+ new ThreadSafeCallbacks<>();
+
+ // deviceId -> (recipientId -> callbacks)
+ private final Map<String, Map<UUID, ThreadSafeCallbacks<DeviceCallback>>> mDeviceCallbacks =
+ new ConcurrentHashMap<>();
+
+ // deviceId -> device
+ private final Map<String, InternalConnectedDevice> mConnectedDevices =
+ new ConcurrentHashMap<>();
+
+ // recipientId -> (deviceId -> message bytes)
+ private final Map<UUID, Map<String, byte[]>> mRecipientMissedMessages =
+ new ConcurrentHashMap<>();
+
+ // Recipient ids that received multiple callback registrations indicate that the recipient id
+ // has been compromised. Another party now has access the messages intended for that recipient.
+ // As a safeguard, that recipient id will be added to this list and blocked from further
+ // callback notifications.
+ private final Set<UUID> mBlacklistedRecipients = new CopyOnWriteArraySet<>();
+
+ private final AtomicBoolean mIsConnectingToUserDevice = new AtomicBoolean(false);
+
+ private String mNameForAssociation;
+
+ private AssociationCallback mAssociationCallback;
+
+ @Retention(SOURCE)
+ @IntDef(prefix = { "DEVICE_ERROR_" },
+ value = {
+ DEVICE_ERROR_INVALID_HANDSHAKE,
+ DEVICE_ERROR_INVALID_MSG,
+ DEVICE_ERROR_INVALID_DEVICE_ID,
+ DEVICE_ERROR_INVALID_VERIFICATION,
+ DEVICE_ERROR_INVALID_CHANNEL_STATE,
+ DEVICE_ERROR_INVALID_ENCRYPTION_KEY,
+ DEVICE_ERROR_STORAGE_FAILURE,
+ DEVICE_ERROR_INVALID_SECURITY_KEY,
+ DEVICE_ERROR_INSECURE_RECIPIENT_ID_DETECTED
+ }
+ )
+ public @interface DeviceError {}
+ public static final int DEVICE_ERROR_INVALID_HANDSHAKE = 0;
+ public static final int DEVICE_ERROR_INVALID_MSG = 1;
+ public static final int DEVICE_ERROR_INVALID_DEVICE_ID = 2;
+ public static final int DEVICE_ERROR_INVALID_VERIFICATION = 3;
+ public static final int DEVICE_ERROR_INVALID_CHANNEL_STATE = 4;
+ public static final int DEVICE_ERROR_INVALID_ENCRYPTION_KEY = 5;
+ public static final int DEVICE_ERROR_STORAGE_FAILURE = 6;
+ public static final int DEVICE_ERROR_INVALID_SECURITY_KEY = 7;
+ public static final int DEVICE_ERROR_INSECURE_RECIPIENT_ID_DETECTED = 8;
+
+ public ConnectedDeviceManager(@NonNull Context context) {
+ this(context, new ConnectedDeviceStorage(context), new BleCentralManager(context),
+ new BlePeripheralManager(context),
+ UUID.fromString(context.getString(R.string.car_service_uuid)),
+ UUID.fromString(context.getString(R.string.car_association_service_uuid)),
+ context.getString(R.string.car_bg_mask),
+ UUID.fromString(context.getString(R.string.car_secure_write_uuid)),
+ UUID.fromString(context.getString(R.string.car_secure_read_uuid)));
+ }
+
+ private ConnectedDeviceManager(
+ @NonNull Context context,
+ @NonNull ConnectedDeviceStorage storage,
+ @NonNull BleCentralManager bleCentralManager,
+ @NonNull BlePeripheralManager blePeripheralManager,
+ @NonNull UUID serviceUuid,
+ @NonNull UUID associationServiceUuid,
+ @NonNull String bgMask,
+ @NonNull UUID writeCharacteristicUuid,
+ @NonNull UUID readCharacteristicUuid) {
+ this(storage,
+ new CarBleCentralManager(context, bleCentralManager, storage, serviceUuid, bgMask,
+ writeCharacteristicUuid, readCharacteristicUuid),
+ new CarBlePeripheralManager(blePeripheralManager, storage, associationServiceUuid,
+ writeCharacteristicUuid, readCharacteristicUuid));
+ }
+
+ @VisibleForTesting
+ ConnectedDeviceManager(
+ @NonNull ConnectedDeviceStorage storage,
+ @NonNull CarBleCentralManager centralManager,
+ @NonNull CarBlePeripheralManager peripheralManager) {
+ Executor callbackExecutor = Executors.newSingleThreadExecutor();
+ mStorage = storage;
+ mCentralManager = centralManager;
+ mPeripheralManager = peripheralManager;
+ mCentralManager.registerCallback(generateCarBleCallback(centralManager), callbackExecutor);
+ mPeripheralManager.registerCallback(generateCarBleCallback(peripheralManager),
+ callbackExecutor);
+ mStorage.setAssociatedDeviceCallback(mAssociatedDeviceCallback);
+ }
+
+ /**
+ * Start internal processes and begin discovering devices. Must be called before any
+ * connections can be made using {@link #connectToActiveUserDevice()}.
+ */
+ public void start() {
+ logd(TAG, "Starting ConnectedDeviceManager.");
+ //mCentralManager.start();
+ mPeripheralManager.start();
+ connectToActiveUserDevice();
+ }
+
+ /** Clean up internal processes and disconnect any active connections. */
+ public void cleanup() {
+ logd(TAG, "Cleaning up ConnectedDeviceManager.");
+ mIsConnectingToUserDevice.set(false);
+ mCentralManager.stop();
+ mPeripheralManager.stop();
+ mDeviceCallbacks.clear();
+ mDeviceAssociationCallbacks.clear();
+ mActiveUserConnectionCallbacks.clear();
+ mAllUserConnectionCallbacks.clear();
+ mStorage.clearAssociationDeviceCallback();
+ }
+
+ /** Returns {@link List<ConnectedDevice>} of devices currently connected. */
+ @NonNull
+ public List<ConnectedDevice> getActiveUserConnectedDevices() {
+ List<ConnectedDevice> activeUserConnectedDevices = new ArrayList<>();
+ for (InternalConnectedDevice device : mConnectedDevices.values()) {
+ if (device.mConnectedDevice.isAssociatedWithActiveUser()) {
+ activeUserConnectedDevices.add(device.mConnectedDevice);
+ }
+ }
+ logd(TAG, "Returned " + activeUserConnectedDevices.size() + " active user devices.");
+ return activeUserConnectedDevices;
+ }
+
+ /**
+ * Register a callback for triggered associated device related events.
+ *
+ * @param callback {@link DeviceAssociationCallback} to register.
+ * @param executor {@link Executor} to execute triggers on.
+ */
+ public void registerDeviceAssociationCallback(@NonNull DeviceAssociationCallback callback,
+ @NonNull @CallbackExecutor Executor executor) {
+ mDeviceAssociationCallbacks.add(callback, executor);
+ }
+
+ /**
+ * Unregister a device association callback.
+ *
+ * @param callback {@link DeviceAssociationCallback} to unregister.
+ */
+ public void unregisterDeviceAssociationCallback(@NonNull DeviceAssociationCallback callback) {
+ mDeviceAssociationCallbacks.remove(callback);
+ }
+
+ /**
+ * Register a callback for manager triggered connection events for only the currently active
+ * user's devices.
+ *
+ * @param callback {@link ConnectionCallback} to register.
+ * @param executor {@link Executor} to execute triggers on.
+ */
+ public void registerActiveUserConnectionCallback(@NonNull ConnectionCallback callback,
+ @NonNull @CallbackExecutor Executor executor) {
+ mActiveUserConnectionCallbacks.add(callback, executor);
+ }
+
+ /**
+ * Unregister a connection callback from manager.
+ *
+ * @param callback {@link ConnectionCallback} to unregister.
+ */
+ public void unregisterConnectionCallback(ConnectionCallback callback) {
+ mActiveUserConnectionCallbacks.remove(callback);
+ mAllUserConnectionCallbacks.remove(callback);
+ }
+
+ /** Connect to a device for the active user if available. */
+ @VisibleForTesting
+ void connectToActiveUserDevice() {
+ Executors.defaultThreadFactory().newThread(() -> {
+ logd(TAG, "Received request to connect to active user's device.");
+ connectToActiveUserDeviceInternal();
+ }).start();
+ }
+
+ private void connectToActiveUserDeviceInternal() {
+ try {
+ if (mIsConnectingToUserDevice.get()) {
+ logd(TAG, "A request has already been made to connect to this user's device. "
+ + "Ignoring redundant request.");
+ return;
+ }
+ List<String> userDeviceIds = mStorage.getActiveUserAssociatedDeviceIds();
+ if (userDeviceIds.isEmpty()) {
+ logw(TAG, "No devices associated with active user. Ignoring.");
+ return;
+ }
+
+ // Only currently support one device per user for fast association, so take the
+ // first one.
+ String userDeviceId = userDeviceIds.get(0);
+ if (mConnectedDevices.containsKey(userDeviceId)) {
+ logd(TAG, "Device has already been connected. No need to attempt connection "
+ + "again.");
+ return;
+ }
+ mIsConnectingToUserDevice.set(true);
+ mPeripheralManager.connectToDevice(UUID.fromString(userDeviceId));
+ } catch (Exception e) {
+ loge(TAG, "Exception while attempting connection with active user's device.", e);
+ }
+ }
+
+ /**
+ * Start the association with a new device.
+ *
+ * @param callback Callback for association events.
+ */
+ public void startAssociation(@NonNull AssociationCallback callback) {
+ mAssociationCallback = callback;
+ mPeripheralManager.startAssociation(getNameForAssociation(), mInternalAssociationCallback);
+ }
+
+ /** Stop the association with any device. */
+ public void stopAssociation(@NonNull AssociationCallback callback) {
+ if (mAssociationCallback != callback) {
+ logd(TAG, "Stop association called with unrecognized callback. Ignoring.");
+ return;
+ }
+ mAssociationCallback = null;
+ mPeripheralManager.stopAssociation(mInternalAssociationCallback);
+ }
+
+ /**
+ * Get a list of associated devices for the given user.
+ *
+ * @return Associated device list.
+ */
+ @NonNull
+ public List<AssociatedDevice> getActiveUserAssociatedDevices() {
+ return mStorage.getActiveUserAssociatedDevices();
+ }
+
+ /** Notify that the user has accepted a pairing code or any out-of-band confirmation. */
+ public void notifyOutOfBandAccepted() {
+ mPeripheralManager.notifyOutOfBandAccepted();
+ }
+
+ /**
+ * Remove the associated device with the given device identifier for the current user.
+ *
+ * @param deviceId Device identifier.
+ */
+ public void removeActiveUserAssociatedDevice(@NonNull String deviceId) {
+ if (mConnectedDevices.containsKey(deviceId)) {
+ removeConnectedDevice(deviceId, mPeripheralManager);
+ mPeripheralManager.stop();
+ }
+ mStorage.removeAssociatedDeviceForActiveUser(deviceId);
+ logd(TAG, "Successfully removed associated device " + deviceId + ".");
+ }
+
+ /**
+ * Register a callback for a specific device and recipient.
+ *
+ * @param device {@link ConnectedDevice} to register triggers on.
+ * @param recipientId {@link UUID} to register as recipient of.
+ * @param callback {@link DeviceCallback} to register.
+ * @param executor {@link Executor} on which to execute callback.
+ */
+ public void registerDeviceCallback(@NonNull ConnectedDevice device, @NonNull UUID recipientId,
+ @NonNull DeviceCallback callback, @NonNull @CallbackExecutor Executor executor) {
+ if (isRecipientBlacklisted(recipientId)) {
+ notifyOfBlacklisting(device, recipientId, callback, executor);
+ return;
+ }
+ logd(TAG, "New callback registered on device " + device.getDeviceId() + " for recipient "
+ + recipientId);
+ String deviceId = device.getDeviceId();
+ Map<UUID, ThreadSafeCallbacks<DeviceCallback>> recipientCallbacks =
+ mDeviceCallbacks.computeIfAbsent(deviceId, key -> new HashMap<>());
+
+ // Device already has a callback registered with this recipient UUID. For the
+ // protection of the user, this UUID is now blacklisted from future subscriptions
+ // and the original subscription is notified and removed.
+ if (recipientCallbacks.containsKey(recipientId)) {
+ blacklistRecipient(deviceId, recipientId);
+ notifyOfBlacklisting(device, recipientId, callback, executor);
+ return;
+ }
+
+ ThreadSafeCallbacks<DeviceCallback> newCallbacks = new ThreadSafeCallbacks<>();
+ newCallbacks.add(callback, executor);
+ recipientCallbacks.put(recipientId, newCallbacks);
+
+ byte[] message = popMissedMessage(recipientId, device.getDeviceId());
+ if (message != null) {
+ newCallbacks.invoke(deviceCallback ->
+ deviceCallback.onMessageReceived(device, message));
+ }
+ }
+
+ private void notifyOfBlacklisting(@NonNull ConnectedDevice device, @NonNull UUID recipientId,
+ @NonNull DeviceCallback callback, @NonNull Executor executor) {
+ loge(TAG, "Multiple callbacks registered for recipient " + recipientId + "! Your "
+ + "recipient id is no longer secure and has been blocked from future use.");
+ executor.execute(() ->
+ callback.onDeviceError(device, DEVICE_ERROR_INSECURE_RECIPIENT_ID_DETECTED));
+ }
+
+ private void saveMissedMessage(@NonNull String deviceId, @NonNull UUID recipientId,
+ @NonNull byte[] message) {
+ // Store last message in case recipient registers callbacks in the future.
+ logd(TAG, "No recipient registered for device " + deviceId + " and recipient "
+ + recipientId + " combination. Saving message.");
+ mRecipientMissedMessages.putIfAbsent(recipientId, new HashMap<>());
+ mRecipientMissedMessages.get(recipientId).putIfAbsent(deviceId, message);
+ }
+
+ /**
+ * Remove the last message sent for this device prior to a {@link DeviceCallback} being
+ * registered.
+ *
+ * @param recipientId Recipient's id
+ * @param deviceId Device id
+ * @return The last missed {@code byte[]} of the message, or {@code null} if no messages were
+ * missed.
+ */
+ @Nullable
+ private byte[] popMissedMessage(@NonNull UUID recipientId, @NonNull String deviceId) {
+ Map<String, byte[]> missedMessages = mRecipientMissedMessages.get(recipientId);
+ if (missedMessages == null) {
+ return null;
+ }
+
+ return missedMessages.remove(deviceId);
+ }
+
+ /**
+ * Unregister callback from device events.
+ *
+ * @param device {@link ConnectedDevice} callback was registered on.
+ * @param recipientId {@link UUID} callback was registered under.
+ * @param callback {@link DeviceCallback} to unregister.
+ */
+ public void unregisterDeviceCallback(@NonNull ConnectedDevice device,
+ @NonNull UUID recipientId, @NonNull DeviceCallback callback) {
+ logd(TAG, "Device callback unregistered on device " + device.getDeviceId() + " for "
+ + "recipient " + recipientId + ".");
+
+ Map<UUID, ThreadSafeCallbacks<DeviceCallback>> recipientCallbacks =
+ mDeviceCallbacks.get(device.getDeviceId());
+ if (recipientCallbacks == null) {
+ return;
+ }
+ ThreadSafeCallbacks<DeviceCallback> callbacks = recipientCallbacks.get(recipientId);
+ if (callbacks == null) {
+ return;
+ }
+
+ callbacks.remove(callback);
+ if (callbacks.size() == 0) {
+ recipientCallbacks.remove(recipientId);
+ }
+ }
+
+ /**
+ * Securely send message to a device.
+ *
+ * @param device {@link ConnectedDevice} to send the message to.
+ * @param recipientId Recipient {@link UUID}.
+ * @param message Message to send.
+ * @throws IllegalStateException Secure channel has not been established.
+ */
+ public void sendMessageSecurely(@NonNull ConnectedDevice device, @NonNull UUID recipientId,
+ @NonNull byte[] message) throws IllegalStateException {
+ sendMessage(device, recipientId, message, /* isEncrypted = */ true);
+ }
+
+ /**
+ * Send an unencrypted message to a device.
+ *
+ * @param device {@link ConnectedDevice} to send the message to.
+ * @param recipientId Recipient {@link UUID}.
+ * @param message Message to send.
+ */
+ public void sendMessageUnsecurely(@NonNull ConnectedDevice device, @NonNull UUID recipientId,
+ @NonNull byte[] message) {
+ sendMessage(device, recipientId, message, /* isEncrypted = */ false);
+ }
+
+ private void sendMessage(@NonNull ConnectedDevice device, @NonNull UUID recipientId,
+ @NonNull byte[] message, boolean isEncrypted) throws IllegalStateException {
+ String deviceId = device.getDeviceId();
+ logd(TAG, "Sending new message to device " + deviceId + " for " + recipientId
+ + " containing " + message.length + ". Message will be sent securely: "
+ + isEncrypted + ".");
+
+ InternalConnectedDevice connectedDevice = mConnectedDevices.get(deviceId);
+ if (connectedDevice == null) {
+ loge(TAG, "Attempted to send message to unknown device " + deviceId + ". Ignoring.");
+ return;
+ }
+
+ if (isEncrypted && !connectedDevice.mConnectedDevice.hasSecureChannel()) {
+ throw new IllegalStateException("Cannot send a message securely to device that has not "
+ + "established a secure channel.");
+ }
+
+ connectedDevice.mCarBleManager.sendMessage(deviceId,
+ new DeviceMessage(recipientId, isEncrypted, message));
+ }
+
+ private boolean isRecipientBlacklisted(UUID recipientId) {
+ return mBlacklistedRecipients.contains(recipientId);
+ }
+
+ private void blacklistRecipient(@NonNull String deviceId, @NonNull UUID recipientId) {
+ Map<UUID, ThreadSafeCallbacks<DeviceCallback>> recipientCallbacks =
+ mDeviceCallbacks.get(deviceId);
+ if (recipientCallbacks == null) {
+ // Should never happen, but null-safety check.
+ return;
+ }
+
+ ThreadSafeCallbacks<DeviceCallback> existingCallback = recipientCallbacks.get(recipientId);
+ if (existingCallback == null) {
+ // Should never happen, but null-safety check.
+ return;
+ }
+
+ InternalConnectedDevice connectedDevice = mConnectedDevices.get(deviceId);
+ if (connectedDevice != null) {
+ recipientCallbacks.get(recipientId).invoke(
+ callback ->
+ callback.onDeviceError(connectedDevice.mConnectedDevice,
+ DEVICE_ERROR_INSECURE_RECIPIENT_ID_DETECTED)
+ );
+ }
+
+ recipientCallbacks.remove(recipientId);
+ mBlacklistedRecipients.add(recipientId);
+ }
+
+ @VisibleForTesting
+ void addConnectedDevice(@NonNull String deviceId, @NonNull CarBleManager bleManager) {
+ if (mConnectedDevices.containsKey(deviceId)) {
+ // Device already connected. No-op until secure channel established.
+ return;
+ }
+ logd(TAG, "New device with id " + deviceId + " connected.");
+ ConnectedDevice connectedDevice = new ConnectedDevice(
+ deviceId,
+ /* deviceName = */ null,
+ mStorage.getActiveUserAssociatedDeviceIds().contains(deviceId),
+ /* hasSecureChannel = */ false
+ );
+
+ mConnectedDevices.put(deviceId, new InternalConnectedDevice(connectedDevice, bleManager));
+ invokeConnectionCallbacks(connectedDevice.isAssociatedWithActiveUser(),
+ callback -> callback.onDeviceConnected(connectedDevice));
+ }
+
+ @VisibleForTesting
+ void removeConnectedDevice(@NonNull String deviceId, @NonNull CarBleManager bleManager) {
+ logd(TAG, "Device " + deviceId + " disconnected from manager " + bleManager);
+ InternalConnectedDevice connectedDevice = getConnectedDeviceForManager(deviceId,
+ bleManager);
+
+ // If disconnect happened on peripheral, open for future requests to connect.
+ if (bleManager == mPeripheralManager) {
+ mIsConnectingToUserDevice.set(false);
+ }
+
+ if (connectedDevice == null) {
+ return;
+ }
+
+ mConnectedDevices.remove(deviceId);
+ boolean isAssociated = connectedDevice.mConnectedDevice.isAssociatedWithActiveUser();
+ invokeConnectionCallbacks(isAssociated,
+ callback -> callback.onDeviceDisconnected(connectedDevice.mConnectedDevice));
+
+ if (isAssociated) {
+ // Try to regain connection to active user's device.
+ connectToActiveUserDevice();
+ }
+ }
+
+ @VisibleForTesting
+ void onSecureChannelEstablished(@NonNull String deviceId,
+ @NonNull CarBleManager bleManager) {
+ if (mConnectedDevices.get(deviceId) == null) {
+ loge(TAG, "Secure channel established on unknown device " + deviceId + ".");
+ return;
+ }
+ ConnectedDevice connectedDevice = mConnectedDevices.get(deviceId).mConnectedDevice;
+ ConnectedDevice updatedConnectedDevice = new ConnectedDevice(connectedDevice.getDeviceId(),
+ connectedDevice.getDeviceName(), connectedDevice.isAssociatedWithActiveUser(),
+ /* hasSecureChannel = */ true);
+
+ boolean notifyCallbacks = getConnectedDeviceForManager(deviceId, bleManager) != null;
+
+ // TODO (b/143088482) Implement interrupt
+ // Ignore if central already holds the active device connection and interrupt the
+ // connection.
+
+ mConnectedDevices.put(deviceId,
+ new InternalConnectedDevice(updatedConnectedDevice, bleManager));
+ logd(TAG, "Secure channel established to " + deviceId + " . Notifying callbacks: "
+ + notifyCallbacks + ".");
+ if (notifyCallbacks) {
+ notifyAllDeviceCallbacks(deviceId,
+ callback -> callback.onSecureChannelEstablished(updatedConnectedDevice));
+ }
+ }
+
+ @VisibleForTesting
+ void onMessageReceived(@NonNull String deviceId, @NonNull DeviceMessage message) {
+ logd(TAG, "New message received from device " + deviceId + " intended for "
+ + message.getRecipient() + " containing " + message.getMessage().length
+ + " bytes.");
+
+ InternalConnectedDevice connectedDevice = mConnectedDevices.get(deviceId);
+ if (connectedDevice == null) {
+ logw(TAG, "Received message from unknown device " + deviceId + "or to unknown "
+ + "recipient " + message.getRecipient() + ".");
+ return;
+ }
+ UUID recipientId = message.getRecipient();
+ Map<UUID, ThreadSafeCallbacks<DeviceCallback>> deviceCallbacks =
+ mDeviceCallbacks.get(deviceId);
+ if (deviceCallbacks == null) {
+ saveMissedMessage(deviceId, recipientId, message.getMessage());
+ return;
+ }
+ ThreadSafeCallbacks<DeviceCallback> recipientCallbacks =
+ deviceCallbacks.get(recipientId);
+ if (recipientCallbacks == null) {
+ saveMissedMessage(deviceId, recipientId, message.getMessage());
+ return;
+ }
+
+ recipientCallbacks.invoke(
+ callback -> callback.onMessageReceived(connectedDevice.mConnectedDevice,
+ message.getMessage()));
+ }
+
+ @VisibleForTesting
+ void deviceErrorOccurred(@NonNull String deviceId) {
+ InternalConnectedDevice connectedDevice = mConnectedDevices.get(deviceId);
+ if (connectedDevice == null) {
+ logw(TAG, "Failed to establish secure channel on unknown device " + deviceId + ".");
+ return;
+ }
+
+ notifyAllDeviceCallbacks(deviceId,
+ callback -> callback.onDeviceError(connectedDevice.mConnectedDevice,
+ DEVICE_ERROR_INVALID_SECURITY_KEY));
+ }
+
+ @VisibleForTesting
+ void onAssociationCompleted(@NonNull String deviceId) {
+ InternalConnectedDevice connectedDevice =
+ getConnectedDeviceForManager(deviceId, mPeripheralManager);
+ if (connectedDevice == null) {
+ return;
+ }
+
+ // The previous device is now obsolete and should be replaced with a new one properly
+ // reflecting the state of belonging to the active user and notify features.
+ if (connectedDevice.mConnectedDevice.isAssociatedWithActiveUser()) {
+ // Device was already marked as belonging to active user. No need to reissue callbacks.
+ return;
+ }
+ removeConnectedDevice(deviceId, mPeripheralManager);
+ addConnectedDevice(deviceId, mPeripheralManager);
+ }
+
+ @NonNull
+ private List<String> getActiveUserDeviceIds() {
+ return mStorage.getActiveUserAssociatedDeviceIds();
+ }
+
+ @Nullable
+ private InternalConnectedDevice getConnectedDeviceForManager(@NonNull String deviceId,
+ @NonNull CarBleManager bleManager) {
+ InternalConnectedDevice connectedDevice = mConnectedDevices.get(deviceId);
+ if (connectedDevice != null && connectedDevice.mCarBleManager == bleManager) {
+ return connectedDevice;
+ }
+
+ return null;
+ }
+
+ private void invokeConnectionCallbacks(boolean belongsToActiveUser,
+ @NonNull Consumer<ConnectionCallback> notification) {
+ logd(TAG, "Notifying connection callbacks for device belonging to active user "
+ + belongsToActiveUser + ".");
+ if (belongsToActiveUser) {
+ mActiveUserConnectionCallbacks.invoke(notification);
+ }
+ mAllUserConnectionCallbacks.invoke(notification);
+ }
+
+ private void notifyAllDeviceCallbacks(@NonNull String deviceId,
+ @NonNull Consumer<DeviceCallback> notification) {
+ logd(TAG, "Notifying all device callbacks for device " + deviceId + ".");
+ Map<UUID, ThreadSafeCallbacks<DeviceCallback>> deviceCallbacks =
+ mDeviceCallbacks.get(deviceId);
+ if (deviceCallbacks == null) {
+ return;
+ }
+
+ for (ThreadSafeCallbacks<DeviceCallback> callbacks : deviceCallbacks.values()) {
+ callbacks.invoke(notification);
+ }
+ }
+
+ /**
+ * Returns the name that should be used for the device during enrollment of a trusted device.
+ *
+ * <p>The returned name will be a combination of a prefix sysprop and randomized digits.
+ */
+ @NonNull
+ private String getNameForAssociation() {
+ if (mNameForAssociation == null) {
+ mNameForAssociation = ByteUtils.generateRandomNumberString(DEVICE_NAME_LENGTH_LIMIT);
+ }
+ return mNameForAssociation;
+ }
+
+ @NonNull
+ private CarBleManager.Callback generateCarBleCallback(@NonNull CarBleManager carBleManager) {
+ return new CarBleManager.Callback() {
+ @Override
+ public void onDeviceConnected(String deviceId) {
+ addConnectedDevice(deviceId, carBleManager);
+ }
+
+ @Override
+ public void onDeviceDisconnected(String deviceId) {
+ removeConnectedDevice(deviceId, carBleManager);
+ }
+
+ @Override
+ public void onSecureChannelEstablished(String deviceId) {
+ ConnectedDeviceManager.this.onSecureChannelEstablished(deviceId, carBleManager);
+ }
+
+ @Override
+ public void onMessageReceived(String deviceId, DeviceMessage message) {
+ ConnectedDeviceManager.this.onMessageReceived(deviceId, message);
+ }
+
+ @Override
+ public void onSecureChannelError(String deviceId) {
+ deviceErrorOccurred(deviceId);
+ }
+ };
+ }
+
+ private final AssociationCallback mInternalAssociationCallback = new AssociationCallback() {
+ @Override
+ public void onAssociationStartSuccess(String deviceName) {
+ if (mAssociationCallback != null) {
+ mAssociationCallback.onAssociationStartSuccess(deviceName);
+ }
+ }
+
+ @Override
+ public void onAssociationStartFailure() {
+ if (mAssociationCallback != null) {
+ mAssociationCallback.onAssociationStartFailure();
+ }
+ }
+
+ @Override
+ public void onAssociationError(int error) {
+ if (mAssociationCallback != null) {
+ mAssociationCallback.onAssociationError(error);
+ }
+ }
+
+ @Override
+ public void onVerificationCodeAvailable(String code) {
+ if (mAssociationCallback != null) {
+ mAssociationCallback.onVerificationCodeAvailable(code);
+ }
+ }
+
+ @Override
+ public void onAssociationCompleted(String deviceId) {
+ if (mAssociationCallback != null) {
+ mAssociationCallback.onAssociationCompleted(deviceId);
+ }
+ ConnectedDeviceManager.this.onAssociationCompleted(deviceId);
+ }
+ };
+
+ private final AssociatedDeviceCallback mAssociatedDeviceCallback =
+ new AssociatedDeviceCallback() {
+ @Override
+ public void onAssociatedDeviceAdded(String deviceId) {
+ mDeviceAssociationCallbacks.invoke(callback ->
+ callback.onAssociatedDeviceAdded(deviceId));
+ }
+
+ @Override
+ public void onAssociatedDeviceRemoved(String deviceId) {
+ mDeviceAssociationCallbacks.invoke(callback ->
+ callback.onAssociatedDeviceRemoved(deviceId));
+ }
+
+ @Override
+ public void onAssociatedDeviceUpdated(AssociatedDevice device) {
+ mDeviceAssociationCallbacks.invoke(callback ->
+ callback.onAssociatedDeviceUpdated(device));
+ }
+ };
+
+ /** Callback for triggered connection events from {@link ConnectedDeviceManager}. */
+ public interface ConnectionCallback {
+ /** Triggered when a new device has connected. */
+ void onDeviceConnected(@NonNull ConnectedDevice device);
+
+ /** Triggered when a device has disconnected. */
+ void onDeviceDisconnected(@NonNull ConnectedDevice device);
+ }
+
+ /** Triggered device events for a connected device from {@link ConnectedDeviceManager}. */
+ public interface DeviceCallback {
+ /**
+ * Triggered when secure channel has been established on a device. Encrypted messaging now
+ * available.
+ */
+ void onSecureChannelEstablished(@NonNull ConnectedDevice device);
+
+ /** Triggered when a new message is received from a device. */
+ void onMessageReceived(@NonNull ConnectedDevice device, @NonNull byte[] message);
+
+ /** Triggered when an error has occurred for a device. */
+ void onDeviceError(@NonNull ConnectedDevice device, @DeviceError int error);
+ }
+
+ /** Callback for association device related events. */
+ public interface DeviceAssociationCallback {
+
+ /** Triggered when an associated device has been added */
+ void onAssociatedDeviceAdded(@NonNull String deviceId);
+
+ /** Triggered when an associated device has been removed. */
+ void onAssociatedDeviceRemoved(@NonNull String deviceId);
+
+ /** Triggered when the name of an associated device has been updated. */
+ void onAssociatedDeviceUpdated(@NonNull AssociatedDevice device);
+ }
+
+ private static class InternalConnectedDevice {
+ private final ConnectedDevice mConnectedDevice;
+ private final CarBleManager mCarBleManager;
+
+ InternalConnectedDevice(@NonNull ConnectedDevice connectedDevice,
+ @NonNull CarBleManager carBleManager) {
+ mConnectedDevice = connectedDevice;
+ mCarBleManager = carBleManager;
+ }
+ }
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/ble/BleCentralManager.java b/connected-device-lib/src/com/android/car/connecteddevice/ble/BleCentralManager.java
new file mode 100644
index 0000000..ca83a05
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/ble/BleCentralManager.java
@@ -0,0 +1,194 @@
+/*
+ * Copyright (C) 2019 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.car.connecteddevice.ble;
+
+import static com.android.car.connecteddevice.util.SafeLog.logd;
+import static com.android.car.connecteddevice.util.SafeLog.loge;
+import static com.android.car.connecteddevice.util.SafeLog.logw;
+
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.le.BluetoothLeScanner;
+import android.bluetooth.le.ScanCallback;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanResult;
+import android.bluetooth.le.ScanSettings;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Handler;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Class that manages BLE scanning operations.
+ */
+public class BleCentralManager {
+
+ private static final String TAG = "BleCentralManager";
+
+ private static final int RETRY_LIMIT = 5;
+
+ private static final int RETRY_INTERVAL_MS = 1000;
+
+ private final Context mContext;
+
+ private final Handler mHandler;
+
+ private List<ScanFilter> mScanFilters;
+
+ private ScanSettings mScanSettings;
+
+ private ScanCallback mScanCallback;
+
+ private BluetoothLeScanner mScanner;
+
+ private int mScannerStartCount = 0;
+
+ private AtomicInteger mScannerState = new AtomicInteger(STOPPED);
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ STOPPED,
+ STARTED,
+ SCANNING
+ })
+ private @interface ScannerState {}
+ private static final int STOPPED = 0;
+ private static final int STARTED = 1;
+ private static final int SCANNING = 2;
+
+ public BleCentralManager(@NonNull Context context) {
+ mContext = context;
+ mHandler = new Handler(context.getMainLooper());
+ }
+
+ /**
+ * Start the BLE scanning process.
+ *
+ * @param filters Optional list of {@link ScanFilter}s to apply to scan results.
+ * @param settings {@link ScanSettings} to apply to scanner.
+ * @param callback {@link ScanCallback} for scan events.
+ */
+ public void startScanning(@Nullable List<ScanFilter> filters, @NonNull ScanSettings settings,
+ @NonNull ScanCallback callback) {
+ if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) {
+ loge(TAG, "Attempted start scanning, but system does not support BLE. Ignoring");
+ return;
+ }
+ logd(TAG, "Request received to start scanning.");
+ mScannerStartCount = 0;
+ mScanFilters = filters;
+ mScanSettings = settings;
+ mScanCallback = callback;
+ updateScannerState(STARTED);
+ startScanningInternally();
+ }
+
+ /** Stop the scanner */
+ public void stopScanning() {
+ logd(TAG, "Attempting to stop scanning");
+ if (mScanner != null) {
+ mScanner.stopScan(mInternalScanCallback);
+ }
+ mScanCallback = null;
+ updateScannerState(STOPPED);
+ }
+
+ /** Returns {@code true} if currently scanning, {@code false} otherwise. */
+ public boolean isScanning() {
+ return mScannerState.get() == SCANNING;
+ }
+
+ /** Clean up the scanning process. */
+ public void cleanup() {
+ if (isScanning()) {
+ stopScanning();
+ }
+ }
+
+ private void startScanningInternally() {
+ logd(TAG, "Attempting to start scanning");
+ if (mScanner == null && BluetoothAdapter.getDefaultAdapter() != null) {
+ mScanner = BluetoothAdapter.getDefaultAdapter().getBluetoothLeScanner();
+ }
+ if (mScanner != null) {
+ mScanner.startScan(mScanFilters, mScanSettings, mInternalScanCallback);
+ updateScannerState(SCANNING);
+ } else {
+ mHandler.postDelayed(() -> {
+ // Keep trying
+ logd(TAG, "Scanner unavailable. Trying again.");
+ startScanningInternally();
+ }, RETRY_INTERVAL_MS);
+ }
+ }
+
+ private void updateScannerState(@ScannerState int newState) {
+ mScannerState.set(newState);
+ }
+
+ private final ScanCallback mInternalScanCallback = new ScanCallback() {
+ @Override
+ public void onScanResult(int callbackType, ScanResult result) {
+ if (mScanCallback != null) {
+ mScanCallback.onScanResult(callbackType, result);
+ }
+ }
+
+ @Override
+ public void onBatchScanResults(List<ScanResult> results) {
+ logd(TAG, "Batch scan found " + results.size() + " results.");
+ if (mScanCallback != null) {
+ mScanCallback.onBatchScanResults(results);
+ }
+ }
+
+ @Override
+ public void onScanFailed(int errorCode) {
+ if (mScannerStartCount >= RETRY_LIMIT) {
+ loge(TAG, "Cannot start BLE Scanner. Scanning Retry count: "
+ + mScannerStartCount);
+ if (mScanCallback != null) {
+ mScanCallback.onScanFailed(errorCode);
+ }
+ return;
+ }
+
+ mScannerStartCount++;
+ logw(TAG, "BLE Scanner failed to start. Error: "
+ + errorCode
+ + " Retry: "
+ + mScannerStartCount);
+ switch(errorCode) {
+ case SCAN_FAILED_ALREADY_STARTED:
+ // Scanner already started. Do nothing.
+ break;
+ case SCAN_FAILED_APPLICATION_REGISTRATION_FAILED:
+ case SCAN_FAILED_INTERNAL_ERROR:
+ mHandler.postDelayed(BleCentralManager.this::startScanningInternally,
+ RETRY_INTERVAL_MS);
+ break;
+ default:
+ // Ignore other codes.
+ }
+ }
+ };
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/ble/BleDeviceMessageStream.java b/connected-device-lib/src/com/android/car/connecteddevice/ble/BleDeviceMessageStream.java
new file mode 100644
index 0000000..f91693b
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/ble/BleDeviceMessageStream.java
@@ -0,0 +1,384 @@
+/*
+ * 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.android.car.connecteddevice.ble;
+
+import static com.android.car.connecteddevice.BleStreamProtos.BleOperationProto.OperationType;
+import static com.android.car.connecteddevice.BleStreamProtos.BlePacketProto.BlePacket;
+import static com.android.car.connecteddevice.BleStreamProtos.VersionExchangeProto.BleVersionExchange;
+import static com.android.car.connecteddevice.util.SafeLog.logd;
+import static com.android.car.connecteddevice.util.SafeLog.loge;
+import static com.android.car.connecteddevice.util.SafeLog.logw;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.os.Handler;
+import android.os.Looper;
+
+import com.android.car.connecteddevice.BleStreamProtos.BleDeviceMessageProto.BleDeviceMessage;
+import com.android.car.connecteddevice.util.ByteUtils;
+import com.android.car.protobuf.ByteString;
+import com.android.car.protobuf.InvalidProtocolBufferException;
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.HashMap;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+
+/** BLE message stream to a device. */
+class BleDeviceMessageStream {
+
+ private static final String TAG = "BleDeviceMessageStream";
+
+ // Only version 2 of the messaging and version 1 of the security supported.
+ private static final int MESSAGING_VERSION = 2;
+ private static final int SECURITY_VERSION = 1;
+
+ /*
+ * During bandwidth testing, it was discovered that allowing the stream to send as fast as it
+ * can blocked outgoing notifications from being received by the connected device. Adding a
+ * throttle to the outgoing messages alleviated this block and allowed both sides to
+ * send/receive in parallel successfully.
+ */
+ private static final long THROTTLE_DEFAULT_MS = 10L;
+ private static final long THROTTLE_WAIT_MS = 75L;
+
+ private final ArrayDeque<BlePacket> mPacketQueue = new ArrayDeque<>();
+
+ private final HashMap<Integer, ByteArrayOutputStream> mPendingData =
+ new HashMap<>();
+
+ private final MessageIdGenerator mMessageIdGenerator = new MessageIdGenerator();
+
+ private final Handler mHandler = new Handler(Looper.getMainLooper());
+
+ private final AtomicBoolean mIsVersionExchanged = new AtomicBoolean(false);
+
+ private final AtomicBoolean mIsSendingInProgress = new AtomicBoolean(false);
+
+ private final AtomicLong mThrottleDelay = new AtomicLong(THROTTLE_DEFAULT_MS);
+
+ private final BlePeripheralManager mBlePeripheralManager;
+
+ private final BluetoothDevice mDevice;
+
+ private final BluetoothGattCharacteristic mWriteCharacteristic;
+
+ private final BluetoothGattCharacteristic mReadCharacteristic;
+
+ private MessageReceivedListener mMessageReceivedListener;
+
+ private MessageReceivedErrorListener mMessageReceivedErrorListener;
+
+ /*
+ * This initial value is 20 because BLE has a default write of 23 bytes. However, 3 bytes are
+ * subtracted due to bytes being reserved for the command type and attribute ID.
+ */
+ private int mMaxWriteSize = 20;
+
+ BleDeviceMessageStream(@NonNull BlePeripheralManager blePeripheralManager,
+ @NonNull BluetoothDevice device,
+ @NonNull BluetoothGattCharacteristic writeCharacteristic,
+ @NonNull BluetoothGattCharacteristic readCharacteristic) {
+ mBlePeripheralManager = blePeripheralManager;
+ mDevice = device;
+ mWriteCharacteristic = writeCharacteristic;
+ mReadCharacteristic = readCharacteristic;
+ mBlePeripheralManager.addOnCharacteristicWriteListener(this::onCharacteristicWrite);
+ mBlePeripheralManager.addOnCharacteristicReadListener(this::onCharacteristicRead);
+ }
+
+ /**
+ * Writes the given message to the write characteristic of this stream with operation type
+ * {@code CLIENT_MESSAGE}.
+ *
+ * This method will handle the chunking of messages based on the max write size.
+ *
+ * @param deviceMessage The data object contains recipient, isPayloadEncrypted and message.
+ */
+ void writeMessage(@NonNull DeviceMessage deviceMessage) {
+ writeMessage(deviceMessage, OperationType.CLIENT_MESSAGE);
+ }
+
+ /**
+ * Writes the given message to the write characteristic of this stream.
+ *
+ * This method will handle the chunking of messages based on the max write size. If it is
+ * a handshake message, the message recipient should be {@code null} and it cannot be
+ * encrypted.
+ *
+ * @param deviceMessage The data object contains recipient, isPayloadEncrypted and message.
+ * @param operationType The {@link OperationType} of this message.
+ */
+ void writeMessage(@NonNull DeviceMessage deviceMessage, OperationType operationType) {
+ logd(TAG, "Writing message to device: " + mDevice.getAddress() + ".");
+ BleDeviceMessage.Builder builder = BleDeviceMessage.newBuilder()
+ .setOperation(operationType)
+ .setIsPayloadEncrypted(deviceMessage.isMessageEncrypted())
+ .setPayload(ByteString.copyFrom(deviceMessage.getMessage()));
+
+ UUID recipient = deviceMessage.getRecipient();
+ if (recipient != null) {
+ builder.setRecipient(ByteString.copyFrom(ByteUtils.uuidToBytes(recipient)));
+ }
+
+ BleDeviceMessage bleDeviceMessage = builder.build();
+ byte[] rawBytes = bleDeviceMessage.toByteArray();
+ List<BlePacket> blePackets;
+ try {
+ blePackets = BlePacketFactory.makeBlePackets(rawBytes, mMessageIdGenerator.next(),
+ mMaxWriteSize);
+ } catch (BlePacketFactoryException e) {
+ loge(TAG, "Error while creating message packets.", e);
+ return;
+ }
+ mPacketQueue.addAll(blePackets);
+ writeNextMessageInQueue();
+ }
+
+ private void writeNextMessageInQueue() {
+ mHandler.postDelayed(() -> {
+ if (mPacketQueue.isEmpty()) {
+ logd(TAG, "No more packets to send.");
+ return;
+ }
+ if (mIsSendingInProgress.get()) {
+ logd(TAG, "Unable to send packet at this time.");
+ return;
+ }
+
+ mIsSendingInProgress.set(true);
+ BlePacket packet = mPacketQueue.remove();
+ logd(TAG, "Writing packet " + packet.getPacketNumber() + " of "
+ + packet.getTotalPackets() + " for " + packet.getMessageId() + ".");
+ mWriteCharacteristic.setValue(packet.toByteArray());
+ mBlePeripheralManager.notifyCharacteristicChanged(mDevice, mWriteCharacteristic,
+ /* confirm = */ false);
+ }, mThrottleDelay.get());
+ }
+
+ private void onCharacteristicRead(@NonNull BluetoothDevice device) {
+ if (!mDevice.equals(device)) {
+ logw(TAG, "Received a read notification from a device (" + device.getAddress()
+ + ") that is not the expected device (" + mDevice.getAddress() + ") registered "
+ + "to this stream. Ignoring.");
+ return;
+ }
+
+ logd(TAG, "Releasing lock on characteristic.");
+ mIsSendingInProgress.set(false);
+ writeNextMessageInQueue();
+ }
+
+ private void onCharacteristicWrite(@NonNull BluetoothDevice device,
+ @NonNull BluetoothGattCharacteristic characteristic, @NonNull byte[] value) {
+ logd(TAG, "Received a message from a device (" + device.getAddress() + ").");
+ if (!mDevice.equals(device)) {
+ logw(TAG, "Received a message from a device (" + device.getAddress() + ") that is not "
+ + "the expected device (" + mDevice.getAddress() + ") registered to this "
+ + "stream. Ignoring.");
+ return;
+ }
+
+ if (!characteristic.getUuid().equals(mReadCharacteristic.getUuid())) {
+ logw(TAG, "Received a write to a characteristic (" + characteristic.getUuid() + ") that"
+ + " is not the expected UUID (" + mReadCharacteristic.getUuid() + "). "
+ + "Ignoring.");
+ return;
+ }
+
+ if (!mIsVersionExchanged.get()) {
+ processVersionExchange(device, value);
+ return;
+ }
+
+ BlePacket packet;
+ try {
+ packet = BlePacket.parseFrom(value);
+ } catch (InvalidProtocolBufferException e) {
+ loge(TAG, "Can not parse Ble packet from client.", e);
+ if (mMessageReceivedErrorListener != null) {
+ mMessageReceivedErrorListener.onMessageReceivedError(e);
+ }
+ return;
+ }
+ processPacket(packet);
+ }
+
+ private void processVersionExchange(@NonNull BluetoothDevice device, @NonNull byte[] value) {
+ BleVersionExchange versionExchange;
+ try {
+ versionExchange = BleVersionExchange.parseFrom(value);
+ } catch (InvalidProtocolBufferException e) {
+ loge(TAG, "Could not parse version exchange message", e);
+ if (mMessageReceivedErrorListener != null) {
+ mMessageReceivedErrorListener.onMessageReceivedError(e);
+ }
+ return;
+ }
+ int minMessagingVersion = versionExchange.getMinSupportedMessagingVersion();
+ int maxMessagingVersion = versionExchange.getMaxSupportedMessagingVersion();
+ int minSecurityVersion = versionExchange.getMinSupportedSecurityVersion();
+ int maxSecurityVersion = versionExchange.getMaxSupportedSecurityVersion();
+ if (minMessagingVersion > MESSAGING_VERSION || maxMessagingVersion < MESSAGING_VERSION
+ || minSecurityVersion > SECURITY_VERSION || maxSecurityVersion < SECURITY_VERSION) {
+ loge(TAG, "Unsupported message version for min " + minMessagingVersion + " and max "
+ + maxMessagingVersion + " or security version for " + minSecurityVersion
+ + " and max " + maxSecurityVersion + ".");
+ if (mMessageReceivedErrorListener != null) {
+ mMessageReceivedErrorListener.onMessageReceivedError(
+ new IllegalStateException("Unsupported version."));
+ }
+ return;
+ }
+
+ BleVersionExchange headunitVersion = BleVersionExchange.newBuilder()
+ .setMinSupportedMessagingVersion(MESSAGING_VERSION)
+ .setMaxSupportedMessagingVersion(MESSAGING_VERSION)
+ .setMinSupportedSecurityVersion(SECURITY_VERSION)
+ .setMaxSupportedSecurityVersion(SECURITY_VERSION)
+ .build();
+ mWriteCharacteristic.setValue(headunitVersion.toByteArray());
+ mBlePeripheralManager.notifyCharacteristicChanged(device, mWriteCharacteristic,
+ /* confirm = */ false);
+ mIsVersionExchanged.set(true);
+ logd(TAG, "Sent supported version to the phone.");
+ }
+
+ @VisibleForTesting
+ void processPacket(@NonNull BlePacket packet) {
+ // Messages are coming in. Need to throttle outgoing messages to allow outgoing
+ // notifications to make it to the device.
+ mThrottleDelay.set(THROTTLE_WAIT_MS);
+
+ int messageId = packet.getMessageId();
+ ByteArrayOutputStream currentPayloadStream =
+ mPendingData.getOrDefault(messageId, new ByteArrayOutputStream());
+ mPendingData.putIfAbsent(messageId, currentPayloadStream);
+
+ byte[] payload = packet.getPayload().toByteArray();
+ try {
+ currentPayloadStream.write(payload);
+ } catch (IOException e) {
+ loge(TAG, "Error writing packet to stream.", e);
+ if (mMessageReceivedErrorListener != null) {
+ mMessageReceivedErrorListener.onMessageReceivedError(e);
+ }
+ return;
+ }
+ logd(TAG, "Parsed packet " + packet.getPacketNumber() + " of "
+ + packet.getTotalPackets() + " for message " + messageId + ". Writing "
+ + payload.length + ".");
+
+ if (packet.getPacketNumber() != packet.getTotalPackets()) {
+ return;
+ }
+
+ byte[] messageBytes = currentPayloadStream.toByteArray();
+ mPendingData.remove(messageId);
+
+ // All message packets received. Resetting throttle back to default until next message
+ // started.
+ mThrottleDelay.set(THROTTLE_DEFAULT_MS);
+
+ logd(TAG, "Received complete device message " + messageId + " of " + messageBytes.length
+ + " bytes.");
+ BleDeviceMessage message;
+ try {
+ message = BleDeviceMessage.parseFrom(messageBytes);
+ } catch (InvalidProtocolBufferException e) {
+ loge(TAG, "Cannot parse device message from client.", e);
+ if (mMessageReceivedErrorListener != null) {
+ mMessageReceivedErrorListener.onMessageReceivedError(e);
+ }
+ return;
+ }
+
+ DeviceMessage deviceMessage = new DeviceMessage(
+ ByteUtils.bytesToUUID(message.getRecipient().toByteArray()),
+ message.getIsPayloadEncrypted(), message.getPayload().toByteArray());
+ if (mMessageReceivedListener != null) {
+ mMessageReceivedListener.onMessageReceived(deviceMessage, message.getOperation());
+ }
+ }
+
+ /** The maximum amount of bytes that can be written over BLE. */
+ void setMaxWriteSize(int maxWriteSize) {
+ mMaxWriteSize = maxWriteSize;
+ }
+
+ /**
+ * Set the given listener to be notified when a new message was received from the
+ * client. If listener is {@code null}, clear.
+ */
+ void setMessageReceivedListener(@Nullable MessageReceivedListener listener) {
+ mMessageReceivedListener = listener;
+ }
+
+ /**
+ * Set the given listener to be notified when there was an error during receiving
+ * message from the client. If listener is {@code null}, clear.
+ */
+ void setMessageReceivedErrorListener(
+ @Nullable MessageReceivedErrorListener listener) {
+ mMessageReceivedErrorListener = listener;
+ }
+
+ /**
+ * Listener to be invoked when a complete message is received from the client.
+ */
+ interface MessageReceivedListener {
+
+ /**
+ * Called when a complete message is received from the client.
+ *
+ * @param deviceMessage The message received from the client.
+ * @param operationType The {@link OperationType} of the received message.
+ */
+ void onMessageReceived(@NonNull DeviceMessage deviceMessage, OperationType operationType);
+ }
+
+ /**
+ * Listener to be invoked when there was an error during receiving message from the client.
+ */
+ interface MessageReceivedErrorListener {
+ /**
+ * Called when there was an error during receiving message from the client.
+ *
+ * @param exception The error.
+ */
+ void onMessageReceivedError(@NonNull Exception exception);
+ }
+
+ /** A generator of unique IDs for messages. */
+ private static class MessageIdGenerator {
+ private final AtomicInteger mMessageId = new AtomicInteger(0);
+
+ int next() {
+ int current = mMessageId.getAndIncrement();
+ mMessageId.compareAndSet(Integer.MAX_VALUE, 0);
+ return current;
+ }
+ }
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/ble/BlePacketFactory.java b/connected-device-lib/src/com/android/car/connecteddevice/ble/BlePacketFactory.java
new file mode 100644
index 0000000..a0d0bb1
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/ble/BlePacketFactory.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright (C) 2019 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.car.connecteddevice.ble;
+
+import static com.android.car.connecteddevice.util.SafeLog.loge;
+
+import com.android.car.connecteddevice.BleStreamProtos.BlePacketProto.BlePacket;
+import com.android.car.protobuf.ByteString;
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Factory for creating {@link BlePacket} protos.
+ */
+class BlePacketFactory {
+ private static final String TAG = "BlePacketFactory";
+
+ /**
+ * The size in bytes of a {@code fixed32} field in the proto.
+ */
+ private static final int FIXED_32_SIZE = 4;
+
+ /**
+ * The bytes needed to encode the field number in the proto.
+ *
+ * <p>Since the {@link BlePacket} only has 4 fields, it will only take 1 additional byte to
+ * encode.
+ */
+ private static final int FIELD_NUMBER_ENCODING_SIZE = 1;
+
+ /**
+ * The size in bytes of field {@code packet_number}. The proto field is a {@code fixed32}.
+ */
+ private static final int PACKET_NUMBER_ENCODING_SIZE =
+ FIXED_32_SIZE + FIELD_NUMBER_ENCODING_SIZE;
+
+ /**
+ * Split given data if necessary to fit within the given {@code maxSize}.
+ *
+ * @param payload The payload to potentially split across multiple {@link BlePacket}s.
+ * @param messageId The unique id for identifying message.
+ * @param maxSize The maximum size of each chunk.
+ * @return A list of {@link BlePacket}s.
+ * @throws BlePacketFactoryException if an error occurred during the splitting of data.
+ */
+ static List<BlePacket> makeBlePackets(byte[] payload, int messageId, int maxSize)
+ throws BlePacketFactoryException {
+ List<BlePacket> blePackets = new ArrayList<>();
+ int payloadSize = payload.length;
+ int totalPackets = getTotalPacketNumber(messageId, payloadSize, maxSize);
+ int maxPayloadSize = maxSize
+ - getPacketHeaderSize(totalPackets, messageId, Math.min(payloadSize, maxSize));
+
+ int start = 0;
+ int end = Math.min(payloadSize, maxPayloadSize);
+ for (int packetNum = 1; packetNum <= totalPackets; packetNum++) {
+ blePackets.add(BlePacket.newBuilder()
+ .setPacketNumber(packetNum)
+ .setTotalPackets(totalPackets)
+ .setMessageId(messageId)
+ .setPayload(ByteString.copyFrom(Arrays.copyOfRange(payload, start, end)))
+ .build());
+ start = end;
+ end = Math.min(start + maxPayloadSize, payloadSize);
+ }
+ return blePackets;
+ }
+
+ /**
+ * Compute the header size for the {@link BlePacket} proto in bytes. This method assumes that
+ * the proto contains a payload.
+ */
+ @VisibleForTesting
+ static int getPacketHeaderSize(int totalPackets, int messageId, int payloadSize) {
+ return FIXED_32_SIZE + FIELD_NUMBER_ENCODING_SIZE
+ + getEncodedSize(totalPackets) + FIELD_NUMBER_ENCODING_SIZE
+ + getEncodedSize(messageId) + FIELD_NUMBER_ENCODING_SIZE
+ + getEncodedSize(payloadSize) + FIELD_NUMBER_ENCODING_SIZE;
+ }
+
+ /**
+ * Compute the total packets required to encode a payload of the given size.
+ */
+ @VisibleForTesting
+ static int getTotalPacketNumber(int messageId, int payloadSize, int maxSize)
+ throws BlePacketFactoryException {
+ int headerSizeWithoutTotalPackets = FIXED_32_SIZE + FIELD_NUMBER_ENCODING_SIZE
+ + getEncodedSize(messageId) + FIELD_NUMBER_ENCODING_SIZE
+ + getEncodedSize(Math.min(payloadSize, maxSize)) + FIELD_NUMBER_ENCODING_SIZE;
+
+ for (int value = 1; value <= PACKET_NUMBER_ENCODING_SIZE; value++) {
+ int packetHeaderSize = headerSizeWithoutTotalPackets + value
+ + FIELD_NUMBER_ENCODING_SIZE;
+ int maxPayloadSize = maxSize - packetHeaderSize;
+ if (maxPayloadSize < 0) {
+ throw new BlePacketFactoryException("Packet header size too large.");
+ }
+ int totalPackets = (int) Math.ceil(payloadSize / (double) maxPayloadSize);
+ if (getEncodedSize(totalPackets) == value) {
+ return totalPackets;
+ }
+ }
+
+ loge(TAG, "Cannot get valid total packet number for message: messageId: "
+ + messageId + ", payloadSize: " + payloadSize + ", maxSize: " + maxSize);
+ throw new BlePacketFactoryException("No valid total packet number.");
+ }
+
+ /**
+ * This method implements Protocol Buffers encoding algorithm.
+ *
+ * <p>Computes the number of bytes that would be needed to store a 32-bit variant.
+ *
+ * @param value the data that need to be encoded
+ * @return the size of the encoded data
+ * @see <a href="https://developers.google.com/protocol-buffers/docs/encoding#varints">
+ * Protocol Buffers Encoding</a>
+ */
+ private static int getEncodedSize(int value) {
+ if (value < 0) {
+ return 10;
+ }
+ if ((value & (~0 << 7)) == 0) {
+ return 1;
+ }
+ if ((value & (~0 << 14)) == 0) {
+ return 2;
+ }
+ if ((value & (~0 << 21)) == 0) {
+ return 3;
+ }
+ if ((value & (~0 << 28)) == 0) {
+ return 4;
+ }
+ return 5;
+ }
+
+ private BlePacketFactory() {}
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/ble/BlePacketFactoryException.java b/connected-device-lib/src/com/android/car/connecteddevice/ble/BlePacketFactoryException.java
new file mode 100644
index 0000000..690ce28
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/ble/BlePacketFactoryException.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2019 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.car.connecteddevice.ble;
+
+/**
+ * Exception for signaling {@link BlePacketFactory} errors.
+ */
+class BlePacketFactoryException extends Exception {
+ BlePacketFactoryException(String message) {
+ super(message);
+ }
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/ble/BlePeripheralManager.java b/connected-device-lib/src/com/android/car/connecteddevice/ble/BlePeripheralManager.java
new file mode 100644
index 0000000..6a064d5
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/ble/BlePeripheralManager.java
@@ -0,0 +1,535 @@
+/*
+ * Copyright (C) 2019 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.car.connecteddevice.ble;
+
+import static android.bluetooth.BluetoothProfile.GATT_SERVER;
+
+import static com.android.car.connecteddevice.util.SafeLog.logd;
+import static com.android.car.connecteddevice.util.SafeLog.loge;
+import static com.android.car.connecteddevice.util.SafeLog.logw;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCallback;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattServer;
+import android.bluetooth.BluetoothGattServerCallback;
+import android.bluetooth.BluetoothGattService;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.le.AdvertiseCallback;
+import android.bluetooth.le.AdvertiseData;
+import android.bluetooth.le.AdvertiseSettings;
+import android.bluetooth.le.BluetoothLeAdvertiser;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Handler;
+
+import com.android.car.connecteddevice.util.ByteUtils;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.CopyOnWriteArraySet;
+
+/**
+ * A generic class that manages BLE peripheral operations like start/stop advertising, notifying
+ * connects/disconnects and reading/writing values to GATT characteristics.
+ */
+// TODO(b/123248433) This could move to a separate comms library.
+public class BlePeripheralManager {
+ private static final String TAG = "BlePeripheralManager";
+
+ private static final int BLE_RETRY_LIMIT = 5;
+ private static final int BLE_RETRY_INTERVAL_MS = 1000;
+
+ private static final int GATT_SERVER_RETRY_LIMIT = 20;
+ private static final int GATT_SERVER_RETRY_DELAY_MS = 200;
+
+ // https://www.bluetooth.com/specifications/gatt/viewer?attributeXmlFile=org.bluetooth
+ // .service.generic_access.xml
+ private static final UUID GENERIC_ACCESS_PROFILE_UUID =
+ UUID.fromString("00001800-0000-1000-8000-00805f9b34fb");
+ // https://www.bluetooth.com/specifications/gatt/viewer?attributeXmlFile=org.bluetooth
+ // .characteristic.gap.device_name.xml
+ private static final UUID DEVICE_NAME_UUID =
+ UUID.fromString("00002a00-0000-1000-8000-00805f9b34fb");
+
+ private final Handler mHandler;
+
+ private final Context mContext;
+ private final Set<Callback> mCallbacks = new CopyOnWriteArraySet<>();
+ private final Set<OnCharacteristicWriteListener> mWriteListeners = new HashSet<>();
+ private final Set<OnCharacteristicReadListener> mReadListeners = new HashSet<>();
+
+ private int mMtuSize = 20;
+
+ private BluetoothManager mBluetoothManager;
+ private BluetoothLeAdvertiser mAdvertiser;
+ private BluetoothGattServer mGattServer;
+ private BluetoothGatt mBluetoothGatt;
+ private int mAdvertiserStartCount;
+ private int mGattServerRetryStartCount;
+ private BluetoothGattService mBluetoothGattService;
+ private AdvertiseCallback mAdvertiseCallback;
+ private AdvertiseData mAdvertiseData;
+
+ public BlePeripheralManager(Context context) {
+ mContext = context;
+ mHandler = new Handler(mContext.getMainLooper());
+ }
+
+ /**
+ * Registers the given callback to be notified of various events within the {@link
+ * BlePeripheralManager}.
+ *
+ * @param callback The callback to be notified.
+ */
+ void registerCallback(@NonNull Callback callback) {
+ mCallbacks.add(callback);
+ }
+
+ /**
+ * Unregisters a previously registered callback.
+ *
+ * @param callback The callback to unregister.
+ */
+ void unregisterCallback(@NonNull Callback callback) {
+ mCallbacks.remove(callback);
+ }
+
+ /**
+ * Adds a listener to be notified of a write to characteristics.
+ *
+ * @param listener The listener to invoke.
+ */
+ void addOnCharacteristicWriteListener(@NonNull OnCharacteristicWriteListener listener) {
+ mWriteListeners.add(listener);
+ }
+
+ /**
+ * Removes the given listener from being notified of characteristic writes.
+ *
+ * @param listener The listener to remove.
+ */
+ void removeOnCharacteristicWriteListener(@NonNull OnCharacteristicWriteListener listener) {
+ mWriteListeners.remove(listener);
+ }
+
+ /**
+ * Adds a listener to be notified of reads to characteristics.
+ *
+ * @param listener The listener to invoke.
+ */
+ void addOnCharacteristicReadListener(@NonNull OnCharacteristicReadListener listener) {
+ mReadListeners.add(listener);
+ }
+
+ /**
+ * Removes the given listener from being notified of characteristic reads.
+ *
+ * @param listener The listener to remove.
+ */
+ void removeOnCharacteristicReadistener(@NonNull OnCharacteristicReadListener listener) {
+ mReadListeners.remove(listener);
+ }
+
+ /**
+ * Returns the current MTU size.
+ *
+ * @return The size of the MTU in bytes.
+ */
+ int getMtuSize() {
+ return mMtuSize;
+ }
+
+ /**
+ * Starts the GATT server with the given {@link BluetoothGattService} and begins advertising.
+ *
+ * <p>It is possible that BLE service is still in TURNING_ON state when this method is invoked.
+ * Therefore, several retries will be made to ensure advertising is started.
+ *
+ * @param service {@link BluetoothGattService} that will be discovered by clients
+ * @param data {@link AdvertiseData} data to advertise
+ * @param advertiseCallback {@link AdvertiseCallback} callback for advertiser
+ */
+ void startAdvertising(
+ BluetoothGattService service, AdvertiseData data, AdvertiseCallback advertiseCallback) {
+ logd(TAG, "startAdvertising: " + service.getUuid());
+ if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) {
+ loge(TAG, "Attempted start advertising, but system does not support BLE. Ignoring.");
+ return;
+ }
+ // Clears previous session before starting advertising.
+ cleanup();
+ mBluetoothGattService = service;
+ mAdvertiseCallback = advertiseCallback;
+ mAdvertiseData = data;
+ mGattServerRetryStartCount = 0;
+ mBluetoothManager = (BluetoothManager) mContext.getSystemService(Context.BLUETOOTH_SERVICE);
+ mGattServer = mBluetoothManager.openGattServer(mContext, mGattServerCallback);
+ openGattServer();
+ }
+
+ /**
+ * Stops the GATT server from advertising.
+ *
+ * @param advertiseCallback The callback that is associated with the advertisement.
+ */
+ void stopAdvertising(AdvertiseCallback advertiseCallback) {
+ if (mAdvertiser != null) {
+ logd(TAG, "Stop advertising.");
+ mAdvertiser.stopAdvertising(advertiseCallback);
+ }
+ }
+
+ /**
+ * Notifies the characteristic change via {@link BluetoothGattServer}
+ */
+ void notifyCharacteristicChanged(
+ @NonNull BluetoothDevice device,
+ @NonNull BluetoothGattCharacteristic characteristic,
+ boolean confirm) {
+ if (mGattServer == null) {
+ return;
+ }
+
+ if (!mGattServer.notifyCharacteristicChanged(device, characteristic, confirm)) {
+ loge(TAG, "notifyCharacteristicChanged failed");
+ }
+ }
+
+ /**
+ * Connect the Gatt server of the remote device to retrieve device name.
+ */
+ final void retrieveDeviceName(BluetoothDevice device) {
+ mBluetoothGatt = device.connectGatt(mContext, false, mGattCallback);
+ }
+
+ /**
+ * Returns the currently opened GATT server within this manager.
+ *
+ * @return An opened GATT server or {@code null} if none have been opened.
+ */
+ @Nullable
+ BluetoothGattServer getGattServer() {
+ return mGattServer;
+ }
+
+ /**
+ * Cleans up the BLE GATT server state.
+ */
+ void cleanup() {
+ // Stops the advertiser, scanner and GATT server. This needs to be done to avoid leaks.
+ if (mAdvertiser != null) {
+ mAdvertiser.stopAdvertising(mAdvertiseCallback);
+ }
+ // Clears all registered listeners. IHU only supports single connection in peripheral role.
+ mReadListeners.clear();
+ mWriteListeners.clear();
+ mAdvertiser = null;
+
+ if (mGattServer == null) {
+ return;
+ }
+ mGattServer.clearServices();
+ try {
+ for (BluetoothDevice d : mBluetoothManager.getConnectedDevices(GATT_SERVER)) {
+ logd(TAG, "Disconnecting from " + d.getAddress());
+ mGattServer.cancelConnection(d);
+ }
+ } catch (UnsupportedOperationException e) {
+ loge(TAG, "Error getting connected devices", e);
+ } finally {
+ stopGattServer();
+ }
+ }
+
+ /**
+ * Close the GATT Server
+ */
+ void stopGattServer() {
+ if (mGattServer == null) {
+ return;
+ }
+ logd(TAG, "stopGattServer");
+ if (mBluetoothGatt != null) {
+ mGattServer.cancelConnection(mBluetoothGatt.getDevice());
+ mBluetoothGatt.disconnect();
+ }
+ mGattServer.clearServices();
+ mGattServer.close();
+ mGattServer = null;
+ }
+
+ private void openGattServer() {
+ // Only open one Gatt server.
+ if (mGattServer != null) {
+ logd(TAG, "Gatt Server created, retry count: " + mGattServerRetryStartCount);
+ mGattServer.clearServices();
+ mGattServer.addService(mBluetoothGattService);
+ AdvertiseSettings settings =
+ new AdvertiseSettings.Builder()
+ .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
+ .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
+ .setConnectable(true)
+ .build();
+ mAdvertiserStartCount = 0;
+ startAdvertisingInternally(settings, mAdvertiseData, mAdvertiseCallback);
+ mGattServerRetryStartCount = 0;
+ } else if (mGattServerRetryStartCount < GATT_SERVER_RETRY_LIMIT) {
+ mGattServer = mBluetoothManager.openGattServer(mContext, mGattServerCallback);
+ mGattServerRetryStartCount++;
+ mHandler.postDelayed(() -> openGattServer(), GATT_SERVER_RETRY_DELAY_MS);
+ } else {
+ loge(TAG, "Gatt server not created - exceeded retry limit.");
+ }
+ }
+
+ private void startAdvertisingInternally(
+ AdvertiseSettings settings, AdvertiseData data, AdvertiseCallback advertiseCallback) {
+ if (BluetoothAdapter.getDefaultAdapter() != null) {
+ mAdvertiser = BluetoothAdapter.getDefaultAdapter().getBluetoothLeAdvertiser();
+ }
+
+ if (mAdvertiser != null) {
+ logd(TAG, "Advertiser created, retry count: " + mAdvertiserStartCount);
+ mAdvertiser.startAdvertising(settings, data, advertiseCallback);
+ mAdvertiserStartCount = 0;
+ } else if (mAdvertiserStartCount < BLE_RETRY_LIMIT) {
+ mHandler.postDelayed(
+ () -> startAdvertisingInternally(settings, data, advertiseCallback),
+ BLE_RETRY_INTERVAL_MS);
+ mAdvertiserStartCount += 1;
+ } else {
+ loge(
+ TAG,
+ "Cannot start BLE Advertisement. Advertise Retry count: "
+ + mAdvertiserStartCount,
+ null);
+ }
+ }
+
+ private final BluetoothGattServerCallback mGattServerCallback =
+ new BluetoothGattServerCallback() {
+ @Override
+ public void onConnectionStateChange(BluetoothDevice device, int status,
+ int newState) {
+ logd(TAG, "BLE Connection State Change: " + newState);
+ switch (newState) {
+ case BluetoothProfile.STATE_CONNECTED:
+ for (Callback callback : mCallbacks) {
+ callback.onRemoteDeviceConnected(device);
+ }
+ break;
+ case BluetoothProfile.STATE_DISCONNECTED:
+ for (Callback callback : mCallbacks) {
+ callback.onRemoteDeviceDisconnected(device);
+ }
+ break;
+ default:
+ logw(TAG, "Connection state not connecting or disconnecting; ignoring: "
+ + newState);
+ }
+ }
+
+ @Override
+ public void onServiceAdded(int status, BluetoothGattService service) {
+ logd(TAG, "Service added status: " + status + " uuid: " + service.getUuid());
+ }
+
+ @Override
+ public void onCharacteristicWriteRequest(
+ BluetoothDevice device,
+ int requestId,
+ BluetoothGattCharacteristic characteristic,
+ boolean preparedWrite,
+ boolean responseNeeded,
+ int offset,
+ byte[] value) {
+ mGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset,
+ value);
+ for (OnCharacteristicWriteListener listener : mWriteListeners) {
+ listener.onCharacteristicWrite(device, characteristic, value);
+ }
+ }
+
+ @Override
+ public void onDescriptorWriteRequest(
+ BluetoothDevice device,
+ int requestId,
+ BluetoothGattDescriptor descriptor,
+ boolean preparedWrite,
+ boolean responseNeeded,
+ int offset,
+ byte[] value) {
+ logd(TAG, "Write request for descriptor: "
+ + descriptor.getUuid()
+ + "; value: "
+ + ByteUtils.byteArrayToHexString(value));
+
+ mGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset,
+ value);
+ }
+
+ @Override
+ public void onMtuChanged(BluetoothDevice device, int mtu) {
+ logd(TAG, "onMtuChanged: " + mtu + " for device " + device.getAddress());
+
+ mMtuSize = mtu;
+
+ for (Callback callback : mCallbacks) {
+ callback.onMtuSizeChanged(mtu);
+ }
+ }
+
+ @Override
+ public void onNotificationSent(BluetoothDevice device, int status) {
+ super.onNotificationSent(device, status);
+ if (status == BluetoothGatt.GATT_SUCCESS) {
+ logd(TAG, "Notification sent successfully. Device: " + device.getAddress()
+ + ", Status: " + status + ". Notifying all listeners.");
+ for (OnCharacteristicReadListener listener : mReadListeners) {
+ listener.onCharacteristicRead(device);
+ }
+ } else {
+ loge(TAG, "Notification failed. Device: " + device + ", Status: "
+ + status);
+ }
+ }
+ };
+
+ private final BluetoothGattCallback mGattCallback =
+ new BluetoothGattCallback() {
+ @Override
+ public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
+ logd(TAG, "Gatt Connection State Change: " + newState);
+ switch (newState) {
+ case BluetoothProfile.STATE_CONNECTED:
+ logd(TAG, "Gatt connected");
+ mBluetoothGatt.discoverServices();
+ break;
+ case BluetoothProfile.STATE_DISCONNECTED:
+ logd(TAG, "Gatt Disconnected");
+ break;
+ default:
+ logd(TAG, "Connection state not connecting or disconnecting; ignoring: "
+ + newState);
+ }
+ }
+
+ @Override
+ public void onServicesDiscovered(BluetoothGatt gatt, int status) {
+ logd(TAG, "Gatt Services Discovered");
+ BluetoothGattService gapService = mBluetoothGatt.getService(
+ GENERIC_ACCESS_PROFILE_UUID);
+ if (gapService == null) {
+ loge(TAG, "Generic Access Service is null.");
+ return;
+ }
+ BluetoothGattCharacteristic deviceNameCharacteristic =
+ gapService.getCharacteristic(DEVICE_NAME_UUID);
+ if (deviceNameCharacteristic == null) {
+ loge(TAG, "Device Name Characteristic is null.");
+ return;
+ }
+ mBluetoothGatt.readCharacteristic(deviceNameCharacteristic);
+ }
+
+ @Override
+ public void onCharacteristicRead(
+ BluetoothGatt gatt, BluetoothGattCharacteristic characteristic,
+ int status) {
+ if (status == BluetoothGatt.GATT_SUCCESS) {
+ String deviceName = characteristic.getStringValue(0);
+ logd(TAG, "BLE Device Name: " + deviceName);
+
+ for (Callback callback : mCallbacks) {
+ callback.onDeviceNameRetrieved(deviceName);
+ }
+ } else {
+ loge(TAG, "Reading GAP Failed: " + status);
+ }
+ }
+ };
+
+ /**
+ * Interface to be notified of various events within the {@link BlePeripheralManager}.
+ */
+ interface Callback {
+ /**
+ * Triggered when the name of the remote device is retrieved.
+ *
+ * @param deviceName Name of the remote device.
+ */
+ void onDeviceNameRetrieved(@Nullable String deviceName);
+
+ /**
+ * Triggered if a remote client has requested to change the MTU for a given connection.
+ *
+ * @param size The new MTU size.
+ */
+ void onMtuSizeChanged(int size);
+
+ /**
+ * Triggered when a device (GATT client) connected.
+ *
+ * @param device Remote device that connected on BLE.
+ */
+ void onRemoteDeviceConnected(@NonNull BluetoothDevice device);
+
+ /**
+ * Triggered when a device (GATT client) disconnected.
+ *
+ * @param device Remote device that disconnected on BLE.
+ */
+ void onRemoteDeviceDisconnected(@NonNull BluetoothDevice device);
+ }
+
+ /**
+ * An interface for classes that wish to be notified of writes to a characteristic.
+ */
+ interface OnCharacteristicWriteListener {
+ /**
+ * Triggered when this BlePeripheralManager receives a write request from a remote device.
+ *
+ * @param device The bluetooth device that holds the characteristic.
+ * @param characteristic The characteristic that was written to.
+ * @param value The value that was written.
+ */
+ void onCharacteristicWrite(
+ @NonNull BluetoothDevice device,
+ @NonNull BluetoothGattCharacteristic characteristic,
+ @NonNull byte[] value);
+ }
+
+ /**
+ * An interface for classes that wish to be notified of reads on a characteristic.
+ */
+ interface OnCharacteristicReadListener {
+ /**
+ * Triggered when this BlePeripheralManager receives a read request from a remote device.
+ *
+ * @param device The bluetooth device that holds the characteristic.
+ */
+ void onCharacteristicRead(@NonNull BluetoothDevice device);
+ }
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/ble/CarBleCentralManager.java b/connected-device-lib/src/com/android/car/connecteddevice/ble/CarBleCentralManager.java
new file mode 100644
index 0000000..00b8113
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/ble/CarBleCentralManager.java
@@ -0,0 +1,332 @@
+/*
+ * 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.android.car.connecteddevice.ble;
+
+import static com.android.car.connecteddevice.util.SafeLog.logd;
+import static com.android.car.connecteddevice.util.SafeLog.loge;
+import static com.android.car.connecteddevice.util.SafeLog.logw;
+import static com.android.car.connecteddevice.util.ScanDataAnalyzer.containsUuidsInOverflow;
+
+import android.annotation.NonNull;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCallback;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattService;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.le.ScanCallback;
+import android.bluetooth.le.ScanRecord;
+import android.bluetooth.le.ScanResult;
+import android.bluetooth.le.ScanSettings;
+import android.content.Context;
+import android.os.ParcelUuid;
+
+import com.android.car.connecteddevice.storage.ConnectedDeviceStorage;
+
+import java.math.BigInteger;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CopyOnWriteArraySet;
+
+/**
+ * Communication manager for a car that maintains continuous connections with all devices in the car
+ * for the duration of a drive.
+ */
+public class CarBleCentralManager extends CarBleManager {
+
+ private static final String TAG = "CarBleCentralManager";
+
+ // system/bt/internal_include/bt_target.h#GATT_MAX_PHY_CHANNEL
+ private static final int MAX_CONNECTIONS = 7;
+
+ private static final UUID CHARACTERISTIC_CONFIG =
+ UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
+
+ private final ScanSettings mScanSettings = new ScanSettings.Builder()
+ .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
+ .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
+ .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
+ .build();
+
+ private final CopyOnWriteArraySet<BleDevice> mIgnoredDevices = new CopyOnWriteArraySet<>();
+
+ private final Context mContext;
+
+ private final BleCentralManager mBleCentralManager;
+
+ private final UUID mServiceUuid;
+
+ private final UUID mWriteCharacteristicUuid;
+
+ private final UUID mReadCharacteristicUuid;
+
+ private final BigInteger mParsedBgServiceBitMask;
+
+ /**
+ * Create a new manager.
+ *
+ * @param context The caller's [Context].
+ * @param bleCentralManager [BleCentralManager] for establishing connections.
+ * @param connectedDeviceStorage Shared [ConnectedDeviceStorage] for companion features.
+ * @param serviceUuid [UUID] of peripheral's service.
+ * @param bgServiceMask iOS overflow bit mask for service UUID.
+ * @param writeCharacteristicUuid [UUID] of characteristic the car will write to.
+ * @param readCharacteristicUuid [UUID] of characteristic the device will write to.
+ */
+ public CarBleCentralManager(
+ @NonNull Context context,
+ @NonNull BleCentralManager bleCentralManager,
+ @NonNull ConnectedDeviceStorage connectedDeviceStorage,
+ @NonNull UUID serviceUuid,
+ @NonNull String bgServiceMask,
+ @NonNull UUID writeCharacteristicUuid,
+ @NonNull UUID readCharacteristicUuid) {
+ super(connectedDeviceStorage);
+ mContext = context;
+ mBleCentralManager = bleCentralManager;
+ mServiceUuid = serviceUuid;
+ mWriteCharacteristicUuid = writeCharacteristicUuid;
+ mReadCharacteristicUuid = readCharacteristicUuid;
+ mParsedBgServiceBitMask = new BigInteger(bgServiceMask, 16);
+ }
+
+ @Override
+ public void start() {
+ super.start();
+ mBleCentralManager.startScanning(/* filters = */ null, mScanSettings, mScanCallback);
+ }
+
+ @Override
+ public void stop() {
+ super.stop();
+ mBleCentralManager.stopScanning();
+ }
+
+ private void ignoreDevice(@NonNull BleDevice device) {
+ mIgnoredDevices.add(device);
+ }
+
+ private boolean isDeviceIgnored(@NonNull BluetoothDevice device) {
+ for (BleDevice bleDevice : mIgnoredDevices) {
+ if (device.equals(bleDevice.mDevice)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean shouldAttemptConnection(@NonNull ScanResult result) {
+ // Ignore any results that are not connectable.
+ if (!result.isConnectable()) {
+ return false;
+ }
+
+ // Do not attempt to connect if we have already hit our max. This should rarely happen
+ // and is protecting against a race condition of scanning stopped and new results coming in.
+ if (getConnectedDevicesCount() >= MAX_CONNECTIONS) {
+ return false;
+ }
+
+ BluetoothDevice device = result.getDevice();
+
+ // Do not connect if device has already been ignored.
+ if (isDeviceIgnored(device)) {
+ return false;
+ }
+
+ // Check if already attempting to connect to this device.
+ if (getConnectedDevice(device) != null) {
+ return false;
+ }
+
+
+ // Ignore any device without a scan record.
+ ScanRecord scanRecord = result.getScanRecord();
+ if (scanRecord == null) {
+ return false;
+ }
+
+ // Connect to any device that is advertising our service UUID.
+ List<ParcelUuid> serviceUuids = scanRecord.getServiceUuids();
+ if (serviceUuids != null) {
+ for (ParcelUuid serviceUuid : serviceUuids) {
+ if (serviceUuid.getUuid().equals(mServiceUuid)) {
+ return true;
+ }
+ }
+ }
+ if (containsUuidsInOverflow(scanRecord.getBytes(), mParsedBgServiceBitMask)) {
+ return true;
+ }
+
+ // Can safely ignore devices advertising unrecognized service uuids.
+ if (serviceUuids != null && !serviceUuids.isEmpty()) {
+ return false;
+ }
+
+ // TODO(b/139066293): Current implementation quickly exhausts connections resulting in
+ // greatly reduced performance for connecting to devices we know we want to connect to.
+ // Return true once fixed.
+ return false;
+ }
+
+ private void startDeviceConnection(@NonNull BluetoothDevice device) {
+ BluetoothGatt gatt = device.connectGatt(mContext, /* autoConnect = */ false,
+ mConnectionCallback, BluetoothDevice.TRANSPORT_LE);
+ if (gatt == null) {
+ return;
+ }
+
+ BleDevice bleDevice = new BleDevice(device, gatt);
+ bleDevice.mState = BleDeviceState.CONNECTING;
+ addConnectedDevice(bleDevice);
+
+ // Stop scanning if we have reached the maximum number of connections.
+ if (getConnectedDevicesCount() >= MAX_CONNECTIONS) {
+ mBleCentralManager.stopScanning();
+ }
+ }
+
+ private void deviceConnected(@NonNull BleDevice device) {
+ if (device.mGatt == null) {
+ loge(TAG, "Device connected with null gatt. Disconnecting.");
+ deviceDisconnected(device, BluetoothProfile.STATE_DISCONNECTED);
+ return;
+ }
+ device.mState = BleDeviceState.PENDING_VERIFICATION;
+ device.mGatt.discoverServices();
+ logd(TAG, "New device connected: " + device.mGatt.getDevice().getAddress()
+ + ". Active connections: " + getConnectedDevicesCount() + ".");
+ }
+
+ private void deviceDisconnected(@NonNull BleDevice device, int status) {
+ removeConnectedDevice(device);
+ if (device.mGatt != null) {
+ device.mGatt.close();
+ }
+ if (device.mDeviceId != null) {
+ mCallbacks.invoke(callback -> callback.onDeviceDisconnected(device.mDeviceId));
+ }
+ logd(TAG, "Device with id " + device.mDeviceId + " disconnected with state " + status
+ + ". Remaining active connections: " + getConnectedDevicesCount() + ".");
+ }
+
+ private final ScanCallback mScanCallback = new ScanCallback() {
+ @Override
+ public void onScanResult(int callbackType, ScanResult result) {
+ super.onScanResult(callbackType, result);
+ if (shouldAttemptConnection(result)) {
+ startDeviceConnection(result.getDevice());
+ }
+ }
+
+ @Override
+ public void onScanFailed(int errorCode) {
+ super.onScanFailed(errorCode);
+ loge(TAG, "BLE scanning failed with error code: " + errorCode);
+ }
+ };
+
+ private final BluetoothGattCallback mConnectionCallback = new BluetoothGattCallback() {
+ @Override
+ public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
+ super.onConnectionStateChange(gatt, status, newState);
+ if (gatt == null) {
+ logw(TAG, "Null gatt passed to onConnectionStateChange. Ignoring.");
+ return;
+ }
+
+ BleDevice connectedDevice = getConnectedDevice(gatt);
+ if (connectedDevice == null) {
+ return;
+ }
+
+ switch (newState) {
+ case BluetoothProfile.STATE_CONNECTED:
+ deviceConnected(connectedDevice);
+ break;
+ case BluetoothProfile.STATE_DISCONNECTED:
+ deviceDisconnected(connectedDevice, status);
+ break;
+ default:
+ logd(TAG, "Connection state changed. New state: " + newState + " status: "
+ + status);
+ }
+ }
+
+ @Override
+ public void onServicesDiscovered(BluetoothGatt gatt, int status) {
+ super.onServicesDiscovered(gatt, status);
+ if (gatt == null) {
+ logw(TAG, "Null gatt passed to onServicesDiscovered. Ignoring.");
+ return;
+ }
+
+ BleDevice connectedDevice = getConnectedDevice(gatt);
+ if (connectedDevice == null) {
+ return;
+ }
+ BluetoothGattService service = gatt.getService(mServiceUuid);
+ if (service == null) {
+ ignoreDevice(connectedDevice);
+ gatt.disconnect();
+ return;
+ }
+
+ connectedDevice.mState = BleDeviceState.CONNECTED;
+ BluetoothGattCharacteristic writeCharacteristic =
+ service.getCharacteristic(mWriteCharacteristicUuid);
+ BluetoothGattCharacteristic readCharacteristic =
+ service.getCharacteristic(mReadCharacteristicUuid);
+ if (writeCharacteristic == null || readCharacteristic == null) {
+ logw(TAG, "Unable to find expected characteristics on peripheral.");
+ gatt.disconnect();
+ return;
+ }
+
+ // Turn on notifications for read characteristic.
+ BluetoothGattDescriptor descriptor =
+ readCharacteristic.getDescriptor(CHARACTERISTIC_CONFIG);
+ descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
+ if (!gatt.writeDescriptor(descriptor)) {
+ loge(TAG, "Write descriptor to read characteristic failed.");
+ gatt.disconnect();
+ return;
+ }
+
+ if (!gatt.setCharacteristicNotification(readCharacteristic, /* enable = */ true)) {
+ loge(TAG, "Set notifications to read characteristic failed.");
+ gatt.disconnect();
+ return;
+ }
+
+ logd(TAG, "Service and characteristics successfully discovered.");
+ }
+
+ @Override
+ public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor,
+ int status) {
+ super.onDescriptorWrite(gatt, descriptor, status);
+ if (gatt == null) {
+ logw(TAG, "Null gatt passed to onDescriptorWrite. Ignoring.");
+ return;
+ }
+ // TODO(b/141312136): Create SecureBleChannel and assign to connectedDevice.
+ }
+ };
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/ble/CarBleManager.java b/connected-device-lib/src/com/android/car/connecteddevice/ble/CarBleManager.java
new file mode 100644
index 0000000..d649d10
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/ble/CarBleManager.java
@@ -0,0 +1,251 @@
+/*
+ * Copyright (C) 2019 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.car.connecteddevice.ble;
+
+import static com.android.car.connecteddevice.util.SafeLog.logd;
+import static com.android.car.connecteddevice.util.SafeLog.logw;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+
+import com.android.car.connecteddevice.storage.ConnectedDeviceStorage;
+import com.android.car.connecteddevice.util.ThreadSafeCallbacks;
+
+import java.util.concurrent.CopyOnWriteArraySet;
+import java.util.concurrent.Executor;
+
+/**
+ * Generic BLE manager for a car that keeps track of connected devices and their associated
+ * callbacks.
+ */
+public abstract class CarBleManager {
+
+ private static final String TAG = "CarBleManager";
+
+ final ConnectedDeviceStorage mStorage;
+
+ final CopyOnWriteArraySet<BleDevice> mConnectedDevices = new CopyOnWriteArraySet<>();
+
+ final ThreadSafeCallbacks<Callback> mCallbacks = new ThreadSafeCallbacks<>();
+
+ protected CarBleManager(@NonNull ConnectedDeviceStorage connectedDeviceStorage) {
+ mStorage = connectedDeviceStorage;
+ }
+
+ /**
+ * Initialize and start the manager.
+ */
+ public void start() {
+ }
+
+ /**
+ * Stop the manager and clean up.
+ */
+ public void stop() {
+ for (BleDevice device : mConnectedDevices) {
+ if (device.mGatt != null) {
+ device.mGatt.close();
+ }
+ }
+ mConnectedDevices.clear();
+ mCallbacks.clear();
+ }
+
+ /**
+ * Register a {@link Callback} to be notified on the {@link Executor}.
+ */
+ public void registerCallback(@NonNull Callback callback, @NonNull Executor executor) {
+ mCallbacks.add(callback, executor);
+ }
+
+ /**
+ * Unregister a callback.
+ *
+ * @param callback The {@link Callback} to unregister.
+ */
+ public void unregisterCallback(@NonNull Callback callback) {
+ mCallbacks.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) {
+ BleDevice 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 BleDevice}.
+ * @param message {@link DeviceMessage} to send.
+ */
+ public void sendMessage(@NonNull BleDevice device, @NonNull DeviceMessage message) {
+ String deviceId = device.mDeviceId;
+ if (deviceId == null) {
+ deviceId = "Unidentified device";
+ }
+
+ logd(TAG, "Writing " + message.getMessage().length + " bytes to " + deviceId + ".");
+
+
+ if (message.isMessageEncrypted()) {
+ device.mSecureChannel.sendEncryptedMessage(message);
+ } else {
+ device.mSecureChannel.getStream().writeMessage(message);
+ }
+ }
+
+ /**
+ * Get the {@link BleDevice} with matching {@link BluetoothGatt} if available. Returns
+ * {@code null} if no matches are found.
+ */
+ @Nullable
+ BleDevice getConnectedDevice(@NonNull BluetoothGatt gatt) {
+ for (BleDevice device : mConnectedDevices) {
+ if (device.mGatt == gatt) {
+ return device;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the {@link BleDevice} with matching {@link BluetoothDevice} if available. Returns
+ * {@code null} if no matches are found.
+ */
+ @Nullable
+ BleDevice getConnectedDevice(@NonNull BluetoothDevice device) {
+ for (BleDevice connectedDevice : mConnectedDevices) {
+ if (device.equals(connectedDevice.mDevice)) {
+ return connectedDevice;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the {@link BleDevice} with matching device id if available. Returns {@code null} if
+ * no matches are found.
+ */
+ @Nullable
+ BleDevice getConnectedDevice(@NonNull String deviceId) {
+ for (BleDevice device : mConnectedDevices) {
+ if (deviceId.equals(device.mDeviceId)) {
+ return device;
+ }
+ }
+
+ return null;
+ }
+
+ /** Add the {@link BleDevice} that has connected. */
+ void addConnectedDevice(@NonNull BleDevice device) {
+ mConnectedDevices.add(device);
+ }
+
+ /** Return the number of devices currently connected. */
+ int getConnectedDevicesCount() {
+ return mConnectedDevices.size();
+ }
+
+ /** Remove [@link BleDevice} that has been disconnected. */
+ void removeConnectedDevice(@NonNull BleDevice device) {
+ mConnectedDevices.remove(device);
+ }
+
+ /** State for a connected device. */
+ enum BleDeviceState {
+ CONNECTING,
+ PENDING_VERIFICATION,
+ CONNECTED,
+ UNKNOWN
+ }
+
+ /**
+ * Container class to hold information about a connected device.
+ */
+ static class BleDevice {
+
+ BluetoothDevice mDevice;
+ BluetoothGatt mGatt;
+ BleDeviceState mState;
+ String mDeviceId;
+ SecureBleChannel mSecureChannel;
+
+ BleDevice(@NonNull BluetoothDevice device, @Nullable BluetoothGatt gatt) {
+ mDevice = device;
+ mGatt = gatt;
+ mState = BleDeviceState.UNKNOWN;
+ }
+ }
+
+ /**
+ * Callback for triggered events from {@link CarBleManager}.
+ */
+ 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);
+ }
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/ble/CarBlePeripheralManager.java b/connected-device-lib/src/com/android/car/connecteddevice/ble/CarBlePeripheralManager.java
new file mode 100644
index 0000000..4745fdb
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/ble/CarBlePeripheralManager.java
@@ -0,0 +1,491 @@
+/*
+ * Copyright (C) 2019 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.car.connecteddevice.ble;
+
+import static com.android.car.connecteddevice.util.SafeLog.logd;
+import static com.android.car.connecteddevice.util.SafeLog.loge;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattService;
+import android.bluetooth.le.AdvertiseCallback;
+import android.bluetooth.le.AdvertiseData;
+import android.bluetooth.le.AdvertiseSettings;
+import android.car.encryptionrunner.EncryptionRunnerFactory;
+import android.os.ParcelUuid;
+
+import com.android.car.connecteddevice.AssociationCallback;
+import com.android.car.connecteddevice.model.AssociatedDevice;
+import com.android.car.connecteddevice.storage.ConnectedDeviceStorage;
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.UUID;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Communication manager that allows for targeted connections to a specific device in the car.
+ */
+public class CarBlePeripheralManager extends CarBleManager {
+
+ private static final String TAG = "CarBlePeripheralManager";
+
+ // Attribute protocol bytes attached to message. Available write size is MTU size minus att
+ // bytes.
+ private static final int ATT_PROTOCOL_BYTES = 3;
+
+ // Arbitrary delay time for a retry of association advertising if bluetooth adapter name change
+ // fails.
+ private static final long ASSOCIATE_ADVERTISING_DELAY_MS = 10L;
+
+ private static final UUID CLIENT_CHARACTERISTIC_CONFIG =
+ UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
+
+ private final BluetoothGattDescriptor mDescriptor =
+ new BluetoothGattDescriptor(CLIENT_CHARACTERISTIC_CONFIG,
+ BluetoothGattDescriptor.PERMISSION_READ
+ | BluetoothGattDescriptor.PERMISSION_WRITE);
+
+ private final ScheduledExecutorService mScheduler =
+ Executors.newSingleThreadScheduledExecutor();
+
+ private final BlePeripheralManager mBlePeripheralManager;
+
+ private final UUID mAssociationServiceUuid;
+
+ private final BluetoothGattCharacteristic mWriteCharacteristic;
+
+ private final BluetoothGattCharacteristic mReadCharacteristic;
+
+ // BLE default is 23, minus 3 bytes for ATT_PROTOCOL.
+ private int mWriteSize = 20;
+
+ private String mOriginalBluetoothName;
+
+ private String mClientDeviceName;
+
+ private String mClientDeviceAddress;
+
+ private AssociationCallback mAssociationCallback;
+
+ private AdvertiseCallback mAdvertiseCallback;
+
+ /**
+ * Initialize a new instance of manager.
+ *
+ * @param blePeripheralManager {@link BlePeripheralManager} for establishing connection.
+ * @param connectedDeviceStorage Shared {@link ConnectedDeviceStorage} for companion features.
+ * @param associationServiceUuid {@link UUID} of association service.
+ * @param writeCharacteristicUuid {@link UUID} of characteristic the car will write to.
+ * @param readCharacteristicUuid {@link UUID} of characteristic the device will write to.
+ */
+ public CarBlePeripheralManager(@NonNull BlePeripheralManager blePeripheralManager,
+ @NonNull ConnectedDeviceStorage connectedDeviceStorage,
+ @NonNull UUID associationServiceUuid, @NonNull UUID writeCharacteristicUuid,
+ @NonNull UUID readCharacteristicUuid) {
+ super(connectedDeviceStorage);
+ mBlePeripheralManager = blePeripheralManager;
+ mAssociationServiceUuid = associationServiceUuid;
+ mDescriptor.setValue(BluetoothGattDescriptor.ENABLE_INDICATION_VALUE);
+ mWriteCharacteristic = new BluetoothGattCharacteristic(writeCharacteristicUuid,
+ BluetoothGattCharacteristic.PROPERTY_NOTIFY,
+ BluetoothGattCharacteristic.PROPERTY_READ);
+ mReadCharacteristic = new BluetoothGattCharacteristic(readCharacteristicUuid,
+ BluetoothGattCharacteristic.PROPERTY_WRITE
+ | BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE,
+ BluetoothGattCharacteristic.PERMISSION_WRITE);
+ mReadCharacteristic.addDescriptor(mDescriptor);
+ }
+
+ @Override
+ public void stop() {
+ super.stop();
+ reset();
+ }
+
+ private void reset() {
+ resetBluetoothAdapterName();
+ mClientDeviceAddress = null;
+ mClientDeviceName = null;
+ mAssociationCallback = null;
+ mBlePeripheralManager.cleanup();
+ mConnectedDevices.clear();
+ }
+
+ /** Connect to device with provided id. */
+ public void connectToDevice(@NonNull UUID deviceId) {
+ for (BleDevice device : mConnectedDevices) {
+ if (UUID.fromString(device.mDeviceId).equals(deviceId)) {
+ // Already connected to this device. Ignore requests to connect again.
+ return;
+ }
+ }
+
+ // Clear any previous session before starting a new one.
+ reset();
+
+ mAdvertiseCallback = new AdvertiseCallback() {
+ @Override
+ public void onStartSuccess(AdvertiseSettings settingsInEffect) {
+ super.onStartSuccess(settingsInEffect);
+ logd(TAG, "Successfully started advertising for device " + deviceId + ".");
+ }
+ };
+ mBlePeripheralManager.registerCallback(mReconnectPeripheralCallback);
+ startAdvertising(deviceId, mAdvertiseCallback, /* includeDeviceName = */ false);
+ }
+
+ @Nullable
+ private BleDevice getConnectedDevice() {
+ if (mConnectedDevices.isEmpty()) {
+ return null;
+ }
+ return mConnectedDevices.iterator().next();
+ }
+
+ /** Start the association with a new device */
+ public void startAssociation(@NonNull String nameForAssociation,
+ @NonNull AssociationCallback callback) {
+ BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+ if (adapter == null) {
+ loge(TAG, "Bluetooth is unavailable on this device. Unable to start associating.");
+ return;
+ }
+
+ reset();
+ mAssociationCallback = callback;
+ if (mOriginalBluetoothName == null) {
+ mOriginalBluetoothName = adapter.getName();
+ }
+ adapter.setName(nameForAssociation);
+ logd(TAG, "Changing bluetooth adapter name from " + mOriginalBluetoothName + " to "
+ + nameForAssociation + ".");
+ mBlePeripheralManager.registerCallback(mAssociationPeripheralCallback);
+ mAdvertiseCallback = new AdvertiseCallback() {
+ @Override
+ public void onStartSuccess(AdvertiseSettings settingsInEffect) {
+ super.onStartSuccess(settingsInEffect);
+ callback.onAssociationStartSuccess(nameForAssociation);
+ logd(TAG, "Successfully started advertising for association.");
+ }
+
+ @Override
+ public void onStartFailure(int errorCode) {
+ super.onStartFailure(errorCode);
+ callback.onAssociationStartFailure();
+ logd(TAG, "Failed to start advertising for association. Error code: " + errorCode);
+ }
+ };
+ attemptAssociationAdvertising(nameForAssociation, callback);
+ }
+
+ /** Stop the association with any device. */
+ public void stopAssociation(@NonNull AssociationCallback callback) {
+ if (!isAssociating() || callback != mAssociationCallback) {
+ return;
+ }
+ reset();
+ }
+
+ private void attemptAssociationAdvertising(@NonNull String adapterName,
+ @NonNull AssociationCallback callback) {
+ if (mOriginalBluetoothName != null
+ && adapterName.equals(BluetoothAdapter.getDefaultAdapter().getName())) {
+ startAdvertising(mAssociationServiceUuid, mAdvertiseCallback,
+ /* includeDeviceName = */ true);
+ return;
+ }
+
+ ScheduledFuture future = mScheduler.schedule(
+ () -> attemptAssociationAdvertising(adapterName, callback),
+ ASSOCIATE_ADVERTISING_DELAY_MS, TimeUnit.MILLISECONDS);
+ if (future.isCancelled()) {
+ // Association failed to start.
+ callback.onAssociationStartFailure();
+ return;
+ }
+ logd(TAG, "Adapter name change has not taken affect prior to advertising attempt. Trying "
+ + "again in " + ASSOCIATE_ADVERTISING_DELAY_MS + " milliseconds.");
+ }
+
+ private void startAdvertising(@NonNull UUID serviceUuid, @NonNull AdvertiseCallback callback,
+ boolean includeDeviceName) {
+ BluetoothGattService gattService = new BluetoothGattService(serviceUuid,
+ BluetoothGattService.SERVICE_TYPE_PRIMARY);
+ gattService.addCharacteristic(mWriteCharacteristic);
+ gattService.addCharacteristic(mReadCharacteristic);
+
+ AdvertiseData advertiseData = new AdvertiseData.Builder()
+ .setIncludeDeviceName(includeDeviceName)
+ .addServiceUuid(new ParcelUuid(serviceUuid))
+ .build();
+ mBlePeripheralManager.startAdvertising(gattService, advertiseData, callback);
+ }
+
+ /** 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;
+ }
+
+ SecureBleChannel secureChannel = getConnectedDevice().mSecureChannel;
+ if (secureChannel == null) {
+ disconnectWithError("Null SecureBleChannel found for the current connected device "
+ + "when out-of-band confirmation received.");
+ return;
+ }
+
+ secureChannel.notifyOutOfBandAccepted();
+ }
+
+ @VisibleForTesting
+ @Nullable
+ SecureBleChannel getConnectedDeviceChannel() {
+ BleDevice connectedDevice = getConnectedDevice();
+ if (connectedDevice == null) {
+ return null;
+ }
+
+ return connectedDevice.mSecureChannel;
+ }
+
+ private void setDeviceId(@NonNull String deviceId) {
+ logd(TAG, "Setting device id: " + deviceId);
+ BleDevice connectedDevice = getConnectedDevice();
+ if (connectedDevice == null) {
+ disconnectWithError("Null connected device found when device id received.");
+ return;
+ }
+
+ connectedDevice.mDeviceId = deviceId;
+ mCallbacks.invoke(callback -> callback.onDeviceConnected(deviceId));
+ }
+
+ private void disconnectWithError(@NonNull String errorMessage) {
+ loge(TAG, errorMessage);
+ reset();
+ }
+
+ private void resetBluetoothAdapterName() {
+ if (mOriginalBluetoothName == null) {
+ return;
+ }
+ logd(TAG, "Changing bluetooth adapter name back to " + mOriginalBluetoothName + ".");
+ BluetoothAdapter.getDefaultAdapter().setName(mOriginalBluetoothName);
+ mOriginalBluetoothName = null;
+ }
+
+ private void addConnectedDevice(BluetoothDevice device, boolean isReconnect) {
+ mBlePeripheralManager.stopAdvertising(mAdvertiseCallback);
+ mClientDeviceAddress = device.getAddress();
+ mClientDeviceName = device.getName();
+ if (mClientDeviceName == null) {
+ logd(TAG, "Device connected, but name is null; issuing request to retrieve device "
+ + "name.");
+ mBlePeripheralManager.retrieveDeviceName(device);
+ }
+
+ BleDeviceMessageStream secureStream = new BleDeviceMessageStream(mBlePeripheralManager,
+ device, mWriteCharacteristic, mReadCharacteristic);
+ secureStream.setMaxWriteSize(mWriteSize);
+ SecureBleChannel secureChannel = new SecureBleChannel(secureStream, mStorage, isReconnect,
+ EncryptionRunnerFactory.newRunner());
+ secureChannel.registerCallback(mSecureChannelCallback);
+ BleDevice bleDevice = new BleDevice(device, /* gatt = */ null);
+ bleDevice.mSecureChannel = secureChannel;
+ addConnectedDevice(bleDevice);
+ }
+
+ private void setMtuSize(int mtuSize) {
+ mWriteSize = mtuSize - ATT_PROTOCOL_BYTES;
+ BleDevice connectedDevice = getConnectedDevice();
+ if (connectedDevice != null
+ && connectedDevice.mSecureChannel != null
+ && connectedDevice.mSecureChannel.getStream() != null) {
+ connectedDevice.mSecureChannel.getStream().setMaxWriteSize(mWriteSize);
+ }
+ }
+
+ private boolean isAssociating() {
+ return mAssociationCallback != null;
+ }
+
+ private final BlePeripheralManager.Callback mReconnectPeripheralCallback =
+ new BlePeripheralManager.Callback() {
+
+ @Override
+ public void onDeviceNameRetrieved(String deviceName) {
+ // Ignored.
+ }
+
+ @Override
+ public void onMtuSizeChanged(int size) {
+ setMtuSize(size);
+ }
+
+ @Override
+ public void onRemoteDeviceConnected(BluetoothDevice device) {
+ addConnectedDevice(device, /* isReconnect= */ true);
+ }
+
+ @Override
+ public void onRemoteDeviceDisconnected(BluetoothDevice device) {
+ String deviceId = null;
+ BleDevice connectedDevice = getConnectedDevice(device);
+ if (connectedDevice != null) {
+ deviceId = connectedDevice.mDeviceId;
+ }
+ final String finalDeviceId = deviceId;
+ if (finalDeviceId != null) {
+ mCallbacks.invoke(callback -> callback.onDeviceDisconnected(finalDeviceId));
+ }
+ reset();
+ }
+ };
+
+ private final BlePeripheralManager.Callback mAssociationPeripheralCallback =
+ new BlePeripheralManager.Callback() {
+ @Override
+ public void onDeviceNameRetrieved(String deviceName) {
+ if (deviceName == null) {
+ return;
+ }
+ mClientDeviceName = deviceName;
+ BleDevice connectedDevice = getConnectedDevice();
+ if (connectedDevice == null || connectedDevice.mDeviceId == null) {
+ return;
+ }
+ mStorage.updateAssociatedDeviceName(connectedDevice.mDeviceId, deviceName);
+ }
+
+ @Override
+ public void onMtuSizeChanged(int size) {
+ setMtuSize(size);
+ }
+
+ @Override
+ public void onRemoteDeviceConnected(BluetoothDevice device) {
+ resetBluetoothAdapterName();
+ addConnectedDevice(device, /* isReconnect = */ false);
+ BleDevice connectedDevice = getConnectedDevice();
+ if (connectedDevice == null || connectedDevice.mSecureChannel == null) {
+ return;
+ }
+ connectedDevice.mSecureChannel.setShowVerificationCodeListener(
+ code -> {
+ if (!isAssociating()) {
+ loge(TAG, "No valid callback for association.");
+ return;
+ }
+ mAssociationCallback.onVerificationCodeAvailable(code);
+ });
+ }
+
+ @Override
+ public void onRemoteDeviceDisconnected(BluetoothDevice device) {
+ BleDevice connectedDevice = getConnectedDevice(device);
+ if (connectedDevice != null && connectedDevice.mDeviceId != null) {
+ mCallbacks.invoke(callback -> callback.onDeviceDisconnected(
+ connectedDevice.mDeviceId));
+ }
+ reset();
+ }
+ };
+
+ private final SecureBleChannel.Callback mSecureChannelCallback =
+ new SecureBleChannel.Callback() {
+ @Override
+ public void onSecureChannelEstablished() {
+ BleDevice connectedDevice = getConnectedDevice();
+ if (connectedDevice == null || connectedDevice.mDeviceId == null) {
+ disconnectWithError("Null device id found when secure channel "
+ + "established.");
+ return;
+ }
+ String deviceId = connectedDevice.mDeviceId;
+ if (mClientDeviceAddress == 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.");
+ mStorage.addAssociatedDeviceForActiveUser(
+ new AssociatedDevice(deviceId, mClientDeviceAddress,
+ mClientDeviceName));
+ if (mAssociationCallback != null) {
+ mAssociationCallback.onAssociationCompleted(deviceId);
+ mAssociationCallback = null;
+ }
+ }
+ mCallbacks.invoke(callback -> callback.onSecureChannelEstablished(deviceId));
+ }
+
+ @Override
+ public void onEstablishSecureChannelFailure(int error) {
+ BleDevice connectedDevice = getConnectedDevice();
+ if (connectedDevice == null || connectedDevice.mDeviceId == null) {
+ disconnectWithError("Null device id found when secure channel failed to "
+ + "establish.");
+ return;
+ }
+ String deviceId = connectedDevice.mDeviceId;
+ mCallbacks.invoke(callback -> callback.onSecureChannelError(deviceId));
+
+ if (isAssociating()) {
+ mAssociationCallback.onAssociationError(error);
+ disconnectWithError("Error while establishing secure connection.");
+ }
+ }
+
+ @Override
+ public void onMessageReceived(DeviceMessage deviceMessage) {
+ BleDevice connectedDevice = getConnectedDevice();
+ if (connectedDevice == null || connectedDevice.mDeviceId == null) {
+ disconnectWithError("Null device id found when message received.");
+ return;
+ }
+
+ logd(TAG, "Received new message from " + connectedDevice.mDeviceId
+ + " with " + deviceMessage.getMessage().length + " in its payload. "
+ + "Notifying " + mCallbacks.size() + " callbacks.");
+ mCallbacks.invoke(
+ callback ->callback.onMessageReceived(connectedDevice.mDeviceId,
+ deviceMessage));
+ }
+
+ @Override
+ public void onMessageReceivedError(Exception exception) {
+ // TODO(b/143879960) Extend the message error from here to continue up the
+ // chain.
+ }
+
+ @Override
+ public void onDeviceIdReceived(String deviceId) {
+ setDeviceId(deviceId);
+ }
+ };
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/ble/DeviceMessage.java b/connected-device-lib/src/com/android/car/connecteddevice/ble/DeviceMessage.java
new file mode 100644
index 0000000..9d3ac48
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/ble/DeviceMessage.java
@@ -0,0 +1,87 @@
+/*
+ * 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.android.car.connecteddevice.ble;
+
+import static com.android.car.connecteddevice.BleStreamProtos.BleDeviceMessageProto.BleDeviceMessage;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.UUID;
+
+/** Holds the needed data from a {@link BleDeviceMessage}. */
+public class DeviceMessage {
+
+ private static final String TAG = "DeviceMessage";
+
+ private final UUID mRecipient;
+
+ private final boolean mIsMessageEncrypted;
+
+ private byte[] mMessage;
+
+ public DeviceMessage(@Nullable UUID recipient, boolean isMessageEncrypted,
+ @NonNull byte[] message) {
+ mRecipient = recipient;
+ mIsMessageEncrypted = isMessageEncrypted;
+ mMessage = message;
+ }
+
+ /** Returns the recipient for this message. {@code null} if no recipient set. */
+ @Nullable
+ public UUID getRecipient() {
+ return mRecipient;
+ }
+
+ /** Returns whether this message is encrypted. */
+ public boolean isMessageEncrypted() {
+ return mIsMessageEncrypted;
+ }
+
+ /** Returns the message payload. */
+ @Nullable
+ public byte[] getMessage() {
+ return mMessage;
+ }
+
+ /** Set the message payload. */
+ public void setMessage(@NonNull byte[] message) {
+ mMessage = message;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this) {
+ return true;
+ }
+ if (!(obj instanceof DeviceMessage)) {
+ return false;
+ }
+ DeviceMessage deviceMessage = (DeviceMessage) obj;
+ return Objects.equals(mRecipient, deviceMessage.mRecipient)
+ && mIsMessageEncrypted == deviceMessage.mIsMessageEncrypted
+ && Arrays.equals(mMessage, deviceMessage.mMessage);
+ }
+
+ @Override
+ public int hashCode() {
+ return 31 * Objects.hash(mRecipient, mIsMessageEncrypted)
+ + Arrays.hashCode(mMessage);
+ }
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/ble/SecureBleChannel.java b/connected-device-lib/src/com/android/car/connecteddevice/ble/SecureBleChannel.java
new file mode 100644
index 0000000..a821186
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/ble/SecureBleChannel.java
@@ -0,0 +1,477 @@
+/*
+ * 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.android.car.connecteddevice.ble;
+
+import static com.android.car.connecteddevice.util.SafeLog.logd;
+import static com.android.car.connecteddevice.util.SafeLog.loge;
+
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.car.encryptionrunner.EncryptionRunner;
+import android.car.encryptionrunner.EncryptionRunnerFactory;
+import android.car.encryptionrunner.HandshakeException;
+import android.car.encryptionrunner.HandshakeMessage;
+import android.car.encryptionrunner.HandshakeMessage.HandshakeState;
+import android.car.encryptionrunner.Key;
+
+import com.android.car.connecteddevice.BleStreamProtos.BleOperationProto.OperationType;
+import com.android.car.connecteddevice.storage.ConnectedDeviceStorage;
+import com.android.car.connecteddevice.util.ByteUtils;
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.security.SignatureException;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+/**
+ * Establishes a secure channel with {@link EncryptionRunner} over {@link BleDeviceMessageStream} as
+ * server side, sends and receives messages securely after the secure channel has been established.
+ */
+class SecureBleChannel {
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(prefix = { "CHANNEL_ERROR" },
+ value = {
+ CHANNEL_ERROR_INVALID_HANDSHAKE,
+ CHANNEL_ERROR_INVALID_MSG,
+ CHANNEL_ERROR_INVALID_DEVICE_ID,
+ CHANNEL_ERROR_INVALID_VERIFICATION,
+ CHANNEL_ERROR_INVALID_STATE,
+ CHANNEL_ERROR_INVALID_ENCRYPTION_KEY,
+ CHANNEL_ERROR_STORAGE_ERROR
+ }
+ )
+ @interface ChannelError { }
+
+ /** Indicates an error during a Handshake of EncryptionRunner. */
+ static final int CHANNEL_ERROR_INVALID_HANDSHAKE = 0;
+ /** Received an invalid handshake message or has an invalid handshake message to send. */
+ static final int CHANNEL_ERROR_INVALID_MSG = 1;
+ /** Unable to retrieve a valid id. */
+ static final int CHANNEL_ERROR_INVALID_DEVICE_ID = 2;
+ /** Unable to get verification code or there's a error during pin verification. */
+ static final int CHANNEL_ERROR_INVALID_VERIFICATION = 3;
+ /** Encountered an unexpected handshake state. */
+ static final int CHANNEL_ERROR_INVALID_STATE = 4;
+ /** Failed to get a valid previous/new encryption key.*/
+ static final int CHANNEL_ERROR_INVALID_ENCRYPTION_KEY = 5;
+ /** Failed to save the encryption key*/
+ static final int CHANNEL_ERROR_STORAGE_ERROR = 6;
+
+ @VisibleForTesting
+ static final byte[] CONFIRMATION_SIGNAL = "True".getBytes();
+
+ private static final String TAG = "SecureBleChannel";
+
+ private final BleDeviceMessageStream mStream;
+
+ private final ConnectedDeviceStorage mStorage;
+
+ private final boolean mIsReconnect;
+
+ private final EncryptionRunner mEncryptionRunner;
+
+ private final AtomicReference<Key> mEncryptionKey = new AtomicReference<>();
+
+ private @HandshakeState int mState = HandshakeState.UNKNOWN;
+
+ private String mDeviceId;
+
+ private Callback mCallback;
+
+ private ShowVerificationCodeListener mShowVerificationCodeListener;
+
+ SecureBleChannel(@NonNull BleDeviceMessageStream stream,
+ @NonNull ConnectedDeviceStorage storage) {
+ this(stream, storage, /* isReconnect = */ true, EncryptionRunnerFactory.newRunner());
+ }
+
+ SecureBleChannel(@NonNull BleDeviceMessageStream stream,
+ @NonNull ConnectedDeviceStorage storage, boolean isReconnect,
+ @NonNull EncryptionRunner encryptionRunner) {
+ mStream = stream;
+ mStorage = storage;
+ mIsReconnect = isReconnect;
+ mEncryptionRunner = encryptionRunner;
+ mEncryptionRunner.setIsReconnect(isReconnect);
+ mStream.setMessageReceivedListener(mStreamListener);
+ }
+
+ private void processHandshake(@NonNull byte[] message) throws HandshakeException {
+ switch (mState) {
+ case HandshakeState.UNKNOWN:
+ processHandshakeUnknown(message);
+ break;
+ case HandshakeState.IN_PROGRESS:
+ processHandshakeInProgress(message);
+ break;
+ case HandshakeState.RESUMING_SESSION:
+ processHandshakeResumingSession(message);
+ break;
+ default:
+ loge(TAG, "Encountered unexpected handshake state: " + mState + ". Received "
+ + "message: " + ByteUtils.byteArrayToHexString(message) + ".");
+ notifySecureChannelFailure(CHANNEL_ERROR_INVALID_STATE);
+ }
+ }
+
+ private void processHandshakeUnknown(@NonNull byte[] message) throws HandshakeException {
+ if (mDeviceId != null) {
+ logd(TAG, "Responding to handshake init request.");
+ HandshakeMessage handshakeMessage = mEncryptionRunner.respondToInitRequest(message);
+ mState = handshakeMessage.getHandshakeState();
+ sendHandshakeMessage(handshakeMessage.getNextMessage());
+ return;
+ }
+ UUID deviceId = ByteUtils.bytesToUUID(message);
+ if (deviceId == null) {
+ loge(TAG, "Received invalid device id. Ignoring.");
+ return;
+ }
+ mDeviceId = deviceId.toString();
+ if (mIsReconnect && !hasEncryptionKey(mDeviceId)) {
+ loge(TAG, "Attempted to reconnect device but no key found. Aborting secure channel.");
+ notifySecureChannelFailure(CHANNEL_ERROR_INVALID_DEVICE_ID);
+ return;
+ }
+ notifyCallback(callback -> callback.onDeviceIdReceived(mDeviceId));
+ sendUniqueIdToClient();
+ }
+
+ private void processHandshakeInProgress(@NonNull byte[] message) throws HandshakeException {
+ logd(TAG, "Continuing handshake.");
+ HandshakeMessage handshakeMessage = mEncryptionRunner.continueHandshake(message);
+ mState = handshakeMessage.getHandshakeState();
+
+ boolean isValidStateForAssociation = !mIsReconnect
+ && mState == HandshakeState.VERIFICATION_NEEDED;
+ boolean isValidStateForReconnect = mIsReconnect
+ && mState == HandshakeState.RESUMING_SESSION;
+ if (!isValidStateForAssociation && !isValidStateForReconnect) {
+ loge(TAG, "processHandshakeInProgress: Encountered unexpected handshake state: "
+ + mState + ".");
+ notifySecureChannelFailure(CHANNEL_ERROR_INVALID_STATE);
+ return;
+ }
+
+ if (!isValidStateForAssociation) {
+ return;
+ }
+
+ String code = handshakeMessage.getVerificationCode();
+ if (code == null) {
+ loge(TAG, "Unable to get verification code.");
+ notifySecureChannelFailure(CHANNEL_ERROR_INVALID_VERIFICATION);
+ return;
+ }
+
+ if (mShowVerificationCodeListener != null) {
+ logd(TAG, "Showing pairing code: " + code);
+ mShowVerificationCodeListener.showVerificationCode(code);
+ }
+ }
+
+ private void processHandshakeResumingSession(@NonNull byte[] message)
+ throws HandshakeException {
+ logd(TAG, "Start reconnection authentication.");
+ if (mDeviceId == null) {
+ loge(TAG, "processHandshakeResumingSession: Unable to resume session, device id is "
+ + "null.");
+ notifySecureChannelFailure(CHANNEL_ERROR_INVALID_DEVICE_ID);
+ return;
+ }
+
+ byte[] previousKey = mStorage.getEncryptionKey(mDeviceId);
+ if (previousKey == null) {
+ loge(TAG, "Unable to resume session, previous key is null.");
+ notifySecureChannelFailure(CHANNEL_ERROR_INVALID_ENCRYPTION_KEY);
+ return;
+ }
+
+ HandshakeMessage handshakeMessage = mEncryptionRunner.authenticateReconnection(message,
+ previousKey);
+ mState = handshakeMessage.getHandshakeState();
+ if (mState != HandshakeState.FINISHED) {
+ loge(TAG, "Unable to resume session, unexpected next handshake state: " + mState + ".");
+ notifySecureChannelFailure(CHANNEL_ERROR_INVALID_STATE);
+ return;
+ }
+
+ Key newKey = handshakeMessage.getKey();
+ if (newKey == null) {
+ loge(TAG, "Unable to resume session, new key is null.");
+ notifySecureChannelFailure(CHANNEL_ERROR_INVALID_ENCRYPTION_KEY);
+ return;
+ }
+
+ logd(TAG, "Saved new key for reconnection.");
+ mStorage.saveEncryptionKey(mDeviceId, newKey.asBytes());
+ mEncryptionKey.set(newKey);
+ sendServerAuthToClient(handshakeMessage.getNextMessage());
+ notifyCallback(callback -> callback.onSecureChannelEstablished());
+ }
+
+ private void sendUniqueIdToClient() {
+ UUID uniqueId = mStorage.getUniqueId();
+ DeviceMessage deviceMessage = new DeviceMessage(/* recipient = */ null,
+ /* isMessageEncrypted = */ false, ByteUtils.uuidToBytes(uniqueId));
+ logd(TAG, "Sending car's device id of " + uniqueId + " to device.");
+ mStream.writeMessage(deviceMessage, OperationType.ENCRYPTION_HANDSHAKE);
+ }
+
+ private boolean hasEncryptionKey(@NonNull String id) {
+ return mStorage.getEncryptionKey(id) != null;
+ }
+
+ private void sendHandshakeMessage(@Nullable byte[] message) {
+ if (message == null) {
+ loge(TAG, "Unable to send next handshake message, message is null.");
+ notifySecureChannelFailure(CHANNEL_ERROR_INVALID_MSG);
+ return;
+ }
+
+ logd(TAG, "Send handshake message: " + ByteUtils.byteArrayToHexString(message) + ".");
+ DeviceMessage deviceMessage = new DeviceMessage(/* recipient = */ null,
+ /* isMessageEncrypted = */ false, message);
+ mStream.writeMessage(deviceMessage, OperationType.ENCRYPTION_HANDSHAKE);
+ }
+
+ private void sendServerAuthToClient(@Nullable byte[] message) {
+ if (message == null) {
+ loge(TAG, "Unable to send server authentication message to client, message is null.");
+ notifySecureChannelFailure(CHANNEL_ERROR_INVALID_MSG);
+ return;
+ }
+ DeviceMessage deviceMessage = new DeviceMessage(/* recipient = */ null,
+ /* isMessageEncrypted = */ false, message);
+ mStream.writeMessage(deviceMessage, OperationType.ENCRYPTION_HANDSHAKE);
+ }
+
+ /**
+ * Send an encrypted message.
+ * <p>Note: This should be called only after the secure channel has been established.</p>
+ *
+ * @param deviceMessage The {@link DeviceMessage} to encrypt and send.
+ */
+ void sendEncryptedMessage(@NonNull DeviceMessage deviceMessage) throws IllegalStateException {
+ if (!deviceMessage.isMessageEncrypted()) {
+ loge(TAG, "Encryption not required for this message " + deviceMessage + ".");
+ return;
+ }
+ Key key = mEncryptionKey.get();
+ if (key == null) {
+ throw new IllegalStateException("Secure channel has not been established.");
+ }
+
+ byte[] encryptedMessage = key.encryptData(deviceMessage.getMessage());
+ deviceMessage.setMessage(encryptedMessage);
+ mStream.writeMessage(deviceMessage, OperationType.CLIENT_MESSAGE);
+ }
+
+ /**
+ * Called by the client to notify that the user has accepted a pairing code or any out-of-band
+ * confirmation, and send confirmation signals to remote bluetooth device.
+ */
+ void notifyOutOfBandAccepted() {
+ HandshakeMessage message;
+ try {
+ message = mEncryptionRunner.verifyPin();
+ } catch (HandshakeException e) {
+ loge(TAG, "Error during PIN verification", e);
+ notifySecureChannelFailure(CHANNEL_ERROR_INVALID_VERIFICATION);
+ return;
+ }
+ if (message.getHandshakeState() != HandshakeState.FINISHED) {
+ loge(TAG, "Handshake not finished after calling verify PIN. Instead got "
+ + "state: " + message.getHandshakeState() + ".");
+ notifySecureChannelFailure(CHANNEL_ERROR_INVALID_STATE);
+ return;
+ }
+
+ Key localKey = message.getKey();
+ if (localKey == null) {
+ loge(TAG, "Unable to finish association, generated key is null.");
+ notifySecureChannelFailure(CHANNEL_ERROR_INVALID_ENCRYPTION_KEY);
+ return;
+ }
+
+ mState = message.getHandshakeState();
+ mStorage.saveEncryptionKey(mDeviceId, localKey.asBytes());
+ mEncryptionKey.set(localKey);
+ if (mDeviceId == null) {
+ loge(TAG, "Unable to finish association, device id is null.");
+ notifySecureChannelFailure(CHANNEL_ERROR_INVALID_DEVICE_ID);
+ return;
+ }
+ logd(TAG, "Pairing code successfully verified and encryption key saved. Sending "
+ + "confirmation to device.");
+ notifyCallback(Callback::onSecureChannelEstablished);
+ DeviceMessage deviceMessage = new DeviceMessage(/* recipient = */ null,
+ /* isMessageEncrypted = */ false, CONFIRMATION_SIGNAL);
+ mStream.writeMessage(deviceMessage, OperationType.ENCRYPTION_HANDSHAKE);
+ }
+
+ /** Get the BLE stream backing this channel. */
+ @NonNull
+ BleDeviceMessageStream getStream() {
+ return mStream;
+ }
+
+ /**Set the listener that notifies to show verification code. {@code null} to clear.*/
+ void setShowVerificationCodeListener(@Nullable ShowVerificationCodeListener listener) {
+ mShowVerificationCodeListener = listener;
+ }
+
+ @VisibleForTesting
+ @Nullable
+ ShowVerificationCodeListener getShowVerificationCodeListener() {
+ return mShowVerificationCodeListener;
+ }
+
+ /** Register a callback that notifies secure channel events. */
+ void registerCallback(Callback callback) {
+ mCallback = callback;
+ }
+
+ /** Unregister a callback. */
+ void unregisterCallback(Callback callback) {
+ if (callback == mCallback) {
+ mCallback = null;
+ }
+ }
+
+ @VisibleForTesting
+ @Nullable
+ Callback getCallback() {
+ return mCallback;
+ }
+
+ private void notifyCallback(Consumer<Callback> notification) {
+ if (mCallback != null) {
+ notification.accept(mCallback);
+ }
+ }
+
+ private void notifySecureChannelFailure(@ChannelError int error) {
+ loge(TAG, "Secure channel error: " + error);
+ notifyCallback(callback -> callback.onEstablishSecureChannelFailure(error));
+ }
+
+ private final BleDeviceMessageStream.MessageReceivedListener mStreamListener =
+ new BleDeviceMessageStream.MessageReceivedListener() {
+ @Override
+ public void onMessageReceived(DeviceMessage deviceMessage,
+ OperationType operationType) {
+ byte[] message = deviceMessage.getMessage();
+ switch(operationType) {
+ case ENCRYPTION_HANDSHAKE:
+ logd(TAG, "Message received and handed off to handshake.");
+ try {
+ processHandshake(message);
+ } catch (HandshakeException e) {
+ loge(TAG, "Handshake failed.", e);
+ notifyCallback(callback -> callback.onEstablishSecureChannelFailure(
+ CHANNEL_ERROR_INVALID_HANDSHAKE));
+ }
+ break;
+ case CLIENT_MESSAGE:
+ logd(TAG, "Received client message.");
+ if (!deviceMessage.isMessageEncrypted()) {
+ notifyCallback(callback -> callback.onMessageReceived(
+ deviceMessage));
+ return;
+ }
+ Key key = mEncryptionKey.get();
+ if (key == null) {
+ loge(TAG, "Received encrypted message before secure channel has "
+ + "been established.");
+ notifyCallback(callback -> callback.onMessageReceivedError(null));
+ return;
+ }
+ try {
+ byte[] decryptedPayload =
+ key.decryptData(deviceMessage.getMessage());
+ deviceMessage.setMessage(decryptedPayload);
+ notifyCallback(
+ callback -> callback.onMessageReceived(deviceMessage));
+ } catch (SignatureException e) {
+ loge(TAG, "Could not decrypt client credentials.", e);
+ notifyCallback(callback -> callback.onMessageReceivedError(e));
+ }
+ break;
+ default:
+ loge(TAG, "Received unexpected operation type: " + operationType + ".");
+ }
+ }
+ };
+
+ /**
+ * Callbacks that will be invoked during establishing secure channel, sending and receiving
+ * messages securely.
+ */
+ interface Callback {
+ /**
+ * Invoked when secure channel has been established successfully.
+ */
+ void onSecureChannelEstablished();
+
+ /**
+ * Invoked when a {@link ChannelError} has been encountered in attempting to establish
+ * a secure channel.
+ *
+ * @param error The failure indication.
+ */
+ void onEstablishSecureChannelFailure(@SecureBleChannel.ChannelError int error);
+
+ /**
+ * Invoked when a complete message is received securely from the client and decrypted.
+ *
+ * @param deviceMessage The {@link DeviceMessage} with decrypted message.
+ */
+ void onMessageReceived(@NonNull DeviceMessage deviceMessage);
+
+ /**
+ * Invoked when there was an error during a processing or decrypting of a client message.
+ *
+ * @param exception The error.
+ */
+ void onMessageReceivedError(@Nullable Exception exception);
+
+ /**
+ * Invoked when the device id was received from the client.
+ *
+ * @param deviceId The unique device id of client.
+ */
+ void onDeviceIdReceived(@NonNull String deviceId);
+ }
+
+ /**
+ * Listener that will be invoked to display verification code.
+ */
+ interface ShowVerificationCodeListener {
+ /**
+ * Invoke when a verification need to be displayed during device association.
+ *
+ * @param code The verification code to show.
+ */
+ void showVerificationCode(@NonNull String code);
+ }
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/model/AssociatedDevice.java b/connected-device-lib/src/com/android/car/connecteddevice/model/AssociatedDevice.java
new file mode 100644
index 0000000..f23c2ca
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/model/AssociatedDevice.java
@@ -0,0 +1,86 @@
+/*
+ * 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.android.car.connecteddevice.model;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+
+import java.util.Objects;
+
+/**
+ * Contains basic info of an associated device.
+ */
+public class AssociatedDevice {
+
+ private final String mDeviceId;
+
+ private final String mDeviceAddress;
+
+ private final String mDeviceName;
+
+
+ /**
+ * Create a new AssociatedDevice.
+ *
+ * @param deviceId Id of the associated device.
+ * @param deviceAddress Address of the associated device.
+ * @param deviceName Name of the associated device. {@code null} if not known.
+ */
+ public AssociatedDevice(@NonNull String deviceId, @NonNull String deviceAddress,
+ @Nullable String deviceName) {
+ mDeviceId = deviceId;
+ mDeviceAddress = deviceAddress;
+ mDeviceName = deviceName;
+ }
+
+ /** Returns the id for this device. */
+ @NonNull
+ public String getDeviceId() {
+ return mDeviceId;
+ }
+
+ /** Returns the address for this device. */
+ @NonNull
+ public String getDeviceAddress() {
+ return mDeviceAddress;
+ }
+
+ /** Returns the name for this device or {@code null} if not known. */
+ @Nullable
+ public String getDeviceName() {
+ return mDeviceName;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this) {
+ return true;
+ }
+ if (!(obj instanceof AssociatedDevice)) {
+ return false;
+ }
+ AssociatedDevice associatedDevice = (AssociatedDevice) obj;
+ return Objects.equals(mDeviceId, associatedDevice.mDeviceId)
+ && Objects.equals(mDeviceAddress, associatedDevice.mDeviceAddress)
+ && Objects.equals(mDeviceName, associatedDevice.mDeviceName);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(mDeviceId, mDeviceAddress, mDeviceName);
+ }
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/model/ConnectedDevice.java b/connected-device-lib/src/com/android/car/connecteddevice/model/ConnectedDevice.java
new file mode 100644
index 0000000..d65f97d
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/model/ConnectedDevice.java
@@ -0,0 +1,96 @@
+/*
+ * 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.android.car.connecteddevice.model;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+
+import java.util.Objects;
+
+/**
+ * View model representing a connected device.
+ */
+public class ConnectedDevice {
+
+ private final String mDeviceId;
+
+ private final String mDeviceName;
+
+ private final boolean mBelongsToActiveUser;
+
+ private final boolean mHasSecureChannel;
+
+ /**
+ * Create a new connected device.
+ *
+ * @param deviceId Id of the connected device.
+ * @param deviceName Name of the connected device. {@code null} if not known.
+ * @param belongsToActiveUser User associated with this device is currently in the foreground.
+ * @param hasSecureChannel {@code true} if a secure channel is available for this device.
+ */
+ public ConnectedDevice(@NonNull String deviceId, @Nullable String deviceName,
+ boolean belongsToActiveUser, boolean hasSecureChannel) {
+ mDeviceId = deviceId;
+ mDeviceName = deviceName;
+ mBelongsToActiveUser = belongsToActiveUser;
+ mHasSecureChannel = hasSecureChannel;
+ }
+
+ /** Returns the id for this device. */
+ @NonNull
+ public String getDeviceId() {
+ return mDeviceId;
+ }
+
+ /** Returns the name for this device or {@code null} if not known. */
+ @Nullable
+ public String getDeviceName() {
+ return mDeviceName;
+ }
+
+ /**
+ * Returns {@code true} if this device is associated with the user currently in the foreground.
+ */
+ public boolean isAssociatedWithActiveUser() {
+ return mBelongsToActiveUser;
+ }
+
+ /** Returns {@code true} if this device has a secure channel available. */
+ public boolean hasSecureChannel() {
+ return mHasSecureChannel;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this) {
+ return true;
+ }
+ if (!(obj instanceof ConnectedDevice)) {
+ return false;
+ }
+ ConnectedDevice connectedDevice = (ConnectedDevice) obj;
+ return Objects.equals(mDeviceId, connectedDevice.mDeviceId)
+ && Objects.equals(mDeviceName, connectedDevice.mDeviceName)
+ && mBelongsToActiveUser == connectedDevice.mBelongsToActiveUser
+ && mHasSecureChannel == connectedDevice.mHasSecureChannel;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(mDeviceId, mDeviceName, mBelongsToActiveUser, mHasSecureChannel);
+ }
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/storage/AssociatedDeviceDao.java b/connected-device-lib/src/com/android/car/connecteddevice/storage/AssociatedDeviceDao.java
new file mode 100644
index 0000000..c041d58
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/storage/AssociatedDeviceDao.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2019 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.car.connecteddevice.storage;
+
+import androidx.room.Dao;
+import androidx.room.Delete;
+import androidx.room.Insert;
+import androidx.room.OnConflictStrategy;
+import androidx.room.Query;
+
+import java.util.List;
+
+/**
+ * Queries for associated device table.
+ */
+@Dao
+public interface AssociatedDeviceDao {
+
+ /** Get an associated device based on device id. */
+ @Query("SELECT * FROM associated_devices WHERE id LIKE :deviceId LIMIT 1")
+ AssociatedDeviceEntity getAssociatedDevice(String deviceId);
+
+ /** Get all {@link AssociatedDeviceEntity}s associated with a user. */
+ @Query("SELECT * FROM associated_devices WHERE userId LIKE :userId")
+ List<AssociatedDeviceEntity> getAssociatedDevicesForUser(int userId);
+
+ /**
+ * Add a {@link AssociatedDeviceEntity}. Replace if a device already exists with the same
+ * device id.
+ */
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ void addOrReplaceAssociatedDevice(AssociatedDeviceEntity associatedDevice);
+
+ /** Remove a {@link AssociatedDeviceEntity}. */
+ @Delete
+ void removeAssociatedDevice(AssociatedDeviceEntity connectedDevice);
+
+ /** Get the key associated with a device id. */
+ @Query("SELECT * FROM associated_device_keys WHERE id LIKE :deviceId LIMIT 1")
+ AssociatedDeviceKeyEntity getAssociatedDeviceKey(String deviceId);
+
+ /**
+ * Add a {@link AssociatedDeviceKeyEntity}. Replace if a device key already exists with the
+ * same device id.
+ */
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ void addOrReplaceAssociatedDeviceKey(AssociatedDeviceKeyEntity keyEntity);
+
+ /** Remove a {@link AssociatedDeviceKeyEntity}. */
+ @Delete
+ void removeAssociatedDeviceKey(AssociatedDeviceKeyEntity keyEntity);
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/storage/AssociatedDeviceEntity.java b/connected-device-lib/src/com/android/car/connecteddevice/storage/AssociatedDeviceEntity.java
new file mode 100644
index 0000000..cc97717
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/storage/AssociatedDeviceEntity.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2019 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.car.connecteddevice.storage;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.room.Entity;
+import androidx.room.PrimaryKey;
+
+import com.android.car.connecteddevice.model.AssociatedDevice;
+
+/** Table entity representing an associated device. */
+@Entity(tableName = "associated_devices")
+public class AssociatedDeviceEntity {
+
+ /** Id of the device. */
+ @PrimaryKey
+ @NonNull
+ public String id;
+
+ /** Id of user associated with this device. */
+ public int userId;
+
+ /** Bluetooth address of the device. */
+ @Nullable
+ public String address;
+
+ /** Bluetooth device name. */
+ @Nullable
+ public String name;
+
+ public AssociatedDeviceEntity() { }
+
+ public AssociatedDeviceEntity(int userId, AssociatedDevice associatedDevice) {
+ this.userId = userId;
+ id = associatedDevice.getDeviceId();
+ address = associatedDevice.getDeviceAddress();
+ name = associatedDevice.getDeviceName();
+ }
+
+ /** Return a new {@link AssociatedDevice} of this entity. */
+ public AssociatedDevice toAssociatedDevice() {
+ return new AssociatedDevice(id, address, name);
+ }
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/storage/AssociatedDeviceKeyEntity.java b/connected-device-lib/src/com/android/car/connecteddevice/storage/AssociatedDeviceKeyEntity.java
new file mode 100644
index 0000000..6cd791f
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/storage/AssociatedDeviceKeyEntity.java
@@ -0,0 +1,41 @@
+/*
+ * 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.android.car.connecteddevice.storage;
+
+import androidx.annotation.NonNull;
+import androidx.room.Entity;
+import androidx.room.PrimaryKey;
+
+/** Table entity representing a key for an associated device. */
+@Entity(tableName = "associated_device_keys")
+public class AssociatedDeviceKeyEntity {
+
+ /** Id of the device. */
+ @PrimaryKey
+ @NonNull
+ public String id;
+
+ @NonNull
+ public String encryptedKey;
+
+ public AssociatedDeviceKeyEntity() { }
+
+ public AssociatedDeviceKeyEntity(String deviceId, String encryptedKey) {
+ id = deviceId;
+ this.encryptedKey = encryptedKey;
+ }
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/storage/ConnectedDeviceDatabase.java b/connected-device-lib/src/com/android/car/connecteddevice/storage/ConnectedDeviceDatabase.java
new file mode 100644
index 0000000..3671440
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/storage/ConnectedDeviceDatabase.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2019 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.car.connecteddevice.storage;
+
+import androidx.room.Database;
+import androidx.room.RoomDatabase;
+
+/** Database for connected devices. */
+@Database(entities = { AssociatedDeviceEntity.class, AssociatedDeviceKeyEntity.class }, version = 1,
+ exportSchema = false)
+public abstract class ConnectedDeviceDatabase extends RoomDatabase {
+ /** Return the DAO for the associated device table. */
+ public abstract AssociatedDeviceDao associatedDeviceDao();
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/storage/ConnectedDeviceStorage.java b/connected-device-lib/src/com/android/car/connecteddevice/storage/ConnectedDeviceStorage.java
new file mode 100644
index 0000000..7b05981
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/storage/ConnectedDeviceStorage.java
@@ -0,0 +1,440 @@
+/*
+ * Copyright (C) 2019 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.car.connecteddevice.storage;
+
+import static com.android.car.connecteddevice.util.SafeLog.logd;
+import static com.android.car.connecteddevice.util.SafeLog.loge;
+import static com.android.car.connecteddevice.util.SafeLog.logw;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.app.ActivityManager;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.security.keystore.KeyGenParameterSpec;
+import android.security.keystore.KeyProperties;
+import android.util.Base64;
+
+import androidx.room.Room;
+
+import com.android.car.connecteddevice.R;
+import com.android.car.connecteddevice.model.AssociatedDevice;
+
+import java.io.IOException;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.Key;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.NoSuchProviderException;
+import java.security.UnrecoverableKeyException;
+import java.security.cert.CertificateException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+import javax.crypto.BadPaddingException;
+import javax.crypto.Cipher;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.KeyGenerator;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.spec.GCMParameterSpec;
+
+/** Storage for Trusted Devices in a car. */
+public class ConnectedDeviceStorage {
+ private static final String TAG = "CompanionStorage";
+
+ private static final String UNIQUE_ID_KEY = "CTABM_unique_id";
+ private static final String KEY_ALIAS = "Ukey2Key";
+ private static final String CIPHER_TRANSFORMATION = "AES/GCM/NoPadding";
+ private static final String KEYSTORE_PROVIDER = "AndroidKeyStore";
+ private static final String DATABASE_NAME = "connected-device-database";
+ private static final String IV_SPEC_SEPARATOR = ";";
+ // This delimiter separates deviceId and deviceInfo, so it has to differ from the
+ // TrustedDeviceInfo delimiter. Once new API can be added, deviceId will be added to
+ // TrustedDeviceInfo and this delimiter will be removed.
+
+ // The length of the authentication tag for a cipher in GCM mode. The GCM specification states
+ // that this length can only have the values {128, 120, 112, 104, 96}. Using the highest
+ // possible value.
+ private static final int GCM_AUTHENTICATION_TAG_LENGTH = 128;
+
+ private final Context mContext;
+
+ private SharedPreferences mSharedPreferences;
+
+ private UUID mUniqueId;
+
+ private AssociatedDeviceDao mAssociatedDeviceDatabase;
+
+ private AssociatedDeviceCallback mAssociatedDeviceCallback;
+
+ public ConnectedDeviceStorage(@NonNull Context context) {
+ mContext = context;
+ mAssociatedDeviceDatabase = Room.databaseBuilder(context, ConnectedDeviceDatabase.class,
+ DATABASE_NAME)
+ .fallbackToDestructiveMigration()
+ .build()
+ .associatedDeviceDao();
+ }
+
+ /**
+ * Set a callback for associated device updates.
+ *
+ * @param callback {@link AssociatedDeviceCallback} to set.
+ */
+ public void setAssociatedDeviceCallback(
+ @NonNull AssociatedDeviceCallback callback) {
+ mAssociatedDeviceCallback = callback;
+ }
+
+ /** Clear the callback for association device callback updates. */
+ public void clearAssociationDeviceCallback() {
+ mAssociatedDeviceCallback = null;
+ }
+
+ /**
+ * Get communication encryption key for the given device
+ *
+ * @param deviceId id of trusted device
+ * @return encryption key, null if device id is not recognized
+ */
+ @Nullable
+ public byte[] getEncryptionKey(@NonNull String deviceId) {
+ AssociatedDeviceKeyEntity entity =
+ mAssociatedDeviceDatabase.getAssociatedDeviceKey(deviceId);
+ if (entity == null) {
+ logd(TAG, "Encryption key not found!");
+ return null;
+ }
+ String[] values = entity.encryptedKey.split(IV_SPEC_SEPARATOR, -1);
+
+ if (values.length != 2) {
+ logd(TAG, "Stored encryption key had the wrong length.");
+ return null;
+ }
+
+ byte[] encryptedKey = Base64.decode(values[0], Base64.DEFAULT);
+ byte[] ivSpec = Base64.decode(values[1], Base64.DEFAULT);
+ return decryptWithKeyStore(KEY_ALIAS, encryptedKey, ivSpec);
+ }
+
+ /**
+ * Save encryption key for the given device
+ *
+ * @param deviceId did of trusted device
+ * @param encryptionKey encryption key
+ */
+ public void saveEncryptionKey(@NonNull String deviceId, @NonNull byte[] encryptionKey) {
+ String encryptedKey = encryptWithKeyStore(KEY_ALIAS, encryptionKey);
+ AssociatedDeviceKeyEntity entity = new AssociatedDeviceKeyEntity(deviceId, encryptedKey);
+ mAssociatedDeviceDatabase.addOrReplaceAssociatedDeviceKey(entity);
+ logd(TAG, "Successfully wrote encryption key.");
+ }
+
+ /**
+ * Encrypt value with designated key
+ *
+ * <p>The encrypted value is of the form:
+ *
+ * <p>key + IV_SPEC_SEPARATOR + ivSpec
+ *
+ * <p>The {@code ivSpec} is needed to decrypt this key later on.
+ *
+ * @param keyAlias KeyStore alias for key to use
+ * @param value a value to encrypt
+ * @return encrypted value, null if unable to encrypt
+ */
+ @Nullable
+ private String encryptWithKeyStore(@NonNull String keyAlias, @Nullable byte[] value) {
+ if (value == null) {
+ logw(TAG, "Received a null key value.");
+ return null;
+ }
+
+ Key key = getKeyStoreKey(keyAlias);
+ try {
+ Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORMATION);
+ cipher.init(Cipher.ENCRYPT_MODE, key);
+ return Base64.encodeToString(cipher.doFinal(value), Base64.DEFAULT)
+ + IV_SPEC_SEPARATOR
+ + Base64.encodeToString(cipher.getIV(), Base64.DEFAULT);
+ } catch (IllegalBlockSizeException
+ | BadPaddingException
+ | NoSuchAlgorithmException
+ | NoSuchPaddingException
+ | IllegalStateException
+ | InvalidKeyException e) {
+ loge(TAG, "Unable to encrypt value with key " + keyAlias, e);
+ return null;
+ }
+ }
+
+ /**
+ * Decrypt value with designated key
+ *
+ * @param keyAlias KeyStore alias for key to use
+ * @param value encrypted value
+ * @return decrypted value, null if unable to decrypt
+ */
+ @Nullable
+ private byte[] decryptWithKeyStore(
+ @NonNull String keyAlias, @Nullable byte[] value, @NonNull byte[] ivSpec) {
+ if (value == null) {
+ return null;
+ }
+
+ try {
+ Key key = getKeyStoreKey(keyAlias);
+ Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORMATION);
+ cipher.init(
+ Cipher.DECRYPT_MODE, key,
+ new GCMParameterSpec(GCM_AUTHENTICATION_TAG_LENGTH, ivSpec));
+ return cipher.doFinal(value);
+ } catch (IllegalBlockSizeException
+ | BadPaddingException
+ | NoSuchAlgorithmException
+ | NoSuchPaddingException
+ | IllegalStateException
+ | InvalidKeyException
+ | InvalidAlgorithmParameterException e) {
+ loge(TAG, "Unable to decrypt value with key " + keyAlias, e);
+ return null;
+ }
+ }
+
+ @Nullable
+ private static Key getKeyStoreKey(@NonNull String keyAlias) {
+ KeyStore keyStore;
+ try {
+ keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER);
+ keyStore.load(null);
+ if (!keyStore.containsAlias(keyAlias)) {
+ KeyGenerator keyGenerator =
+ KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES,
+ KEYSTORE_PROVIDER);
+ keyGenerator.init(
+ new KeyGenParameterSpec.Builder(
+ keyAlias,
+ KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .build());
+ keyGenerator.generateKey();
+ }
+ return keyStore.getKey(keyAlias, null);
+
+ } catch (KeyStoreException
+ | NoSuchAlgorithmException
+ | UnrecoverableKeyException
+ | NoSuchProviderException
+ | CertificateException
+ | IOException
+ | InvalidAlgorithmParameterException e) {
+ loge(TAG, "Unable to retrieve key " + keyAlias + " from KeyStore.", e);
+ throw new IllegalStateException(e);
+ }
+ }
+
+ @NonNull
+ private SharedPreferences getSharedPrefs() {
+ // This should be called only after user 0 is unlocked.
+ if (mSharedPreferences != null) {
+ return mSharedPreferences;
+ }
+ mSharedPreferences = mContext.getSharedPreferences(
+ mContext.getString(R.string.connected_device_shared_preferences),
+ Context.MODE_PRIVATE);
+ return mSharedPreferences;
+
+ }
+
+ /**
+ * Get the unique id for head unit. Persists on device until factory reset. This should be
+ * called only after user 0 is unlocked.
+ *
+ * @return unique id
+ */
+ @NonNull
+ public UUID getUniqueId() {
+ if (mUniqueId != null) {
+ return mUniqueId;
+ }
+
+ SharedPreferences prefs = getSharedPrefs();
+ if (prefs.contains(UNIQUE_ID_KEY)) {
+ mUniqueId = UUID.fromString(prefs.getString(UNIQUE_ID_KEY, null));
+ logd(TAG,
+ "Found existing trusted unique id: " + prefs.getString(UNIQUE_ID_KEY, ""));
+ }
+
+ if (mUniqueId == null) {
+ mUniqueId = UUID.randomUUID();
+ prefs.edit().putString(UNIQUE_ID_KEY, mUniqueId.toString()).apply();
+ logd(TAG,
+ "Generated new trusted unique id: " + prefs.getString(UNIQUE_ID_KEY, ""));
+ }
+
+ return mUniqueId;
+ }
+
+ /**
+ * Get a list of associated devices for the given user.
+ *
+ * @param userId The identifier of the user.
+ * @return Associated device list.
+ */
+ @NonNull
+ public List<AssociatedDevice> getAssociatedDevicesForUser(@NonNull int userId) {
+ List<AssociatedDeviceEntity> entities =
+ mAssociatedDeviceDatabase.getAssociatedDevicesForUser(userId);
+
+ if (entities == null) {
+ return new ArrayList<>();
+ }
+
+ ArrayList<AssociatedDevice> userDevices = new ArrayList<>();
+ for (AssociatedDeviceEntity entity : entities) {
+ userDevices.add(entity.toAssociatedDevice());
+ }
+
+ return userDevices;
+ }
+
+ /**
+ * Get a list of associated devices for the current user.
+ *
+ * @return Associated device list.
+ */
+ @NonNull
+ public List<AssociatedDevice> getActiveUserAssociatedDevices() {
+ return getAssociatedDevicesForUser(ActivityManager.getCurrentUser());
+ }
+
+ /**
+ * Returns a list of device ids of associated devices for the given user.
+ *
+ * @param userId The user id for whom we want to know the device ids.
+ * @return List of device ids.
+ */
+ @NonNull
+ public List<String> getAssociatedDeviceIdsForUser(@NonNull int userId) {
+ List<AssociatedDevice> userDevices = getAssociatedDevicesForUser(userId);
+ ArrayList<String> userDeviceIds = new ArrayList<>();
+
+ for (AssociatedDevice device : userDevices) {
+ userDeviceIds.add(device.getDeviceId());
+ }
+
+ return userDeviceIds;
+ }
+
+ /**
+ * Returns a list of device ids of associated devices for the current user.
+ *
+ * @return List of device ids.
+ */
+ @NonNull
+ public List<String> getActiveUserAssociatedDeviceIds() {
+ return getAssociatedDeviceIdsForUser(ActivityManager.getCurrentUser());
+ }
+
+ /**
+ * Add the associated device of the given deviceId for the currently active user.
+ *
+ * @param device New associated device to be added.
+ */
+ public void addAssociatedDeviceForActiveUser(@NonNull AssociatedDevice device) {
+ addAssociatedDeviceForUser(ActivityManager.getCurrentUser(), device);
+ if (mAssociatedDeviceCallback != null) {
+ mAssociatedDeviceCallback.onAssociatedDeviceAdded(device.getDeviceId());
+ }
+ }
+
+
+ /**
+ * Add the associated device of the given deviceId for the given user.
+ *
+ * @param userId The identifier of the user.
+ * @param device New associated device to be added.
+ */
+ public void addAssociatedDeviceForUser(int userId, @NonNull AssociatedDevice device) {
+ AssociatedDeviceEntity entity = new AssociatedDeviceEntity(userId, device);
+ mAssociatedDeviceDatabase.addOrReplaceAssociatedDevice(entity);
+ }
+
+ /**
+ * Update the name for an associated device.
+ *
+ * @param deviceId The id of the associated device.
+ * @param name The name to replace with.
+ */
+ public void updateAssociatedDeviceName(@NonNull String deviceId, @NonNull String name) {
+ AssociatedDeviceEntity entity = mAssociatedDeviceDatabase.getAssociatedDevice(deviceId);
+ if (entity == null) {
+ logw(TAG, "Attempt to update name on an unrecognized device " + deviceId
+ + ". Ignoring.");
+ return;
+ }
+ entity.name = name;
+ mAssociatedDeviceDatabase.addOrReplaceAssociatedDevice(entity);
+ if (mAssociatedDeviceCallback != null) {
+ mAssociatedDeviceCallback.onAssociatedDeviceUpdated(
+ new AssociatedDevice(deviceId, entity.address, name));
+ }
+
+ }
+
+ /**
+ * Remove the associated device of the given deviceId for the given user.
+ *
+ * @param userId The identifier of the user.
+ * @param deviceId The identifier of the device to be cleared.
+ */
+ public void removeAssociatedDevice(int userId, @NonNull String deviceId) {
+ AssociatedDeviceEntity entity = mAssociatedDeviceDatabase.getAssociatedDevice(deviceId);
+ if (entity == null || entity.userId != userId) {
+ return;
+ }
+ mAssociatedDeviceDatabase.removeAssociatedDevice(entity);
+ }
+
+ /**
+ * Clear the associated device of the given deviceId for the current user.
+ *
+ * @param deviceId The identifier of the device to be cleared.
+ */
+ public void removeAssociatedDeviceForActiveUser(@NonNull String deviceId) {
+ removeAssociatedDevice(ActivityManager.getCurrentUser(), deviceId);
+ if (mAssociatedDeviceCallback != null) {
+ mAssociatedDeviceCallback.onAssociatedDeviceRemoved(deviceId);
+ }
+ }
+
+ /** Callback for association device related events. */
+ public interface AssociatedDeviceCallback {
+ /** Triggered when an associated device has been added */
+ void onAssociatedDeviceAdded(@NonNull String deviceId);
+
+ /** Triggered when an associated device has been removed. */
+ void onAssociatedDeviceRemoved(@NonNull String deviceId);
+
+ /** Triggered when an associated device has been updated. */
+ void onAssociatedDeviceUpdated(@NonNull AssociatedDevice device);
+ }
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/util/ByteUtils.java b/connected-device-lib/src/com/android/car/connecteddevice/util/ByteUtils.java
new file mode 100644
index 0000000..3d07227
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/util/ByteUtils.java
@@ -0,0 +1,159 @@
+/*
+ * Copyright (C) 2019 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.car.connecteddevice.util;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SuppressLint;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.UUID;
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * Utility classes for manipulating bytes.
+ */
+public final class ByteUtils {
+ // https://developer.android.com/reference/java/util/UUID
+ private static final int UUID_LENGTH = 16;
+
+ private ByteUtils() {
+ }
+
+ /**
+ * Returns a byte buffer corresponding to the passed long argument.
+ *
+ * @param primitive data to convert format.
+ */
+ public static byte[] longToBytes(long primitive) {
+ ByteBuffer buffer = ByteBuffer.allocate(Long.BYTES);
+ buffer.putLong(primitive);
+ return buffer.array();
+ }
+
+ /**
+ * Returns a byte buffer corresponding to the passed long argument.
+ *
+ * @param array data to convert format.
+ */
+ public static long bytesToLong(byte[] array) {
+ ByteBuffer buffer = ByteBuffer.allocate(Long.SIZE / Byte.SIZE);
+ buffer.put(array);
+ buffer.flip();
+ long value = buffer.getLong();
+ return value;
+ }
+
+ /**
+ * Returns a String in Hex format that is formed from the bytes in the byte array Useful for
+ * debugging
+ *
+ * @param array the byte array
+ * @return the Hex string version of the input byte array
+ */
+ public static String byteArrayToHexString(byte[] array) {
+ StringBuilder sb = new StringBuilder(array.length * 2);
+ for (byte b : array) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Convert UUID to Big Endian byte array
+ *
+ * @param uuid UUID to convert
+ * @return the byte array representing the UUID
+ */
+ @NonNull
+ public static byte[] uuidToBytes(@NonNull UUID uuid) {
+
+ return ByteBuffer.allocate(UUID_LENGTH)
+ .order(ByteOrder.BIG_ENDIAN)
+ .putLong(uuid.getMostSignificantBits())
+ .putLong(uuid.getLeastSignificantBits())
+ .array();
+ }
+
+ /**
+ * Convert Big Endian byte array to UUID
+ *
+ * @param bytes byte array to convert
+ * @return the UUID representing the byte array, or null if not a valid UUID
+ */
+ @Nullable
+ public static UUID bytesToUUID(@NonNull byte[] bytes) {
+ if (bytes.length != UUID_LENGTH) {
+ return null;
+ }
+
+ ByteBuffer buffer = ByteBuffer.wrap(bytes);
+ return new UUID(buffer.getLong(), buffer.getLong());
+ }
+
+ /**
+ * Generate a random zero-filled string of given length
+ *
+ * @param length of string
+ * @return generated string
+ */
+ @SuppressLint("DefaultLocale") // Should always have the same format regardless of locale
+ public static String generateRandomNumberString(int length) {
+ return String.format(
+ "%0" + length + "d",
+ ThreadLocalRandom.current().nextInt((int) Math.pow(10, length)));
+ }
+
+ /**
+ * Generate a {@link byte[]} with random bytes.
+ *
+ * @param size of array to generate.
+ * @return generated {@link byte[]}.
+ */
+ @NonNull
+ public static byte[] randomBytes(int size) {
+ byte[] array = new byte[size];
+ ThreadLocalRandom.current().nextBytes(array);
+ return array;
+ }
+
+ /**
+ * Concatentate the given 2 byte arrays
+ *
+ * @param a input array 1
+ * @param b input array 2
+ * @return concatenated array of arrays 1 and 2
+ */
+ @Nullable
+ public static byte[] concatByteArrays(@Nullable byte[] a, @Nullable byte[] b) {
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ try {
+ if (a != null) {
+ outputStream.write(a);
+ }
+ if (b != null) {
+ outputStream.write(b);
+ }
+ } catch (IOException e) {
+ return null;
+ }
+ return outputStream.toByteArray();
+ }
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/util/IdManager.kt b/connected-device-lib/src/com/android/car/connecteddevice/util/IdManager.kt
new file mode 100644
index 0000000..91d06b7
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/util/IdManager.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2019 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.car.connecteddevice.util
+
+import com.android.internal.annotations.GuardedBy
+
+/** Class for managing unique numeric ids. */
+internal class IdManager {
+
+ private val lock = object
+ @Volatile
+ @GuardedBy("lock")
+ private var nextVal = 0
+
+ private var openVals = 0
+
+ /**
+ * Returns the next available id from the pool and reserves it from future use until released.
+ */
+ fun reserve(): Int {
+ synchronized(lock) {
+ return nextVal++
+ }
+ }
+
+ /** Release the [value] back to id pool. */
+ fun releaseReservation(value: Int) {
+ synchronized(lock) {
+ if (value == nextVal - 1) {
+ nextVal = value
+ } else {
+ openVals++
+ }
+ if (nextVal == openVals) {
+ nextVal = 0
+ openVals = 0
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/util/RemoteCallbackBinder.java b/connected-device-lib/src/com/android/car/connecteddevice/util/RemoteCallbackBinder.java
new file mode 100644
index 0000000..e18366b
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/util/RemoteCallbackBinder.java
@@ -0,0 +1,74 @@
+/*
+ * 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.android.car.connecteddevice.util;
+
+import static com.android.car.connecteddevice.util.SafeLog.logd;
+
+import android.os.IBinder;
+import android.os.RemoteException;
+
+import java.util.function.Consumer;
+
+/**
+ * Class that holds the binder of a remote callback and an action to be executed when this
+ * binder dies.
+ * It registers for death notification of the {@link #mCallbackBinder} and executes
+ * {@link #mOnDiedConsumer} when {@link #mCallbackBinder} dies.
+ */
+public class RemoteCallbackBinder implements IBinder.DeathRecipient {
+ private static final String TAG = "BinderClient";
+ private final IBinder mCallbackBinder;
+ private final Consumer<IBinder> mOnDiedConsumer;
+
+ public RemoteCallbackBinder(IBinder binder, Consumer<IBinder> onBinderDied) {
+ mCallbackBinder = binder;
+ mOnDiedConsumer = onBinderDied;
+ try {
+ binder.linkToDeath(this, 0);
+ } catch (RemoteException e) {
+ logd(TAG, "Cannot link death recipient to binder " + mCallbackBinder + ", "
+ + e);
+ }
+ }
+
+ @Override
+ public void binderDied() {
+ logd(TAG, "Binder died " + mCallbackBinder);
+ mOnDiedConsumer.accept(mCallbackBinder);
+ cleanUp();
+ }
+
+ /** Clean up the client. */
+ public void cleanUp() {
+ mCallbackBinder.unlinkToDeath(this, 0);
+ }
+
+ /** Get the callback binder of the client. */
+ public IBinder getCallbackBinder() {
+ return mCallbackBinder;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ return mCallbackBinder.equals(obj);
+ }
+
+ @Override
+ public int hashCode() {
+ return mCallbackBinder.hashCode();
+ }
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/util/SafeLog.java b/connected-device-lib/src/com/android/car/connecteddevice/util/SafeLog.java
new file mode 100644
index 0000000..6ab18ce
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/util/SafeLog.java
@@ -0,0 +1,70 @@
+/*
+ * 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.android.car.connecteddevice.util;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.util.Log;
+
+/**
+ * Convenience logging methods that respect whitelisted tags.
+ */
+public class SafeLog {
+
+ private SafeLog() { }
+
+ /** Log message if tag is whitelisted for {@code Log.VERBOSE}. */
+ public static void logv(@NonNull String tag, @NonNull String message) {
+ if (Log.isLoggable(tag, Log.VERBOSE)) {
+ Log.v(tag, message);
+ }
+ }
+
+ /** Log message if tag is whitelisted for {@code Log.INFO}. */
+ public static void logi(@NonNull String tag, @NonNull String message) {
+ if (Log.isLoggable(tag, Log.INFO)) {
+ Log.i(tag, message);
+ }
+ }
+
+ /** Log message if tag is whitelisted for {@code Log.DEBUG}. */
+ public static void logd(@NonNull String tag, @NonNull String message) {
+ if (Log.isLoggable(tag, Log.DEBUG)) {
+ Log.d(tag, message);
+ }
+ }
+
+ /** Log message if tag is whitelisted for {@code Log.WARN}. */
+ public static void logw(@NonNull String tag, @NonNull String message) {
+ if (Log.isLoggable(tag, Log.WARN)) {
+ Log.w(tag, message);
+ }
+ }
+
+ /** Log message if tag is whitelisted for {@code Log.ERROR}. */
+ public static void loge(@NonNull String tag, @NonNull String message) {
+ loge(tag, message, /* exception = */ null);
+ }
+
+ /** Log message and optional exception if tag is whitelisted for {@code Log.ERROR}. */
+ public static void loge(@NonNull String tag, @NonNull String message,
+ @Nullable Exception exception) {
+ if (Log.isLoggable(tag, Log.ERROR)) {
+ Log.e(tag, message, exception);
+ }
+ }
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/util/ScanDataAnalyzer.java b/connected-device-lib/src/com/android/car/connecteddevice/util/ScanDataAnalyzer.java
new file mode 100644
index 0000000..6748bba
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/util/ScanDataAnalyzer.java
@@ -0,0 +1,124 @@
+/*
+ * 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.android.car.connecteddevice.util;
+
+import static com.android.car.connecteddevice.util.SafeLog.logw;
+
+import android.annotation.NonNull;
+import android.bluetooth.le.ScanResult;
+
+import java.math.BigInteger;
+
+/**
+ * Analyzer of {@link ScanResult} data to identify an Apple device that is advertising from the
+ * background.
+ */
+public class ScanDataAnalyzer {
+
+ private static final String TAG = "ScanDataAnalyzer";
+
+ private static final byte IOS_OVERFLOW_LENGTH = (byte) 0x14;
+ private static final byte IOS_ADVERTISING_TYPE = (byte) 0xff;
+ private static final int IOS_ADVERTISING_TYPE_LENGTH = 1;
+ private static final long IOS_OVERFLOW_CUSTOM_ID = 0x4c0001;
+ private static final int IOS_OVERFLOW_CUSTOM_ID_LENGTH = 3;
+ private static final int IOS_OVERFLOW_CONTENT_LENGTH =
+ IOS_OVERFLOW_LENGTH - IOS_OVERFLOW_CUSTOM_ID_LENGTH - IOS_ADVERTISING_TYPE_LENGTH;
+
+ private ScanDataAnalyzer() { }
+
+ /**
+ * Returns {@code true} if the given bytes from a [ScanResult] contains service UUIDs once the
+ * given serviceUuidMask is applied.
+ *
+ * When an iOS peripheral device goes into a background state, the service UUIDs and other
+ * identifying information are removed from the advertising data and replaced with a hashed
+ * bit in a special "overflow" area. There is no documentation on the layout of this area,
+ * and the below was compiled from experimentation and examples from others who have worked
+ * on reverse engineering iOS background peripherals.
+ *
+ * My best guess is Apple is taking the service UUID and hashing it into a bloom filter. This
+ * would allow any device with the same hashing function to filter for all devices that
+ * might contain the desired service. Since we do not have access to this hashing function,
+ * we must first advertise our service from an iOS device and manually inspect the bit that
+ * is flipped. Once known, it can be passed to serviceUuidMask and used as a filter.
+ *
+ * EXAMPLE
+ *
+ * Foreground contents:
+ * 02011A1107FB349B5F8000008000100000C53A00000709546573746572000000000000000000000000000000000000000000000000000000000000000000
+ *
+ * Background contents:
+ * 02011A14FF4C0001000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000
+ *
+ * The overflow bytes are comprised of four parts:
+ * Length -> 14
+ * Advertising type -> FF
+ * Id custom to Apple -> 4C0001
+ * Contents where hashed values are stored -> 00000000000000000000000000200000
+ *
+ * Apple's documentation on advertising from the background:
+ * https://developer.apple.com/library/archive/documentation/NetworkingInternetWeb/Conceptual/CoreBluetooth_concepts/CoreBluetoothBackgroundProcessingForIOSApps/PerformingTasksWhileYourAppIsInTheBackground.html#//apple_ref/doc/uid/TP40013257-CH7-SW9
+ *
+ * Other similar reverse engineering:
+ * http://www.pagepinner.com/2014/04/how-to-get-ble-overflow-hash-bit-from.html
+ */
+ public static boolean containsUuidsInOverflow(@NonNull byte[] scanData,
+ @NonNull BigInteger serviceUuidMask) {
+ byte[] overflowBytes = new byte[IOS_OVERFLOW_CONTENT_LENGTH];
+ int overflowPtr = 0;
+ int outPtr = 0;
+ try {
+ while (overflowPtr < scanData.length - IOS_OVERFLOW_LENGTH) {
+ byte length = scanData[overflowPtr++];
+ if (length == 0) {
+ break;
+ } else if (length != IOS_OVERFLOW_LENGTH) {
+ continue;
+ }
+
+ if (scanData[overflowPtr++] != IOS_ADVERTISING_TYPE) {
+ return false;
+ }
+
+ byte[] idBytes = new byte[IOS_OVERFLOW_CUSTOM_ID_LENGTH];
+ for (int i = 0; i < IOS_OVERFLOW_CUSTOM_ID_LENGTH; i++) {
+ idBytes[i] = scanData[overflowPtr++];
+ }
+
+ if (!new BigInteger(idBytes).equals(BigInteger.valueOf(IOS_OVERFLOW_CUSTOM_ID))) {
+ return false;
+ }
+
+ for (outPtr = 0; outPtr < IOS_OVERFLOW_CONTENT_LENGTH; outPtr++) {
+ overflowBytes[outPtr] = scanData[overflowPtr++];
+ }
+ break;
+ }
+
+ if (outPtr == IOS_OVERFLOW_CONTENT_LENGTH) {
+ BigInteger overflowBytesValue = new BigInteger(overflowBytes);
+ return overflowBytesValue.and(serviceUuidMask).signum() == 1;
+ }
+
+ } catch (ArrayIndexOutOfBoundsException e) {
+ logw(TAG, "Inspecting advertisement overflow bytes went out of bounds.");
+ }
+
+ return false;
+ }
+}
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/util/ThreadSafeCallbacks.java b/connected-device-lib/src/com/android/car/connecteddevice/util/ThreadSafeCallbacks.java
new file mode 100644
index 0000000..b3d3ef1
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/util/ThreadSafeCallbacks.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2019 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.car.connecteddevice.util;
+
+import android.annotation.CallbackExecutor;
+import android.annotation.NonNull;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executor;
+import java.util.function.Consumer;
+
+/**
+ * Class for invoking thread-safe callbacks.
+ *
+ * @param <T> Callback type.
+ */
+public class ThreadSafeCallbacks<T> {
+
+ private final ConcurrentHashMap<T, Executor> mCallbacks = new ConcurrentHashMap<>();
+
+ /** Add a callback to be notified on its executor. */
+ public void add(@NonNull T callback, @NonNull @CallbackExecutor Executor executor) {
+ mCallbacks.put(callback, executor);
+ }
+
+ /** Remove a callback from the collection. */
+ public void remove(@NonNull T callback) {
+ mCallbacks.remove(callback);
+ }
+
+ /** Clear all callbacks from the collection. */
+ public void clear() {
+ mCallbacks.clear();
+ }
+
+ /** Return the number of callbacks in collection. */
+ public int size() {
+ return mCallbacks.size();
+ }
+
+ /** Invoke notification on all callbacks with their supplied {@link Executor}. */
+ public void invoke(Consumer<T> notification) {
+ mCallbacks.forEach((callback, executor) ->
+ executor.execute(() -> notification.accept(callback)));
+ }
+}
diff --git a/connected-device-lib/tests/unit/Android.bp b/connected-device-lib/tests/unit/Android.bp
new file mode 100644
index 0000000..9cf29ba
--- /dev/null
+++ b/connected-device-lib/tests/unit/Android.bp
@@ -0,0 +1,50 @@
+//
+// Copyright (C) 2019 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.
+//
+
+android_test {
+ name: "connected-device-lib-unit-tests",
+
+ srcs: ["src/**/*.java"],
+
+ libs: [
+ "android.test.runner",
+ "android.test.base",
+ "android.test.mock",
+ ],
+
+ static_libs: [
+ "android.car",
+ "androidx.test.core",
+ "androidx.test.ext.junit",
+ "androidx.test.rules",
+ "connected-device-lib",
+ "mockito-target-extended-minus-junit4",
+ "testables",
+ "truth-prebuilt",
+ ],
+
+ jni_libs: [
+ // For mockito extended
+ "libdexmakerjvmtiagent",
+ "libstaticjvmtiagent",
+ ],
+
+ platform_apis: true,
+
+ certificate: "platform",
+
+ privileged: true,
+}
\ No newline at end of file
diff --git a/connected-device-lib/tests/unit/AndroidManifest.xml b/connected-device-lib/tests/unit/AndroidManifest.xml
new file mode 100644
index 0000000..9863ccf
--- /dev/null
+++ b/connected-device-lib/tests/unit/AndroidManifest.xml
@@ -0,0 +1,37 @@
+<!--
+ ~ Copyright (C) 2019 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.
+ -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.android.car.connecteddevice.tests.unit">
+
+ <!-- Needed for BLE scanning/advertising -->
+ <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
+ <uses-permission android:name="android.permission.BLUETOOTH"/>
+ <uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
+
+ <!-- Needed for detecting foreground user -->
+ <uses-permission android:name="android.permission.INTERACT_ACROSS_USERS"/>
+ <uses-permission android:name="android.permission.MANAGE_USERS" />
+
+ <application android:testOnly="true"
+ android:debuggable="true">
+ <uses-library android:name="android.test.runner" />
+ </application>
+
+ <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
+ android:targetPackage="com.android.car.connecteddevice.tests.unit"
+ android:label="Connected Device Lib Test Cases" />
+</manifest>
diff --git a/connected-device-lib/tests/unit/README.md b/connected-device-lib/tests/unit/README.md
new file mode 100644
index 0000000..4543058
--- /dev/null
+++ b/connected-device-lib/tests/unit/README.md
@@ -0,0 +1,24 @@
+# Instructions for running unit tests
+
+### Build unit test module
+
+`m connected-device-lib-unit-tests`
+
+### Install resulting apk on device
+
+`adb install -r -t $OUT/testcases/connected-device-lib-unit-tests/arm64/connected-device-lib-unit-tests.apk`
+
+### Run all tests
+
+`adb shell am instrument -w com.android.car.connecteddevice.tests.unit`
+
+### Run tests in a class
+
+`adb shell am instrument -w -e class com.android.car.connecteddevice.<classPath> com.android.car.connecteddevice.tests.unit`
+
+### Run a specific test
+
+`adb shell am instrument -w -e class com.android.car.connecteddevice.<classPath>#<testMethod> com.android.car.connecteddevice.tests.unit`
+
+More general information can be found at
+http://developer.android.com/reference/android/support/test/runner/AndroidJUnitRunner.html
\ No newline at end of file
diff --git a/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ConnectedDeviceManagerTest.java b/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ConnectedDeviceManagerTest.java
new file mode 100644
index 0000000..c335852
--- /dev/null
+++ b/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ConnectedDeviceManagerTest.java
@@ -0,0 +1,626 @@
+/*
+ * Copyright (C) 2019 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.car.connecteddevice;
+
+import static com.android.car.connecteddevice.ConnectedDeviceManager.DEVICE_ERROR_INSECURE_RECIPIENT_ID_DETECTED;
+import static com.android.car.connecteddevice.ConnectedDeviceManager.DEVICE_ERROR_INVALID_SECURITY_KEY;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.atMost;
+import static org.mockito.Mockito.mockitoSession;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.annotation.NonNull;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.android.car.connecteddevice.ConnectedDeviceManager.ConnectionCallback;
+import com.android.car.connecteddevice.ConnectedDeviceManager.DeviceAssociationCallback;
+import com.android.car.connecteddevice.ConnectedDeviceManager.DeviceCallback;
+import com.android.car.connecteddevice.ble.CarBleCentralManager;
+import com.android.car.connecteddevice.ble.CarBleManager;
+import com.android.car.connecteddevice.ble.CarBlePeripheralManager;
+import com.android.car.connecteddevice.ble.DeviceMessage;
+import com.android.car.connecteddevice.model.AssociatedDevice;
+import com.android.car.connecteddevice.model.ConnectedDevice;
+import com.android.car.connecteddevice.storage.ConnectedDeviceStorage;
+import com.android.car.connecteddevice.storage.ConnectedDeviceStorage.AssociatedDeviceCallback;
+import com.android.car.connecteddevice.util.ByteUtils;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoSession;
+import org.mockito.quality.Strictness;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+
+@RunWith(AndroidJUnit4.class)
+public class ConnectedDeviceManagerTest {
+
+ private final Executor mCallbackExecutor = Executors.newSingleThreadExecutor();
+
+ private final UUID mRecipientId = UUID.randomUUID();
+
+ @Mock
+ private ConnectedDeviceStorage mMockStorage;
+
+ @Mock
+ private CarBlePeripheralManager mMockPeripheralManager;
+
+ @Mock
+ private CarBleCentralManager mMockCentralManager;
+
+ private ConnectedDeviceManager mConnectedDeviceManager;
+
+ private MockitoSession mMockingSession;
+
+ private AssociatedDeviceCallback mAssociatedDeviceCallback;
+
+ @Before
+ public void setUp() {
+ mMockingSession = mockitoSession()
+ .initMocks(this)
+ .strictness(Strictness.LENIENT)
+ .startMocking();
+ ArgumentCaptor<AssociatedDeviceCallback> callbackCaptor = ArgumentCaptor
+ .forClass(AssociatedDeviceCallback.class);
+ mConnectedDeviceManager = new ConnectedDeviceManager(mMockStorage, mMockCentralManager,
+ mMockPeripheralManager);
+ verify(mMockStorage).setAssociatedDeviceCallback(callbackCaptor.capture());
+ mAssociatedDeviceCallback = callbackCaptor.getValue();
+ mConnectedDeviceManager.start();
+ }
+
+ @After
+ public void tearDown() {
+ if (mMockingSession != null) {
+ mMockingSession.finishMocking();
+ }
+ }
+
+ @Test
+ public void getActiveUserConnectedDevices_initiallyShouldReturnEmptyList() {
+ assertThat(mConnectedDeviceManager.getActiveUserConnectedDevices()).isEmpty();
+ }
+
+ @Test
+ public void getActiveUserConnectedDevices_includesNewlyConnectedDevice() {
+ String deviceId = connectNewDevice(mMockCentralManager);
+ List<ConnectedDevice> activeUserDevices =
+ mConnectedDeviceManager.getActiveUserConnectedDevices();
+ ConnectedDevice expectedDevice = new ConnectedDevice(deviceId, /* deviceName = */ null,
+ /* belongsToActiveUser = */ true, /* hasSecureChannel = */ false);
+ assertThat(activeUserDevices).containsExactly(expectedDevice);
+ }
+
+ @Test
+ public void getActiveUserConnectedDevices_excludesDevicesNotBelongingToActiveUser() {
+ String deviceId = UUID.randomUUID().toString();
+ String otherUserDeviceId = UUID.randomUUID().toString();
+ when(mMockStorage.getActiveUserAssociatedDeviceIds()).thenReturn(
+ Collections.singletonList(otherUserDeviceId));
+ mConnectedDeviceManager.addConnectedDevice(deviceId, mMockCentralManager);
+ assertThat(mConnectedDeviceManager.getActiveUserConnectedDevices()).isEmpty();
+ }
+
+ @Test
+ public void getActiveUserConnectedDevices_reflectsSecureChannelEstablished() {
+ String deviceId = connectNewDevice(mMockCentralManager);
+ mConnectedDeviceManager.onSecureChannelEstablished(deviceId, mMockCentralManager);
+ ConnectedDevice connectedDevice =
+ mConnectedDeviceManager.getActiveUserConnectedDevices().get(0);
+ assertThat(connectedDevice.hasSecureChannel()).isTrue();
+ }
+
+ @Test
+ public void getActiveUserConnectedDevices_excludesDisconnectedDevice() {
+ String deviceId = connectNewDevice(mMockCentralManager);
+ mConnectedDeviceManager.removeConnectedDevice(deviceId, mMockCentralManager);
+ assertThat(mConnectedDeviceManager.getActiveUserConnectedDevices()).isEmpty();
+ }
+
+ @Test
+ public void getActiveUserConnectedDevices_unaffectedByOtherManagerDisconnect() {
+ String deviceId = connectNewDevice(mMockCentralManager);
+ mConnectedDeviceManager.removeConnectedDevice(deviceId, mMockPeripheralManager);
+ assertThat(mConnectedDeviceManager.getActiveUserConnectedDevices()).hasSize(1);
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void sendMessageSecurely_throwsIllegalStateExceptionIfNoSecureChannel() {
+ connectNewDevice(mMockCentralManager);
+ ConnectedDevice device = mConnectedDeviceManager.getActiveUserConnectedDevices().get(0);
+ UUID recipientId = UUID.randomUUID();
+ byte[] message = ByteUtils.randomBytes(10);
+ mConnectedDeviceManager.sendMessageSecurely(device, recipientId, message);
+ }
+
+ @Test
+ public void sendMessageSecurely_sendsEncryptedMessage() {
+ String deviceId = connectNewDevice(mMockCentralManager);
+ mConnectedDeviceManager.onSecureChannelEstablished(deviceId, mMockCentralManager);
+ ConnectedDevice device = mConnectedDeviceManager.getActiveUserConnectedDevices().get(0);
+ UUID recipientId = UUID.randomUUID();
+ byte[] message = ByteUtils.randomBytes(10);
+ mConnectedDeviceManager.sendMessageSecurely(device, recipientId, message);
+ ArgumentCaptor<DeviceMessage> messageCaptor = ArgumentCaptor.forClass(DeviceMessage.class);
+ verify(mMockCentralManager).sendMessage(eq(deviceId), messageCaptor.capture());
+ assertThat(messageCaptor.getValue().isMessageEncrypted()).isTrue();
+ }
+
+ @Test
+ public void sendMessageSecurely_doesNotSendIfDeviceDisconnected() {
+ String deviceId = connectNewDevice(mMockCentralManager);
+ ConnectedDevice device = mConnectedDeviceManager.getActiveUserConnectedDevices().get(0);
+ mConnectedDeviceManager.removeConnectedDevice(deviceId, mMockCentralManager);
+ UUID recipientId = UUID.randomUUID();
+ byte[] message = ByteUtils.randomBytes(10);
+ mConnectedDeviceManager.sendMessageSecurely(device, recipientId, message);
+ verify(mMockCentralManager, times(0)).sendMessage(eq(deviceId), any(DeviceMessage.class));
+ }
+
+ @Test
+ public void sendMessageUnsecurely_sendsMessageWithoutEncryption() {
+ String deviceId = connectNewDevice(mMockCentralManager);
+ ConnectedDevice device = mConnectedDeviceManager.getActiveUserConnectedDevices().get(0);
+ UUID recipientId = UUID.randomUUID();
+ byte[] message = ByteUtils.randomBytes(10);
+ mConnectedDeviceManager.sendMessageUnsecurely(device, recipientId, message);
+ ArgumentCaptor<DeviceMessage> messageCaptor = ArgumentCaptor.forClass(DeviceMessage.class);
+ verify(mMockCentralManager).sendMessage(eq(deviceId), messageCaptor.capture());
+ assertThat(messageCaptor.getValue().isMessageEncrypted()).isFalse();
+ }
+
+ @Test
+ public void connectionCallback_onDeviceConnectedInvokedForNewlyConnectedDevice()
+ throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ ConnectionCallback connectionCallback = createConnectionCallback(semaphore);
+ mConnectedDeviceManager.registerActiveUserConnectionCallback(connectionCallback,
+ mCallbackExecutor);
+ String deviceId = connectNewDevice(mMockCentralManager);
+ assertThat(tryAcquire(semaphore)).isTrue();
+ ArgumentCaptor<ConnectedDevice> deviceCaptor =
+ ArgumentCaptor.forClass(ConnectedDevice.class);
+ verify(connectionCallback).onDeviceConnected(deviceCaptor.capture());
+ ConnectedDevice connectedDevice = deviceCaptor.getValue();
+ assertThat(connectedDevice.getDeviceId()).isEqualTo(deviceId);
+ assertThat(connectedDevice.hasSecureChannel()).isFalse();
+ }
+
+ @Test
+ public void connectionCallback_onDeviceConnectedNotInvokedDeviceConnectedForDifferentUser()
+ throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ ConnectionCallback connectionCallback = createConnectionCallback(semaphore);
+ mConnectedDeviceManager.registerActiveUserConnectionCallback(connectionCallback,
+ mCallbackExecutor);
+ String deviceId = UUID.randomUUID().toString();
+ String otherUserDeviceId = UUID.randomUUID().toString();
+ when(mMockStorage.getActiveUserAssociatedDeviceIds()).thenReturn(
+ Collections.singletonList(otherUserDeviceId));
+ mConnectedDeviceManager.addConnectedDevice(deviceId, mMockCentralManager);
+ assertThat(tryAcquire(semaphore)).isFalse();
+ }
+
+ @Test
+ public void connectionCallback_onDeviceConnectedNotInvokedForDifferentBleManager()
+ throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ String deviceId = connectNewDevice(mMockPeripheralManager);
+ ConnectionCallback connectionCallback = createConnectionCallback(semaphore);
+ mConnectedDeviceManager.registerActiveUserConnectionCallback(connectionCallback,
+ mCallbackExecutor);
+ mConnectedDeviceManager.addConnectedDevice(deviceId, mMockCentralManager);
+ assertThat(tryAcquire(semaphore)).isFalse();
+ }
+
+ @Test
+ public void connectionCallback_onDeviceDisconnectedInvokedForActiveUserDevice()
+ throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ String deviceId = connectNewDevice(mMockCentralManager);
+ ConnectionCallback connectionCallback = createConnectionCallback(semaphore);
+ mConnectedDeviceManager.registerActiveUserConnectionCallback(connectionCallback,
+ mCallbackExecutor);
+ mConnectedDeviceManager.removeConnectedDevice(deviceId, mMockCentralManager);
+ assertThat(tryAcquire(semaphore)).isTrue();
+ ArgumentCaptor<ConnectedDevice> deviceCaptor =
+ ArgumentCaptor.forClass(ConnectedDevice.class);
+ verify(connectionCallback).onDeviceDisconnected(deviceCaptor.capture());
+ assertThat(deviceCaptor.getValue().getDeviceId()).isEqualTo(deviceId);
+ }
+
+ @Test
+ public void connectionCallback_onDeviceDisconnectedNotInvokedDeviceForDifferentUser()
+ throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ String deviceId = UUID.randomUUID().toString();
+ mConnectedDeviceManager.addConnectedDevice(deviceId, mMockCentralManager);
+ ConnectionCallback connectionCallback = createConnectionCallback(semaphore);
+ mConnectedDeviceManager.registerActiveUserConnectionCallback(connectionCallback,
+ mCallbackExecutor);
+ mConnectedDeviceManager.removeConnectedDevice(deviceId, mMockCentralManager);
+ assertThat(tryAcquire(semaphore)).isFalse();
+ }
+
+ @Test
+ public void unregisterConnectionCallback_removesCallbackAndNotInvoked()
+ throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ ConnectionCallback connectionCallback = createConnectionCallback(semaphore);
+ mConnectedDeviceManager.registerActiveUserConnectionCallback(connectionCallback,
+ mCallbackExecutor);
+ mConnectedDeviceManager.unregisterConnectionCallback(connectionCallback);
+ connectNewDevice(mMockCentralManager);
+ assertThat(tryAcquire(semaphore)).isFalse();
+ }
+
+ @Test
+ public void registerDeviceCallback_blacklistsDuplicateRecipientId()
+ throws InterruptedException {
+ connectNewDevice(mMockCentralManager);
+ ConnectedDevice connectedDevice =
+ mConnectedDeviceManager.getActiveUserConnectedDevices().get(0);
+ Semaphore firstSemaphore = new Semaphore(0);
+ Semaphore secondSemaphore = new Semaphore(0);
+ Semaphore thirdSemaphore = new Semaphore(0);
+ DeviceCallback firstDeviceCallback = createDeviceCallback(firstSemaphore);
+ DeviceCallback secondDeviceCallback = createDeviceCallback(secondSemaphore);
+ DeviceCallback thirdDeviceCallback = createDeviceCallback(thirdSemaphore);
+
+ // Register three times for following chain of events:
+ // 1. First callback registered without issue.
+ // 2. Second callback with same recipientId triggers blacklisting both callbacks and issues
+ // error callbacks on both. Both callbacks should be unregistered at this point.
+ // 3. Third callback gets rejected at registration and issues error callback.
+
+ mConnectedDeviceManager.registerDeviceCallback(connectedDevice, mRecipientId,
+ firstDeviceCallback, mCallbackExecutor);
+ mConnectedDeviceManager.registerDeviceCallback(connectedDevice, mRecipientId,
+ secondDeviceCallback, mCallbackExecutor);
+ DeviceMessage message = new DeviceMessage(mRecipientId, false, new byte[10]);
+ mConnectedDeviceManager.onMessageReceived(connectedDevice.getDeviceId(), message);
+ assertThat(tryAcquire(firstSemaphore)).isTrue();
+ assertThat(tryAcquire(secondSemaphore)).isTrue();
+ verify(firstDeviceCallback)
+ .onDeviceError(connectedDevice, DEVICE_ERROR_INSECURE_RECIPIENT_ID_DETECTED);
+ verify(secondDeviceCallback)
+ .onDeviceError(connectedDevice, DEVICE_ERROR_INSECURE_RECIPIENT_ID_DETECTED);
+ verify(firstDeviceCallback, times(0)).onMessageReceived(any(), any());
+ verify(secondDeviceCallback, times(0)).onMessageReceived(any(), any());
+
+ mConnectedDeviceManager.registerDeviceCallback(connectedDevice, mRecipientId,
+ thirdDeviceCallback, mCallbackExecutor);
+ assertThat(tryAcquire(thirdSemaphore)).isTrue();
+ verify(thirdDeviceCallback)
+ .onDeviceError(connectedDevice, DEVICE_ERROR_INSECURE_RECIPIENT_ID_DETECTED);
+ }
+
+ @Test
+ public void deviceCallback_onSecureChannelEstablishedInvoked() throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ connectNewDevice(mMockCentralManager);
+ ConnectedDevice connectedDevice =
+ mConnectedDeviceManager.getActiveUserConnectedDevices().get(0);
+ DeviceCallback deviceCallback = createDeviceCallback(semaphore);
+ mConnectedDeviceManager.registerDeviceCallback(connectedDevice, mRecipientId,
+ deviceCallback, mCallbackExecutor);
+ mConnectedDeviceManager.onSecureChannelEstablished(connectedDevice.getDeviceId(),
+ mMockCentralManager);
+ connectedDevice =
+ mConnectedDeviceManager.getActiveUserConnectedDevices().get(0);
+ assertThat(tryAcquire(semaphore)).isTrue();
+ verify(deviceCallback).onSecureChannelEstablished(connectedDevice);
+ }
+
+ @Test
+ public void deviceCallback_onSecureChannelEstablishedNotInvokedWithSecondBleManager()
+ throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ connectNewDevice(mMockCentralManager);
+ ConnectedDevice connectedDevice =
+ mConnectedDeviceManager.getActiveUserConnectedDevices().get(0);
+ mConnectedDeviceManager.onSecureChannelEstablished(connectedDevice.getDeviceId(),
+ mMockCentralManager);
+ DeviceCallback deviceCallback = createDeviceCallback(semaphore);
+ mConnectedDeviceManager.registerDeviceCallback(connectedDevice, mRecipientId,
+ deviceCallback, mCallbackExecutor);
+ mConnectedDeviceManager.onSecureChannelEstablished(connectedDevice.getDeviceId(),
+ mMockPeripheralManager);
+ assertThat(tryAcquire(semaphore)).isFalse();
+ }
+
+ @Test
+ public void deviceCallback_onMessageReceivedInvokedForSameRecipientId()
+ throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ connectNewDevice(mMockCentralManager);
+ ConnectedDevice connectedDevice =
+ mConnectedDeviceManager.getActiveUserConnectedDevices().get(0);
+ DeviceCallback deviceCallback = createDeviceCallback(semaphore);
+ mConnectedDeviceManager.registerDeviceCallback(connectedDevice, mRecipientId,
+ deviceCallback, mCallbackExecutor);
+ byte[] payload = ByteUtils.randomBytes(10);
+ DeviceMessage message = new DeviceMessage(mRecipientId, false, payload);
+ mConnectedDeviceManager.onMessageReceived(connectedDevice.getDeviceId(), message);
+ assertThat(tryAcquire(semaphore)).isTrue();
+ verify(deviceCallback).onMessageReceived(connectedDevice, payload);
+ }
+
+ @Test
+ public void deviceCallback_onMessageReceivedNotInvokedForDifferentRecipientId()
+ throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ connectNewDevice(mMockCentralManager);
+ ConnectedDevice connectedDevice =
+ mConnectedDeviceManager.getActiveUserConnectedDevices().get(0);
+ DeviceCallback deviceCallback = createDeviceCallback(semaphore);
+ mConnectedDeviceManager.registerDeviceCallback(connectedDevice, mRecipientId,
+ deviceCallback, mCallbackExecutor);
+ byte[] payload = ByteUtils.randomBytes(10);
+ DeviceMessage message = new DeviceMessage(UUID.randomUUID(), false, payload);
+ mConnectedDeviceManager.onMessageReceived(connectedDevice.getDeviceId(), message);
+ assertThat(tryAcquire(semaphore)).isFalse();
+ }
+
+ @Test
+ public void deviceCallback_onDeviceErrorInvokedOnChannelError() throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ connectNewDevice(mMockCentralManager);
+ ConnectedDevice connectedDevice =
+ mConnectedDeviceManager.getActiveUserConnectedDevices().get(0);
+ DeviceCallback deviceCallback = createDeviceCallback(semaphore);
+ mConnectedDeviceManager.registerDeviceCallback(connectedDevice, mRecipientId,
+ deviceCallback, mCallbackExecutor);
+ mConnectedDeviceManager.deviceErrorOccurred(connectedDevice.getDeviceId());
+ assertThat(tryAcquire(semaphore)).isTrue();
+ verify(deviceCallback).onDeviceError(connectedDevice, DEVICE_ERROR_INVALID_SECURITY_KEY);
+ }
+
+ @Test
+ public void unregisterDeviceCallback_removesCallbackAndNotInvoked()
+ throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ connectNewDevice(mMockCentralManager);
+ ConnectedDevice connectedDevice =
+ mConnectedDeviceManager.getActiveUserConnectedDevices().get(0);
+ DeviceCallback deviceCallback = createDeviceCallback(semaphore);
+ mConnectedDeviceManager.registerDeviceCallback(connectedDevice, mRecipientId,
+ deviceCallback, mCallbackExecutor);
+ mConnectedDeviceManager.unregisterDeviceCallback(connectedDevice, mRecipientId,
+ deviceCallback);
+ mConnectedDeviceManager.onSecureChannelEstablished(connectedDevice.getDeviceId(),
+ mMockPeripheralManager);
+ assertThat(tryAcquire(semaphore)).isFalse();
+ }
+
+ @Test
+ public void registerDeviceCallback_sendsMissedMessageAfterRegistration()
+ throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ connectNewDevice(mMockCentralManager);
+ ConnectedDevice connectedDevice =
+ mConnectedDeviceManager.getActiveUserConnectedDevices().get(0);
+ byte[] payload = ByteUtils.randomBytes(10);
+ DeviceMessage message = new DeviceMessage(mRecipientId, false, payload);
+ mConnectedDeviceManager.onMessageReceived(connectedDevice.getDeviceId(), message);
+ DeviceCallback deviceCallback = createDeviceCallback(semaphore);
+ mConnectedDeviceManager.registerDeviceCallback(connectedDevice, mRecipientId,
+ deviceCallback, mCallbackExecutor);
+ assertThat(tryAcquire(semaphore)).isTrue();
+ verify(deviceCallback).onMessageReceived(connectedDevice, payload);
+ }
+
+ @Test
+ public void registerDeviceCallback_doesNotSendMissedMessageForDifferentRecipient()
+ throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ connectNewDevice(mMockCentralManager);
+ ConnectedDevice connectedDevice =
+ mConnectedDeviceManager.getActiveUserConnectedDevices().get(0);
+ byte[] payload = ByteUtils.randomBytes(10);
+ DeviceMessage message = new DeviceMessage(UUID.randomUUID(), false, payload);
+ mConnectedDeviceManager.onMessageReceived(connectedDevice.getDeviceId(), message);
+ DeviceCallback deviceCallback = createDeviceCallback(semaphore);
+ mConnectedDeviceManager.registerDeviceCallback(connectedDevice, mRecipientId,
+ deviceCallback, mCallbackExecutor);
+ assertThat(tryAcquire(semaphore)).isFalse();
+ }
+
+ @Test
+ public void registerDeviceCallback_doesNotSendMissedMessageForDifferentDevice()
+ throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ connectNewDevice(mMockCentralManager);
+ connectNewDevice(mMockCentralManager);
+ List<ConnectedDevice> connectedDevices =
+ mConnectedDeviceManager.getActiveUserConnectedDevices();
+ ConnectedDevice connectedDevice = connectedDevices.get(0);
+ ConnectedDevice otherDevice = connectedDevices.get(1);
+ byte[] payload = ByteUtils.randomBytes(10);
+ DeviceMessage message = new DeviceMessage(mRecipientId, false, payload);
+ mConnectedDeviceManager.onMessageReceived(otherDevice.getDeviceId(), message);
+ DeviceCallback deviceCallback = createDeviceCallback(semaphore);
+ mConnectedDeviceManager.registerDeviceCallback(connectedDevice, mRecipientId,
+ deviceCallback, mCallbackExecutor);
+ assertThat(tryAcquire(semaphore)).isFalse();
+ }
+
+ @Test
+ public void onAssociationCompleted_disconnectsOriginalDeviceAndReconnectsAsActiveUser()
+ throws InterruptedException {
+ String deviceId = UUID.randomUUID().toString();
+ mConnectedDeviceManager.addConnectedDevice(deviceId, mMockPeripheralManager);
+ Semaphore semaphore = new Semaphore(0);
+ ConnectionCallback connectionCallback = createConnectionCallback(semaphore);
+ mConnectedDeviceManager.registerActiveUserConnectionCallback(connectionCallback,
+ mCallbackExecutor);
+ when(mMockStorage.getActiveUserAssociatedDeviceIds()).thenReturn(
+ Collections.singletonList(deviceId));
+ mConnectedDeviceManager.onAssociationCompleted(deviceId);
+ assertThat(tryAcquire(semaphore)).isTrue();
+ }
+
+ private boolean tryAcquire(Semaphore semaphore) throws InterruptedException {
+ return semaphore.tryAcquire(100, TimeUnit.MILLISECONDS);
+ }
+
+ @Test
+ public void deviceAssociationCallback_onAssociatedDeviceAdded() throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ DeviceAssociationCallback callback = createDeviceAssociationCallback(semaphore);
+ mConnectedDeviceManager.registerDeviceAssociationCallback(callback, mCallbackExecutor);
+ String deviceId = UUID.randomUUID().toString();
+ mAssociatedDeviceCallback.onAssociatedDeviceAdded(deviceId);
+ assertThat(tryAcquire(semaphore)).isTrue();
+ verify(callback).onAssociatedDeviceAdded(eq(deviceId));
+ }
+
+ @Test
+ public void deviceAssociationCallback_onAssociationDeviceRemoved() throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ DeviceAssociationCallback callback = createDeviceAssociationCallback(semaphore);
+ mConnectedDeviceManager.registerDeviceAssociationCallback(callback, mCallbackExecutor);
+ String deviceId = UUID.randomUUID().toString();
+ mAssociatedDeviceCallback.onAssociatedDeviceRemoved(deviceId);
+ assertThat(tryAcquire(semaphore)).isTrue();
+ verify(callback).onAssociatedDeviceRemoved(eq(deviceId));
+ }
+
+ @Test
+ public void deviceAssociationCallback_onAssociatedDeviceUpdated() throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ DeviceAssociationCallback callback = createDeviceAssociationCallback(semaphore);
+ mConnectedDeviceManager.registerDeviceAssociationCallback(callback, mCallbackExecutor);
+ String deviceId = UUID.randomUUID().toString();
+ String deviceAddress = "00:11:22:33:44:55";
+ String deviceName = "TEST_NAME";
+ AssociatedDevice testDevice = new AssociatedDevice(deviceId, deviceAddress, deviceName);
+ mAssociatedDeviceCallback.onAssociatedDeviceUpdated(testDevice);
+ assertThat(tryAcquire(semaphore)).isTrue();
+ verify(callback).onAssociatedDeviceUpdated(eq(testDevice));
+ }
+
+ @Test
+ public void removeConnectedDevice_startsAdvertisingForActiveUserDevice()
+ throws InterruptedException {
+ String deviceId = UUID.randomUUID().toString();
+ when(mMockStorage.getActiveUserAssociatedDeviceIds()).thenReturn(
+ Collections.singletonList(deviceId));
+ mConnectedDeviceManager.addConnectedDevice(deviceId, mMockPeripheralManager);
+ mConnectedDeviceManager.removeConnectedDevice(deviceId, mMockPeripheralManager);
+ Thread.sleep(100); // Async process so need to allow it time to complete.
+ // ConnectedDeviceManager.start() also invokes connectToDevice(), so expect # of calls = 2.
+ verify(mMockPeripheralManager, atMost(2)).connectToDevice(eq(UUID.fromString(deviceId)));
+ }
+
+ @Test
+ public void removeConnectedDevice__doesNotAdvertiseForNonActiveUserDevice()
+ throws InterruptedException {
+ String deviceId = UUID.randomUUID().toString();
+ String userDeviceId = UUID.randomUUID().toString();
+ when(mMockStorage.getActiveUserAssociatedDeviceIds()).thenReturn(
+ Collections.singletonList(userDeviceId));
+ mConnectedDeviceManager.addConnectedDevice(deviceId, mMockPeripheralManager);
+ mConnectedDeviceManager.removeConnectedDevice(deviceId, mMockPeripheralManager);
+ Thread.sleep(100); // Async process so need to allow it time to complete.
+ // ConnectedDeviceManager.start() invokes connectToDevice(), so expect # of calls = 1.
+ verify(mMockPeripheralManager).connectToDevice(eq(UUID.fromString(userDeviceId)));
+ }
+
+ @NonNull
+ private String connectNewDevice(@NonNull CarBleManager carBleManager) {
+ String deviceId = UUID.randomUUID().toString();
+ when(mMockStorage.getActiveUserAssociatedDeviceIds()).thenReturn(
+ Collections.singletonList(deviceId));
+ mConnectedDeviceManager.addConnectedDevice(deviceId, carBleManager);
+ return deviceId;
+ }
+
+ @NonNull
+ private ConnectionCallback createConnectionCallback(@NonNull final Semaphore semaphore) {
+ return spy(new ConnectionCallback() {
+ @Override
+ public void onDeviceConnected(ConnectedDevice device) {
+ semaphore.release();
+ }
+
+ @Override
+ public void onDeviceDisconnected(ConnectedDevice device) {
+ semaphore.release();
+ }
+ });
+ }
+
+ @NonNull
+ private DeviceCallback createDeviceCallback(@NonNull final Semaphore semaphore) {
+ return spy(new DeviceCallback() {
+ @Override
+ public void onSecureChannelEstablished(ConnectedDevice device) {
+ semaphore.release();
+ }
+
+ @Override
+ public void onMessageReceived(ConnectedDevice device, byte[] message) {
+ semaphore.release();
+ }
+
+ @Override
+ public void onDeviceError(ConnectedDevice device, int error) {
+ semaphore.release();
+ }
+ });
+ }
+
+ @NonNull
+ private DeviceAssociationCallback createDeviceAssociationCallback(
+ @NonNull final Semaphore semaphore) {
+ return spy(new DeviceAssociationCallback() {
+ @Override
+ public void onAssociatedDeviceAdded(String deviceId) {
+ semaphore.release();
+ }
+
+ @Override
+ public void onAssociatedDeviceRemoved(String deviceId) {
+ semaphore.release();
+ }
+
+ @Override
+ public void onAssociatedDeviceUpdated(AssociatedDevice device) {
+ semaphore.release();
+ }
+ });
+ }
+}
diff --git a/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/BleDeviceMessageStreamTest.java b/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/BleDeviceMessageStreamTest.java
new file mode 100644
index 0000000..b45b6f2
--- /dev/null
+++ b/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/BleDeviceMessageStreamTest.java
@@ -0,0 +1,199 @@
+/*
+ * 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.android.car.connecteddevice.ble;
+
+import static com.android.car.connecteddevice.BleStreamProtos.BleDeviceMessageProto.BleDeviceMessage;
+import static com.android.car.connecteddevice.BleStreamProtos.BleOperationProto.OperationType;
+import static com.android.car.connecteddevice.BleStreamProtos.BlePacketProto.BlePacket;
+import static com.android.car.connecteddevice.ble.BleDeviceMessageStream.MessageReceivedListener;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mockitoSession;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+import android.annotation.NonNull;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGattCharacteristic;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.android.car.connecteddevice.util.ByteUtils;
+import com.android.car.protobuf.ByteString;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoSession;
+import org.mockito.quality.Strictness;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
+
+@RunWith(AndroidJUnit4.class)
+public class BleDeviceMessageStreamTest {
+
+ private static final String TAG = "BleDeviceMessageStreamTest";
+
+ private BleDeviceMessageStream mStream;
+
+ @Mock
+ private BlePeripheralManager mMockBlePeripheralManager;
+
+ @Mock
+ private BluetoothDevice mMockBluetoothDevice;
+
+ @Mock
+ private BluetoothGattCharacteristic mMockWriteCharacteristic;
+
+ @Mock
+ private BluetoothGattCharacteristic mMockReadCharacteristic;
+
+ private MockitoSession mMockingSession;
+
+ @Before
+ public void setup() {
+ mMockingSession = mockitoSession()
+ .initMocks(this)
+ .strictness(Strictness.LENIENT)
+ .startMocking();
+
+ mStream = new BleDeviceMessageStream(mMockBlePeripheralManager, mMockBluetoothDevice,
+ mMockWriteCharacteristic, mMockReadCharacteristic);
+ }
+
+ @After
+ public void cleanup() {
+ if (mMockingSession != null) {
+ mMockingSession.finishMocking();
+ }
+ }
+
+ @Test
+ public void processPacket_notifiesWithEntireMessageForSinglePacketMessage()
+ throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ MessageReceivedListener listener = createMessageReceivedListener(semaphore);
+ mStream.setMessageReceivedListener(listener);
+ byte[] data = ByteUtils.randomBytes(5);
+ processMessage(data);
+ assertThat(tryAcquire(semaphore)).isTrue();
+ ArgumentCaptor<DeviceMessage> messageCaptor = ArgumentCaptor.forClass(DeviceMessage.class);
+ verify(listener).onMessageReceived(messageCaptor.capture(), any());
+ }
+
+ @Test
+ public void processPacket_notifiesWithEntireMessageForMultiPacketMessage()
+ throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ MessageReceivedListener listener = createMessageReceivedListener(semaphore);
+ mStream.setMessageReceivedListener(listener);
+ byte[] data = ByteUtils.randomBytes(750);
+ processMessage(data);
+ assertThat(tryAcquire(semaphore)).isTrue();
+ ArgumentCaptor<DeviceMessage> messageCaptor = ArgumentCaptor.forClass(DeviceMessage.class);
+ verify(listener).onMessageReceived(messageCaptor.capture(), any());
+ assertThat(Arrays.equals(data, messageCaptor.getValue().getMessage())).isTrue();
+ }
+
+ @Test
+ public void processPacket_receivingMultipleMessagesInParallelParsesSuccessfully()
+ throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ MessageReceivedListener listener = createMessageReceivedListener(semaphore);
+ mStream.setMessageReceivedListener(listener);
+ byte[] data = ByteUtils.randomBytes(750);
+ List<BlePacket> packets1 = createPackets(data);
+ List<BlePacket> packets2 = createPackets(data);
+
+ for (int i = 0; i < packets1.size(); i++) {
+ mStream.processPacket(packets1.get(i));
+ if (i == packets1.size() - 1) {
+ break;
+ }
+ mStream.processPacket(packets2.get(i));
+ }
+ assertThat(tryAcquire(semaphore)).isTrue();
+ ArgumentCaptor<DeviceMessage> messageCaptor = ArgumentCaptor.forClass(DeviceMessage.class);
+ verify(listener).onMessageReceived(messageCaptor.capture(), any());
+ assertThat(Arrays.equals(data, messageCaptor.getValue().getMessage())).isTrue();
+
+ semaphore = new Semaphore(0);
+ listener = createMessageReceivedListener(semaphore);
+ mStream.setMessageReceivedListener(listener);
+ mStream.processPacket(packets2.get(packets2.size() - 1));
+ verify(listener).onMessageReceived(messageCaptor.capture(), any());
+ assertThat(Arrays.equals(data, messageCaptor.getValue().getMessage())).isTrue();
+ }
+
+ @Test
+ public void processPacket_doesNotNotifyOfNewMessageIfNotAllPacketsReceived()
+ throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ MessageReceivedListener listener = createMessageReceivedListener(semaphore);
+ mStream.setMessageReceivedListener(listener);
+ byte[] data = ByteUtils.randomBytes(750);
+ List<BlePacket> packets = createPackets(data);
+ for (int i = 0; i < packets.size() - 1; i++) {
+ mStream.processPacket(packets.get(i));
+ }
+ assertThat(tryAcquire(semaphore)).isFalse();
+ }
+
+ @NonNull
+ private List<BlePacket> createPackets(byte[] data) {
+ try {
+ BleDeviceMessage message = BleDeviceMessage.newBuilder()
+ .setPayload(ByteString.copyFrom(data))
+ .setOperation(OperationType.CLIENT_MESSAGE)
+ .build();
+ return BlePacketFactory.makeBlePackets(message.toByteArray(),
+ ThreadLocalRandom.current().nextInt(), 500);
+ } catch (Exception e) {
+ assertWithMessage("Uncaught exception while making packets.").fail();
+ return new ArrayList<>();
+ }
+ }
+
+ private void processMessage(byte[] data) {
+ List<BlePacket> packets = createPackets(data);
+ for (BlePacket packet : packets) {
+ mStream.processPacket(packet);
+ }
+ }
+
+ private boolean tryAcquire(Semaphore semaphore) throws InterruptedException {
+ return semaphore.tryAcquire(100, TimeUnit.MILLISECONDS);
+ }
+
+ @NonNull
+ private MessageReceivedListener createMessageReceivedListener(
+ Semaphore semaphore) {
+ return spy((deviceMessage, operationType) -> semaphore.release());
+ }
+
+}
diff --git a/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/BlePacketFactoryTest.java b/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/BlePacketFactoryTest.java
new file mode 100644
index 0000000..8e8682f
--- /dev/null
+++ b/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/BlePacketFactoryTest.java
@@ -0,0 +1,170 @@
+/*
+ * Copyright (C) 2019 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.car.connecteddevice.ble;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.android.car.connecteddevice.BleStreamProtos.BlePacketProto.BlePacket;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.ByteArrayOutputStream;
+import java.util.List;
+import java.util.Random;
+
+@RunWith(AndroidJUnit4.class)
+public class BlePacketFactoryTest {
+ @Test
+ public void testGetHeaderSize() {
+ // 1 byte to encode the ID, 1 byte for the field number.
+ int messageId = 1;
+ int messageIdEncodingSize = 2;
+
+ // 1 byte for the payload size, 1 byte for the field number.
+ int payloadSize = 2;
+ int payloadSizeEncodingSize = 2;
+
+ // 1 byte for total packets, 1 byte for field number.
+ int totalPackets = 5;
+ int totalPacketsEncodingSize = 2;
+
+ // Packet number if a fixed32, so 4 bytes + 1 byte for field number.
+ int packetNumberEncodingSize = 5;
+
+ int expectedHeaderSize = messageIdEncodingSize + payloadSizeEncodingSize
+ + totalPacketsEncodingSize + packetNumberEncodingSize;
+
+ assertThat(BlePacketFactory.getPacketHeaderSize(totalPackets, messageId, payloadSize))
+ .isEqualTo(expectedHeaderSize);
+ }
+
+ @Test
+ public void testGetTotalPackets_withVarintSize1_returnsCorrectPackets()
+ throws BlePacketFactoryException {
+ int messageId = 1;
+ int maxSize = 49;
+ int payloadSize = 100;
+
+ // This leaves us 40 bytes to use for the payload and its encoding size. Assuming a varint
+ // of size 1 means it takes 2 bytes to encode its value. This leaves 38 bytes for the
+ // payload. ceil(payloadSize/38) gives the total packets.
+ int expectedTotalPackets = 3;
+
+ assertThat(BlePacketFactory.getTotalPacketNumber(messageId, payloadSize, maxSize))
+ .isEqualTo(expectedTotalPackets);
+ }
+
+ @Test
+ public void testGetTotalPackets_withVarintSize2_returnsCorrectPackets()
+ throws BlePacketFactoryException {
+ int messageId = 1;
+ int maxSize = 49;
+ int payloadSize = 6000;
+
+ // This leaves us 40 bytes to use for the payload and its encoding size. Assuming a varint
+ // of size 2 means it takes 3 bytes to encode its value. This leaves 37 bytes for the
+ // payload. ceil(payloadSize/37) gives the total packets.
+ int expectedTotalPackets = 163;
+
+ assertThat(BlePacketFactory.getTotalPacketNumber(messageId, payloadSize, maxSize))
+ .isEqualTo(expectedTotalPackets);
+ }
+
+ @Test
+ public void testGetTotalPackets_withVarintSize3_returnsCorrectPackets()
+ throws BlePacketFactoryException {
+ int messageId = 1;
+ int maxSize = 49;
+ int payloadSize = 1000000;
+
+ // This leaves us 40 bytes to use for the payload and its encoding size. Assuming a varint
+ // of size 3 means it takes 4 bytes to encode its value. This leaves 36 bytes for the
+ // payload. ceil(payloadSize/36) gives the total packets.
+ int expectedTotalPackets = 27778;
+
+ assertThat(BlePacketFactory.getTotalPacketNumber(messageId, payloadSize, maxSize))
+ .isEqualTo(expectedTotalPackets);
+ }
+
+ @Test
+ public void testGetTotalPackets_withVarintSize4_returnsCorrectPackets()
+ throws BlePacketFactoryException {
+ int messageId = 1;
+ int maxSize = 49;
+ int payloadSize = 178400320;
+
+ // This leaves us 40 bytes to use for the payload and its encoding size. Assuming a varint
+ // of size 4 means it takes 5 bytes to encode its value. This leaves 35 bytes for the
+ // payload. ceil(payloadSize/35) gives the total packets.
+ int expectedTotalPackets = 5097152;
+
+ assertThat(BlePacketFactory.getTotalPacketNumber(messageId, payloadSize, maxSize))
+ .isEqualTo(expectedTotalPackets);
+ }
+
+ @Test
+ public void testMakePackets_correctlyChunksPayload() throws Exception {
+ // Payload of size 100, but maxSize of 1000 to ensure it fits.
+ byte[] payload = makePayload(/* length= */ 100);
+ int maxSize = 1000;
+
+ List<BlePacket> packets =
+ BlePacketFactory.makeBlePackets(payload, /* mesageId= */ 1, maxSize);
+
+ assertThat(packets).hasSize(1);
+
+ ByteArrayOutputStream reconstructedPayload = new ByteArrayOutputStream();
+
+ // Combine together all the payloads within the BlePackets.
+ for (BlePacket packet : packets) {
+ reconstructedPayload.write(packet.getPayload().toByteArray());
+ }
+
+ assertThat(reconstructedPayload.toByteArray()).isEqualTo(payload);
+ }
+
+ @Test
+ public void testMakePackets_correctlyChunksSplitPayload() throws Exception {
+ // Payload size of 10000 but max size of 50 to ensure the payload is split.
+ byte[] payload = makePayload(/* length= */ 10000);
+ int maxSize = 50;
+
+ List<BlePacket> packets =
+ BlePacketFactory.makeBlePackets(payload, /* mesageId= */ 1, maxSize);
+
+ assertThat(packets.size()).isGreaterThan(1);
+
+ ByteArrayOutputStream reconstructedPayload = new ByteArrayOutputStream();
+
+ // Combine together all the payloads within the BlePackets.
+ for (BlePacket packet : packets) {
+ reconstructedPayload.write(packet.getPayload().toByteArray());
+ }
+
+ assertThat(reconstructedPayload.toByteArray()).isEqualTo(payload);
+ }
+
+ /** Creates a byte array of the given length, populated with random bytes. */
+ private byte[] makePayload(int length) {
+ byte[] payload = new byte[length];
+ new Random().nextBytes(payload);
+ return payload;
+ }
+}
diff --git a/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/CarBlePeripheralManagerTest.java b/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/CarBlePeripheralManagerTest.java
new file mode 100644
index 0000000..adba67b
--- /dev/null
+++ b/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/CarBlePeripheralManagerTest.java
@@ -0,0 +1,253 @@
+/*
+ * Copyright (C) 2019 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.car.connecteddevice.ble;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mockitoSession;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+
+import android.annotation.NonNull;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.le.AdvertiseCallback;
+import android.bluetooth.le.AdvertiseData;
+import android.bluetooth.le.AdvertiseSettings;
+import android.car.encryptionrunner.EncryptionRunnerFactory;
+import android.car.encryptionrunner.Key;
+import android.os.ParcelUuid;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.android.car.connecteddevice.AssociationCallback;
+import com.android.car.connecteddevice.model.AssociatedDevice;
+import com.android.car.connecteddevice.storage.ConnectedDeviceStorage;
+import com.android.car.connecteddevice.util.ByteUtils;
+
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoSession;
+import org.mockito.quality.Strictness;
+
+import java.util.UUID;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+
+@RunWith(AndroidJUnit4.class)
+public class CarBlePeripheralManagerTest {
+ private static final UUID ASSOCIATION_SERVICE_UUID = UUID.randomUUID();
+ private static final UUID WRITE_UUID = UUID.randomUUID();
+ private static final UUID READ_UUID = UUID.randomUUID();
+ private static final int DEVICE_NAME_LENGTH_LIMIT = 8;
+ private static final String TEST_REMOTE_DEVICE_ADDRESS = "00:11:22:33:AA:BB";
+ private static final UUID TEST_REMOTE_DEVICE_ID = UUID.randomUUID();
+ private static final String TEST_VERIFICATION_CODE = "000000";
+ private static final byte[] TEST_KEY = "Key".getBytes();
+ private static String sAdapterName;
+
+ @Mock private BlePeripheralManager mMockPeripheralManager;
+ @Mock private ConnectedDeviceStorage mMockStorage;
+
+ private CarBlePeripheralManager mCarBlePeripheralManager;
+
+ private MockitoSession mMockitoSession;
+
+ @BeforeClass
+ public static void beforeSetUp() {
+ sAdapterName = BluetoothAdapter.getDefaultAdapter().getName();
+ }
+ @Before
+ public void setUp() {
+ mMockitoSession = mockitoSession()
+ .initMocks(this)
+ .strictness(Strictness.LENIENT)
+ .startMocking();
+ mCarBlePeripheralManager = new CarBlePeripheralManager(mMockPeripheralManager, mMockStorage,
+ ASSOCIATION_SERVICE_UUID, WRITE_UUID, READ_UUID);
+ }
+
+ @After
+ public void tearDown() {
+ mCarBlePeripheralManager.stop();
+ if (mMockitoSession != null) {
+ mMockitoSession.finishMocking();
+ }
+ }
+
+ @AfterClass
+ public static void afterTearDown() {
+ BluetoothAdapter.getDefaultAdapter().setName(sAdapterName);
+ }
+
+ @Test
+ public void testStartAssociationAdvertisingSuccess() {
+ Semaphore semaphore = new Semaphore(0);
+ AssociationCallback callback = createAssociationCallback(semaphore);
+ String testDeviceName = getNameForAssociation();
+ startAssociation(callback, testDeviceName);
+ ArgumentCaptor<AdvertiseData> dataCaptor = ArgumentCaptor.forClass(AdvertiseData.class);
+ verify(mMockPeripheralManager, timeout(3000)).startAdvertising(any(),
+ dataCaptor.capture(), any());
+ AdvertiseData data = dataCaptor.getValue();
+ assertThat(data.getIncludeDeviceName()).isTrue();
+ ParcelUuid expected = new ParcelUuid(ASSOCIATION_SERVICE_UUID);
+ assertThat(data.getServiceUuids().get(0)).isEqualTo(expected);
+ assertThat(BluetoothAdapter.getDefaultAdapter().getName()).isEqualTo(testDeviceName);
+ }
+
+ @Test
+ public void testStartAssociationAdvertisingFailure() throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ AssociationCallback callback = createAssociationCallback(semaphore);
+ startAssociation(callback, getNameForAssociation());
+ ArgumentCaptor<AdvertiseCallback> callbackCaptor =
+ ArgumentCaptor.forClass(AdvertiseCallback.class);
+ verify(mMockPeripheralManager, timeout(3000))
+ .startAdvertising(any(), any(), callbackCaptor.capture());
+ AdvertiseCallback advertiseCallback = callbackCaptor.getValue();
+ int testErrorCode = 2;
+ advertiseCallback.onStartFailure(testErrorCode);
+ assertThat(tryAcquire(semaphore)).isTrue();
+ verify(callback).onAssociationStartFailure();
+ }
+
+ @Test
+ public void testNotifyAssociationSuccess() throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ AssociationCallback callback = createAssociationCallback(semaphore);
+ String testDeviceName = getNameForAssociation();
+ startAssociation(callback, testDeviceName);
+ ArgumentCaptor<AdvertiseCallback> callbackCaptor =
+ ArgumentCaptor.forClass(AdvertiseCallback.class);
+ verify(mMockPeripheralManager, timeout(3000))
+ .startAdvertising(any(), any(), callbackCaptor.capture());
+ AdvertiseCallback advertiseCallback = callbackCaptor.getValue();
+ AdvertiseSettings settings = new AdvertiseSettings.Builder().build();
+ advertiseCallback.onStartSuccess(settings);
+ assertThat(tryAcquire(semaphore)).isTrue();
+ verify(callback).onAssociationStartSuccess(eq(testDeviceName));
+ }
+
+ @Test
+ public void testShowVerificationCode() throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ AssociationCallback callback = createAssociationCallback(semaphore);
+ SecureBleChannel channel = getChannelForAssociation(callback);
+ channel.getShowVerificationCodeListener().showVerificationCode(TEST_VERIFICATION_CODE);
+ assertThat(tryAcquire(semaphore)).isTrue();
+ verify(callback).onVerificationCodeAvailable(eq(TEST_VERIFICATION_CODE));
+ }
+
+ @Test
+ public void testAssociationSuccess() throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ AssociationCallback callback = createAssociationCallback(semaphore);
+ SecureBleChannel channel = getChannelForAssociation(callback);
+ SecureBleChannel.Callback channelCallback = channel.getCallback();
+ assertThat(channelCallback).isNotNull();
+ channelCallback.onDeviceIdReceived(TEST_REMOTE_DEVICE_ID.toString());
+ Key key = EncryptionRunnerFactory.newDummyRunner().keyOf(TEST_KEY);
+ channelCallback.onSecureChannelEstablished();
+ ArgumentCaptor<AssociatedDevice> deviceCaptor =
+ ArgumentCaptor.forClass(AssociatedDevice.class);
+ verify(mMockStorage).addAssociatedDeviceForActiveUser(deviceCaptor.capture());
+ AssociatedDevice device = deviceCaptor.getValue();
+ assertThat(device.getDeviceId()).isEqualTo(TEST_REMOTE_DEVICE_ID.toString());
+ assertThat(tryAcquire(semaphore)).isTrue();
+ verify(callback).onAssociationCompleted(eq(TEST_REMOTE_DEVICE_ID.toString()));
+ }
+
+ @Test
+ public void testAssociationFailure_channelError() throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ AssociationCallback callback = createAssociationCallback(semaphore);
+ SecureBleChannel channel = getChannelForAssociation(callback);
+ SecureBleChannel.Callback channelCallback = channel.getCallback();
+ int testErrorCode = 1;
+ assertThat(channelCallback).isNotNull();
+ channelCallback.onDeviceIdReceived(TEST_REMOTE_DEVICE_ID.toString());
+ channelCallback.onEstablishSecureChannelFailure(testErrorCode);
+ assertThat(tryAcquire(semaphore)).isTrue();
+ verify(callback).onAssociationError(eq(testErrorCode));
+ }
+
+ private BlePeripheralManager.Callback startAssociation(AssociationCallback callback,
+ String deviceName) {
+ ArgumentCaptor<BlePeripheralManager.Callback> callbackCaptor =
+ ArgumentCaptor.forClass(BlePeripheralManager.Callback.class);
+ mCarBlePeripheralManager.startAssociation(deviceName, callback);
+ verify(mMockPeripheralManager, timeout(3000)).registerCallback(callbackCaptor.capture());
+ return callbackCaptor.getValue();
+ }
+
+ private SecureBleChannel getChannelForAssociation(AssociationCallback callback) {
+ BlePeripheralManager.Callback bleManagerCallback = startAssociation(callback,
+ getNameForAssociation());
+ BluetoothDevice bluetoothDevice = BluetoothAdapter.getDefaultAdapter()
+ .getRemoteDevice(TEST_REMOTE_DEVICE_ADDRESS);
+ bleManagerCallback.onRemoteDeviceConnected(bluetoothDevice);
+ return mCarBlePeripheralManager.getConnectedDeviceChannel();
+ }
+
+ private boolean tryAcquire(Semaphore semaphore) throws InterruptedException {
+ return semaphore.tryAcquire(100, TimeUnit.MILLISECONDS);
+ }
+
+ private String getNameForAssociation() {
+ return ByteUtils.generateRandomNumberString(DEVICE_NAME_LENGTH_LIMIT);
+
+ }
+
+ @NonNull
+ private AssociationCallback createAssociationCallback(@NonNull final Semaphore semaphore) {
+ return spy(new AssociationCallback() {
+ @Override
+ public void onAssociationStartSuccess(String deviceName) {
+ semaphore.release();
+ }
+ @Override
+ public void onAssociationStartFailure() {
+ semaphore.release();
+ }
+
+ @Override
+ public void onAssociationError(int error) {
+ semaphore.release();
+ }
+
+ @Override
+ public void onVerificationCodeAvailable(String code) {
+ semaphore.release();
+ }
+
+ @Override
+ public void onAssociationCompleted(String deviceId) {
+ semaphore.release();
+ }
+ });
+ }
+}
diff --git a/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/SecureBleChannelTest.java b/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/SecureBleChannelTest.java
new file mode 100644
index 0000000..2960e49
--- /dev/null
+++ b/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/SecureBleChannelTest.java
@@ -0,0 +1,221 @@
+/*
+ * Copyright (C) 2019 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.car.connecteddevice.ble;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyString;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.car.encryptionrunner.DummyEncryptionRunner;
+import android.car.encryptionrunner.EncryptionRunnerFactory;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.android.car.connecteddevice.BleStreamProtos.BleOperationProto.OperationType;
+import com.android.car.connecteddevice.ble.BleDeviceMessageStream.MessageReceivedListener;
+import com.android.car.connecteddevice.storage.ConnectedDeviceStorage;
+import com.android.car.connecteddevice.util.ByteUtils;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.UUID;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+
+@RunWith(AndroidJUnit4.class)
+public final class SecureBleChannelTest {
+ private static final UUID CLIENT_DEVICE_ID =
+ UUID.fromString("a5645523-3280-410a-90c1-582a6c6f4969");
+ private static final UUID SERVER_DEVICE_ID =
+ UUID.fromString("a29f0c74-2014-4b14-ac02-be6ed15b545a");
+
+ private SecureBleChannel mChannel;
+ private MessageReceivedListener mMessageReceivedListener;
+
+ @Mock private BleDeviceMessageStream mStreamMock;
+ @Mock private ConnectedDeviceStorage mStorageMock;
+ @Mock private SecureBleChannel.ShowVerificationCodeListener mShowVerificationCodeListenerMock;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+ when(mStorageMock.getUniqueId()).thenReturn(SERVER_DEVICE_ID);
+ }
+
+ @Test
+ public void testEncryptionHandshake_Association() throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ ChannelCallback callbackSpy = spy(new ChannelCallback(semaphore));
+ setUpSecureBleChannel_Association(callbackSpy);
+ ArgumentCaptor<String> deviceIdCaptor = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor<DeviceMessage> messageCaptor =
+ ArgumentCaptor.forClass(DeviceMessage.class);
+
+ sendDeviceId();
+ assertThat(semaphore.tryAcquire(100, TimeUnit.MILLISECONDS)).isTrue();
+ verify(callbackSpy).onDeviceIdReceived(deviceIdCaptor.capture());
+ verify(mStreamMock).writeMessage(messageCaptor.capture(), any());
+ byte[] deviceIdMessage = messageCaptor.getValue().getMessage();
+ assertThat(deviceIdMessage).isEqualTo(ByteUtils.uuidToBytes(SERVER_DEVICE_ID));
+ assertThat(deviceIdCaptor.getValue()).isEqualTo(CLIENT_DEVICE_ID.toString());
+
+ initHandshakeMessage();
+ verify(mStreamMock, times(2)).writeMessage(messageCaptor.capture(), any());
+ byte[] response = messageCaptor.getValue().getMessage();
+ assertThat(response).isEqualTo(DummyEncryptionRunner.INIT_RESPONSE.getBytes());
+
+ respondToContinueMessage();
+ verify(mShowVerificationCodeListenerMock).showVerificationCode(anyString());
+
+ mChannel.notifyOutOfBandAccepted();
+ verify(mStreamMock, times(3)).writeMessage(messageCaptor.capture(), any());
+ byte[] confirmMessage = messageCaptor.getValue().getMessage();
+ assertThat(confirmMessage).isEqualTo(SecureBleChannel.CONFIRMATION_SIGNAL);
+ assertThat(semaphore.tryAcquire(100, TimeUnit.MILLISECONDS)).isTrue();
+ verify(callbackSpy).onSecureChannelEstablished();
+ }
+
+ @Test
+ public void testEncryptionHandshake_Association_wrongInitHandshakeMessage()
+ throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ ChannelCallback callbackSpy = spy(new ChannelCallback(semaphore));
+ setUpSecureBleChannel_Association(callbackSpy);
+
+ sendDeviceId();
+ assertThat(semaphore.tryAcquire(100, TimeUnit.MILLISECONDS)).isTrue();
+
+ // Wrong init handshake message
+ respondToContinueMessage();
+ assertThat(semaphore.tryAcquire(100, TimeUnit.MILLISECONDS)).isTrue();
+ verify(callbackSpy).onEstablishSecureChannelFailure(
+ eq(SecureBleChannel.CHANNEL_ERROR_INVALID_HANDSHAKE)
+ );
+ }
+
+ @Test
+ public void testEncryptionHandshake_Association_wrongRespondToContinueMessage()
+ throws InterruptedException {
+ Semaphore semaphore = new Semaphore(0);
+ ChannelCallback callbackSpy = spy(new ChannelCallback(semaphore));
+ setUpSecureBleChannel_Association(callbackSpy);
+
+ sendDeviceId();
+ assertThat(semaphore.tryAcquire(100, TimeUnit.MILLISECONDS)).isTrue();
+
+ initHandshakeMessage();
+
+ // Wrong respond to continue message
+ initHandshakeMessage();
+ assertThat(semaphore.tryAcquire(100, TimeUnit.MILLISECONDS)).isTrue();
+ verify(callbackSpy).onEstablishSecureChannelFailure(
+ eq(SecureBleChannel.CHANNEL_ERROR_INVALID_HANDSHAKE)
+ );
+ }
+
+ private void setUpSecureBleChannel_Association(ChannelCallback callback) {
+ mChannel = new SecureBleChannel(
+ mStreamMock,
+ mStorageMock,
+ /* isReconnect = */ false,
+ EncryptionRunnerFactory.newDummyRunner()
+ );
+ mChannel.registerCallback(callback);
+ mChannel.setShowVerificationCodeListener(mShowVerificationCodeListenerMock);
+ ArgumentCaptor<MessageReceivedListener> listenerCaptor =
+ ArgumentCaptor.forClass(MessageReceivedListener.class);
+ verify(mStreamMock).setMessageReceivedListener(listenerCaptor.capture());
+ mMessageReceivedListener = listenerCaptor.getValue();
+ }
+
+ private void sendDeviceId() {
+ DeviceMessage message = new DeviceMessage(
+ /* recipient = */ null,
+ /* isMessageEncrypted = */ false,
+ ByteUtils.uuidToBytes(CLIENT_DEVICE_ID)
+ );
+ mMessageReceivedListener.onMessageReceived(message, OperationType.ENCRYPTION_HANDSHAKE);
+ }
+
+ private void initHandshakeMessage() {
+ DeviceMessage message = new DeviceMessage(
+ /* recipient = */ null,
+ /* isMessageEncrypted = */ false,
+ DummyEncryptionRunner.INIT.getBytes()
+ );
+ mMessageReceivedListener.onMessageReceived(message, OperationType.ENCRYPTION_HANDSHAKE);
+ }
+
+ private void respondToContinueMessage() {
+ DeviceMessage message = new DeviceMessage(
+ /* recipient = */ null,
+ /* isMessageEncrypted = */ false,
+ DummyEncryptionRunner.CLIENT_RESPONSE.getBytes()
+ );
+ mMessageReceivedListener.onMessageReceived(message, OperationType.ENCRYPTION_HANDSHAKE);
+ }
+
+ /**
+ * Add the thread control logic into {@link SecureBleChannel.Callback} only for spy purpose.
+ *
+ * <p>The callback will release the semaphore which hold by one test when this callback
+ * is called, telling the test that it can verify certain behaviors which will only occurred
+ * after the callback is notified. This is needed mainly because of the callback is notified
+ * in a different thread.
+ */
+ class ChannelCallback implements SecureBleChannel.Callback {
+ private final Semaphore mSemaphore;
+ ChannelCallback(Semaphore semaphore) {
+ mSemaphore = semaphore;
+ }
+ @Override
+ public void onSecureChannelEstablished() {
+ mSemaphore.release();
+ }
+
+ @Override
+ public void onEstablishSecureChannelFailure(int error) {
+ mSemaphore.release();
+ }
+
+ @Override
+ public void onMessageReceived(DeviceMessage deviceMessage) {
+ mSemaphore.release();
+ }
+
+ @Override
+ public void onMessageReceivedError(Exception exception) {
+ mSemaphore.release();
+ }
+
+ @Override
+ public void onDeviceIdReceived(String deviceId) {
+ mSemaphore.release();
+ }
+ }
+}
diff --git a/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/storage/ConnectedDeviceStorageTest.java b/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/storage/ConnectedDeviceStorageTest.java
new file mode 100644
index 0000000..964f59b
--- /dev/null
+++ b/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/storage/ConnectedDeviceStorageTest.java
@@ -0,0 +1,141 @@
+/*
+ * Copyright (C) 2019 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.car.connecteddevice.storage;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.util.Pair;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.android.car.connecteddevice.model.AssociatedDevice;
+import com.android.car.connecteddevice.util.ByteUtils;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+@RunWith(AndroidJUnit4.class)
+public final class ConnectedDeviceStorageTest {
+ private final Context mContext = ApplicationProvider.getApplicationContext();
+
+ private final int mActiveUserId = 10;
+
+ private ConnectedDeviceStorage mConnectedDeviceStorage;
+
+ private List<Pair<Integer, AssociatedDevice>> mAddedAssociatedDevices;
+
+ @Before
+ public void setUp() {
+ mConnectedDeviceStorage = new ConnectedDeviceStorage(mContext);
+ mAddedAssociatedDevices = new ArrayList<>();
+ }
+
+ @After
+ public void tearDown() {
+ // Clear any associated devices added during tests.
+ for (Pair<Integer, AssociatedDevice> device : mAddedAssociatedDevices) {
+ mConnectedDeviceStorage.removeAssociatedDevice(device.first,
+ device.second.getDeviceId());
+ }
+ }
+
+ @Test
+ public void getAssociatedDeviceIdsForUser_includesNewlyAddedDevice() {
+ AssociatedDevice addedDevice = addRandomAssociatedDevice(mActiveUserId);
+ List<String> associatedDevices =
+ mConnectedDeviceStorage.getAssociatedDeviceIdsForUser(mActiveUserId);
+ assertThat(associatedDevices).containsExactly(addedDevice.getDeviceId());
+ }
+
+ @Test
+ public void getAssociatedDeviceIdsForUser_excludesDeviceAddedForOtherUser() {
+ addRandomAssociatedDevice(mActiveUserId);
+ List<String> associatedDevices =
+ mConnectedDeviceStorage.getAssociatedDeviceIdsForUser(mActiveUserId + 1);
+ assertThat(associatedDevices).isEmpty();
+ }
+
+ @Test
+ public void getAssociatedDeviceIdsForUser_excludesRemovedDevice() {
+ AssociatedDevice addedDevice = addRandomAssociatedDevice(mActiveUserId);
+ mConnectedDeviceStorage.removeAssociatedDevice(mActiveUserId, addedDevice.getDeviceId());
+ List<String> associatedDevices =
+ mConnectedDeviceStorage.getAssociatedDeviceIdsForUser(mActiveUserId);
+ assertThat(associatedDevices).isEmpty();
+ }
+
+ @Test
+ public void getAssociatedDevicesForUser_includesNewlyAddedDevice() {
+ AssociatedDevice addedDevice = addRandomAssociatedDevice(mActiveUserId);
+ List<AssociatedDevice> associatedDevices =
+ mConnectedDeviceStorage.getAssociatedDevicesForUser(mActiveUserId);
+ assertThat(associatedDevices).containsExactly(addedDevice);
+ }
+
+ @Test
+ public void getAssociatedDevicesForUser_excludesDeviceAddedForOtherUser() {
+ addRandomAssociatedDevice(mActiveUserId);
+ List<String> associatedDevices =
+ mConnectedDeviceStorage.getAssociatedDeviceIdsForUser(mActiveUserId + 1);
+ assertThat(associatedDevices).isEmpty();
+ }
+
+ @Test
+ public void getAssociatedDevicesForUser_excludesRemovedDevice() {
+ AssociatedDevice addedDevice = addRandomAssociatedDevice(mActiveUserId);
+ mConnectedDeviceStorage.removeAssociatedDevice(mActiveUserId, addedDevice.getDeviceId());
+ List<AssociatedDevice> associatedDevices =
+ mConnectedDeviceStorage.getAssociatedDevicesForUser(mActiveUserId);
+ assertThat(associatedDevices).isEmpty();
+ }
+
+ @Test
+ public void getEncryptionKey_returnsSavedKey() {
+ String deviceId = addRandomAssociatedDevice(mActiveUserId).getDeviceId();
+ byte[] key = ByteUtils.randomBytes(16);
+ mConnectedDeviceStorage.saveEncryptionKey(deviceId, key);
+ assertThat(mConnectedDeviceStorage.getEncryptionKey(deviceId)).isEqualTo(key);
+ }
+
+ @Test
+ public void getEncryptionKey_returnsNullForUnrecognizedDeviceId() {
+ String deviceId = addRandomAssociatedDevice(mActiveUserId).getDeviceId();
+ mConnectedDeviceStorage.saveEncryptionKey(deviceId, ByteUtils.randomBytes(16));
+ assertThat(mConnectedDeviceStorage.getEncryptionKey(UUID.randomUUID().toString())).isNull();
+ }
+
+ private AssociatedDevice addRandomAssociatedDevice(int userId) {
+ AssociatedDevice device = new AssociatedDevice(UUID.randomUUID().toString(),
+ "00:00:00:00:00:00", "Test Device");
+ addAssociatedDevice(userId, device, ByteUtils.randomBytes(16));
+ return device;
+ }
+
+ private void addAssociatedDevice(int userId, AssociatedDevice device, byte[] encryptionKey) {
+ mConnectedDeviceStorage.addAssociatedDeviceForUser(userId, device);
+ mConnectedDeviceStorage.saveEncryptionKey(device.getDeviceId(), encryptionKey);
+ mAddedAssociatedDevices.add(new Pair<>(userId, device));
+ }
+}
diff --git a/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/util/ScanDataAnalyzerTest.java b/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/util/ScanDataAnalyzerTest.java
new file mode 100644
index 0000000..92e8d34
--- /dev/null
+++ b/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/util/ScanDataAnalyzerTest.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2019 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.car.connecteddevice.util;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.math.BigInteger;
+
+@RunWith(AndroidJUnit4.class)
+public class ScanDataAnalyzerTest {
+ private static final BigInteger CORRECT_DATA =
+ new BigInteger(
+ "02011A14FF4C000100000000000000000000000000200000000000000000000000000000"
+ + "0000000000000000000000000000000000000000000000000000",
+ 16);
+
+ private static final BigInteger CORRECT_MASK =
+ new BigInteger("00000000000000000000000000200000", 16);
+
+ private static final BigInteger MULTIPLE_BIT_MASK =
+ new BigInteger("00000000000000000100000000200000", 16);
+
+ @Test
+ public void containsUuidsInOverflow_correctBitFlipped_shouldReturnTrue() {
+ assertThat(
+ ScanDataAnalyzer.containsUuidsInOverflow(CORRECT_DATA.toByteArray(), CORRECT_MASK))
+ .isTrue();
+ }
+
+ @Test
+ public void containsUuidsInOverflow_bitNotFlipped_shouldReturnFalse() {
+ assertThat(
+ ScanDataAnalyzer.containsUuidsInOverflow(
+ CORRECT_DATA.negate().toByteArray(), CORRECT_MASK))
+ .isFalse();
+ }
+
+ @Test
+ public void containsUuidsInOverflow_maskWithMultipleBitsIncompleteMatch_shouldReturnTrue() {
+ assertThat(
+ ScanDataAnalyzer.containsUuidsInOverflow(CORRECT_DATA.toByteArray(),
+ MULTIPLE_BIT_MASK))
+ .isTrue();
+ }
+
+ @Test
+ public void containsUuidsInOverflow_incorrectLengthByte_shouldReturnFalse() {
+ // Incorrect length of 0x20
+ byte[] data =
+ new BigInteger(
+ "02011A20FF4C00010000000000000000000000000020000000000000000000000000000000"
+ + "00000000000000000000000000000000000000000000000000",
+ 16)
+ .toByteArray();
+ BigInteger mask = new BigInteger("00000000000000000000000000200000", 16);
+ assertThat(ScanDataAnalyzer.containsUuidsInOverflow(data, mask)).isFalse();
+ }
+
+ @Test
+ public void containsUuidsInOverflow_incorrectAdTypeByte_shouldReturnFalse() {
+ // Incorrect advertising type of 0xEF
+ byte[] data =
+ new BigInteger(
+ "02011A14EF4C00010000000000000000000000000020000000000000000000000000000000"
+ + "00000000000000000000000000000000000000000000000000",
+ 16)
+ .toByteArray();
+ assertThat(ScanDataAnalyzer.containsUuidsInOverflow(data, CORRECT_MASK)).isFalse();
+ }
+
+ @Test
+ public void containsUuidsInOverflow_incorrectCustomId_shouldReturnFalse() {
+ // Incorrect custom id of 0x4C1001
+ byte[] data =
+ new BigInteger(
+ "02011A14FF4C10010000000000000000000000000020000000000000000000000000000000"
+ + "00000000000000000000000000000000000000000000000000",
+ 16)
+ .toByteArray();
+ assertThat(ScanDataAnalyzer.containsUuidsInOverflow(data, CORRECT_MASK)).isFalse();
+ }
+
+ @Test
+ public void containsUuidsInOverflow_incorrectContentLength_shouldReturnFalse() {
+ byte[] data = new BigInteger("02011A14FF4C1001000000000000000000000000002", 16)
+ .toByteArray();
+ assertThat(ScanDataAnalyzer.containsUuidsInOverflow(data, CORRECT_MASK)).isFalse();
+ }
+}