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();
+    }
+}