Project import generated by Copybara

Included changes:

Release-Id: aae-companiondevice-android_20220330.00_RC02
Change-Id: I82de0aedcf48eeb38fae8f85888f1b5b4dffd92e
diff --git a/companiondevice/AndroidManifest.xml b/companiondevice/AndroidManifest.xml
index 0a80967..138c9e3 100644
--- a/companiondevice/AndroidManifest.xml
+++ b/companiondevice/AndroidManifest.xml
@@ -88,8 +88,8 @@
                 android:resource="@string/car_association_service_uuid" />
             <meta-data android:name="com.google.android.connecteddevice.transport_protocols"
                 android:resource="@array/transport_protocols" />
-            <meta-data android:name="com.google.android.connecteddevice.supported_oob_channels"
-                android:resource="@array/supported_oob_channels"/>
+            <meta-data android:name="com.google.android.connecteddevice.car_eap_oob_protocol_name"
+                android:resource="@string/car_eap_oob_protocol_name" />
 
         </service>
         <service
@@ -121,6 +121,12 @@
                 android:resource="@bool/enable_proxy" />
             <meta-data android:name="com.google.android.connecteddevice.transport_protocols"
                 android:resource="@array/transport_protocols" />
+            <meta-data android:name="com.google.android.connecteddevice.supported_oob_channels"
+                android:resource="@array/supported_oob_channels"/>
+            <meta-data android:name="com.google.android.connecteddevice.car_eap_client_name"
+                android:resource="@string/car_eap_client_name" />
+            <meta-data android:name="com.google.android.connecteddevice.car_eap_service_name"
+                android:resource="@string/car_eap_service_name" />
         </service>
         <activity android:name=".AssociationActivity"
                   android:exported="true"
diff --git a/companiondevice/build.gradle b/companiondevice/build.gradle
index 83c039e..c12b2f5 100644
--- a/companiondevice/build.gradle
+++ b/companiondevice/build.gradle
@@ -14,7 +14,7 @@
         applicationId "com.google.android.companiondevicesupport"
         minSdkVersion 29
         targetSdkVersion 31
-        versionCode 1357
+        versionCode 1441
         versionName "1.0"
 
         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
diff --git a/companiondevice/res/values/config.xml b/companiondevice/res/values/config.xml
index 3f9d9b2..4ce667a 100644
--- a/companiondevice/res/values/config.xml
+++ b/companiondevice/res/values/config.xml
@@ -20,6 +20,11 @@
     <!-- Platform values -->
     <string name="car_association_service_uuid" translatable="false">5e2a68a4-27be-43f9-8d1e-4546976fabd7</string>
 
+    <!-- EAP OOB values -->
+    <string name="car_eap_oob_protocol_name" translatable="false">com.google.companion.oob-association</string>
+    <string name="car_eap_client_name" translatable="false">com.google.android.connecteddevice.transport</string>
+    <string name="car_eap_service_name" translatable="false">com.panasonic.iapx.serviceconnector</string>
+
     <!-- Services to start early in the single user (user 0). -->
     <string-array name="early_single_user_services" translatable="false">
         <item>com.google.android.companiondevicesupport/com.google.android.connecteddevice.trust.TrustedDeviceManagerService</item>
@@ -40,8 +45,8 @@
 
     <!-- Supported OOB verification approaches. -->
     <string-array name="supported_oob_channels" translatable="false">
-        <item>BT_RFCOMM</item>
-        <item>PRE_ASSOCIATION</item>
+        <item>eap</item>
+        <item>spp</item>
     </string-array>
 
     <!-- Supported transport protocols. -->
diff --git a/companiondevice/res/values/strings.xml b/companiondevice/res/values/strings.xml
index ecf5294..d64dfa3 100644
--- a/companiondevice/res/values/strings.xml
+++ b/companiondevice/res/values/strings.xml
@@ -39,7 +39,7 @@
     <!-- Instruction for connecting to car [CHAR LIMIT=100] -->
     <string name="connect_to_targe_car_instruction_text">Connect to &lt;b><xliff:g id="car_name" example="MyVehicle">%1$s</xliff:g>&lt;/b> <xliff:g id="advertised_car_name" example="(Vehicle 0000)">%2$s</xliff:g></string>
     <!-- Instruction for scanning QR code [CHAR LIMIT=150] -->
-    <string name="qr_instruction_text">Scan the QR code or open MyCompanion to connect to &lt;b><xliff:g id="car_name" example="MyVehicle">%1$s</xliff:g>&lt;/b> <xliff:g id="advertised_car_name" example="(Vehicle 0000)">%2$s</xliff:g></string>
+    <string name="qr_instruction_text">Use your phone to scan the QR code or open MyCompanion to connect to &lt;b><xliff:g id="car_name" example="MyVehicle">%1$s</xliff:g>&lt;/b> <xliff:g id="advertised_car_name" example="(Vehicle 0000)">%2$s</xliff:g></string>
     <!-- Instruction for connecting to car [CHAR LIMIT=100] -->
     <string name="connect_to_car_instruction_text">Connect to the&#160;car</string>
     <!-- Title for confirm pairing code fragment [CHAR LIMIT=60]-->
@@ -74,6 +74,13 @@
     <string name="turn_on_bluetooth_dialog_title">Turn on Bluetooth&#63;</string>
     <!-- Message for turn on bluetooth dialog [CHAR LIMIT=150]-->
     <string name="turn_on_bluetooth_dialog_message">To connect to your companion device, your car needs Bluetooth.</string>
+    <!-- Title for CompanionDevice not available dialog [CHAR LIMIT=40] -->
+    <string name="companion_not_available_dialog_title">This setting isn\’t available</string>
+    <!-- Message for CompanionDevice not available dialog [CHAR LIMIT=150]-->
+    <string name="companion_not_available_dialog_message">Guest profiles can\’t access CompanionDevice</string>
+    <!-- Switch user message for CompanionDevice not available dialog [CHAR LIMIT=200]-->
+    <string name="companion_not_available_dialog_switch_user_message">Guest profiles can\’t access CompanionDevice. To change your profile, go to Settings > Users.</string>
+
 
     <!-- Accept button text [CHAR LIMIT=20] -->
     <string name="accept">Accept</string>
@@ -111,6 +118,8 @@
     <string name="disconnect">Disconnect</string>
     <!-- Skip button text [CHAR LIMIT=20] -->
     <string name="skip">Skip</string>
+    <!-- change profile button text [CHAR LIMIT=40] -->
+    <string name="change_profile">Change profile</string>
 
     <!-- Connected with secure channel status text [CHAR LIMIT=20]-->
     <string name="connected">Connected</string>
diff --git a/companiondevice/src/com/google/android/companiondevicesupport/AssociationActivity.java b/companiondevice/src/com/google/android/companiondevicesupport/AssociationActivity.java
index e89a04c..266648a 100644
--- a/companiondevice/src/com/google/android/companiondevicesupport/AssociationActivity.java
+++ b/companiondevice/src/com/google/android/companiondevicesupport/AssociationActivity.java
@@ -21,16 +21,19 @@
 import static com.android.car.ui.toolbar.Toolbar.State.SUBPAGE;
 import static com.google.android.connecteddevice.util.SafeLog.logd;
 import static com.google.android.connecteddevice.util.SafeLog.loge;
+import static com.google.android.connecteddevice.util.SafeLog.logw;
 
 import android.Manifest.permission;
 import android.app.AlertDialog;
 import android.app.Dialog;
 import android.bluetooth.BluetoothAdapter;
+import android.content.ComponentName;
 import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.os.Build.VERSION;
 import android.os.Build.VERSION_CODES;
 import android.os.Bundle;
+import android.os.UserManager;
 import androidx.fragment.app.DialogFragment;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentActivity;
@@ -40,6 +43,7 @@
 import androidx.activity.result.ActivityResultLauncher;
 import androidx.activity.result.contract.ActivityResultContracts;
 import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
 import androidx.core.content.ContextCompat;
 import androidx.lifecycle.ViewModelProvider;
 import androidx.lifecycle.ViewModelStoreOwner;
@@ -67,6 +71,7 @@
   private static final String TURN_ON_BLUETOOTH_FRAGMENT_TAG = "TurnOnBluetoothFragment";
   private static final String ASSOCIATION_ERROR_FRAGMENT_TAG = "AssociationErrorFragment";
   private static final String TURN_ON_BLUETOOTH_DIALOG_TAG = "TurnOnBluetoothDialog";
+  private static final String COMPANION_NOT_AVAILABLE_DIALOG_TAG = "CompanionNotAvailableDialog";
   private static final String EXTRA_AUTH_IS_SETUP_WIZARD = "is_setup_wizard";
   private static final String EXTRA_AUTH_IS_SETUP_PROFILE = "is_setup_profile_association";
   private static final String EXTRA_USE_IMMERSIVE_MODE = "useImmersiveMode";
@@ -74,6 +79,12 @@
   private static final String ASSOCIATED_DEVICE_DETAILS_BACKSTACK_NAME =
       "AssociatedDeviceDetailsBackstack";
 
+  private static final String PROFILE_SWITCH_PACKAGE_NAME = "com.android.car.settings";
+  private static final String PROFILE_SWITCH_CLASS_NAME_R =
+      "com.android.car.settings.users.UserSwitcherActivity";
+  private static final String PROFILE_SWITCH_CLASS_NAME =
+      "com.android.car.settings.profiles.ProfileSwitcherActivity";
+
   private final List<String> requiredPermissions = new ArrayList<>();
 
   private ToolbarController toolbar;
@@ -148,6 +159,10 @@
   protected void onStart() {
     super.onStart();
     handleImmersive();
+    UserManager userManager = getSystemService(UserManager.class);
+    if (userManager.isGuestUser()) {
+      showCompanionNotAvailableDialog();
+    }
   }
 
   @Override
@@ -292,6 +307,7 @@
                 case STARTED:
                   runOnUiThread(
                       () -> {
+                        logd(TAG, "Association state is started; Show companion landing fragment.");
                         showCompanionLandingFragment();
                         showProgressBar();
                       });
@@ -313,7 +329,12 @@
             });
     model
         .getAssociatedDevicesDetails()
-        .observe(this, this::handleAssociatedDevicesDetailsChange);
+        .observe(
+            this,
+            devicesDetails -> {
+              boolean isServiceConnected = model.isServiceConnected().getValue();
+              handleAssociatedDevicesDetailsAndConnectionChange(devicesDetails, isServiceConnected);
+            });
     model
         .getRemovedDevice()
         .observe(
@@ -329,12 +350,9 @@
             this,
             isServiceConnected -> {
               logd(TAG, "Service connection status: " + isServiceConnected);
-              if (isServiceConnected) {
-                showCompanionLandingFragment();
-                hideProgressBar();
-              } else {
-                showLoadingScreen();
-              }
+              List<AssociatedDeviceDetails> devicesDetails =
+                  model.getAssociatedDevicesDetails().getValue();
+              handleAssociatedDevicesDetailsAndConnectionChange(devicesDetails, isServiceConnected);
             });
   }
 
@@ -349,7 +367,13 @@
     }
   }
 
-  private void handleAssociatedDevicesDetailsChange(List<AssociatedDeviceDetails> devicesDetails) {
+  private void handleAssociatedDevicesDetailsAndConnectionChange(
+      List<AssociatedDeviceDetails> devicesDetails, boolean isServiceConnected) {
+    if (!isServiceConnected) {
+      logw(TAG, "Service not connected, ignore device details change.");
+      showLoadingScreen();
+      return;
+    }
     // An empty list means that there are no more associated devices.
     if (devicesDetails.isEmpty()) {
       handleEmptyDeviceList();
@@ -440,13 +464,13 @@
           "Device list is empty, but landing fragment already showing. Nothing more to be done.");
       return;
     }
-
+    logd(TAG, "No associated device, showing landing screen.");
+    hideProgressBar();
     showCompanionLandingFragment();
   }
 
   private void showCompanionLandingFragment() {
     maybeClearDetailsFragmentFromBackstack();
-    dismissButtons();
 
     if (getResources().getBoolean(R.bool.enable_qr_code)) {
       logd(TAG, "Showing LandingFragment with QR code.");
@@ -459,6 +483,7 @@
     if (fragment != null && fragment.isVisible()) {
       return;
     }
+    dismissButtons();
     fragment = CompanionLandingFragment.newInstance(isStartedForSuw);
     launchFragment(fragment, COMPANION_LANDING_FRAGMENT_TAG);
   }
@@ -473,10 +498,9 @@
     }
     fragment =
         CompanionQrCodeLandingFragment.newInstance(isStartedForSuw, isStartedForSetupProfile);
+    dismissButtons();
     launchFragment(fragment, COMPANION_LANDING_FRAGMENT_TAG);
-    if (isStartedForSetupProfile) {
-      showSkipButton();
-    }
+    showSkipButton();
   }
 
   private void showTurnOnBluetoothFragment() {
@@ -567,6 +591,7 @@
 
   private void dismissButtons() {
     if (isStartedForSuw) {
+      logd(TAG, "Dismissing SUW toolbar buttons.");
       carSetupWizardLayout.setPrimaryToolbarButtonVisible(false);
       carSetupWizardLayout.setSecondaryToolbarButtonVisible(false);
       return;
@@ -576,8 +601,11 @@
 
   private void showSkipButton() {
     if (!isStartedForSuw || hideSkipButton) {
+      logw(TAG, "Not in SUW or hideSkipButton is true; Do not show skip button.");
       return;
     }
+    logd(TAG, "Show skip button on SUW page.");
+    carSetupWizardLayout.setPrimaryToolbarButtonFlat(true);
     carSetupWizardLayout.setPrimaryToolbarButtonText(getString(R.string.skip));
     carSetupWizardLayout.setPrimaryToolbarButtonVisible(true);
     carSetupWizardLayout.setPrimaryToolbarButtonListener(
@@ -628,6 +656,11 @@
     }
   }
 
+  private void showCompanionNotAvailableDialog() {
+    CompanionNotAvailableDialogFragment fragment = new CompanionNotAvailableDialogFragment();
+    fragment.show(getSupportFragmentManager(), COMPANION_NOT_AVAILABLE_DIALOG_TAG);
+  }
+
   /** Checks for the details page being shown and clears it from the Fragment backstack. */
   private void maybeClearDetailsFragmentFromBackstack() {
     // The details page is only added to the backstack if passenger mode is enabled. Also, no need
@@ -706,4 +739,44 @@
           .create();
     }
   }
+
+  /** Dialog fragment to notify CompanionDevice is not available to guest user. */
+  public static class CompanionNotAvailableDialogFragment extends DialogFragment {
+    @Override
+    public Dialog onCreateDialog(Bundle savedInstanceState) {
+      @StringRes
+      int messageResId =
+          VERSION.SDK_INT <= VERSION_CODES.R
+              ? R.string.companion_not_available_dialog_switch_user_message
+              : R.string.companion_not_available_dialog_message;
+      AlertDialog.Builder builder =
+          new AlertDialog.Builder(getActivity())
+              .setTitle(getString(R.string.companion_not_available_dialog_title))
+              .setMessage(getString(messageResId))
+              .setNegativeButton(getString(R.string.ok), (d, w) -> getActivity().finish())
+              .setCancelable(false);
+      addChangeProfileButton(builder);
+      Dialog dialog = builder.create();
+      dialog.setCanceledOnTouchOutside(/* cancel= */ false);
+      return dialog;
+    }
+
+    private void addChangeProfileButton(AlertDialog.Builder builder) {
+      if (VERSION.SDK_INT <= VERSION_CODES.Q) {
+        return;
+      }
+      String profileSwitcherClassName =
+          VERSION.SDK_INT >= VERSION_CODES.S
+              ? PROFILE_SWITCH_CLASS_NAME
+              : PROFILE_SWITCH_CLASS_NAME_R;
+      builder.setPositiveButton(
+          getString(R.string.change_profile),
+          (d, w) -> {
+            Intent intent = new Intent();
+            intent.setComponent(
+                new ComponentName(PROFILE_SWITCH_PACKAGE_NAME, profileSwitcherClassName));
+            getActivity().startActivity(intent);
+          });
+    }
+  }
 }
diff --git a/libs/connecteddevice/build.gradle b/libs/connecteddevice/build.gradle
index 6105fad..13c674b 100644
--- a/libs/connecteddevice/build.gradle
+++ b/libs/connecteddevice/build.gradle
@@ -13,12 +13,12 @@
 }
 
 android {
-    compileSdkVersion 30
+    compileSdkVersion 31
     aidlPackageWhiteList "com/google/android/connecteddevice/api/IDeviceAssociationCallback.aidl"
 
     defaultConfig {
         minSdkVersion 29
-        targetSdkVersion 30
+        targetSdkVersion 31
         versionCode 1
         versionName "1.0"
 
@@ -51,7 +51,7 @@
         main {
             manifest.srcFile 'AndroidManifest.xml'
             java.srcDirs = ['src']
-            aidl.srcDirs = java.srcDirs
+            aidl.srcDirs = [java.srcDirs, 'src/com/google/android/connecteddevice/transport/eap']
             proto {
                 srcDir java.srcDirs
             }
diff --git a/libs/connecteddevice/res/values/config.xml b/libs/connecteddevice/res/values/config.xml
new file mode 100644
index 0000000..281973d
--- /dev/null
+++ b/libs/connecteddevice/res/values/config.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ Copyright (C) 2022 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>
+    <!-- Current version of the SDK -->
+    <string name="hu_companion_sdk_version" translatable="false">1.3.1</string>
+</resources>
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/connection/ChannelResolver.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/connection/ChannelResolver.kt
index 49809d5..fb349c1 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/connection/ChannelResolver.kt
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/connection/ChannelResolver.kt
@@ -157,11 +157,6 @@
         .build()
     device.protocol.sendData(device.protocolId, carVersion.toByteArray(), /* callback= */ null)
 
-    if (!isReconnect) {
-      logd(TAG, "Send OOB data to remote device $device.")
-      oobRunner?.sendOobData(device)
-    }
-
     resolveStream(device)
   }
 
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/core/FeatureCoordinator.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/core/FeatureCoordinator.kt
index 0860ace..34afa5c 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/core/FeatureCoordinator.kt
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/core/FeatureCoordinator.kt
@@ -16,6 +16,7 @@
 package com.google.android.connecteddevice.core
 
 import android.os.ParcelUuid
+import androidx.annotation.GuardedBy
 import androidx.annotation.VisibleForTesting
 import com.google.android.connecteddevice.api.IAssociationCallback
 import com.google.android.connecteddevice.api.IConnectionCallback
@@ -34,12 +35,14 @@
 import com.google.android.connecteddevice.util.ByteUtils
 import com.google.android.connecteddevice.util.SafeLog.logd
 import com.google.android.connecteddevice.util.SafeLog.loge
+import com.google.android.connecteddevice.util.SafeLog.logw
 import java.util.UUID
 import java.util.concurrent.ConcurrentHashMap
 import java.util.concurrent.CopyOnWriteArrayList
-import java.util.concurrent.CopyOnWriteArraySet
 import java.util.concurrent.Executor
 import java.util.concurrent.Executors
+import java.util.concurrent.locks.ReentrantLock
+import kotlin.concurrent.withLock
 
 /** Coordinator between features and connected devices. */
 class FeatureCoordinator
@@ -59,21 +62,24 @@
 
   private val allConnectionCallbacks = AidlThreadSafeCallbacks<IConnectionCallback>()
 
-  // deviceId -> (recipientId -> callbacks)
+  private val lock = ReentrantLock()
+
+  // deviceId -> (recipientId -> callbacks)s
+  @GuardedBy("lock")
   private val deviceCallbacks:
     MutableMap<String, MutableMap<ParcelUuid, AidlThreadSafeCallbacks<IDeviceCallback>>> =
     ConcurrentHashMap()
 
-  // recipientId -> (deviceId -> message bytes)
-  private val recipientMissedMessages:
-    MutableMap<ParcelUuid, MutableMap<String, MutableList<DeviceMessage>>> =
-    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 val blockedRecipients: MutableSet<ParcelUuid> = CopyOnWriteArraySet()
+  @GuardedBy("lock") private val blockedRecipients = mutableSetOf<ParcelUuid>()
+
+  // recipientId -> (deviceId -> message bytes)
+  private val recipientMissedMessages:
+    MutableMap<ParcelUuid, MutableMap<String, MutableList<DeviceMessage>>> =
+    ConcurrentHashMap()
 
   init {
     controller.registerCallback(createDeviceControllerCallback(), callbackExecutor)
@@ -89,7 +95,7 @@
   /** Disconnect all devices and reset state. */
   fun reset() {
     logd(TAG, "Resetting coordinator.")
-    deviceCallbacks.clear()
+    lock.withLock { deviceCallbacks.clear() }
     controller.reset()
     recipientMissedMessages.clear()
   }
@@ -125,18 +131,42 @@
     recipientId: ParcelUuid,
     callback: IDeviceCallback
   ) {
-    if (blockedRecipients.contains(recipientId)) {
+    val registrationSuccessful =
+      lock.withLock { registerDeviceCallbackLocked(connectedDevice, recipientId, callback) }
+
+    if (registrationSuccessful) {
+      notifyOfMissedMessages(connectedDevice, recipientId, callback)
+    } else {
       notifyRecipientBlocked(connectedDevice, recipientId, callback)
-      return
+    }
+  }
+
+  /**
+   * Registers the given [callback] to be notified of device events on the specified
+   * [connectedDevice] that are specific to the [recipientId].
+   *
+   * If the registration is successful, then `true` is returned. The caller of this method should
+   * have acquired [lock].
+   */
+  @GuardedBy("lock")
+  private fun registerDeviceCallbackLocked(
+    connectedDevice: ConnectedDevice,
+    recipientId: ParcelUuid,
+    callback: IDeviceCallback
+  ): Boolean {
+    if (recipientId in blockedRecipients) {
+      return false
     }
 
     val recipientCallbacks =
       deviceCallbacks.computeIfAbsent(connectedDevice.deviceId) { ConcurrentHashMap() }
     val newCallbacks =
       AidlThreadSafeCallbacks<IDeviceCallback>().apply { add(callback, callbackExecutor) }
+
     recipientCallbacks.computeIfPresent(recipientId) { _, callbacks ->
       if (callbacks.isEmpty) null else callbacks
     }
+
     val previousCallbacks = recipientCallbacks.putIfAbsent(recipientId, newCallbacks)
 
     // Device already has a callback registered with this recipient UUID. For the
@@ -148,15 +178,14 @@
       previousCallbacks.invoke {
         it.onDeviceError(connectedDevice, DEVICE_ERROR_INSECURE_RECIPIENT_ID_DETECTED)
       }
-      notifyRecipientBlocked(connectedDevice, recipientId, callback)
-      return
+      return false
     }
 
     logd(
       TAG,
       "New callback registered on device ${connectedDevice.deviceId} for recipient $recipientId."
     )
-    notifyOfMissedMessages(connectedDevice, recipientId, callback)
+    return true
   }
 
   private fun notifyRecipientBlocked(
@@ -194,14 +223,40 @@
     recipientId: ParcelUuid,
     callback: IDeviceCallback
   ) {
+    lock.withLock { unregisterDeviceCallbackLocked(connectedDevice, recipientId, callback) }
+  }
+
+  /**
+   * Unregisters the given [callback] from being notified of device events for the specified
+   * [recipientId] on the [connectedDevice].
+   *
+   * The caller should ensure that they have acquired [lock].
+   */
+  @GuardedBy("lock")
+  private fun unregisterDeviceCallbackLocked(
+    connectedDevice: ConnectedDevice,
+    recipientId: ParcelUuid,
+    callback: IDeviceCallback
+  ) {
+    val callbacks = deviceCallbacks[connectedDevice.deviceId]?.get(recipientId)
+    if (callbacks == null) {
+      logw(
+        TAG,
+        "Request to unregister callback on device ${connectedDevice.deviceId} for recipient " +
+          "$recipientId, but none registered."
+      )
+      return
+    }
+
+    callbacks.remove(callback)
+
     logd(
       TAG,
       "Device callback unregistered on device ${connectedDevice.deviceId} for recipient " +
         "$recipientId."
     )
-    val callbacks = deviceCallbacks[connectedDevice.deviceId]?.get(recipientId)
-    callbacks?.remove(callback)
-    if (callbacks?.isEmpty == true) {
+
+    if (callbacks.isEmpty) {
       deviceCallbacks[connectedDevice.deviceId]?.remove(recipientId)
     }
   }
@@ -341,12 +396,23 @@
 
   @VisibleForTesting
   internal fun onSecureChannelEstablishedInternal(connectedDevice: ConnectedDevice) {
+    val callbacks = lock.withLock { deviceCallbacks[connectedDevice.deviceId]?.values }
+    if (callbacks == null) {
+      logd(
+        TAG,
+        "A secure channel has been established with ${connectedDevice.deviceId}, but no " +
+          "callbacks registered to be notified."
+      )
+      return
+    }
+
     logd(
       TAG,
       "Notifying callbacks that a secure channel has been established with " +
         "${connectedDevice.deviceId}."
     )
-    deviceCallbacks[connectedDevice.deviceId]?.values?.forEach { callback ->
+
+    for (callback in callbacks) {
       callback.invoke { it.onSecureChannelEstablished(connectedDevice) }
     }
   }
@@ -360,8 +426,13 @@
       )
       return
     }
+
     logd(TAG, "Received a new message for ${message.recipient} from ${connectedDevice.deviceId}.")
-    val callback = deviceCallbacks[connectedDevice.deviceId]?.get(ParcelUuid(message.recipient))
+
+    val callback =
+      lock.withLock {
+        deviceCallbacks[connectedDevice.deviceId]?.get(ParcelUuid(message.recipient))
+      }
 
     if (callback == null) {
       logd(TAG, "Recipient has not registered a callback yet. Saving missed message.")
@@ -369,6 +440,8 @@
       return
     }
 
+    logd(TAG, "Notifying callback for recipient ${message.recipient}")
+
     callback.invoke { it.onMessageReceived(connectedDevice, message) }
   }
 
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/core/MultiProtocolDeviceController.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/core/MultiProtocolDeviceController.kt
index 321518f..6c744f8 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/core/MultiProtocolDeviceController.kt
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/core/MultiProtocolDeviceController.kt
@@ -208,7 +208,7 @@
     }
     val startAssociationResponse =
       StartAssociationResponse(
-        oobRunner.generateOobData(),
+        oobRunner.sendOobData(),
         ByteUtils.hexStringToByteArray(nameForAssociation),
         nameForAssociation
       )
@@ -731,6 +731,7 @@
       }
       newDevice
     }
+    oobRunner.reset()
   }
 
   private fun convertTempAssociationDeviceToRealDevice(
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/model/TransportProtocols.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/model/TransportProtocols.kt
index 235766f..8c8b0c6 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/model/TransportProtocols.kt
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/model/TransportProtocols.kt
@@ -22,4 +22,7 @@
 
   /** Classic Bluetooth SPP protocol. */
   const val PROTOCOL_SPP = "spp"
+
+  /** Classic Bluetooth protocol for iOS device. */
+  const val PROTOCOL_EAP = "eap"
 }
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/notificationmsg/common/BaseNotificationDelegate.java b/libs/connecteddevice/src/com/google/android/connecteddevice/notificationmsg/common/BaseNotificationDelegate.java
index 803c74a..8196328 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/notificationmsg/common/BaseNotificationDelegate.java
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/notificationmsg/common/BaseNotificationDelegate.java
@@ -28,6 +28,8 @@
 import android.graphics.Typeface;
 import android.graphics.drawable.BitmapDrawable;
 import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
 import android.os.Bundle;
 import androidx.annotation.Nullable;
 import androidx.core.app.NotificationCompat;
@@ -374,7 +376,11 @@
             .setClassName(context, context.getClass().getName())
             .putExtra(EXTRA_CONVERSATION_KEY, conversationKey);
 
-    return PendingIntent.getForegroundService(
-        context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
+    int flags = PendingIntent.FLAG_UPDATE_CURRENT;
+    if (Build.VERSION.SDK_INT >= VERSION_CODES.S) {
+        flags |= PendingIntent.FLAG_MUTABLE;
+    }
+
+    return PendingIntent.getForegroundService(context, notificationId, intent, flags);
   }
 }
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/oob/BluetoothRfcommChannel.java b/libs/connecteddevice/src/com/google/android/connecteddevice/oob/BluetoothRfcommChannel.java
deleted file mode 100644
index cab01ed..0000000
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/oob/BluetoothRfcommChannel.java
+++ /dev/null
@@ -1,186 +0,0 @@
-/*
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.google.android.connecteddevice.oob;
-
-import static com.google.android.connecteddevice.util.SafeLog.logd;
-import static com.google.android.connecteddevice.util.SafeLog.loge;
-import static com.google.android.connecteddevice.util.SafeLog.logw;
-
-import android.bluetooth.BluetoothAdapter;
-import android.bluetooth.BluetoothDevice;
-import android.os.Handler;
-import android.os.Looper;
-import android.os.ParcelUuid;
-import android.os.RemoteException;
-import androidx.annotation.NonNull;
-import androidx.annotation.VisibleForTesting;
-import com.google.android.connecteddevice.transport.BluetoothDeviceProvider;
-import com.google.android.connecteddevice.transport.IConnectionProtocol;
-import com.google.android.connecteddevice.transport.ProtocolDevice;
-import com.google.android.connecteddevice.transport.spp.ConnectedDeviceSppDelegateBinder;
-import com.google.android.connecteddevice.transport.spp.Connection;
-import com.google.android.connecteddevice.transport.spp.PendingConnection;
-import com.google.android.connecteddevice.transport.spp.PendingSentMessage;
-import java.util.Set;
-import java.util.UUID;
-import java.util.concurrent.atomic.AtomicBoolean;
-
-/** Handles out of band data exchange over a secure RFCOMM channel. */
-public class BluetoothRfcommChannel implements OobChannel {
-  private static final String TAG = "BluetoothRfcommChannel";
-  // TODO(b/159500330): Generate random UUID.
-  private static final UUID RFCOMM_UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB");
-  private static final int CONNECTION_TIMEOUT_MS = 500;
-
-  private final AtomicBoolean isInterrupted = new AtomicBoolean();
-  private final ConnectedDeviceSppDelegateBinder sppDelegateBinder;
-  private Connection activeConnection;
-  private PendingConnection activePendingConnection;
-
-  public BluetoothRfcommChannel(ConnectedDeviceSppDelegateBinder sppDelegateBinder) {
-    this.sppDelegateBinder = sppDelegateBinder;
-  }
-
-  @Override
-  public boolean completeOobDataExchange(@NonNull ProtocolDevice device, @NonNull byte[] oobData) {
-    IConnectionProtocol protocol = device.getProtocol();
-    if (!(protocol instanceof BluetoothDeviceProvider)) {
-      logw(TAG, "Protocol is not supported by current OOB channel, ignored.");
-      return false;
-    }
-    BluetoothDevice remoteDevice =
-        ((BluetoothDeviceProvider) protocol).getBluetoothDeviceById(device.getProtocolId());
-    completeOobDataExchange(
-        remoteDevice, () -> BluetoothAdapter.getDefaultAdapter().getBondedDevices(), oobData);
-    return true;
-  }
-
-  @VisibleForTesting
-  void completeOobDataExchange(
-      BluetoothDevice remoteDevice, BondedDevicesResolver bondedDevicesResolver, byte[] oobData) {
-    Set<BluetoothDevice> bondedDevices = bondedDevicesResolver.getBondedDevices();
-    if (bondedDevices == null || !bondedDevices.contains(remoteDevice)) {
-      loge(
-          TAG,
-          "This device has not been bonded to device with address " + remoteDevice.getAddress());
-      return;
-    }
-
-    try {
-      PendingConnection connection =
-          sppDelegateBinder.connectAsClient(RFCOMM_UUID, remoteDevice, /* isSecure= */ true);
-      if (connection == null) {
-        loge(TAG, "Connection with " + remoteDevice.getName() + " failed.");
-        return;
-      }
-
-      activePendingConnection = connection;
-      Handler handler = new Handler(Looper.getMainLooper());
-      handler.postDelayed(
-          () -> {
-            logd(TAG, "Cancelling connection with " + remoteDevice.getName());
-            try {
-              sppDelegateBinder.cancelConnectionAttempt(connection);
-            } catch (RemoteException e) {
-              logw(TAG, "Failed to cancel connection attempt with " + remoteDevice.getName());
-            }
-            loge(TAG, "Connection with " + remoteDevice.getName() + " timed out.");
-          },
-          CONNECTION_TIMEOUT_MS);
-
-      connection
-          .setOnConnectedListener(
-              (uuid, btDevice, isSecure, deviceName) -> {
-                handler.removeCallbacksAndMessages(null);
-                activePendingConnection = null;
-                activeConnection =
-                    new Connection(new ParcelUuid(uuid), btDevice, isSecure, deviceName);
-                sendOobData(oobData);
-              })
-          .setOnConnectionErrorListener(
-              () -> {
-                handler.removeCallbacksAndMessages(null);
-                loge(TAG, "Connection with " + remoteDevice.getName() + " failed.");
-              });
-    } catch (RemoteException e) {
-      loge(TAG, "Connection with " + remoteDevice.getName() + " failed.", e);
-    }
-  }
-
-  private void sendOobData(byte[] oobData) {
-    if (isInterrupted.get()) {
-      logd(TAG, "Oob connection is interrupted, data will not be set.");
-      return;
-    }
-
-    if (activeConnection == null) {
-      loge(TAG, "Connection is null, oob data cannot be sent");
-      return;
-    }
-
-    try {
-      PendingSentMessage pendingSentMessage =
-          sppDelegateBinder.sendMessage(activeConnection, oobData);
-
-      if (pendingSentMessage == null) {
-        loge(TAG, "Sending oob data failed");
-        return;
-      }
-
-      pendingSentMessage.setOnSuccessListener(
-          () -> {
-            try {
-              disconnect();
-            } catch (RemoteException e) {
-              logw(TAG, "Sending oob data succeeded, but disconnect failed");
-            }
-          });
-    } catch (RemoteException e) {
-      loge(TAG, "Sending oob data failed", e);
-    }
-  }
-
-  @Override
-  public void interrupt() {
-    logd(TAG, "Interrupt received.");
-    isInterrupted.set(true);
-    try {
-      disconnect();
-    } catch (RemoteException e) {
-      loge(TAG, "Disconnect failed", e);
-    }
-  }
-
-  private void disconnect() throws RemoteException {
-    if (activeConnection != null) {
-      sppDelegateBinder.disconnect(activeConnection);
-      activeConnection = null;
-    }
-    if (activePendingConnection != null) {
-      sppDelegateBinder.cancelConnectionAttempt(activePendingConnection);
-      activePendingConnection = null;
-    }
-  }
-
-  /**
-   * Interface for determining all the devices that the current device is Bluetooth bonded to, used
-   * for testing.
-   */
-  interface BondedDevicesResolver {
-    Set<BluetoothDevice> getBondedDevices();
-  }
-}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/oob/OobChannel.java b/libs/connecteddevice/src/com/google/android/connecteddevice/oob/OobChannel.java
index 7bb2085..f7dbf7b 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/oob/OobChannel.java
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/oob/OobChannel.java
@@ -17,7 +17,6 @@
 package com.google.android.connecteddevice.oob;
 
 import androidx.annotation.NonNull;
-import com.google.android.connecteddevice.transport.ProtocolDevice;
 
 /**
  * An interface for handling out of band data exchange. This interface should be implemented for
@@ -26,21 +25,19 @@
  * <p>Usage is:
  *
  * <ol>
- *   <li>Call {@link OobChannel#completeOobDataExchange(ProtocolDevice, byte[])}
+ *   <li>Call {@link OobChannel#completeOobDataExchange(byte[])}
  *   <li>Provide way to stop the OOB data exchange through {@link OobChannel#interrupt()}
  * </ol>
  */
 public interface OobChannel {
   /**
-   * Exchange out of band data with a remote device. This must be done prior to the start of the
-   * association with that device.
+   * Exchange out of band data with a remote device.
    *
-   * @param protocolDevice The remote device to exchange out of band data with
    * @param oobData The data that will be sent to remote device via OOB channel
    * @return {@code true} if the data exchange is started successfully, otherwise return {@code
    *     false}
    */
-  boolean completeOobDataExchange(@NonNull ProtocolDevice protocolDevice, @NonNull byte[] oobData);
+  boolean completeOobDataExchange(@NonNull byte[] oobData);
 
   /** Interrupt the current data exchange and prevent callbacks from being issued. */
   void interrupt();
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/oob/OobChannelFactory.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/oob/OobChannelFactory.kt
deleted file mode 100644
index 2a353a7..0000000
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/oob/OobChannelFactory.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.google.android.connecteddevice.oob
-
-import com.google.android.connecteddevice.transport.spp.ConnectedDeviceSppDelegateBinder
-
-/** Factory that creates [OobChannel]. */
-open class OobChannelFactory(private val sppBinder: ConnectedDeviceSppDelegateBinder) {
-  /**
-   * Returns [OobChannel] of the type [oobChannelType].
-   *
-   * Throws [IllegalArgumentException] if the [oobChannelType] passed is not supported.
-   */
-  open fun createOobChannel(oobChannelType: String): OobChannel =
-    when (oobChannelType) {
-      BT_RFCOMM -> BluetoothRfcommChannel(sppBinder)
-      PRE_ASSOCIATION -> PassThroughChannel()
-      else -> throw IllegalArgumentException("Unknown OOB channel type: $oobChannelType")
-    }
-
-  companion object {
-    /** Should be passed to [createOobChannel] to create corresponding OOB channel. */
-    const val BT_RFCOMM = "BT_RFCOMM"
-    const val PRE_ASSOCIATION = "PRE_ASSOCIATION"
-  }
-}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/oob/OobRunner.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/oob/OobRunner.kt
index 17d0c07..7b154c7 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/oob/OobRunner.kt
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/oob/OobRunner.kt
@@ -20,6 +20,7 @@
 import androidx.annotation.VisibleForTesting
 import com.google.android.companionprotos.OutOfBandAssociationToken
 import com.google.android.connecteddevice.model.OobData
+import com.google.android.connecteddevice.transport.ProtocolDelegate
 import com.google.android.connecteddevice.transport.ProtocolDevice
 import com.google.android.connecteddevice.util.SafeLog.logd
 import com.google.android.connecteddevice.util.SafeLog.loge
@@ -32,18 +33,19 @@
 import javax.crypto.spec.IvParameterSpec
 
 /**
- * Manages all OOB related actions, those actions should be ordered as below:
- * 1. [generateOobData] returns [ByteArray] which contains the OOB key.
- * 2. [sendOobData] to the remote [ProtocolDevice].
- * 3. [encryptData] and [decryptData] data after the OOB data exchange succeed. Will throw
+ * Manages all OOB related actions,
+ *
+ * The actions taken by this runner should be ordered as below:
+ * 1. [sendOobData] to the remote [ProtocolDevice], OOB data is generated at the same time.
+ * 2. [encryptData] and [decryptData] data after the OOB data exchange succeed. Will throw
  * [IllegalStateException] if attempting to encrypt/decrypt without first having called
- * [generateOobData].
+ * [sendOobData].
  */
 open class OobRunner
 @JvmOverloads
 constructor(
-  private val oobChannelFactory: OobChannelFactory,
-  open val supportedTypes: List<String>,
+  private val delegate: ProtocolDelegate,
+  private val oobProtocolName: String,
   internal val keyAlgorithm: String = KeyProperties.KEY_ALGORITHM_AES
 ) {
   @VisibleForTesting internal var ihuIv = ByteArray(NONCE_LENGTH_BYTES)
@@ -58,10 +60,13 @@
       loge(TAG, "Unable to create cipher with $ALGORITHM.", e)
       throw IllegalStateException(e)
     }
-  private var oobData: OobData? = null
 
-  /** Generate OOB data which should be exchanged with remote device. */
-  open fun generateOobData(): OobData {
+  /**
+   * Returns generates OOB data and iterates through all available OOB channels, establish OOB
+   * connections and send OOB data to remote device.
+   */
+  open fun sendOobData(): OobData {
+    logd(TAG, "Listening for OOB connection to send OOB data.")
     val keyGenerator =
       try {
         KeyGenerator.getInstance(keyAlgorithm)
@@ -75,28 +80,12 @@
     secureRandom.nextBytes(ihuIv)
     secureRandom.nextBytes(mobileIv)
     val oobData = OobData(secretKey.encoded, ihuIv, mobileIv)
-    this.oobData = oobData
-    return oobData
-  }
-
-  /**
-   * Iterate through all available OOB channels, establish OOB channels and send OOB data to remote
-   * device.
-   */
-  open fun sendOobData(protocolDevice: ProtocolDevice) {
-    val dataToSend = oobData
-    if (dataToSend == null) {
-      loge(TAG, "OOB data unavailable, failed to send OOB data.")
-      return
-    }
     establishedOobChannels.clear()
-    for (oobType in supportedTypes) {
-      logd(TAG, "Establish OOB channel and send OOB data with $oobType.")
-      val oobChannel = oobChannelFactory.createOobChannel(oobType)
-      if (oobChannel.completeOobDataExchange(protocolDevice, toOobProto(dataToSend))) {
-        establishedOobChannels.add(oobChannel)
-      }
+    val oobChannel = TransportOobChannel(delegate, oobProtocolName)
+    if (oobChannel.completeOobDataExchange(toOobProto(oobData))) {
+      establishedOobChannels.add(oobChannel)
     }
+    return oobData
   }
 
   /** Encrypt [data] with OOB key, throw exception when encryption failed. */
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/oob/PassThroughChannel.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/oob/PassThroughChannel.kt
deleted file mode 100644
index d67610c..0000000
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/oob/PassThroughChannel.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.google.android.connecteddevice.oob
-
-import com.google.android.connecteddevice.transport.ProtocolDevice
-
-/**
- * An out of band data exchange channel.
- *
- * This class performs no-op. [completeOobDataExchange] returns `true` immediately.
- */
-class PassThroughChannel : OobChannel {
-  override fun completeOobDataExchange(
-    protocolDevice: ProtocolDevice,
-    oobData: ByteArray
-  ): Boolean {
-    return true
-  }
-
-  override fun interrupt() {}
-}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/oob/TransportOobChannel.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/oob/TransportOobChannel.kt
new file mode 100644
index 0000000..dfb4311
--- /dev/null
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/oob/TransportOobChannel.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.connecteddevice.oob
+
+import android.os.ParcelUuid
+import com.google.android.connecteddevice.transport.IConnectionProtocol
+import com.google.android.connecteddevice.transport.IDataSendCallback
+import com.google.android.connecteddevice.transport.IDiscoveryCallback
+import com.google.android.connecteddevice.transport.ProtocolDelegate
+import com.google.android.connecteddevice.transport.ProtocolDevice
+import com.google.android.connecteddevice.util.SafeLog.logd
+import com.google.android.connecteddevice.util.SafeLog.logw
+import java.util.concurrent.CopyOnWriteArrayList
+
+/** Manages OOB data exchange through [ConnectionProtocol]s. */
+class TransportOobChannel(
+  private val delegate: ProtocolDelegate,
+  private val protocolName: String
+) : OobChannel {
+  /** List of [ProtocolDevice]s which is doing OOB data exchange. */
+  private val ongoingOobDataExchange = CopyOnWriteArrayList<ProtocolDevice>()
+
+  override fun completeOobDataExchange(oobData: ByteArray): Boolean {
+    val protocols = delegate.oobProtocols
+    if (protocols.isEmpty()) {
+      logw(TAG, "No available protocol to send OOB data, ignore.")
+      return false
+    }
+    for (protocol in protocols) {
+      protocol.startAssociationDiscovery(
+        protocolName,
+        DEFAULT_UUID,
+        generateDiscoveryCallback(protocol, oobData)
+      )
+    }
+    return true
+  }
+
+  private fun generateDiscoveryCallback(
+    protocol: IConnectionProtocol,
+    oobData: ByteArray
+  ): IDiscoveryCallback {
+    return object : IDiscoveryCallback.Stub() {
+      override fun onDeviceConnected(protocolId: String) {
+        val protocolDevice = ProtocolDevice(protocol, protocolId)
+        ongoingOobDataExchange.add(protocolDevice)
+        logd(
+          TAG,
+          "Oob channel established with id: $protocolId, protocol: $protocol, send OOB data."
+        )
+        protocol.sendData(protocolId, oobData, generateDataSentCallback(protocolDevice))
+      }
+
+      override fun onDiscoveryStartedSuccessfully() {
+        logd(TAG, "OOB discovery started successfully via protocol: $protocol")
+      }
+
+      override fun onDiscoveryFailedToStart() {
+        logw(TAG, "Failed to start OOB discovery via protocol: $protocol")
+      }
+    }
+  }
+
+  private fun generateDataSentCallback(device: ProtocolDevice): IDataSendCallback {
+    return object : IDataSendCallback.Stub() {
+      override fun onDataSentSuccessfully() {
+        logd(TAG, "OOB data sent successfully with protocol id: $device.protocolId.")
+      }
+
+      override fun onDataFailedToSend() {
+        logw(TAG, "OOB data failed to send through protocol: $device.protocol, ignored.")
+        device.protocol.disconnectDevice(device.protocolId)
+        ongoingOobDataExchange.remove(device)
+      }
+    }
+  }
+
+  override fun interrupt() {
+    for (protocolDevice in ongoingOobDataExchange) {
+      protocolDevice.protocol.disconnectDevice(protocolDevice.protocolId)
+    }
+    ongoingOobDataExchange.clear()
+  }
+
+  companion object {
+    private const val TAG = "TransportOobChannel"
+    private val DEFAULT_UUID = ParcelUuid.fromString("00001101-0000-1000-8000-00805F9B34FB")
+  }
+}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/service/ConnectedDeviceService.java b/libs/connecteddevice/src/com/google/android/connecteddevice/service/ConnectedDeviceService.java
index bbc0c8c..e8476a6 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/service/ConnectedDeviceService.java
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/service/ConnectedDeviceService.java
@@ -18,7 +18,6 @@
 
 import static com.google.android.connecteddevice.util.SafeLog.logd;
 import static com.google.android.connecteddevice.util.SafeLog.loge;
-import static java.util.Objects.requireNonNull;
 
 import android.content.Intent;
 import android.os.IBinder;
@@ -30,7 +29,6 @@
 import com.google.android.connecteddevice.core.MultiProtocolDeviceController;
 import com.google.android.connecteddevice.logging.LoggingFeature;
 import com.google.android.connecteddevice.logging.LoggingManager;
-import com.google.android.connecteddevice.oob.OobChannelFactory;
 import com.google.android.connecteddevice.oob.OobRunner;
 import com.google.android.connecteddevice.storage.ConnectedDeviceStorage;
 import com.google.android.connecteddevice.system.SystemFeature;
@@ -39,8 +37,6 @@
 import com.google.android.connecteddevice.transport.spp.ConnectedDeviceSppDelegateBinder;
 import com.google.android.connecteddevice.transport.spp.ConnectedDeviceSppDelegateBinder.OnRemoteCallbackSetListener;
 import com.google.android.connecteddevice.util.EventLog;
-import java.util.Arrays;
-import java.util.List;
 import java.util.UUID;
 import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledExecutorService;
@@ -55,8 +51,14 @@
   private static final String META_ASSOCIATION_SERVICE_UUID =
       "com.google.android.connecteddevice.association_service_uuid";
 
-  private static final String META_SUPPORTED_OOB_CHANNELS =
-      "com.google.android.connecteddevice.supported_oob_channels";
+  private static final String META_EAP_OOB_PROTOCOL_NAME =
+      "com.google.android.connecteddevice.car_eap_oob_protocol_name";
+
+  // The name should be reverse-DNS strings.
+  // Source:
+  // https://developer.apple.com/library/archive/featuredarticles/ExternalAccessoryPT/Introduction/Introduction.html#//apple_ref/doc/uid/TP40009502
+  private static final String DEFAULT_EAP_OOB_PROTOCOL_NAME =
+      "com.google.companion.oob-association";
 
   private static final String META_ENABLE_PASSENGER =
       "com.google.android.connecteddevice.enable_passenger";
@@ -121,15 +123,11 @@
       return;
     }
     logd(TAG, "Initializing FeatureCoordinator version of the platform.");
-    List<String> oobTypes =
-        Arrays.asList(
-            requireNonNull(
-                getMetaStringArray(
-                    META_SUPPORTED_OOB_CHANNELS, /* defaultValue= */ new String[0])));
-
-    OobRunner oobRunner = new OobRunner(new OobChannelFactory(sppDelegateBinder), oobTypes);
     UUID associationUuid = UUID.fromString(requireMetaString(META_ASSOCIATION_SERVICE_UUID));
     boolean enablePassenger = getMetaBoolean(META_ENABLE_PASSENGER, ENABLE_PASSENGER_BY_DEFAULT);
+    String oobProtocolName =
+        getMetaString(META_EAP_OOB_PROTOCOL_NAME, DEFAULT_EAP_OOB_PROTOCOL_NAME);
+    OobRunner oobRunner = new OobRunner(protocolDelegate, oobProtocolName);
     DeviceController deviceController =
         new MultiProtocolDeviceController(
             protocolDelegate, storage, oobRunner, associationUuid, enablePassenger);
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/service/MetaDataService.java b/libs/connecteddevice/src/com/google/android/connecteddevice/service/MetaDataService.java
index 68a5924..6306dde 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/service/MetaDataService.java
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/service/MetaDataService.java
@@ -55,11 +55,10 @@
   }
 
   /**
-   * Return a string from the service's meta-data, or default value if no meta-data matches
-   * the provided name.
+   * Return a string from the service's meta-data, or default value if no meta-data matches the
+   * provided name.
    */
-  @Nullable
-  protected final String getMetaString(@NonNull String name, @Nullable String defaultValue) {
+  protected final String getMetaString(@NonNull String name, @NonNull String defaultValue) {
     if (!bundle.containsKey(name)) {
       return defaultValue;
     }
@@ -103,10 +102,8 @@
    * Return a string array from the service's meta-data, or default value if no meta-data matches
    * the provided name.
    */
-  @Nullable
   protected final String[] getMetaStringArray(
-      @NonNull String name,
-      @Nullable String[] defaultValue) {
+      @NonNull String name, @NonNull String[] defaultValue) {
     if (!bundle.containsKey(name)) {
       return defaultValue;
     }
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/service/TransportService.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/service/TransportService.kt
index 2513696..332581f 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/service/TransportService.kt
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/service/TransportService.kt
@@ -32,6 +32,7 @@
 import com.google.android.connecteddevice.transport.IProtocolDelegate
 import com.google.android.connecteddevice.transport.ble.BlePeripheralProtocol
 import com.google.android.connecteddevice.transport.ble.OnDeviceBlePeripheralManager
+import com.google.android.connecteddevice.transport.eap.EapProtocol
 import com.google.android.connecteddevice.transport.proxy.NetworkSocketFactory
 import com.google.android.connecteddevice.transport.proxy.ProxyBlePeripheralManager
 import com.google.android.connecteddevice.transport.spp.SppProtocol
@@ -57,9 +58,11 @@
 
   /** Maps the currently supported protocol names to the actual protocol implementation. */
   @VisibleForTesting
-  internal val supportedProtocols = ConcurrentHashMap<String, ConnectionProtocol>()
+  internal val initializedProtocols = ConcurrentHashMap<String, ConnectionProtocol>()
 
-  private lateinit var supportedTransportProtocols: Set<String>
+  private lateinit var supportedOobProtocolsName: Set<String>
+
+  private lateinit var supportedTransportProtocolsName: Set<String>
 
   private lateinit var bluetoothManager: BluetoothManager
 
@@ -77,7 +80,7 @@
       override fun onServiceConnected(name: ComponentName, service: IBinder) {
         delegate = IProtocolDelegate.Stub.asInterface(service)
         logd(TAG, "Successfully bound to service and received delegate.")
-        initializeProtocols(supportedTransportProtocols)
+        initializeProtocols(supportedTransportProtocolsName + supportedOobProtocolsName)
       }
 
       override fun onServiceDisconnected(name: ComponentName?) {
@@ -102,16 +105,17 @@
     super.onCreate()
     logd(TAG, "Service created.")
     bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
-    supportedTransportProtocols =
-      getMetaStringArray(META_SUPPORTED_TRANSPORT_PROTOCOLS, DEFAULT_TRANSPORT_PROTOCOLS)?.toSet()
-        ?: emptySet()
-    if (supportedTransportProtocols.isEmpty()) {
+    supportedTransportProtocolsName =
+      getMetaStringArray(META_SUPPORTED_TRANSPORT_PROTOCOLS, DEFAULT_TRANSPORT_PROTOCOLS).toSet()
+    supportedOobProtocolsName =
+      getMetaStringArray(META_SUPPORTED_OOB_CHANNELS, emptyArray()).toSet()
+    if (supportedTransportProtocolsName.isEmpty()) {
       loge(
         TAG,
         "Transport protocols are empty. There must be at least one protocol provided to start " +
           "this service. Reverting to default values."
       )
-      supportedTransportProtocols = DEFAULT_TRANSPORT_PROTOCOLS.toSet()
+      supportedTransportProtocolsName = DEFAULT_TRANSPORT_PROTOCOLS.toSet()
     }
 
     registerReceiver(
@@ -126,7 +130,7 @@
 
   override fun onDestroy() {
     logd(TAG, "Service destroyed.")
-    disconnectProtocols(supportedTransportProtocols)
+    disconnectProtocols(initializedProtocols.keys)
     try {
       unregisterReceiver(bluetoothBroadcastReceiver)
     } catch (e: IllegalArgumentException) {
@@ -144,7 +148,8 @@
 
   private fun onBluetoothStateChanged(state: Int) {
     logd(TAG, "The bluetooth state has changed to $state.")
-    val supportedBluetoothProtocols = supportedTransportProtocols.intersect(BLUETOOTH_PROTOCOLS)
+    val supportedBluetoothProtocols =
+      (supportedTransportProtocolsName + supportedOobProtocolsName).intersect(BLUETOOTH_PROTOCOLS)
     when (state) {
       BluetoothAdapter.STATE_ON -> {
         EventLog.onBleOn()
@@ -165,26 +170,36 @@
     logd(TAG, "Processing ${targetProtocols.size} supported protocols.")
     for (protocol in targetProtocols) {
       logd(TAG, "Adding protocol $protocol to supported protocols.")
-      when (protocol) {
-        TransportProtocols.PROTOCOL_BLE_PERIPHERAL -> maybeAddBlePeripheralProtocol()
-        TransportProtocols.PROTOCOL_SPP -> maybeAddSppProtocol()
-        else -> loge(TAG, "Protocol type $protocol is not recognized. Ignoring.")
-      }
+      addBluetoothProtocol(protocol)
     }
   }
 
-  private fun maybeAddBlePeripheralProtocol() {
+  private fun addBluetoothProtocol(protocolName: String) {
     if (!bluetoothManager.adapter.isEnabled) {
-      logd(TAG, "Bluetooth adapter is currently disabled. Skipping BlePeripheralProtocol.")
+      logd(TAG, "Bluetooth adapter is currently disabled. Skipping $protocolName.")
       return
     }
-    if (supportedProtocols.containsKey(TransportProtocols.PROTOCOL_BLE_PERIPHERAL)) {
-      logd(TAG, "A BlePeripheralProtocol has already been created. Aborting.")
+    if (initializedProtocols.containsKey(protocolName)) {
+      logd(TAG, "A $protocolName has already been created. Aborting.")
       return
     }
-    val protocol = createBlePeripheralProtocol()
-    supportedProtocols[TransportProtocols.PROTOCOL_BLE_PERIPHERAL] = protocol
-    delegate?.addProtocol(protocol)
+    val protocol: ConnectionProtocol =
+      when (protocolName) {
+        TransportProtocols.PROTOCOL_BLE_PERIPHERAL -> createBlePeripheralProtocol()
+        TransportProtocols.PROTOCOL_SPP -> createSppProtocol()
+        TransportProtocols.PROTOCOL_EAP -> createEapProtocol()
+        else -> {
+          loge(TAG, "Protocol type $protocolName is not recognized. Ignoring.")
+          return
+        }
+      }
+    initializedProtocols[protocolName] = protocol
+    if (protocolName in supportedTransportProtocolsName) {
+      delegate?.addProtocol(protocol)
+    }
+    if (protocolName in supportedOobProtocolsName) {
+      delegate?.addOobProtocol(protocol)
+    }
   }
 
   private fun createBlePeripheralProtocol(): BlePeripheralProtocol {
@@ -222,29 +237,31 @@
     )
   }
 
-  private fun maybeAddSppProtocol() {
-    if (!bluetoothManager.adapter.isEnabled) {
-      logd(TAG, "Bluetooth adapter is currently disabled. Skipping SppProtocol.")
-      return
-    }
-    if (supportedProtocols.containsKey(TransportProtocols.PROTOCOL_SPP)) {
-      logd(TAG, "A SppProtocol has already been created. Aborting.")
-      return
-    }
+  private fun createSppProtocol(): SppProtocol {
     val maxSppPacketSize = getMetaInt(META_SPP_PACKET_BYTES, DEFAULT_SPP_PACKET_SIZE_BYTES)
-    val protocol = SppProtocol(context = this, maxSppPacketSize)
-    supportedProtocols[TransportProtocols.PROTOCOL_SPP] = protocol
-    delegate?.addProtocol(protocol)
+    return SppProtocol(context = this, maxSppPacketSize)
+  }
+
+  private fun createEapProtocol(): EapProtocol {
+    val maxSppPacketSize = getMetaInt(META_SPP_PACKET_BYTES, DEFAULT_SPP_PACKET_SIZE_BYTES)
+    val eapClientName = getMetaString(META_EAP_CLIENT_NAME, "")
+    val eapServiceName = getMetaString(META_EAP_SERVICE_NAME, "")
+    return EapProtocol(eapClientName, eapServiceName, maxSppPacketSize)
   }
 
   private fun disconnectProtocols(targetProtocols: Set<String>) {
     for (protocolName in targetProtocols) {
-      val protocol = supportedProtocols[protocolName]
+      val protocol = initializedProtocols[protocolName]
       if (protocol != null) {
         logd(TAG, "Disconnecting $protocolName.")
         protocol.reset()
-        delegate?.removeProtocol(protocol)
-        supportedProtocols.remove(protocolName)
+        initializedProtocols.remove(protocolName)
+        if (protocolName in supportedTransportProtocolsName) {
+          delegate?.removeProtocol(protocol)
+        }
+        if (protocolName in supportedOobProtocolsName) {
+          delegate?.removeOobProtocol(protocol)
+        }
       }
     }
   }
@@ -256,13 +273,24 @@
     const val ACTION_BIND_PROTOCOL = "com.google.android.connecteddevice.BIND_PROTOCOL"
 
     /** `string-array` Supported transport protocols. */
-    private const val META_SUPPORTED_TRANSPORT_PROTOCOLS =
+    @VisibleForTesting
+    internal const val META_SUPPORTED_TRANSPORT_PROTOCOLS =
       "com.google.android.connecteddevice.transport_protocols"
 
+    @VisibleForTesting
+    internal const val META_SUPPORTED_OOB_CHANNELS =
+      "com.google.android.connecteddevice.supported_oob_channels"
+
     // The mac address randomly rotates every 7-15 minutes. To be safe, we will rotate our
     // reconnect advertisement every 6 minutes to avoid crossing a rotation.
     private val MAX_ADVERTISEMENT_DURATION = Duration.ofMinutes(6)
 
+    private const val META_EAP_CLIENT_NAME =
+      "com.google.android.connecteddevice.car_eap_client_name"
+
+    private const val META_EAP_SERVICE_NAME =
+      "com.google.android.connecteddevice.car_eap_service_name"
+
     /** `String` UUID for reconnection advertisement. */
     private const val META_RECONNECT_SERVICE_UUID =
       "com.google.android.connecteddevice.reconnect_service_uuid"
@@ -312,6 +340,10 @@
     private const val PROXY_ENABLED_BY_DEFAULT = false
 
     private val BLUETOOTH_PROTOCOLS =
-      listOf(TransportProtocols.PROTOCOL_BLE_PERIPHERAL, TransportProtocols.PROTOCOL_SPP)
+      setOf(
+        TransportProtocols.PROTOCOL_BLE_PERIPHERAL,
+        TransportProtocols.PROTOCOL_SPP,
+        TransportProtocols.PROTOCOL_EAP
+      )
   }
 }
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/transport/IProtocolDelegate.aidl b/libs/connecteddevice/src/com/google/android/connecteddevice/transport/IProtocolDelegate.aidl
index f03779c..dcc5ddd 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/transport/IProtocolDelegate.aidl
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/transport/IProtocolDelegate.aidl
@@ -23,6 +23,12 @@
   /** Add a protocol to the collection of supported protocols. */
   void addProtocol(in IConnectionProtocol protocol);
 
+  /** Add an OOB protocol to the collection of supported OOB protocols. */
+  void addOobProtocol(in IConnectionProtocol protocol);
+
   /** Remove a protocol from the collection of supported protocols. */
   void removeProtocol(in IConnectionProtocol protocol);
+
+  /** Remove an OOB protocol to the collection of supported OOB protocols. */
+  void removeOobProtocol(in IConnectionProtocol protocol);
 }
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/transport/ProtocolDelegate.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/transport/ProtocolDelegate.kt
index 62af14d..0f29c62 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/transport/ProtocolDelegate.kt
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/transport/ProtocolDelegate.kt
@@ -24,11 +24,20 @@
   /** The list of currently attached [IConnectionProtocol]s. */
   val protocols: List<IConnectionProtocol>
     get() {
-      scrubDeadProtocols()
+      scrubDeadProtocols(_protocols)
       return _protocols
     }
 
-  /** `true` if there are currently no protocols attached. `false` otherwise. */
+  private val _oobProtocols = mutableListOf<IConnectionProtocol>()
+
+  /** Protocols that support OOB data exchange. */
+  val oobProtocols: List<IConnectionProtocol>
+    get() {
+      scrubDeadProtocols(_oobProtocols)
+      return _oobProtocols
+    }
+
+  /** `true` if there are currently no general transport protocols attached. `false` otherwise. */
   val isEmpty: Boolean
     get() = protocols.isEmpty()
 
@@ -39,6 +48,24 @@
   /** Callback registered for protocol changes. */
   var callback: Callback? = null
 
+  override fun addOobProtocol(protocol: IConnectionProtocol) {
+    _oobProtocols.add(protocol)
+    logd(
+      TAG,
+      "Added a new OOB protocol. There are now ${oobProtocols.size} attached OOB " + "protocols."
+    )
+  }
+
+  override fun removeOobProtocol(protocol: IConnectionProtocol) {
+    scrubDeadProtocols(_oobProtocols)
+    _oobProtocols.removeAll { it.asBinder() == protocol.asBinder() }
+    logd(
+      TAG,
+      "Removed a OOB protocol. There are ${oobProtocols.size} remaining OOB attached " +
+        "protocols."
+    )
+  }
+
   override fun addProtocol(protocol: IConnectionProtocol) {
     _protocols.add(protocol)
     logd(TAG, "Added a new protocol. There are now ${protocols.size} attached protocols.")
@@ -46,7 +73,7 @@
   }
 
   override fun removeProtocol(protocol: IConnectionProtocol) {
-    scrubDeadProtocols()
+    scrubDeadProtocols(_protocols)
     if (!_protocols.removeAll { it.asBinder() == protocol.asBinder() }) {
       return
     }
@@ -54,8 +81,8 @@
     callback?.onProtocolRemoved(protocol)
   }
 
-  private fun scrubDeadProtocols() =
-    _protocols.removeAll {
+  private fun scrubDeadProtocols(protocols: MutableList<IConnectionProtocol>) =
+    protocols.removeAll {
       val isNotAlive = !it.asBinder().isBinderAlive
       if (isNotAlive) {
         callback?.onProtocolRemoved(it)
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/transport/eap/EapProtocol.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/transport/eap/EapProtocol.kt
new file mode 100644
index 0000000..08aef1a
--- /dev/null
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/transport/eap/EapProtocol.kt
@@ -0,0 +1,179 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.connecteddevice.transport.eap
+
+import android.annotation.SuppressLint
+import android.os.IBinder
+import android.os.ParcelUuid
+import android.os.RemoteException
+import androidx.annotation.VisibleForTesting
+import com.google.android.connecteddevice.transport.ConnectChallenge
+import com.google.android.connecteddevice.transport.ConnectionProtocol
+import com.google.android.connecteddevice.transport.IDataSendCallback
+import com.google.android.connecteddevice.transport.IDiscoveryCallback
+import com.google.android.connecteddevice.util.DirectExecutor
+import com.google.android.connecteddevice.util.SafeLog.logd
+import com.google.android.connecteddevice.util.SafeLog.loge
+import com.panasonic.iapx.IDeviceConnection
+import com.panasonic.iapx.IDeviceConnectionDelegate
+import com.panasonic.iapx.IServiceConnector
+import com.panasonic.iapx.IServiceConnectorDelegate
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.Executor
+
+/** Defines how connection is established and data is transferred via EAP channel. */
+class EapProtocol(
+  private val eapClientName: String,
+  private val eapServiceName: String,
+  private val maxPacketSize: Int,
+  callbackExecutor: Executor = DirectExecutor()
+) : ConnectionProtocol(callbackExecutor) {
+  // Stores the connected EAP session id to the real connection pair.
+  private val sessions = ConcurrentHashMap<Long, IDeviceConnection>()
+  private val ongoingDiscoveries = ConcurrentHashMap<String, IDiscoveryCallback>()
+
+  private val serviceDelegate =
+    object : IServiceConnectorDelegate.Stub() {
+      override fun OnServiceConnectionChange(status: Int) {
+        logd(TAG, "Service connection status has changed to $status.")
+        if (status == IServiceConnector.kServiceConnectionReady) {
+          logd(TAG, "Connection to EAP service is ready to use.")
+        }
+      }
+    }
+
+  init {
+    bindToEapService()
+  }
+
+  @SuppressLint("PrivateApi")
+  private fun bindToEapService() {
+    logd(TAG, "Attempting to connect to EAP service $eapServiceName.")
+    val serviceBinder =
+      Class.forName("android.os.ServiceManager")
+        .getMethod("getService", java.lang.String::class.java)
+        .invoke(null, eapServiceName) as?
+        IBinder
+    if (serviceBinder == null) {
+      loge(TAG, "Unable to bind to EAP service. Aborting.")
+      return
+    }
+    val connector = IServiceConnector.Stub.asInterface(serviceBinder)
+    logd(TAG, "Attempting to connect to the EAP client.")
+    connectClient(connector)
+  }
+
+  @VisibleForTesting
+  internal fun connectClient(connector: IServiceConnector) {
+    try {
+      connector.ConnectClient(
+        eapClientName,
+        serviceDelegate.asBinder(),
+        generateDeviceDelegate().asBinder()
+      )
+    } catch (e: RemoteException) {
+      loge(TAG, "Failed to connect the EAP client.", e)
+    }
+  }
+
+  override fun startAssociationDiscovery(
+    name: String,
+    identifier: ParcelUuid,
+    callback: IDiscoveryCallback,
+  ) {
+    ongoingDiscoveries[name] = callback
+  }
+
+  private fun generateDeviceDelegate(): IDeviceConnectionDelegate {
+    return object : IDeviceConnectionDelegate.Stub() {
+      override fun OnConnectionReady(connection: IDeviceConnection?, transportType: Int) {
+        logd(TAG, "Connection is ready on transport $transportType.")
+      }
+
+      override fun OnConnectionClosed(connection: IDeviceConnection?) {
+        logd(TAG, "Connection has been closed.")
+      }
+
+      override fun OnEAPSessionStart(
+        connection: IDeviceConnection,
+        eapSessionId: Long,
+        eapProtocolName: String?
+      ) {
+        logd(TAG, "Starting new session $eapSessionId with protocol $eapProtocolName.")
+        if (ongoingDiscoveries.containsKey(eapProtocolName)) {
+          sessions[eapSessionId] = connection
+          ongoingDiscoveries[eapProtocolName]?.onDeviceConnected(eapSessionId.toString())
+        }
+      }
+
+      override fun OnEAPSessionStop(connection: IDeviceConnection?, eapSessionId: Long) {
+        logd(TAG, "Device disconnected. id: $eapSessionId.")
+        if (sessions.containsKey(eapSessionId)) {
+          sessions.remove(eapSessionId)
+          deviceDisconnectedListeners[eapSessionId.toString()]?.invoke {
+            it.onDeviceDisconnected(eapSessionId.toString())
+          }
+        }
+      }
+
+      override fun OnEAPData(connection: IDeviceConnection?, eapSessionId: Long, data: ByteArray?) {
+        logd(TAG, "Received new data from session $eapSessionId.")
+        if (data != null) {
+          notifyDataReceived(eapSessionId.toString(), data)
+        }
+      }
+
+      override fun OnDeviceNameUpdate(connection: IDeviceConnection?, name: String?) {}
+
+      override fun OnDeviceTransientUUIDUpdate(connection: IDeviceConnection?, uuid: String?) {}
+    }
+  }
+
+  override fun stopAssociationDiscovery() {
+    ongoingDiscoveries.clear()
+  }
+
+  override fun reset() {
+    super.reset()
+    sessions.clear()
+    ongoingDiscoveries.clear()
+  }
+
+  override fun sendData(protocolId: String, data: ByteArray, callback: IDataSendCallback?) {
+    sessions[protocolId.toLong()]?.SendEAPData(protocolId.toLong(), data)
+      ?: loge(TAG, "Unable to find device with session $protocolId.")
+  }
+
+  override fun getMaxWriteSize(protocolId: String): Int {
+    return maxPacketSize
+  }
+
+  override fun isDeviceVerificationRequired() = false
+
+  override fun startConnectionDiscovery(
+    id: ParcelUuid,
+    challenge: ConnectChallenge,
+    callback: IDiscoveryCallback,
+  ) {}
+
+  override fun stopConnectionDiscovery(id: ParcelUuid) {}
+
+  override fun disconnectDevice(protocolId: String) {}
+
+  companion object {
+    private const val TAG = "EapProtocol"
+  }
+}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/transport/eap/com/panasonic/iapx/IDeviceConnection.aidl b/libs/connecteddevice/src/com/google/android/connecteddevice/transport/eap/com/panasonic/iapx/IDeviceConnection.aidl
new file mode 100644
index 0000000..1794f8a
--- /dev/null
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/transport/eap/com/panasonic/iapx/IDeviceConnection.aidl
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2022 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.panasonic.iapx;
+
+interface IDeviceConnection {
+  oneway void DoNotDisturbAnymore() = 0;
+  oneway void SendEAPData(in long eapSessionId, in byte[] data) = 3000;
+}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/transport/eap/com/panasonic/iapx/IDeviceConnectionDelegate.aidl b/libs/connecteddevice/src/com/google/android/connecteddevice/transport/eap/com/panasonic/iapx/IDeviceConnectionDelegate.aidl
new file mode 100644
index 0000000..8e41e6b
--- /dev/null
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/transport/eap/com/panasonic/iapx/IDeviceConnectionDelegate.aidl
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2022 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.panasonic.iapx;
+
+import com.panasonic.iapx.IDeviceConnection;
+
+interface IDeviceConnectionDelegate {
+  oneway void OnConnectionReady(in com.panasonic.iapx.IDeviceConnection connection, in int transportType) = 0;
+  oneway void OnConnectionClosed(in com.panasonic.iapx.IDeviceConnection connection) = 1;
+  oneway void OnDeviceNameUpdate(in com.panasonic.iapx.IDeviceConnection connection, in String name) = 100;
+  oneway void OnDeviceTransientUUIDUpdate(in com.panasonic.iapx.IDeviceConnection connection, in String uuid) = 101;
+  oneway void OnEAPSessionStart(in com.panasonic.iapx.IDeviceConnection connection, in long eapSessionId, in String eapProtocolName) = 3000;
+  oneway void OnEAPSessionStop(in com.panasonic.iapx.IDeviceConnection connection, in long eapSessionId) = 3001;
+  oneway void OnEAPData(in com.panasonic.iapx.IDeviceConnection connection, in long eapSessionId, in byte[] data) = 3010;
+}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/transport/eap/com/panasonic/iapx/IServiceConnector.aidl b/libs/connecteddevice/src/com/google/android/connecteddevice/transport/eap/com/panasonic/iapx/IServiceConnector.aidl
new file mode 100644
index 0000000..afcfc4a
--- /dev/null
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/transport/eap/com/panasonic/iapx/IServiceConnector.aidl
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2022 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.panasonic.iapx;
+
+interface IServiceConnector {
+  void ConnectClient(in String uniqueClientName, in IBinder serviceDelegate, in IBinder deviceConnectionDelegate) = 0;
+  void DisconnectClient(in String uniqueClientName, in IBinder serviceDelegate) = 1;
+  const int kServiceConnectionReady = 1;
+  const int kServiceConnectionRefused = 2;
+  const int kServiceConnectionLost = 3;
+}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/transport/eap/com/panasonic/iapx/IServiceConnectorDelegate.aidl b/libs/connecteddevice/src/com/google/android/connecteddevice/transport/eap/com/panasonic/iapx/IServiceConnectorDelegate.aidl
new file mode 100644
index 0000000..52828f3
--- /dev/null
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/transport/eap/com/panasonic/iapx/IServiceConnectorDelegate.aidl
@@ -0,0 +1,21 @@
+/*
+ * Copyright (C) 2022 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.panasonic.iapx;
+
+interface IServiceConnectorDelegate {
+  oneway void OnServiceConnectionChange(in int status) = 0;
+}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/transport/eap/com/panasonic/iapx/ITransport.aidl b/libs/connecteddevice/src/com/google/android/connecteddevice/transport/eap/com/panasonic/iapx/ITransport.aidl
new file mode 100644
index 0000000..f04a30f
--- /dev/null
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/transport/eap/com/panasonic/iapx/ITransport.aidl
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2022 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.panasonic.iapx;
+
+interface ITransport {
+  const int kTypeSerial = 0;
+  const int kTypeUSBHostMode = 1;
+  const int kTypeUSBDeviceMode = 2;
+  const int kTypeBluetooth = 3;
+  const int kTypeCarPlay = 4;
+}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/transport/spp/SppManager.java b/libs/connecteddevice/src/com/google/android/connecteddevice/transport/spp/SppManager.java
index 3295955..724b394 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/transport/spp/SppManager.java
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/transport/spp/SppManager.java
@@ -68,7 +68,6 @@
   private final ExecutorService taskExecutor = Executors.newSingleThreadExecutor();
   private final ExecutorService writeExecutor = Executors.newSingleThreadExecutor();
   private final Executor taskCallbackExecutor;
-  private BluetoothDevice device;
   // Only the first registered {@code OnMessageReceivedListener} will receive the missed messages.
   private final ConcurrentLinkedQueue<byte[]> missedMessages = new ConcurrentLinkedQueue<>();
 
@@ -134,8 +133,13 @@
               + " is "
               + missedMessages.size());
       byte[] missedMessage = missedMessages.poll();
-      receivedListeners.invoke(
-          receivedListener -> receivedListener.onMessageReceived(device, missedMessage));
+      // Invokes message received callback only when there is currently a connection.
+      if (connectedSocket != null) {
+        receivedListeners.invoke(
+            receivedListener ->
+                receivedListener.onMessageReceived(
+                    connectedSocket.getRemoteDevice(), missedMessage));
+      }
     }
   }
 
@@ -306,6 +310,8 @@
 
     try {
       if (connectedSocket != null) {
+        callbacks.invoke(
+            callback -> callback.onRemoteDeviceDisconnected(connectedSocket.getRemoteDevice()));
         connectedSocket.close();
       }
     } catch (IOException e) {
@@ -314,8 +320,6 @@
 
     connectedSocket = null;
     state = ConnectionState.DISCONNECTED;
-
-    callbacks.invoke(callback -> callback.onRemoteDeviceDisconnected(device));
   }
 
   /**
@@ -325,14 +329,13 @@
    * @param device The BluetoothDevice that has been connected
    */
   @GuardedBy("lock")
-  private void startConnectionLocked(BluetoothSocket socket, BluetoothDevice device) {
+  private void startConnectionLocked(BluetoothSocket socket) {
     logd(TAG, "Connected over Bluetooth socket. Started listening for incoming messages");
 
-    this.device = device;
     connectedSocket = socket;
-
     state = ConnectionState.CONNECTED;
-    callbacks.invoke(callback -> callback.onRemoteDeviceConnected(device));
+    callbacks.invoke(
+        callback -> callback.onRemoteDeviceConnected(connectedSocket.getRemoteDevice()));
 
     InputStream inputStream;
     try {
@@ -366,7 +369,7 @@
               case LISTEN:
               case CONNECTING:
                 logd(TAG, "Starting connection with device " + socket.getRemoteDevice());
-                startConnectionLocked(socket, socket.getRemoteDevice());
+                startConnectionLocked(socket);
                 break;
               case CONNECTED:
                 loge(TAG, "AcceptTask completed while in CONNECTED state. Cosing socket.");
@@ -402,7 +405,10 @@
                     + " while no listener registered, storing the message");
             return;
           }
-          receivedListeners.invoke(listener -> listener.onMessageReceived(device, message));
+          if (connectedSocket != null) {
+            receivedListeners.invoke(
+                listener -> listener.onMessageReceived(connectedSocket.getRemoteDevice(), message));
+          }
         }
 
         @Override
@@ -419,7 +425,7 @@
         public void onConnectionSuccess(BluetoothSocket socket) {
           synchronized (lock) {
             logd(TAG, "onConnectionSucceeded for device " + socket.getRemoteDevice());
-            startConnectionLocked(socket, socket.getRemoteDevice());
+            startConnectionLocked(socket);
           }
         }
 
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/ui/AssociatedDeviceViewModel.java b/libs/connecteddevice/src/com/google/android/connecteddevice/ui/AssociatedDeviceViewModel.java
index d86d1a7..8c95ca7 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/ui/AssociatedDeviceViewModel.java
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/ui/AssociatedDeviceViewModel.java
@@ -28,6 +28,8 @@
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
+import android.os.Handler;
+import android.os.Looper;
 import android.os.ParcelUuid;
 import androidx.annotation.GuardedBy;
 import androidx.annotation.NonNull;
@@ -61,6 +63,8 @@
 
   private static final Duration DISCOVERABLE_DURATION = Duration.ofMinutes(2);
 
+  private static final Duration CONNECT_TIME_OUT_DURATION = Duration.ofSeconds(10);
+
   /** States of association process. */
   public enum AssociationState {
     NONE,
@@ -94,6 +98,8 @@
   private final boolean isPassengerEnabled;
   private final String bleDeviceNamePrefix;
   private final BluetoothAdapter bluetoothAdapter;
+  private final Handler handler = new Handler(Looper.getMainLooper());
+  private final Runnable connectTimeoutRunnable = this::onConnectTimeout;
 
   private final Connector connector;
 
@@ -141,7 +147,7 @@
     this.connector.setFeatureId(new ParcelUuid(UUID.randomUUID()));
 
     this.connector.setCallback(connectorCallback);
-    this.connector.connect();
+    connect();
   }
 
   @Override
@@ -155,6 +161,10 @@
   /** Confirms that the pairing code matches. */
   public void acceptVerification() {
     pairingCode.postValue(null);
+    if (!connector.isConnected()) {
+      loge(TAG, "Failed to accept verification, connector is not connected.");
+      return;
+    }
     connector.acceptVerification();
   }
 
@@ -166,6 +176,10 @@
     }
     advertisedCarName.postValue(null);
     pairingCode.postValue(null);
+    if (!connector.isConnected()) {
+      loge(TAG, "Failed to stop association, connector is not connected.");
+      return;
+    }
     connector.stopAssociation();
     associationState.postValue(AssociationState.NONE);
   }
@@ -178,11 +192,25 @@
 
   /** Removes the association of the given device. */
   public void removeDevice(@NonNull AssociatedDevice device) {
+    if (!connector.isConnected()) {
+      loge(
+          TAG,
+          "Failed to remove device " + device.getDeviceId() + " , connector is not connected.");
+      return;
+    }
     connector.removeAssociatedDevice(device.getDeviceId());
   }
 
   /** Toggles connection of the given associated device. */
   public void toggleConnectionStatusForDevice(@NonNull AssociatedDevice device) {
+    if (!connector.isConnected()) {
+      loge(
+          TAG,
+          "Failed to change connection on device "
+              + device.getDeviceId()
+              + " , connector is not connected.");
+      return;
+    }
     if (device.isConnectionEnabled()) {
       connector.disableAssociatedDeviceConnection(device.getDeviceId());
     } else {
@@ -192,11 +220,24 @@
 
   /** Mark the given device as belonging to the active driver. */
   public void claimDevice(@NonNull AssociatedDevice device) {
+    if (!connector.isConnected()) {
+      loge(
+          TAG, "Failed to claim device " + device.getDeviceId() + " , connector is not connected.");
+      return;
+    }
     connector.claimAssociatedDevice(device.getDeviceId());
   }
 
   /** Mark the given device as unclaimed by any user. */
   public void removeClaimOnDevice(@NonNull AssociatedDevice device) {
+    if (!connector.isConnected()) {
+      loge(
+          TAG,
+          "Failed to remove claim on device "
+              + device.getDeviceId()
+              + " , connector is not connected.");
+      return;
+    }
     connector.removeAssociatedDeviceClaim(device.getDeviceId());
   }
 
@@ -292,6 +333,10 @@
       getApplication().startActivity(discoverableIntent);
     }
 
+    if (!connector.isConnected()) {
+      loge(TAG, "Failed to start association, connector is not connected.");
+      return;
+    }
     if (associationIdentifier != null) {
       connector.startAssociation(associationIdentifier, associationCallback);
     } else {
@@ -385,11 +430,22 @@
     }
   }
 
+  private void connect() {
+    handler.postDelayed(connectTimeoutRunnable, CONNECT_TIME_OUT_DURATION.toMillis());
+    connector.connect();
+  }
+
+  private void onConnectTimeout() {
+    logd(TAG, "Connector failed to connect in " + CONNECT_TIME_OUT_DURATION);
+    connector.disconnect();
+  }
+
   private final Connector.Callback connectorCallback =
       new Connector.Callback() {
         @Override
         public void onConnected() {
           logd(TAG, "Connected to platform.");
+          handler.removeCallbacks(connectTimeoutRunnable);
           isServiceConnected.postValue(true);
 
           if (isPassengerEnabled) {
@@ -405,7 +461,7 @@
         public void onDisconnected() {
           logd(TAG, "Disconnected from the platform.");
           isServiceConnected.postValue(false);
-          connector.connect();
+          connect();
         }
 
         @Override
@@ -444,6 +500,11 @@
           logd(TAG, "Device " + device.getDeviceId() + " has established a secure channel.");
           updateDeviceDetails();
         }
+
+        @Override
+        public void onFailedToConnect() {
+          loge(TAG, "Connector failed to connect.");
+        }
       };
 
   private final IAssociationCallback associationCallback =
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/util/DirectExecutor.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/util/DirectExecutor.kt
new file mode 100644
index 0000000..bd71117
--- /dev/null
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/util/DirectExecutor.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.connecteddevice.util
+
+import java.util.concurrent.Executor
+
+/** Runs commands in current thread. */
+class DirectExecutor : Executor {
+  override fun execute(r: Runnable) {
+    r.run()
+  }
+}
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/connection/ChannelResolverTest.kt b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/connection/ChannelResolverTest.kt
index 11a20e3..d2c4db0 100644
--- a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/connection/ChannelResolverTest.kt
+++ b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/connection/ChannelResolverTest.kt
@@ -41,7 +41,6 @@
 import com.nhaarman.mockitokotlin2.any
 import com.nhaarman.mockitokotlin2.anyOrNull
 import com.nhaarman.mockitokotlin2.argumentCaptor
-import com.nhaarman.mockitokotlin2.doReturn
 import com.nhaarman.mockitokotlin2.eq
 import com.nhaarman.mockitokotlin2.mock
 import com.nhaarman.mockitokotlin2.spy
@@ -70,8 +69,7 @@
   private val mockCallback = mock<ChannelResolver.Callback>()
   private val mockStreamFactory = mock<ProtocolStreamFactory>()
   private val mockStream = mock<ProtocolStream>()
-  private val mockOobRunner =
-    mock<OobRunner> { on { supportedTypes } doReturn SUPPORTED_OOB_CAPABILITIES }
+  private val mockOobRunner = mock<OobRunner>()
   private var mockEncryptionRunner = mock<FakeEncryptionRunner>()
   private val testDevice1 = ProtocolDevice(testProtocol1, TEST_PROTOCOL_ID_1)
   private val testDevice2 = ProtocolDevice(testProtocol2, TEST_PROTOCOL_ID_2)
@@ -146,7 +144,7 @@
   }
 
   @Test
-  fun receivedSupportedVersion_sendVersionMessageAndSendOobData() {
+  fun receivedSupportedVersion_sendVersionMessage() {
     channelResolver.resolveAssociation(mockOobRunner)
     argumentCaptor<IDataReceivedListener>().apply {
       verify(testProtocol1).registerDataReceivedListener(eq(TEST_PROTOCOL_ID_1), capture())
@@ -157,9 +155,8 @@
       verify(testProtocol1).sendData(eq(TEST_PROTOCOL_ID_1), capture(), anyOrNull())
       assertThat(firstValue).isEqualTo(expectedVersion)
     }
-
-    verify(mockOobRunner).sendOobData(any())
   }
+
   @Test
   fun receivedInvalidChallenge_invokeOnError() {
     channelResolver.resolveReconnect(TEST_DEVICE_ID, TEST_CHALLENGE)
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/connection/MultiProtocolSecureChannelV4Test.kt b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/connection/MultiProtocolSecureChannelV4Test.kt
deleted file mode 100644
index f0a275a..0000000
--- a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/connection/MultiProtocolSecureChannelV4Test.kt
+++ /dev/null
@@ -1,259 +0,0 @@
-/*
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.google.android.connecteddevice.connection
-
-import android.content.Context
-import android.os.ParcelUuid
-import android.util.Base64
-import androidx.room.Room
-import androidx.test.core.app.ApplicationProvider
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.google.android.companionprotos.VerificationCode
-import com.google.android.companionprotos.VerificationCodeState
-import com.google.android.connecteddevice.connection.MultiProtocolSecureChannel.ShowVerificationCodeListener
-import com.google.android.connecteddevice.model.DeviceMessage
-import com.google.android.connecteddevice.model.DeviceMessage.OperationType
-import com.google.android.connecteddevice.oob.OobRunner
-import com.google.android.connecteddevice.storage.ConnectedDeviceDatabase
-import com.google.android.connecteddevice.storage.ConnectedDeviceStorage
-import com.google.android.connecteddevice.storage.CryptoHelper
-import com.google.android.connecteddevice.transport.ConnectChallenge
-import com.google.android.connecteddevice.transport.ConnectionProtocol
-import com.google.android.connecteddevice.transport.IDataSendCallback
-import com.google.android.connecteddevice.transport.IDiscoveryCallback
-import com.google.android.connecteddevice.transport.ProtocolDevice
-import com.google.android.encryptionrunner.EncryptionRunnerFactory
-import com.google.android.encryptionrunner.FakeEncryptionRunner
-import com.google.common.truth.Truth.assertThat
-import com.google.common.util.concurrent.MoreExecutors.directExecutor
-import com.google.protobuf.ByteString
-import com.nhaarman.mockitokotlin2.any
-import com.nhaarman.mockitokotlin2.argumentCaptor
-import com.nhaarman.mockitokotlin2.mock
-import com.nhaarman.mockitokotlin2.spy
-import com.nhaarman.mockitokotlin2.times
-import com.nhaarman.mockitokotlin2.verify
-import com.nhaarman.mockitokotlin2.whenever
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-
-private const val PROTOCOL_ID = "testProtocol"
-
-@RunWith(AndroidJUnit4::class)
-class MultiProtocolSecureChannelV4Test {
-  private val context = ApplicationProvider.getApplicationContext<Context>()
-  private val stream1 = spy(ProtocolStream(ProtocolDevice(TestProtocolV4(), PROTOCOL_ID)))
-  private lateinit var storage: ConnectedDeviceStorage
-  private val mockOobRunner: OobRunner = mock()
-  private val mockShowVerificationCodeListener: ShowVerificationCodeListener = mock()
-  private val mockCallback: MultiProtocolSecureChannel.Callback = mock()
-
-  @Before
-  fun setUp() {
-    val database =
-      Room.inMemoryDatabaseBuilder(context, ConnectedDeviceDatabase::class.java)
-        .allowMainThreadQueries()
-        .setQueryExecutor(directExecutor())
-        .build()
-        .associatedDeviceDao()
-    storage = ConnectedDeviceStorage(context, Base64CryptoHelperV4(), database, directExecutor())
-  }
-  @Test
-  fun processVerificationCodeMessage_oobVerification_verifyOobCode() {
-    val secureChannel = setupSecureChannel(false)
-    initHandshakeMessage(secureChannel)
-    respondToContinueMessage(secureChannel)
-    val testPayload = "testPayload".toByteArray()
-    val testVerificationCodeMessage =
-      VerificationCode.newBuilder().run {
-        state = VerificationCodeState.OOB_VERIFICATION
-        payload = ByteString.copyFrom(testPayload)
-        build()
-      }
-    val deviceMessage =
-      DeviceMessage.createOutgoingMessage(
-        /* recipient= */ null,
-        /* isMessageEncrypted= */ false,
-        OperationType.ENCRYPTION_HANDSHAKE,
-        testVerificationCodeMessage.toByteArray()
-      )
-    secureChannel.onDeviceMessageReceived(deviceMessage)
-
-    verify(mockOobRunner).decryptData(testPayload)
-  }
-
-  @Test
-  fun createOobResponse_oobCodeMatch_sendCorrectMessage() {
-    val testPayload = "testPayload".toByteArray()
-    whenever(mockOobRunner.decryptData(testPayload))
-      .thenReturn(FakeEncryptionRunner.VERIFICATION_CODE)
-    whenever(mockOobRunner.encryptData(FakeEncryptionRunner.VERIFICATION_CODE))
-      .thenReturn(testPayload)
-    val secureChannel = setupSecureChannel(false)
-    initHandshakeMessage(secureChannel)
-    respondToContinueMessage(secureChannel)
-    val testVerificationCodeMessage =
-      VerificationCode.newBuilder().run {
-        state = VerificationCodeState.OOB_VERIFICATION
-        payload = ByteString.copyFrom(testPayload)
-        build()
-      }
-    val deviceMessage =
-      DeviceMessage.createOutgoingMessage(
-        /* recipient= */ null,
-        /* isMessageEncrypted= */ false,
-        OperationType.ENCRYPTION_HANDSHAKE,
-        testVerificationCodeMessage.toByteArray()
-      )
-    secureChannel.onDeviceMessageReceived(deviceMessage)
-
-    val confirmationMessage =
-      argumentCaptor<DeviceMessage>().run {
-        verify(stream1, times(2)).sendMessage(capture())
-        secondValue.message
-      }
-    val verificationCodeMessage = VerificationCode.parseFrom(confirmationMessage)
-    assertThat(verificationCodeMessage.state).isEqualTo(VerificationCodeState.OOB_VERIFICATION)
-  }
-
-  @Test
-  fun processVerificationCodeMessage_visualVerification_invokeListener() {
-    val secureChannel = setupSecureChannel(false)
-    initHandshakeMessage(secureChannel)
-    respondToContinueMessage(secureChannel)
-    val testVerificationCodeMessage =
-      VerificationCode.newBuilder().setState(VerificationCodeState.VISUAL_VERIFICATION).build()
-    val deviceMessage =
-      DeviceMessage.createOutgoingMessage(
-        /* recipient= */ null,
-        /* isMessageEncrypted= */ false,
-        OperationType.ENCRYPTION_HANDSHAKE,
-        testVerificationCodeMessage.toByteArray()
-      )
-    secureChannel.onDeviceMessageReceived(deviceMessage)
-
-    verify(mockShowVerificationCodeListener).showVerificationCode(any())
-  }
-
-  @Test
-  fun onVisualVerificationCodeConfirmed_sendConfirmationMessage() {
-    val secureChannel = setupSecureChannel(false)
-    initHandshakeMessage(secureChannel)
-    respondToContinueMessage(secureChannel)
-    val testVerificationCodeMessage =
-      VerificationCode.newBuilder().setState(VerificationCodeState.VISUAL_VERIFICATION).build()
-    val deviceMessage =
-      DeviceMessage.createOutgoingMessage(
-        /* recipient= */ null,
-        /* isMessageEncrypted= */ false,
-        OperationType.ENCRYPTION_HANDSHAKE,
-        testVerificationCodeMessage.toByteArray()
-      )
-    secureChannel.onDeviceMessageReceived(deviceMessage)
-    secureChannel.notifyVerificationCodeAccepted()
-    val confirmationMessage =
-      argumentCaptor<DeviceMessage>().run {
-        verify(stream1, times(2)).sendMessage(capture())
-        secondValue.message
-      }
-    val verificationCodeMessage = VerificationCode.parseFrom(confirmationMessage)
-    assertThat(verificationCodeMessage.state).isEqualTo(VerificationCodeState.VISUAL_CONFIRMATION)
-  }
-
-  private fun setupSecureChannel(
-    isReconnect: Boolean,
-    deviceId: String? = null
-  ): MultiProtocolSecureChannelV4 {
-    val encryptionRunner = EncryptionRunnerFactory.newFakeRunner()
-    encryptionRunner.setIsReconnect(isReconnect)
-    return MultiProtocolSecureChannelV4(
-        stream1,
-        storage,
-        encryptionRunner,
-        deviceId = deviceId,
-        oobRunner = mockOobRunner
-      )
-      .apply {
-        callback = mockCallback
-        showVerificationCodeListener = mockShowVerificationCodeListener
-      }
-  }
-
-  private fun initHandshakeMessage(
-    channel: MultiProtocolSecureChannelV4,
-    message: ByteArray = FakeEncryptionRunner.INIT_MESSAGE
-  ) {
-    val deviceMessage =
-      DeviceMessage.createOutgoingMessage(
-        /* recipient= */ null,
-        /* isMessageEncrypted= */ false,
-        OperationType.ENCRYPTION_HANDSHAKE,
-        message
-      )
-    channel.onDeviceMessageReceived(deviceMessage)
-  }
-
-  private fun respondToContinueMessage(
-    channel: MultiProtocolSecureChannelV4,
-    message: ByteArray = FakeEncryptionRunner.CLIENT_RESPONSE
-  ) {
-    val deviceMessage =
-      DeviceMessage.createOutgoingMessage(
-        /* recipient= */ null,
-        /* isMessageEncrypted= */ false,
-        OperationType.ENCRYPTION_HANDSHAKE,
-        message
-      )
-    channel.onDeviceMessageReceived(deviceMessage)
-  }
-}
-
-private class TestProtocolV4 : ConnectionProtocol() {
-  override fun isDeviceVerificationRequired() = false
-
-  override fun startAssociationDiscovery(
-    name: String,
-    identifier: ParcelUuid,
-    callback: IDiscoveryCallback
-  ) {}
-
-  override fun startConnectionDiscovery(
-    id: ParcelUuid,
-    challenge: ConnectChallenge,
-    callback: IDiscoveryCallback
-  ) {}
-
-  override fun stopAssociationDiscovery() {}
-
-  override fun stopConnectionDiscovery(id: ParcelUuid) {}
-
-  override fun sendData(protocolId: String, data: ByteArray, callback: IDataSendCallback?) {}
-
-  override fun disconnectDevice(protocolId: String) {}
-
-  override fun reset() {}
-
-  override fun getMaxWriteSize(protocolId: String): Int {
-    return 0
-  }
-}
-
-private class Base64CryptoHelperV4 : CryptoHelper {
-  override fun encrypt(value: ByteArray?): String? = Base64.encodeToString(value, Base64.DEFAULT)
-
-  override fun decrypt(value: String?): ByteArray? = Base64.decode(value, Base64.DEFAULT)
-}
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/core/MultiProtocolDeviceControllerTest.kt b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/core/MultiProtocolDeviceControllerTest.kt
index fbde8ce..6263a43 100644
--- a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/core/MultiProtocolDeviceControllerTest.kt
+++ b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/core/MultiProtocolDeviceControllerTest.kt
@@ -78,7 +78,7 @@
   private val testConnectionProtocol: TestConnectionProtocol = spy(TestConnectionProtocol())
   private val mockCallback = mock<Callback>()
   private val mockStream = mock<ProtocolStream>()
-  private val mockOobRunner = mock<OobRunner> { on { generateOobData() } doReturn TEST_OOB_DATA }
+  private val mockOobRunner = mock<OobRunner> { on { sendOobData() } doReturn TEST_OOB_DATA }
   private val mockAssociationCallback = mockToBeAlive<IAssociationCallback>()
   private val mockDeadAssociationCallback = mockToBeDead<IAssociationCallback>()
   private val protocolDelegate = ProtocolDelegate().apply { addProtocol(testConnectionProtocol) }
@@ -251,7 +251,7 @@
       firstValue.onDiscoveryStartedSuccessfully()
     }
 
-    verify(mockOobRunner).generateOobData()
+    verify(mockOobRunner).sendOobData()
     val response =
       argumentCaptor<StartAssociationResponse>().run {
         verify(mockAssociationCallback).onAssociationStartSuccess(capture())
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/oob/BluetoothRfcommChannelTest.java b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/oob/BluetoothRfcommChannelTest.java
deleted file mode 100644
index 7239564..0000000
--- a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/oob/BluetoothRfcommChannelTest.java
+++ /dev/null
@@ -1,230 +0,0 @@
-/*
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.google.android.connecteddevice.oob;
-
-import static com.google.common.truth.Truth.assertThat;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyBoolean;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.doAnswer;
-import static org.mockito.Mockito.doThrow;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-import android.bluetooth.BluetoothDevice;
-import android.bluetooth.BluetoothManager;
-import android.os.ParcelUuid;
-import android.os.RemoteException;
-import androidx.test.core.app.ApplicationProvider;
-import androidx.test.ext.junit.runners.AndroidJUnit4;
-import com.google.android.connecteddevice.transport.BluetoothDeviceProvider;
-import com.google.android.connecteddevice.transport.ConnectChallenge;
-import com.google.android.connecteddevice.transport.ConnectionProtocol;
-import com.google.android.connecteddevice.transport.IDataSendCallback;
-import com.google.android.connecteddevice.transport.IDiscoveryCallback;
-import com.google.android.connecteddevice.transport.ProtocolDevice;
-import com.google.android.connecteddevice.transport.spp.ConnectedDeviceSppDelegateBinder;
-import com.google.android.connecteddevice.transport.spp.PendingConnection;
-import com.google.common.collect.ImmutableSet;
-import java.util.UUID;
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.Mock;
-import org.mockito.invocation.InvocationOnMock;
-import org.mockito.junit.MockitoJUnit;
-import org.mockito.junit.MockitoRule;
-import org.mockito.stubbing.Answer;
-import org.robolectric.shadows.ShadowLooper;
-
-@RunWith(AndroidJUnit4.class)
-public class BluetoothRfcommChannelTest {
-  private static final String DEVICE_ADDRESS = "00:11:22:33:AA:BB";
-  private static final byte[] TEST_MESSAGE = "someData".getBytes(UTF_8);
-  private static final BluetoothDevice TEST_BLUETOOTH_DEVICE =
-      ApplicationProvider.getApplicationContext()
-          .getSystemService(BluetoothManager.class)
-          .getAdapter()
-          .getRemoteDevice(DEVICE_ADDRESS);
-
-  @Rule public final MockitoRule mockito = MockitoJUnit.rule();
-
-  @Mock private BluetoothRfcommChannel bluetoothRfcommChannel;
-  @Mock private TestConnectionProtocol mockValidProtocol;
-  @Mock private ConnectionProtocol mockInvalidProtocol;
-  @Mock private ConnectedDeviceSppDelegateBinder mockSppDelegateBinder;
-
-  @Before
-  public void setUp() {
-    bluetoothRfcommChannel = new BluetoothRfcommChannel(mockSppDelegateBinder);
-  }
-
-  @Test
-  public void completeOobExchange_createRfcommSocketFails_doNotSendData() throws Exception {
-    doThrow(RemoteException.class)
-        .when(mockSppDelegateBinder)
-        .connectAsClient(any(), any(), anyBoolean());
-
-    bluetoothRfcommChannel.completeOobDataExchange(
-        TEST_BLUETOOTH_DEVICE, () -> ImmutableSet.of(TEST_BLUETOOTH_DEVICE), TEST_MESSAGE);
-    verify(mockSppDelegateBinder, never()).sendMessage(any(), any());
-  }
-
-  @Test
-  public void completeOobExchange_noBondedDevices_doNotInitConnection() throws Exception {
-    bluetoothRfcommChannel.completeOobDataExchange(
-        TEST_BLUETOOTH_DEVICE, ImmutableSet::of, TEST_MESSAGE);
-
-    verify(mockSppDelegateBinder, never()).connectAsClient(any(), any(), anyBoolean());
-  }
-
-  @Test
-  public void completeOobExchange_bondedToTheWrongDevice_doNotInitConnection() throws Exception {
-    BluetoothDevice otherBtDevice =
-        ApplicationProvider.getApplicationContext()
-            .getSystemService(BluetoothManager.class)
-            .getAdapter()
-            .getRemoteDevice("BB:AA:33:22:11:00");
-    bluetoothRfcommChannel.completeOobDataExchange(
-        TEST_BLUETOOTH_DEVICE, () -> ImmutableSet.of(otherBtDevice), TEST_MESSAGE);
-
-    verify(mockSppDelegateBinder, never()).connectAsClient(any(), any(), anyBoolean());
-  }
-
-  @Test
-  public void completeOobExchange_timeout_cancelsConnectionAndCallsOnFailed() throws Exception {
-    PendingConnection pendingConnection = requestConnection();
-
-    // Simulate the timeout
-    ShadowLooper.runUiThreadTasksIncludingDelayedTasks();
-
-    verify(mockSppDelegateBinder).cancelConnectionAttempt(pendingConnection);
-  }
-
-  @Test
-  public void interrupt_disconnects() throws Exception {
-    PendingConnection connection = establishConnection();
-    bluetoothRfcommChannel.interrupt();
-    verify(mockSppDelegateBinder).disconnect(connection.toConnection(TEST_BLUETOOTH_DEVICE));
-  }
-
-  @Test
-  public void interrupt_cancelConnectionAttempt() throws Exception {
-    PendingConnection connection = establishConnection();
-    bluetoothRfcommChannel.interrupt();
-    verify(mockSppDelegateBinder).disconnect(connection.toConnection(TEST_BLUETOOTH_DEVICE));
-  }
-
-  @Test
-  public void completeOobDataExchange_supportedProtocol_returnTrue() {
-    String protocolId = "testProtocolId";
-    when(mockValidProtocol.getBluetoothDeviceById(protocolId)).thenReturn(TEST_BLUETOOTH_DEVICE);
-
-    assertThat(
-            bluetoothRfcommChannel.completeOobDataExchange(
-                new ProtocolDevice(mockValidProtocol, protocolId), TEST_MESSAGE))
-        .isTrue();
-  }
-
-  @Test
-  public void completeOobDataExchange_unsupportedProtocol_returnFalse() {
-    String protocolId = "testProtocolId";
-
-    assertThat(
-            bluetoothRfcommChannel.completeOobDataExchange(
-                new ProtocolDevice(mockInvalidProtocol, protocolId), TEST_MESSAGE))
-        .isFalse();
-  }
-
-  private PendingConnection establishConnection() throws Exception {
-    PendingConnection connection = requestConnection();
-    connection.notifyConnected(TEST_BLUETOOTH_DEVICE, TEST_BLUETOOTH_DEVICE.getName());
-    verify(mockSppDelegateBinder).sendMessage(any(), eq(TEST_MESSAGE));
-
-    return connection;
-  }
-
-  private PendingConnection requestConnection() throws Exception {
-    ConnectionResultCaptor connectionCaptor = new ConnectionResultCaptor();
-    doAnswer(connectionCaptor)
-        .when(mockSppDelegateBinder)
-        .connectAsClient(any(), any(), anyBoolean());
-
-    bluetoothRfcommChannel.completeOobDataExchange(
-        TEST_BLUETOOTH_DEVICE, () -> ImmutableSet.of(TEST_BLUETOOTH_DEVICE), TEST_MESSAGE);
-    verify(mockSppDelegateBinder).connectAsClient(any(), any(), anyBoolean());
-
-    return connectionCaptor.getResult();
-  }
-
-  private static class ConnectionResultCaptor implements Answer<PendingConnection> {
-    private PendingConnection result = null;
-
-    public PendingConnection getResult() {
-      return result;
-    }
-
-    @Override
-    public PendingConnection answer(InvocationOnMock invocationOnMock) {
-      UUID uuid = invocationOnMock.getArgument(0);
-      boolean isSecure = invocationOnMock.getArgument(2);
-      result = new PendingConnection(uuid, isSecure);
-      return result;
-    }
-  }
-
-  private static class TestConnectionProtocol extends ConnectionProtocol
-      implements BluetoothDeviceProvider {
-    @Override
-    public BluetoothDevice getBluetoothDeviceById(String protocolId) {
-      return null;
-    }
-
-    @Override
-    public boolean isDeviceVerificationRequired() {
-      return false;
-    }
-
-    @Override
-    public void startAssociationDiscovery(
-        String name, ParcelUuid identifier, IDiscoveryCallback callback) {}
-
-    @Override
-    public void startConnectionDiscovery(
-        ParcelUuid id, ConnectChallenge challenge, IDiscoveryCallback callback) {}
-
-    @Override
-    public void stopAssociationDiscovery() {}
-
-    @Override
-    public void stopConnectionDiscovery(ParcelUuid id) {}
-
-    @Override
-    public void sendData(String protocolId, byte[] data, IDataSendCallback callback) {}
-
-    @Override
-    public void disconnectDevice(String protocolId) {}
-
-    @Override
-    public int getMaxWriteSize(String protocolId) {
-      return 0;
-    }
-  }
-}
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/oob/OobChannelFactoryTest.kt b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/oob/OobChannelFactoryTest.kt
deleted file mode 100644
index d647f72..0000000
--- a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/oob/OobChannelFactoryTest.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.google.android.connecteddevice.oob
-
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.google.android.connecteddevice.transport.spp.ConnectedDeviceSppDelegateBinder
-import com.google.common.truth.Truth.assertThat
-import com.nhaarman.mockitokotlin2.mock
-import org.junit.Assert.assertThrows
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class OobChannelFactoryTest {
-  private val mockSppBinder: ConnectedDeviceSppDelegateBinder = mock()
-  private val factory = OobChannelFactory(mockSppBinder)
-
-  @Test
-  fun createOobChannel_supportedType_returnCorrectChannelType() {
-    val supportedType = OobChannelFactory.BT_RFCOMM
-    val oobChannel = factory.createOobChannel(supportedType)
-
-    assertThat(oobChannel).isInstanceOf(BluetoothRfcommChannel::class.java)
-  }
-
-  @Test
-  fun createOobChannel_supportedType_returnPassThroughChannel() {
-    val supportedType = OobChannelFactory.PRE_ASSOCIATION
-    val oobChannel = factory.createOobChannel(supportedType)
-
-    assertThat(oobChannel).isInstanceOf(PassThroughChannel::class.java)
-  }
-
-  @Test
-  fun createOobChannel_unsupportedChannelType_throwException() {
-    assertThrows(IllegalArgumentException::class.java) {
-      factory.createOobChannel("Unknown channel")
-    }
-  }
-}
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/oob/OobRunnerTest.kt b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/oob/OobRunnerTest.kt
index 5e997d8..88e6934 100644
--- a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/oob/OobRunnerTest.kt
+++ b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/oob/OobRunnerTest.kt
@@ -16,21 +16,12 @@
 
 package com.google.android.connecteddevice.oob
 
-import android.os.ParcelUuid
 import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.android.connecteddevice.core.util.mockToBeAlive
 import com.google.android.connecteddevice.model.OobData
-import com.google.android.connecteddevice.transport.ConnectChallenge
-import com.google.android.connecteddevice.transport.ConnectionProtocol
-import com.google.android.connecteddevice.transport.IDataSendCallback
-import com.google.android.connecteddevice.transport.IDiscoveryCallback
-import com.google.android.connecteddevice.transport.ProtocolDevice
+import com.google.android.connecteddevice.transport.IConnectionProtocol
+import com.google.android.connecteddevice.transport.ProtocolDelegate
 import com.google.common.truth.Truth.assertThat
-import com.nhaarman.mockitokotlin2.any
-import com.nhaarman.mockitokotlin2.mock
-import com.nhaarman.mockitokotlin2.spy
-import com.nhaarman.mockitokotlin2.times
-import com.nhaarman.mockitokotlin2.verify
-import com.nhaarman.mockitokotlin2.whenever
 import org.junit.Assert.assertThrows
 import org.junit.Before
 import org.junit.Test
@@ -38,51 +29,37 @@
 
 @RunWith(AndroidJUnit4::class)
 class OobRunnerTest {
-  private val mockOobChannelFactory = mock<OobChannelFactory>()
-  private val oobRunner = OobRunner(mockOobChannelFactory, SUPPORTED_TYPES_SINGLE)
-  private lateinit var testOobChannel: TestOobChannel
-  private lateinit var testProtocolDevice: ProtocolDevice
+  private val testProtocolName = "testProtocolName"
+  private val testConnectionProtocol = mockToBeAlive<IConnectionProtocol>()
+  private val testProtocolDelegate = ProtocolDelegate()
+  private lateinit var oobRunner: OobRunner
 
   @Before
   fun setUp() {
-    testOobChannel = spy(TestOobChannel())
-    whenever(mockOobChannelFactory.createOobChannel(any())).thenReturn(testOobChannel)
-    testProtocolDevice = ProtocolDevice(TestConnectionProtocol(), "testProtocolId")
+    oobRunner = OobRunner(testProtocolDelegate, testProtocolName)
   }
 
   @Test
   fun sendOobData_startOobDataExchangeSuccessfully_addToEatablishedOobChannels() {
-    oobRunner.generateOobData()
-    testOobChannel.oobDataExchangeResult = true
-    oobRunner.sendOobData(testProtocolDevice)
+    testProtocolDelegate.addOobProtocol(testConnectionProtocol)
+
+    oobRunner.sendOobData()
 
     assertThat(oobRunner.establishedOobChannels).hasSize(1)
   }
 
   @Test
   fun sendOobData_startOobDataExchangeFailed() {
-    testOobChannel.oobDataExchangeResult = false
-    oobRunner.sendOobData(testProtocolDevice)
+    oobRunner.sendOobData()
 
     assertThat(oobRunner.establishedOobChannels).isEmpty()
   }
 
   @Test
-  fun sendOobData_tryAllSupportedChannel() {
-    val oobRunner = OobRunner(mockOobChannelFactory, SUPPORTED_TYPES_TWO)
-    oobRunner.generateOobData()
-    oobRunner.sendOobData(testProtocolDevice)
-
-    verify(mockOobChannelFactory, times(2)).createOobChannel(any())
-  }
-
-  @Test
   fun reset_resetAllStatus() {
-    testOobChannel.oobDataExchangeResult = true
-    oobRunner.generateOobData()
-    oobRunner.sendOobData(testProtocolDevice)
+    testProtocolDelegate.addOobProtocol(testConnectionProtocol)
+    oobRunner.sendOobData()
     oobRunner.reset()
-    assertThat(testOobChannel.isInterrupted).isTrue()
     assertThat(oobRunner.encryptionKey).isNull()
     assertThat(oobRunner.establishedOobChannels).isEmpty()
   }
@@ -94,14 +71,13 @@
 
   @Test
   fun generateOobData_algorithmNotSupported_throwException() {
-    val oobRunner =
-      OobRunner(mockOobChannelFactory, SUPPORTED_TYPES_SINGLE, keyAlgorithm = "UNKNOWN")
-    assertThrows(IllegalStateException::class.java) { oobRunner.generateOobData() }
+    val oobRunner = OobRunner(testProtocolDelegate, testProtocolName, keyAlgorithm = "UNKNOWN")
+    assertThrows(IllegalStateException::class.java) { oobRunner.sendOobData() }
   }
 
   @Test
   fun generateOobData_keyAndNoncesAreNonNullAndOobDataIsSetCorrectly() {
-    val oobData = oobRunner.generateOobData()
+    val oobData = oobRunner.sendOobData()
     assertThat(oobRunner.encryptionKey).isNotNull()
     assertThat(oobRunner.ihuIv).isNotNull()
     assertThat(oobRunner.mobileIv).isNotNull()
@@ -114,7 +90,7 @@
   @Throws(Exception::class)
   fun serverEncryptAndClientDecrypt() {
     val testMessage = "testMessage".toByteArray()
-    oobRunner.generateOobData()
+    oobRunner.sendOobData()
     val encryptedTestMessage = oobRunner.encryptData(testMessage)
 
     switchClientAndServerRole()
@@ -127,14 +103,14 @@
   @Throws(Exception::class)
   fun encryptAndDecryptWithDifferentNonces_throwsException() {
     val testMessage = "testMessage".toByteArray()
-    oobRunner.generateOobData()
+    oobRunner.sendOobData()
     val encryptedMessage = oobRunner.encryptData(testMessage)
     assertThrows(IllegalStateException::class.java) { oobRunner.decryptData(encryptedMessage) }
   }
 
   @Test
   fun decryptWithShortMessage_throwsException() {
-    oobRunner.generateOobData()
+    oobRunner.sendOobData()
     assertThrows(IllegalStateException::class.java) { oobRunner.decryptData("short".toByteArray()) }
   }
 
@@ -159,10 +135,7 @@
     var oobDataExchangeResult = false
     var isInterrupted = false
 
-    override fun completeOobDataExchange(
-      protocolDevice: ProtocolDevice,
-      oobData: ByteArray,
-    ): Boolean {
+    override fun completeOobDataExchange(oobData: ByteArray): Boolean {
       return oobDataExchangeResult
     }
 
@@ -170,33 +143,4 @@
       isInterrupted = true
     }
   }
-
-  private class TestConnectionProtocol : ConnectionProtocol() {
-    override fun isDeviceVerificationRequired() = false
-
-    override fun startAssociationDiscovery(
-      name: String,
-      identifier: ParcelUuid,
-      callback: IDiscoveryCallback,
-    ) {}
-    override fun startConnectionDiscovery(
-      id: ParcelUuid,
-      challenge: ConnectChallenge,
-      callback: IDiscoveryCallback,
-    ) {}
-
-    override fun stopAssociationDiscovery() {}
-    override fun stopConnectionDiscovery(id: ParcelUuid) {}
-    override fun sendData(protocolId: String, data: ByteArray, callback: IDataSendCallback?) {}
-    override fun disconnectDevice(protocolId: String) {}
-    override fun getMaxWriteSize(protocolId: String): Int {
-      return 0
-    }
-  }
-
-  companion object {
-    private val SUPPORTED_TYPES_SINGLE = listOf(OobChannelFactory.BT_RFCOMM)
-    private val SUPPORTED_TYPES_TWO =
-      listOf(OobChannelFactory.BT_RFCOMM, OobChannelFactory.PRE_ASSOCIATION)
-  }
 }
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/oob/PassThroughChannelTest.kt b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/oob/PassThroughChannelTest.kt
deleted file mode 100644
index 0af4f23..0000000
--- a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/oob/PassThroughChannelTest.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.google.android.connecteddevice.oob
-
-import android.os.ParcelUuid
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.google.android.connecteddevice.transport.ConnectChallenge
-import com.google.android.connecteddevice.transport.ConnectionProtocol
-import com.google.android.connecteddevice.transport.IDataSendCallback
-import com.google.android.connecteddevice.transport.IDiscoveryCallback
-import com.google.android.connecteddevice.transport.ProtocolDevice
-import com.google.common.truth.Truth.assertThat
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class PassThroughChannelTest {
-  private val channel = PassThroughChannel()
-  private val testProtocolDevice = ProtocolDevice(TestConnectionProtocol(), "testProtocolId")
-
-  @Test
-  fun startOobExchange_directlyPassThrough_invokeCallback() {
-    assertThat(channel.completeOobDataExchange(testProtocolDevice, "testMessage".toByteArray()))
-      .isTrue()
-  }
-}
-
-private class TestConnectionProtocol : ConnectionProtocol() {
-  override fun isDeviceVerificationRequired() = false
-
-  override fun startAssociationDiscovery(
-    name: String,
-    identifier: ParcelUuid,
-    callback: IDiscoveryCallback
-  ) {}
-  override fun startConnectionDiscovery(
-    id: ParcelUuid,
-    challenge: ConnectChallenge,
-    callback: IDiscoveryCallback
-  ) {}
-
-  override fun stopAssociationDiscovery() {}
-  override fun stopConnectionDiscovery(id: ParcelUuid) {}
-  override fun sendData(protocolId: String, data: ByteArray, callback: IDataSendCallback?) {}
-  override fun disconnectDevice(protocolId: String) {}
-  override fun getMaxWriteSize(protocolId: String): Int {
-    return 0
-  }
-}
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/oob/TransportOobChannelTest.kt b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/oob/TransportOobChannelTest.kt
new file mode 100644
index 0000000..551ce7e
--- /dev/null
+++ b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/oob/TransportOobChannelTest.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.connecteddevice.oob
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.android.connecteddevice.core.util.mockToBeAlive
+import com.google.android.connecteddevice.transport.IConnectionProtocol
+import com.google.android.connecteddevice.transport.IDataSendCallback
+import com.google.android.connecteddevice.transport.IDiscoveryCallback
+import com.google.android.connecteddevice.transport.ProtocolDelegate
+import com.nhaarman.mockitokotlin2.any
+import com.nhaarman.mockitokotlin2.argumentCaptor
+import com.nhaarman.mockitokotlin2.eq
+import com.nhaarman.mockitokotlin2.verify
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class TransportOobChannelTest {
+  private val testConnectionProtocol = mockToBeAlive<IConnectionProtocol>()
+  private lateinit var transportOobChannel: OobChannel
+  private val protocolDelegate = ProtocolDelegate().apply { addOobProtocol(testConnectionProtocol) }
+
+  @Before
+  fun setUp() {
+    transportOobChannel = TransportOobChannel(protocolDelegate, TEST_PROTOCOL_NAME)
+  }
+
+  @Test
+  fun completeOobDataExchange_startProtocolDiscovery() {
+    transportOobChannel.completeOobDataExchange(testMessage)
+
+    verify(testConnectionProtocol).startAssociationDiscovery(eq(TEST_PROTOCOL_NAME), any(), any())
+  }
+
+  @Test
+  fun onDeviceConnected_sendOobData() {
+    transportOobChannel.completeOobDataExchange(testMessage)
+    with(argumentCaptor<IDiscoveryCallback>()) {
+      verify(testConnectionProtocol)
+        .startAssociationDiscovery(eq(TEST_PROTOCOL_NAME), any(), capture())
+      firstValue.onDeviceConnected(TEST_PROTOCOL_ID)
+    }
+    verify(testConnectionProtocol).sendData(eq(TEST_PROTOCOL_ID), eq(testMessage), any())
+  }
+
+  @Test
+  fun onDataFailedToSend_disconnectDevice() {
+    transportOobChannel.completeOobDataExchange(testMessage)
+    with(argumentCaptor<IDiscoveryCallback>()) {
+      verify(testConnectionProtocol)
+        .startAssociationDiscovery(eq(TEST_PROTOCOL_NAME), any(), capture())
+      firstValue.onDeviceConnected(TEST_PROTOCOL_ID)
+    }
+    with(argumentCaptor<IDataSendCallback>()) {
+      verify(testConnectionProtocol).sendData(eq(TEST_PROTOCOL_ID), eq(testMessage), capture())
+      firstValue.onDataFailedToSend()
+    }
+    verify(testConnectionProtocol).disconnectDevice(TEST_PROTOCOL_ID)
+  }
+
+  @Test
+  fun interrupt_disconnectOngoingConnection() {
+    transportOobChannel.completeOobDataExchange(testMessage)
+    with(argumentCaptor<IDiscoveryCallback>()) {
+      verify(testConnectionProtocol)
+        .startAssociationDiscovery(eq(TEST_PROTOCOL_NAME), any(), capture())
+      firstValue.onDeviceConnected(TEST_PROTOCOL_ID)
+    }
+
+    transportOobChannel.interrupt()
+
+    verify(testConnectionProtocol).disconnectDevice(TEST_PROTOCOL_ID)
+  }
+
+  companion object {
+    private const val TEST_PROTOCOL_ID = "testProtocolId"
+    private const val TEST_PROTOCOL_NAME = "testProtocolName"
+    private val testMessage = "testMessage".toByteArray()
+  }
+}
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/service/TransportServiceTest.kt b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/service/TransportServiceTest.kt
index a5b956b..d63f7e4 100644
--- a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/service/TransportServiceTest.kt
+++ b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/service/TransportServiceTest.kt
@@ -7,10 +7,12 @@
 import android.content.Intent
 import android.content.IntentFilter
 import android.content.ServiceConnection
+import android.content.res.Resources
 import android.os.Bundle
 import androidx.test.core.app.ApplicationProvider
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import com.google.android.connecteddevice.core.util.mockToBeAlive
+import com.google.android.connecteddevice.model.TransportProtocols
 import com.google.android.connecteddevice.transport.IProtocolDelegate
 import com.google.common.truth.Truth.assertThat
 import com.nhaarman.mockitokotlin2.any
@@ -22,6 +24,9 @@
 import org.junit.runner.RunWith
 import org.robolectric.Robolectric
 
+private const val OOB_PROTOCOL_CHANNELS_RESOURCE_ID = 0
+private const val PROTOCOL_CHANNELS_RESOURCE_ID = 1
+
 @RunWith(AndroidJUnit4::class)
 class TransportServiceTest {
 
@@ -61,7 +66,7 @@
     verify(mockDelegate).removeProtocol(any())
     assertThat(service.boundService).isNull()
     assertThat(service.receiver).isNull()
-    assertThat(service.supportedProtocols).isEmpty()
+    assertThat(service.initializedProtocols).isEmpty()
   }
 
   @Test
@@ -83,7 +88,7 @@
 
     assertThat(service.delegate).isNull()
     assertThat(service.boundService).isNotNull()
-    assertThat(service.supportedProtocols).isEmpty()
+    assertThat(service.initializedProtocols).isEmpty()
   }
 
   @Test
@@ -118,7 +123,7 @@
     issueBluetoothChangedBroadcast(BluetoothAdapter.STATE_OFF)
 
     verify(mockDelegate).removeProtocol(any())
-    assertThat(service.supportedProtocols).isEmpty()
+    assertThat(service.initializedProtocols).isEmpty()
   }
 
   @Test
@@ -140,6 +145,7 @@
 }
 
 class TestTransportService : TransportService() {
+  private val context = ApplicationProvider.getApplicationContext<Context>()
 
   var boundService: ServiceConnection? = null
 
@@ -171,6 +177,29 @@
   }
 
   override fun retrieveMetaDataBundle(): Bundle {
-    return Bundle()
+    val bundle = Bundle()
+    bundle.putInt(META_SUPPORTED_TRANSPORT_PROTOCOLS, PROTOCOL_CHANNELS_RESOURCE_ID)
+    bundle.putInt(META_SUPPORTED_OOB_CHANNELS, OOB_PROTOCOL_CHANNELS_RESOURCE_ID)
+    return bundle
+  }
+
+  override fun getResources(): Resources {
+    return object :
+      Resources(
+        context.getResources().getAssets(),
+        context.getResources().getDisplayMetrics(),
+        context.getResources().getConfiguration()
+      ) {
+
+      override fun getStringArray(id: Int): Array<String> {
+        when (id) {
+          OOB_PROTOCOL_CHANNELS_RESOURCE_ID ->
+            return arrayOf(TransportProtocols.PROTOCOL_EAP, TransportProtocols.PROTOCOL_SPP)
+          PROTOCOL_CHANNELS_RESOURCE_ID ->
+            return arrayOf(TransportProtocols.PROTOCOL_BLE_PERIPHERAL)
+          else -> throw NotFoundException()
+        }
+      }
+    }
   }
 }
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/transport/ProtocolDelegateTest.kt b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/transport/ProtocolDelegateTest.kt
index 1799746..9def0be 100644
--- a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/transport/ProtocolDelegateTest.kt
+++ b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/transport/ProtocolDelegateTest.kt
@@ -37,6 +37,25 @@
   }
 
   @Test
+  fun addOobProtocol_addedToOobList() {
+    val protocol = mockToBeAlive<IConnectionProtocol>()
+
+    delegate.addOobProtocol(protocol)
+
+    assertThat(delegate.oobProtocols).containsExactly(protocol)
+  }
+
+  @Test
+  fun removeOobProtocol_removeFromOobList() {
+    val protocol = mockToBeAlive<IConnectionProtocol>()
+
+    delegate.addOobProtocol(protocol)
+    delegate.removeOobProtocol(protocol)
+
+    assertThat(delegate.oobProtocols).isEmpty()
+  }
+
+  @Test
   fun removeProtocol_unrecognizedProtocolDoesNotInvokeCallback() {
     val protocol = mockToBeAlive<IConnectionProtocol>()
 
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/transport/eap/EapProtocolTest.kt b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/transport/eap/EapProtocolTest.kt
new file mode 100644
index 0000000..3d4b51a
--- /dev/null
+++ b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/transport/eap/EapProtocolTest.kt
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.connecteddevice.transport.eap
+
+import android.os.IBinder
+import android.os.ParcelUuid
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.android.connecteddevice.core.util.mockToBeAlive
+import com.google.android.connecteddevice.transport.IDataReceivedListener
+import com.google.android.connecteddevice.transport.IDeviceDisconnectedListener
+import com.google.android.connecteddevice.transport.IDiscoveryCallback
+import com.google.common.truth.Truth.assertThat
+import com.nhaarman.mockitokotlin2.any
+import com.nhaarman.mockitokotlin2.argumentCaptor
+import com.nhaarman.mockitokotlin2.eq
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.never
+import com.nhaarman.mockitokotlin2.verify
+import com.panasonic.iapx.IDeviceConnection
+import com.panasonic.iapx.IDeviceConnectionDelegate
+import com.panasonic.iapx.IServiceConnector
+import java.util.UUID
+import kotlin.random.Random
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class EapProtocolTest {
+  private val mockServiceConnector = mock<IServiceConnector>()
+  private val mockDeviceConnection = mock<IDeviceConnection>()
+  private val mockDataReceivedListener = mockToBeAlive<IDataReceivedListener>()
+  private val mockDiscoveryCallback = mockToBeAlive<IDiscoveryCallback>()
+  private val mockDisconnectedListener = mockToBeAlive<IDeviceDisconnectedListener>()
+  private val eapProtocol =
+    EapProtocol(TEST_EAP_CLIENT_NAME, TEST_EAP_SERVICE_NAME, TEST_MAX_WRITE_SIZE)
+
+  @Test
+  fun OnEAPSessionStartWithValidProtocolName_invokeCallback() {
+    val deviceConnectionDelegate = captureDeviceCallback()
+    eapProtocol.startAssociationDiscovery(
+      TEST_EAP_PROTOCOL_NAME,
+      testIdentifier,
+      mockDiscoveryCallback
+    )
+
+    deviceConnectionDelegate.OnEAPSessionStart(
+      mockDeviceConnection,
+      testProtocolId,
+      TEST_EAP_PROTOCOL_NAME
+    )
+
+    verify(mockDiscoveryCallback).onDeviceConnected(testProtocolId.toString())
+  }
+
+  @Test
+  fun OnEAPSessionStartWithInValidProtocolName_doesNotInvokeCallback() {
+    val deviceConnectionDelegate = captureDeviceCallback()
+    eapProtocol.startAssociationDiscovery(
+      TEST_EAP_PROTOCOL_NAME,
+      testIdentifier,
+      mockDiscoveryCallback
+    )
+
+    deviceConnectionDelegate.OnEAPSessionStart(
+      mockDeviceConnection,
+      testProtocolId,
+      "invalidProtocolName"
+    )
+
+    verify(mockDiscoveryCallback, never()).onDeviceConnected(testProtocolId.toString())
+  }
+
+  @Test
+  fun OnEAPSessionStop_invokeCallback() {
+    val deviceConnectionDelegate = captureDeviceCallback()
+    eapProtocol.startAssociationDiscovery(
+      TEST_EAP_PROTOCOL_NAME,
+      testIdentifier,
+      mockDiscoveryCallback
+    )
+    deviceConnectionDelegate.OnEAPSessionStart(
+      mockDeviceConnection,
+      testProtocolId,
+      TEST_EAP_PROTOCOL_NAME
+    )
+    eapProtocol.registerDeviceDisconnectedListener(
+      testProtocolId.toString(),
+      mockDisconnectedListener
+    )
+
+    deviceConnectionDelegate.OnEAPSessionStop(mockDeviceConnection, testProtocolId)
+
+    verify(mockDisconnectedListener).onDeviceDisconnected(testProtocolId.toString())
+  }
+
+  @Test
+  fun OnEAPData_invokeCallback() {
+    val deviceConnectionDelegate = captureDeviceCallback()
+    eapProtocol.registerDataReceivedListener(testProtocolId.toString(), mockDataReceivedListener)
+
+    deviceConnectionDelegate.OnEAPData(mockDeviceConnection, testProtocolId, testMessage)
+
+    verify(mockDataReceivedListener).onDataReceived(testProtocolId.toString(), testMessage)
+  }
+
+  @Test
+  fun sendData_sendDataViaConnection() {
+    val deviceConnectionDelegate = captureDeviceCallback()
+    eapProtocol.startAssociationDiscovery(
+      TEST_EAP_PROTOCOL_NAME,
+      testIdentifier,
+      mockDiscoveryCallback
+    )
+    deviceConnectionDelegate.OnEAPSessionStart(
+      mockDeviceConnection,
+      testProtocolId,
+      TEST_EAP_PROTOCOL_NAME
+    )
+
+    eapProtocol.sendData(testProtocolId.toString(), testMessage, callback = null)
+
+    verify(mockDeviceConnection).SendEAPData(testProtocolId, testMessage)
+  }
+
+  @Test
+  fun stopAssociationDiscovery_doesNotInvokeCallbackOnDeviceConnected() {
+    val deviceConnectionDelegate = captureDeviceCallback()
+    eapProtocol.startAssociationDiscovery(
+      TEST_EAP_PROTOCOL_NAME,
+      testIdentifier,
+      mockDiscoveryCallback
+    )
+
+    eapProtocol.stopAssociationDiscovery()
+
+    deviceConnectionDelegate.OnEAPSessionStart(
+      mockDeviceConnection,
+      testProtocolId,
+      TEST_EAP_PROTOCOL_NAME
+    )
+    verify(mockDiscoveryCallback, never()).onDeviceConnected(testProtocolId.toString())
+  }
+
+  @Test
+  fun reset_clearDiscoveries() {
+    val deviceConnectionDelegate = captureDeviceCallback()
+    eapProtocol.startAssociationDiscovery(
+      TEST_EAP_PROTOCOL_NAME,
+      testIdentifier,
+      mockDiscoveryCallback
+    )
+
+    eapProtocol.reset()
+
+    deviceConnectionDelegate.OnEAPSessionStart(
+      mockDeviceConnection,
+      testProtocolId,
+      TEST_EAP_PROTOCOL_NAME
+    )
+    verify(mockDiscoveryCallback, never()).onDeviceConnected(testProtocolId.toString())
+  }
+
+  @Test
+  fun getMaxWriteSize_returnCorrectValue() {
+    assertThat(eapProtocol.getMaxWriteSize(testProtocolId.toString()))
+      .isEqualTo(TEST_MAX_WRITE_SIZE)
+  }
+
+  private fun captureDeviceCallback(): IDeviceConnectionDelegate {
+    eapProtocol.connectClient(mockServiceConnector)
+    val deviceDelegateBinder =
+      argumentCaptor<IBinder>()
+        .apply {
+          verify(mockServiceConnector).ConnectClient(eq(TEST_EAP_CLIENT_NAME), any(), capture())
+        }
+        .firstValue
+    return IDeviceConnectionDelegate.Stub.asInterface(deviceDelegateBinder)
+  }
+  companion object {
+    private const val TEST_EAP_CLIENT_NAME = "eapClientName"
+    private const val TEST_EAP_SERVICE_NAME = "eapServiceName"
+    private const val TEST_EAP_PROTOCOL_NAME = "eapProtocolName"
+    private const val TEST_MAX_WRITE_SIZE = 100
+    private val testIdentifier = ParcelUuid(UUID.randomUUID())
+    private val testProtocolId = Random.nextLong()
+    private val testMessage = "TestMessage".toByteArray()
+  }
+}
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/transport/spp/SppManagerTest.java b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/transport/spp/SppManagerTest.java
index 950d92d..e77a089 100644
--- a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/transport/spp/SppManagerTest.java
+++ b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/transport/spp/SppManagerTest.java
@@ -107,6 +107,16 @@
   }
 
   @Test
+  public void
+      testReadMessageTaskCallback_onMessageReceivedWithoutConnection_doNotCallOnMessageReceivedListener()
+          throws InterruptedException {
+    sppManager.addOnMessageReceivedListener(mockListener, callbackExecutor);
+    sppManager.connectedSocket = null;
+    sppManager.readMessageTaskCallback.onMessageReceived(testData);
+    verify(mockListener, never()).onMessageReceived(any(), eq(testData));
+  }
+
+  @Test
   public void testReadMessageTaskCallback_onMessageReadError_disconnectRemoteDevice()
       throws InterruptedException {
     sppManager.readMessageTaskCallback.onMessageReadError();
@@ -146,6 +156,15 @@
   }
 
   @Test
+  public void testCleanUp_doNotIssueCallbackWithoutConnection() throws IOException {
+    sppManager.connectedSocket = null;
+
+    sppManager.cleanup();
+
+    verify(mockConnectionCallback, never()).onRemoteDeviceDisconnected(any());
+  }
+
+  @Test
   public void testCleanup_clearConnectTask() {
     sppManager.connect(testBluetoothDevice, TEST_SERVICE_UUID);
 
@@ -171,4 +190,14 @@
     sppManager.addOnMessageReceivedListener(mockListener, callbackExecutor);
     verify(mockListener).onMessageReceived(any(), eq(testData));
   }
+
+  @Test
+  public void testAddOnMessageReceivedListenerWithoutConnection_doNotInvokeCallback() {
+    sppManager.readMessageTaskCallback.onMessageReceived(testData);
+    verify(mockListener, never()).onMessageReceived(any(), eq(testData));
+
+    sppManager.connectedSocket = null;
+    sppManager.addOnMessageReceivedListener(mockListener, callbackExecutor);
+    verify(mockListener, never()).onMessageReceived(any(), eq(testData));
+  }
 }
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/ui/AssociatedDeviceViewModelTest.java b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/ui/AssociatedDeviceViewModelTest.java
index e2d5c89..04c0ad9 100644
--- a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/ui/AssociatedDeviceViewModelTest.java
+++ b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/ui/AssociatedDeviceViewModelTest.java
@@ -16,14 +16,17 @@
 
 package com.google.android.connecteddevice.ui;
 
+import static android.os.Looper.getMainLooper;
 import static com.google.common.truth.Truth.assertThat;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.never;
 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 static org.robolectric.Shadows.shadowOf;
 
 import android.app.Application;
 import android.bluetooth.BluetoothAdapter;
@@ -34,6 +37,7 @@
 import androidx.lifecycle.Observer;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.connecteddevice.api.Connector;
 import com.google.android.connecteddevice.api.FakeConnector;
 import com.google.android.connecteddevice.api.IAssociationCallback;
 import com.google.android.connecteddevice.model.AssociatedDevice;
@@ -76,6 +80,8 @@
       application.getSystemService(BluetoothManager.class).getAdapter();
 
   private final FakeConnector fakeConnector = spy(new FakeConnector());
+  private final NeverConnectFakeConnector neverConnectFakeConnector =
+      spy(new NeverConnectFakeConnector());
 
   @Mock private Observer<AssociationState> mockAssociationStateObserver;
   @Mock private Observer<List<AssociatedDeviceDetails>> mockDeviceDetailsObserver;
@@ -89,13 +95,7 @@
 
   @Before
   public void setUp() throws RemoteException {
-    viewModel =
-        new AssociatedDeviceViewModel(
-            application,
-            /* isSppEnabled= */ false,
-            TEST_BLE_DEVICE_NAME_PREFIX,
-            /* isPassengerEnabled= */ false,
-            fakeConnector);
+    viewModel = createViewModel(fakeConnector);
     adapter.enable();
   }
 
@@ -106,6 +106,15 @@
   }
 
   @Test
+  public void acceptVerification_notInvokedIfServiceBotConnected() {
+    viewModel = createViewModel(neverConnectFakeConnector);
+
+    viewModel.acceptVerification();
+
+    verify(neverConnectFakeConnector, never()).acceptVerification();
+  }
+
+  @Test
   public void removeDevice() {
     AssociatedDevice device = createAssociatedDevice(/* isConnectionEnabled= */ true);
     fakeConnector.addAssociatedDevice(device);
@@ -114,6 +123,16 @@
   }
 
   @Test
+  public void removeDevice_notInvokedIfServiceBotConnected() {
+    viewModel = createViewModel(neverConnectFakeConnector);
+    AssociatedDevice device = createAssociatedDevice(/* isConnectionEnabled= */ true);
+
+    viewModel.removeDevice(device);
+
+    verify(neverConnectFakeConnector, never()).removeAssociatedDevice(anyString());
+  }
+
+  @Test
   public void toggleConnectionStatusForDevice_disableDevice() {
     AssociatedDevice device = createAssociatedDevice(/* isConnectionEnabled= */ true);
     fakeConnector.addAssociatedDevice(device);
@@ -130,6 +149,19 @@
   }
 
   @Test
+  public void toggleConnectionStatusForDevice_notInvokedIfServiceBotConnected() {
+    viewModel = createViewModel(neverConnectFakeConnector);
+    AssociatedDevice device1 = createAssociatedDevice(/* isConnectionEnabled= */ false);
+    AssociatedDevice device2 = createAssociatedDevice(/* isConnectionEnabled= */ true);
+
+    viewModel.toggleConnectionStatusForDevice(device1);
+    viewModel.toggleConnectionStatusForDevice(device2);
+
+    verify(neverConnectFakeConnector, never()).enableAssociatedDeviceConnection(anyString());
+    verify(neverConnectFakeConnector, never()).disableAssociatedDeviceConnection(anyString());
+  }
+
+  @Test
   public void startAssociation_startWithIdentifier() {
     ParcelUuid testIdentifier = new ParcelUuid(UUID.randomUUID());
     viewModel.startAssociation(testIdentifier);
@@ -137,6 +169,14 @@
   }
 
   @Test
+  public void startAssociation_notInvokedIfServiceBotConnected() {
+    viewModel = createViewModel(neverConnectFakeConnector);
+
+    viewModel.startAssociation();
+    verify(neverConnectFakeConnector, never()).startAssociation(any());
+  }
+
+  @Test
   public void startAssociation_deviceNotReady() {
     adapter.disable();
     viewModel.startAssociation();
@@ -385,6 +425,16 @@
   }
 
   @Test
+  public void claimDevice_notInvokedIfServiceBotConnected() {
+    viewModel = createViewModel(neverConnectFakeConnector);
+    AssociatedDevice device = createAssociatedDevice(/* isConnectionEnabled= */ true);
+
+    viewModel.claimDevice(device);
+
+    verify(neverConnectFakeConnector, never()).claimAssociatedDevice(anyString());
+  }
+
+  @Test
   public void removeClaimOnDevice_removesAssociatedDeviceClaim() {
     AssociatedDevice device = createAssociatedDevice(/* isConnectionEnabled= */ true);
     fakeConnector.addAssociatedDevice(device);
@@ -394,6 +444,25 @@
     verify(fakeConnector).removeAssociatedDeviceClaim(device.getDeviceId());
   }
 
+  @Test
+  public void removeClaimOnDevice_notInvokedIfServiceBotConnected() {
+    viewModel = createViewModel(neverConnectFakeConnector);
+    AssociatedDevice device = createAssociatedDevice(/* isConnectionEnabled= */ true);
+
+    viewModel.claimDevice(device);
+
+    verify(neverConnectFakeConnector, never()).claimAssociatedDevice(anyString());
+  }
+
+  @Test
+  public void connectTimeout() {
+    viewModel = createViewModel(neverConnectFakeConnector);
+
+    shadowOf(getMainLooper()).runToEndOfTasks();
+
+    verify(neverConnectFakeConnector).disconnect();
+  }
+
   private void captureAssociationCallback() {
     ArgumentCaptor<IAssociationCallback> associationCallbackCaptor =
         ArgumentCaptor.forClass(IAssociationCallback.class);
@@ -424,4 +493,21 @@
         /* belongsToDriver= */ true,
         /* hasSecureChannel= */ false);
   }
+
+  private AssociatedDeviceViewModel createViewModel(Connector connector) {
+    return new AssociatedDeviceViewModel(
+        application,
+        /* isSppEnabled= */ false,
+        TEST_BLE_DEVICE_NAME_PREFIX,
+        /* isPassengerEnabled= */ false,
+        connector);
+  }
+
+  /** Fake connector that never connects. */
+  private static class NeverConnectFakeConnector extends FakeConnector {
+    @Override
+    public void connect() {
+      // Do nothing
+    }
+  }
 }