RemoteAuth discovery implementation

Ignore-AOSP-First: Dependent changes in CDM are not available in
aosp-main.
Bug: 290264664
Test: atest RemoteAuthUnitTest

Change-Id: I4e33178313d2add09436bf01ce127b3df152974e
diff --git a/remoteauth/service/Android.bp b/remoteauth/service/Android.bp
index dba8b75..215a591 100644
--- a/remoteauth/service/Android.bp
+++ b/remoteauth/service/Android.bp
@@ -32,6 +32,7 @@
     libs: [
         "androidx.annotation_annotation",
         "framework-bluetooth",
+        "framework-connectivity",
         "error_prone_annotations",
         "framework-configinfrastructure",
         "framework-connectivity-pre-jarjar",
diff --git a/remoteauth/service/java/com/android/server/remoteauth/connectivity/CdmConnectionInfo.java b/remoteauth/service/java/com/android/server/remoteauth/connectivity/CdmConnectionInfo.java
index 8bfdd36..d2a828c 100644
--- a/remoteauth/service/java/com/android/server/remoteauth/connectivity/CdmConnectionInfo.java
+++ b/remoteauth/service/java/com/android/server/remoteauth/connectivity/CdmConnectionInfo.java
@@ -34,11 +34,11 @@
 // TODO(b/295407748): Change to use @DataClass.
 // TODO(b/296625303): Change to VANILLA_ICE_CREAM when AssociationInfo is available in V.
 @TargetApi(Build.VERSION_CODES.TIRAMISU)
-public final class CdmConnectionInfo extends ConnectionInfo {
+public final class CdmConnectionInfo extends ConnectionInfo<AssociationInfo> {
     @NonNull private final AssociationInfo mAssociationInfo;
 
     public CdmConnectionInfo(int connectionId, @NonNull AssociationInfo associationInfo) {
-       super(connectionId);
+        super(connectionId);
         mAssociationInfo = associationInfo;
     }
 
@@ -78,10 +78,6 @@
         out.writeTypedObject(mAssociationInfo, 0);
     }
 
-    public AssociationInfo getAssociationInfo() {
-        return mAssociationInfo;
-    }
-
     /** Returns a string representation of ConnectionInfo. */
     @Override
     public String toString() {
@@ -98,11 +94,16 @@
         }
 
         CdmConnectionInfo other = (CdmConnectionInfo) o;
-        return mAssociationInfo.equals(other.getAssociationInfo());
+        return super.equals(o) && mAssociationInfo.equals(other.getConnectionParams());
     }
 
     @Override
     public int hashCode() {
         return Objects.hash(mAssociationInfo);
     }
+
+    @Override
+    public AssociationInfo getConnectionParams() {
+        return mAssociationInfo;
+    }
 }
diff --git a/remoteauth/service/java/com/android/server/remoteauth/connectivity/CdmConnectivityManager.java b/remoteauth/service/java/com/android/server/remoteauth/connectivity/CdmConnectivityManager.java
new file mode 100644
index 0000000..49745c0
--- /dev/null
+++ b/remoteauth/service/java/com/android/server/remoteauth/connectivity/CdmConnectivityManager.java
@@ -0,0 +1,201 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.remoteauth.connectivity;
+
+import static com.android.server.remoteauth.connectivity.DiscoveryFilter.DeviceType;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.TargetApi;
+import android.companion.AssociationInfo;
+import android.companion.AssociationRequest;
+import android.os.Build;
+import android.util.Log;
+
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.Preconditions;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.RejectedExecutionException;
+
+/**
+ * Discovers devices associated with the companion device manager.
+ *
+ * TODO(b/296625303): Change to VANILLA_ICE_CREAM when AssociationInfo is available in V.
+ */
+@TargetApi(Build.VERSION_CODES.TIRAMISU)
+public class CdmConnectivityManager implements ConnectivityManager {
+    private static final String TAG = "CdmConnectivityManager";
+
+    private final CompanionDeviceManagerWrapper mCompanionDeviceManagerWrapper;
+
+    private ExecutorService mExecutor;
+    private Map<DiscoveredDeviceReceiver, Future> mPendingDiscoveryCallbacks =
+            new ConcurrentHashMap<>();
+
+    public CdmConnectivityManager(
+            @NonNull ExecutorService executor,
+            @NonNull CompanionDeviceManagerWrapper companionDeviceManagerWrapper) {
+        mExecutor = executor;
+        mCompanionDeviceManagerWrapper = companionDeviceManagerWrapper;
+    }
+
+    /**
+     * Runs associated discovery callbacks for discovered devices.
+     *
+     * @param discoveredDeviceReceiver callback.
+     * @param device discovered device.
+     */
+    private void notifyOnDiscovered(
+            @NonNull DiscoveredDeviceReceiver discoveredDeviceReceiver,
+            @NonNull DiscoveredDevice device) {
+        Preconditions.checkNotNull(discoveredDeviceReceiver);
+        Preconditions.checkNotNull(device);
+
+        Log.i(TAG, "Notifying discovered device");
+        discoveredDeviceReceiver.onDiscovered(device);
+    }
+
+    /**
+     * Posts an async task to discover CDM associations and run callback if device is discovered.
+     *
+     * @param discoveryFilter filter for association.
+     * @param discoveredDeviceReceiver callback.
+     */
+    private void startDiscoveryAsync(@NonNull DiscoveryFilter discoveryFilter,
+            @NonNull DiscoveredDeviceReceiver discoveredDeviceReceiver) {
+        Preconditions.checkNotNull(discoveryFilter);
+        Preconditions.checkNotNull(discoveredDeviceReceiver);
+
+        List<AssociationInfo> associations = mCompanionDeviceManagerWrapper.getAllAssociations();
+        Log.i(TAG, "Found associations: " + associations.size());
+        for (AssociationInfo association : associations) {
+            String deviceProfile = getDeviceProfileFromType(discoveryFilter.getDeviceType());
+            // TODO(b/297574984): Include device presence signal before notifying discovery result.
+            if (mCompanionDeviceManagerWrapper.getDeviceProfile(association)
+                    .equals(deviceProfile)) {
+                notifyOnDiscovered(
+                        discoveredDeviceReceiver,
+                        createDiscoveredDeviceFromAssociation(association));
+            }
+        }
+    }
+
+    /**
+     * Returns the device profile from association info.
+     *
+     * @param deviceType Discovery filter device type.
+     * @return Device profile string defined in {@link AssociationRequest}.
+     * @throws AssertionError if type cannot be mapped.
+     */
+    private String getDeviceProfileFromType(@DeviceType int deviceType) {
+        if (deviceType == DiscoveryFilter.WATCH) {
+            return AssociationRequest.DEVICE_PROFILE_WATCH;
+        } else {
+            // Should not reach here.
+            throw new AssertionError(deviceType);
+        }
+    }
+
+    /**
+     * Creates discovered device from association info.
+     *
+     * @param info Association info.
+     * @return discovered device object.
+     */
+    private @NonNull DiscoveredDevice createDiscoveredDeviceFromAssociation(
+            @NonNull AssociationInfo info) {
+        return new DiscoveredDevice(
+                new CdmConnectionInfo(info.getId(), info),
+                info.getDisplayName() == null ? "" : info.getDisplayName().toString());
+    }
+
+    /**
+     * Triggers the discovery for CDM associations.
+     *
+     * Runs discovery only if a callback has not been previously registered.
+     *
+     * @param discoveryFilter filter for associations.
+     * @param discoveredDeviceReceiver callback to be run on discovery result.
+     */
+    @Override
+    public void startDiscovery(
+            @NonNull DiscoveryFilter discoveryFilter,
+            @NonNull DiscoveredDeviceReceiver discoveredDeviceReceiver) {
+        Preconditions.checkNotNull(mCompanionDeviceManagerWrapper);
+        Preconditions.checkNotNull(discoveryFilter);
+        Preconditions.checkNotNull(discoveredDeviceReceiver);
+
+        try {
+            mPendingDiscoveryCallbacks.computeIfAbsent(
+                    discoveredDeviceReceiver,
+                    discoveryFuture -> mExecutor.submit(
+                        () -> startDiscoveryAsync(discoveryFilter, discoveryFuture),
+                        /* result= */ null));
+        } catch (RejectedExecutionException | NullPointerException e) {
+            Log.e(TAG, "Failed to start async discovery: " + e.getMessage());
+        }
+    }
+
+    /** Stops discovery. */
+    @Override
+    public void stopDiscovery(
+            @NonNull DiscoveryFilter discoveryFilter,
+            @NonNull DiscoveredDeviceReceiver discoveredDeviceReceiver) {
+        Preconditions.checkNotNull(discoveryFilter);
+        Preconditions.checkNotNull(discoveredDeviceReceiver);
+
+        Future<Void> discoveryFuture = mPendingDiscoveryCallbacks.remove(discoveredDeviceReceiver);
+        if (null != discoveryFuture && !discoveryFuture.cancel(/* mayInterruptIfRunning= */ true)) {
+            Log.d(TAG, "Discovery was possibly completed.");
+        }
+    }
+
+    @Nullable
+    @Override
+    public Connection connect(@NonNull ConnectionInfo connectionInfo,
+            @NonNull EventListener eventListener) {
+        // Not implemented.
+        return null;
+    }
+
+    @Override
+    public void startListening(MessageReceiver messageReceiver) {
+        // Not implemented.
+    }
+
+    @Override
+    public void stopListening(MessageReceiver messageReceiver) {
+        // Not implemented.
+    }
+
+    /**
+     * Returns whether the callback is already registered and pending.
+     *
+     * @param discoveredDeviceReceiver callback
+     * @return true if the callback is pending, false otherwise.
+     */
+    @VisibleForTesting
+    boolean hasPendingCallbacks(@NonNull DiscoveredDeviceReceiver discoveredDeviceReceiver) {
+        return mPendingDiscoveryCallbacks.containsKey(discoveredDeviceReceiver);
+    }
+}
diff --git a/remoteauth/service/java/com/android/server/remoteauth/connectivity/CompanionDeviceManagerWrapper.java b/remoteauth/service/java/com/android/server/remoteauth/connectivity/CompanionDeviceManagerWrapper.java
new file mode 100644
index 0000000..eaf3edb
--- /dev/null
+++ b/remoteauth/service/java/com/android/server/remoteauth/connectivity/CompanionDeviceManagerWrapper.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.remoteauth.connectivity;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.TargetApi;
+import android.companion.AssociationInfo;
+import android.companion.CompanionDeviceManager;
+import android.content.Context;
+import android.os.Build;
+import android.util.Log;
+
+import java.util.List;
+
+/** Wraps {@link android.companion.CompanionDeviceManager} for easier testing. */
+// TODO(b/296625303): Change to VANILLA_ICE_CREAM when AssociationInfo is available in V.
+@TargetApi(Build.VERSION_CODES.TIRAMISU)
+public class CompanionDeviceManagerWrapper {
+    private static final String TAG = "CompanionDeviceManagerWrapper";
+
+    private Context mContext;
+    private CompanionDeviceManager mCompanionDeviceManager;
+
+    public CompanionDeviceManagerWrapper(@NonNull Context context) {
+        mContext = context;
+    }
+
+    /**
+     * Returns device profile string from the association info.
+     *
+     * @param associationInfo the association info.
+     * @return String indicating device profile
+     */
+    @Nullable
+    public String getDeviceProfile(@NonNull AssociationInfo associationInfo) {
+        return associationInfo.getDeviceProfile();
+    }
+
+    /**
+     * Returns all associations.
+     *
+     * @return associations or null if no associated devices present.
+     */
+    @Nullable
+    public List<AssociationInfo> getAllAssociations() {
+        if (mCompanionDeviceManager == null) {
+            try {
+                mCompanionDeviceManager = mContext.getSystemService(CompanionDeviceManager.class);
+            } catch (NullPointerException e) {
+                Log.e(TAG, "CompanionDeviceManager service does not exist: " + e);
+                return null;
+            }
+        }
+
+        try {
+            return mCompanionDeviceManager.getAllAssociations();
+        } catch (RuntimeException e) {
+            Log.e(TAG, "Failed to get CompanionDeviceManager associations: " + e.getMessage());
+        }
+        return null;
+    }
+}
diff --git a/remoteauth/service/java/com/android/server/remoteauth/connectivity/Connection.java b/remoteauth/service/java/com/android/server/remoteauth/connectivity/Connection.java
index eb5458d..dca8b9f 100644
--- a/remoteauth/service/java/com/android/server/remoteauth/connectivity/Connection.java
+++ b/remoteauth/service/java/com/android/server/remoteauth/connectivity/Connection.java
@@ -25,7 +25,6 @@
  * A connection with the peer device.
  *
  * <p>Connections are used to exchange data with the peer device.
- *
  */
 public interface Connection {
     /** Unknown error. */
diff --git a/remoteauth/service/java/com/android/server/remoteauth/connectivity/ConnectionInfo.java b/remoteauth/service/java/com/android/server/remoteauth/connectivity/ConnectionInfo.java
index 39bfa8d..6e0edb4 100644
--- a/remoteauth/service/java/com/android/server/remoteauth/connectivity/ConnectionInfo.java
+++ b/remoteauth/service/java/com/android/server/remoteauth/connectivity/ConnectionInfo.java
@@ -28,9 +28,10 @@
  * <p>Connection information captures the details of underlying connection such as connection id,
  * type of connection and peer device mac address.
  *
+ * @param <T> connection params per connection type.
  */
 // TODO(b/295407748) Change to use @DataClass.
-public abstract class ConnectionInfo implements Parcelable {
+public abstract class ConnectionInfo<T> implements Parcelable {
     int mConnectionId;
 
     public ConnectionInfo(int connectionId) {
@@ -85,4 +86,9 @@
     public int hashCode() {
         return Objects.hash(mConnectionId);
     }
+
+    /**
+     * Returns connection related parameters.
+     */
+    public abstract T getConnectionParams();
 }
diff --git a/remoteauth/service/java/com/android/server/remoteauth/connectivity/ConnectivityManager.java b/remoteauth/service/java/com/android/server/remoteauth/connectivity/ConnectivityManager.java
index bc0d77e..7b30285 100644
--- a/remoteauth/service/java/com/android/server/remoteauth/connectivity/ConnectivityManager.java
+++ b/remoteauth/service/java/com/android/server/remoteauth/connectivity/ConnectivityManager.java
@@ -23,9 +23,7 @@
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 
-/**
- * Performs discovery and triggers a connection to an associated device.
- */
+/** Performs discovery and triggers a connection to an associated device. */
 public interface ConnectivityManager {
     /**
      * Starts device discovery.
@@ -63,8 +61,12 @@
 
     /** Reason codes for connect failure. */
     @Retention(RetentionPolicy.SOURCE)
-    @IntDef({ERROR_REASON_UNKNOWN, ERROR_CONNECTION_TIMED_OUT, ERROR_CONNECTION_REFUSED,
-            ERROR_DEVICE_UNAVAILABLE})
+    @IntDef({
+        ERROR_REASON_UNKNOWN,
+        ERROR_CONNECTION_TIMED_OUT,
+        ERROR_CONNECTION_REFUSED,
+        ERROR_DEVICE_UNAVAILABLE
+    })
     @interface ReasonCode {}
 
     /**
diff --git a/remoteauth/service/java/com/android/server/remoteauth/connectivity/ConnectivityManagerFactory.java b/remoteauth/service/java/com/android/server/remoteauth/connectivity/ConnectivityManagerFactory.java
new file mode 100644
index 0000000..3407fca
--- /dev/null
+++ b/remoteauth/service/java/com/android/server/remoteauth/connectivity/ConnectivityManagerFactory.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.remoteauth.connectivity;
+
+import android.annotation.NonNull;
+import android.content.Context;
+
+import com.android.internal.util.Preconditions;
+
+import java.lang.ref.WeakReference;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Factory class to create different types of connectivity managers based on the underlying
+ * network transports (for example CompanionDeviceManager).
+ */
+public final class ConnectivityManagerFactory {
+    private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
+
+    /**
+     * Creates and returns a ConnectivityManager object depending on connection type.
+     *
+     * @param context of the caller.
+     * @return ConnectivityManager object.
+     */
+    public static ConnectivityManager getConnectivityManager(@NonNull Context context) {
+        Preconditions.checkNotNull(context);
+
+        // For now, we only have one case, but ideally this should create a new type based on some
+        // feature flag.
+        return new CdmConnectivityManager(EXECUTOR, new CompanionDeviceManagerWrapper(
+                new WeakReference<>(context.getApplicationContext()).get()));
+    }
+
+    private ConnectivityManagerFactory() {}
+}
diff --git a/remoteauth/service/java/com/android/server/remoteauth/connectivity/DiscoveredDevice.java b/remoteauth/service/java/com/android/server/remoteauth/connectivity/DiscoveredDevice.java
index a3e1e58..8cad3eb 100644
--- a/remoteauth/service/java/com/android/server/remoteauth/connectivity/DiscoveredDevice.java
+++ b/remoteauth/service/java/com/android/server/remoteauth/connectivity/DiscoveredDevice.java
@@ -25,8 +25,7 @@
     private @NonNull ConnectionInfo mConnectionInfo;
     private @Nullable String mDisplayName;
 
-    public DiscoveredDevice(
-            @NonNull ConnectionInfo connectionInfo, @Nullable String displayName) {
+    public DiscoveredDevice(@NonNull ConnectionInfo connectionInfo, @Nullable String displayName) {
         this.mConnectionInfo = connectionInfo;
         this.mDisplayName = displayName;
     }
diff --git a/remoteauth/service/java/com/android/server/remoteauth/connectivity/DiscoveryFilter.java b/remoteauth/service/java/com/android/server/remoteauth/connectivity/DiscoveryFilter.java
index 36c4b60..3590f20 100644
--- a/remoteauth/service/java/com/android/server/remoteauth/connectivity/DiscoveryFilter.java
+++ b/remoteauth/service/java/com/android/server/remoteauth/connectivity/DiscoveryFilter.java
@@ -31,7 +31,6 @@
  * the filter criteria (device type, name or peer address).
  */
 public final class DiscoveryFilter {
-
     /** Device type WATCH. */
     public static final int WATCH = 0;
 
@@ -86,7 +85,7 @@
         private Builder() {}
 
         /** Static method to create a new builder */
-        public static Builder newInstance() {
+        public static Builder builder() {
             return new Builder();
         }
 
diff --git a/remoteauth/service/java/com/android/server/remoteauth/connectivity/EventListener.java b/remoteauth/service/java/com/android/server/remoteauth/connectivity/EventListener.java
index d07adb1..eaf57e0 100644
--- a/remoteauth/service/java/com/android/server/remoteauth/connectivity/EventListener.java
+++ b/remoteauth/service/java/com/android/server/remoteauth/connectivity/EventListener.java
@@ -18,10 +18,8 @@
 
 import android.annotation.NonNull;
 
-/**
- * Listens to the events from underlying transport.
- */
-interface EventListener {
+/** Listens to the events from underlying transport. */
+public interface EventListener {
     /** Called when remote device is disconnected from the underlying transport. */
     void onDisconnect(@NonNull ConnectionInfo connectionInfo);
 }
diff --git a/remoteauth/tests/unit/Android.bp b/remoteauth/tests/unit/Android.bp
index 629d360..ccfa20e 100644
--- a/remoteauth/tests/unit/Android.bp
+++ b/remoteauth/tests/unit/Android.bp
@@ -29,17 +29,20 @@
         "android.test.base",
         "android.test.mock",
         "android.test.runner",
+        "framework-annotations-lib",
     ],
     compile_multilib: "both",
 
     static_libs: [
         "androidx.test.ext.junit",
+        "androidx.test.ext.truth",
         "androidx.test.rules",
         "com.uwb.support.generic",
         "framework-remoteauth-static",
         "junit",
         "libprotobuf-java-lite",
         "mockito-target-extended-minus-junit4",
+        "mockito-target-minus-junit4",
         "platform-test-annotations",
         "service-remoteauth-pre-jarjar",
         "truth-prebuilt",
diff --git a/remoteauth/tests/unit/AndroidManifest.xml b/remoteauth/tests/unit/AndroidManifest.xml
index 0449409..a5294c8 100644
--- a/remoteauth/tests/unit/AndroidManifest.xml
+++ b/remoteauth/tests/unit/AndroidManifest.xml
@@ -31,5 +31,6 @@
     <instrumentation
         android:name="androidx.test.runner.AndroidJUnitRunner"
         android:targetPackage="android.remoteauth.test"
-        android:label="RemoteAuth Mainline Module Tests" />
+        android:label="RemoteAuth Mainline Module Tests"
+        android:directBootAware="true"/>
 </manifest>
diff --git a/remoteauth/tests/unit/src/com/android/server/remoteauth/connectivity/CdmConnectivityManagerTest.java b/remoteauth/tests/unit/src/com/android/server/remoteauth/connectivity/CdmConnectivityManagerTest.java
new file mode 100644
index 0000000..824344a
--- /dev/null
+++ b/remoteauth/tests/unit/src/com/android/server/remoteauth/connectivity/CdmConnectivityManagerTest.java
@@ -0,0 +1,304 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.remoteauth.connectivity;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.companion.AssociationInfo;
+import android.companion.AssociationRequest;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.google.common.util.concurrent.SettableFuture;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+/** Unit tests for {@link CdmConnectivityManager}. */
+@RunWith(AndroidJUnit4.class)
+public class CdmConnectivityManagerTest {
+    @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+    @Mock CompanionDeviceManagerWrapper mCompanionDeviceManagerWrapper;
+
+    private CdmConnectivityManager mCdmConnectivityManager;
+    private ExecutorService mTestExecutor = Executors.newSingleThreadExecutor();
+
+    @Before
+    public void setUp() {
+        mCdmConnectivityManager =
+                new CdmConnectivityManager(mTestExecutor, mCompanionDeviceManagerWrapper);
+    }
+
+    @After
+    public void tearDown() {
+        mTestExecutor.shutdown();
+    }
+
+    @Test
+    public void testStartDiscovery_callsGetAllAssociationsOnce() throws InterruptedException {
+        mCdmConnectivityManager.startDiscovery(
+                Utils.getFakeDiscoveryFilter(), Utils.getFakeDiscoveredDeviceReceiver());
+
+        mTestExecutor.awaitTermination(1, TimeUnit.SECONDS);
+
+        verify(mCompanionDeviceManagerWrapper, times(1)).getAllAssociations();
+    }
+
+    @Test
+    public void testStartDiscovery_fetchesNoAssociations() {
+        SettableFuture<Boolean> future = SettableFuture.create();
+
+        when(mCompanionDeviceManagerWrapper.getAllAssociations())
+                .thenReturn(Utils.getFakeAssociationInfoList(0));
+
+        DiscoveredDeviceReceiver discoveredDeviceReceiver =
+                new DiscoveredDeviceReceiver() {
+                    @Override
+                    public void onDiscovered(DiscoveredDevice unused) {
+                        future.set(true);
+                    }
+
+                    @Override
+                    public void onLost(DiscoveredDevice unused) {
+                        future.set(true);
+                    }
+                };
+
+        mCdmConnectivityManager.startDiscovery(
+                Utils.getFakeDiscoveryFilter(), discoveredDeviceReceiver);
+
+        assertThat(future.isDone()).isFalse();
+    }
+
+    @Test
+    public void testStartDiscovery_DoesNotReturnNonWatchAssociations() throws InterruptedException {
+        SettableFuture<Boolean> future = SettableFuture.create();
+        DiscoveredDeviceReceiver discoveredDeviceReceiver =
+                new DiscoveredDeviceReceiver() {
+                    @Override
+                    public void onDiscovered(DiscoveredDevice unused) {
+                        future.set(true);
+                    }
+
+                    @Override
+                    public void onLost(DiscoveredDevice unused) {
+                        future.set(true);
+                    }
+                };
+
+        when(mCompanionDeviceManagerWrapper.getDeviceProfile(any(AssociationInfo.class)))
+                .thenReturn(Utils.FAKE_DEVICE_PROFILE);
+
+        mCdmConnectivityManager.startDiscovery(
+                Utils.getFakeDiscoveryFilter(), discoveredDeviceReceiver);
+
+        mTestExecutor.awaitTermination(1, TimeUnit.SECONDS);
+
+        verify(mCompanionDeviceManagerWrapper, times(1)).getAllAssociations();
+        verify(mCompanionDeviceManagerWrapper, times(0))
+                .getDeviceProfile(any(AssociationInfo.class));
+        assertThat(future.isDone()).isFalse();
+    }
+
+    @Test
+    public void testStartDiscovery_returnsOneWatchAssociation() throws InterruptedException {
+        SettableFuture<Boolean> future = SettableFuture.create();
+        DiscoveredDeviceReceiver discoveredDeviceReceiver =
+                new DiscoveredDeviceReceiver() {
+                    @Override
+                    public void onDiscovered(DiscoveredDevice unused) {
+                        future.set(true);
+                    }
+                };
+
+        when(mCompanionDeviceManagerWrapper.getAllAssociations())
+                .thenReturn(Utils.getFakeAssociationInfoList(1));
+        when(mCompanionDeviceManagerWrapper.getDeviceProfile(any(AssociationInfo.class)))
+                .thenReturn(AssociationRequest.DEVICE_PROFILE_WATCH);
+
+        mCdmConnectivityManager.startDiscovery(
+                Utils.getFakeDiscoveryFilter(), discoveredDeviceReceiver);
+
+        mTestExecutor.awaitTermination(1, TimeUnit.SECONDS);
+
+        verify(mCompanionDeviceManagerWrapper, times(1)).getAllAssociations();
+        verify(mCompanionDeviceManagerWrapper, times(1))
+                .getDeviceProfile(any(AssociationInfo.class));
+        assertThat(future.isDone()).isTrue();
+    }
+
+    @Test
+    public void testStartDiscovery_returnsMultipleWatchAssociations() throws InterruptedException {
+        int numAssociations = 3;
+        SettableFuture<Boolean> future = SettableFuture.create();
+        DiscoveredDeviceReceiver discoveredDeviceReceiver =
+                new DiscoveredDeviceReceiver() {
+                    int mNumCallbacks = 0;
+
+                    @Override
+                    public void onDiscovered(DiscoveredDevice unused) {
+                        ++mNumCallbacks;
+                        if (mNumCallbacks == numAssociations) {
+                            future.set(true);
+                        }
+                    }
+                };
+
+        when(mCompanionDeviceManagerWrapper.getAllAssociations())
+                .thenReturn(Utils.getFakeAssociationInfoList(numAssociations));
+        when(mCompanionDeviceManagerWrapper.getDeviceProfile(any(AssociationInfo.class)))
+                .thenReturn(AssociationRequest.DEVICE_PROFILE_WATCH);
+
+        mCdmConnectivityManager.startDiscovery(
+                Utils.getFakeDiscoveryFilter(), discoveredDeviceReceiver);
+
+        mTestExecutor.awaitTermination(1, TimeUnit.SECONDS);
+
+        verify(mCompanionDeviceManagerWrapper, times(1)).getAllAssociations();
+        verify(mCompanionDeviceManagerWrapper, times(numAssociations))
+                .getDeviceProfile(any(AssociationInfo.class));
+        assertThat(future.isDone()).isTrue();
+    }
+
+    @Test
+    public void testMultipleStartDiscovery_runsAllCallbacks() throws InterruptedException {
+        SettableFuture<Boolean> future1 = SettableFuture.create();
+        SettableFuture<Boolean> future2 = SettableFuture.create();
+        DiscoveredDeviceReceiver discoveredDeviceReceiver1 =
+                new DiscoveredDeviceReceiver() {
+                    @Override
+                    public void onDiscovered(DiscoveredDevice unused) {
+                        future1.set(true);
+                    }
+                };
+        DiscoveredDeviceReceiver discoveredDeviceReceiver2 =
+                new DiscoveredDeviceReceiver() {
+                    @Override
+                    public void onDiscovered(DiscoveredDevice unused) {
+                        future2.set(true);
+                    }
+                };
+
+        when(mCompanionDeviceManagerWrapper.getAllAssociations())
+                .thenReturn(Utils.getFakeAssociationInfoList(1));
+        when(mCompanionDeviceManagerWrapper.getDeviceProfile(any(AssociationInfo.class)))
+                .thenReturn(AssociationRequest.DEVICE_PROFILE_WATCH);
+
+        // Start discovery twice with different callbacks.
+        mCdmConnectivityManager.startDiscovery(
+                Utils.getFakeDiscoveryFilter(), discoveredDeviceReceiver1);
+        mCdmConnectivityManager.startDiscovery(
+                Utils.getFakeDiscoveryFilter(), discoveredDeviceReceiver2);
+
+        mTestExecutor.awaitTermination(1, TimeUnit.SECONDS);
+
+        verify(mCompanionDeviceManagerWrapper, times(2)).getAllAssociations();
+        verify(mCompanionDeviceManagerWrapper, times(2))
+                .getDeviceProfile(any(AssociationInfo.class));
+        assertThat(future1.isDone()).isTrue();
+        assertThat(future2.isDone()).isTrue();
+    }
+
+    @Test
+    public void testStartDiscovery_returnsExpectedDiscoveredDevice() throws InterruptedException {
+        SettableFuture<Boolean> future = SettableFuture.create();
+        DiscoveredDeviceReceiver discoveredDeviceReceiver =
+                new DiscoveredDeviceReceiver() {
+                    @Override
+                    public void onDiscovered(DiscoveredDevice device) {
+                        assertThat(device.getConnectionInfo() instanceof CdmConnectionInfo)
+                                .isTrue();
+
+                        CdmConnectionInfo connectionInfo =
+                                (CdmConnectionInfo) device.getConnectionInfo();
+                        if (connectionInfo.getConnectionParams().getDeviceMacAddress().toString()
+                                .equals(Utils.FAKE_PEER_ADDRESS)
+                                && connectionInfo.getConnectionId() == Utils.FAKE_CONNECTION_ID) {
+                            future.set(true);
+                        }
+                    }
+                };
+
+        when(mCompanionDeviceManagerWrapper.getAllAssociations())
+                .thenReturn(Utils.getFakeAssociationInfoList(1));
+        when(mCompanionDeviceManagerWrapper.getDeviceProfile(any(AssociationInfo.class)))
+                .thenReturn(AssociationRequest.DEVICE_PROFILE_WATCH);
+
+        mCdmConnectivityManager.startDiscovery(
+                Utils.getFakeDiscoveryFilter(), discoveredDeviceReceiver);
+
+        mTestExecutor.awaitTermination(1, TimeUnit.SECONDS);
+
+        verify(mCompanionDeviceManagerWrapper, times(1)).getAllAssociations();
+        verify(mCompanionDeviceManagerWrapper, times(1))
+                .getDeviceProfile(any(AssociationInfo.class));
+        assertThat(future.isDone()).isTrue();
+    }
+
+    @Test
+    public void testStopDiscovery_removesCallback() {
+        DiscoveredDeviceReceiver discoveredDeviceReceiver =
+                new DiscoveredDeviceReceiver() {
+                    @Override
+                    public void onDiscovered(DiscoveredDevice unused) {}
+                };
+
+        DiscoveryFilter discoveryFilter = Utils.getFakeDiscoveryFilter();
+        mCdmConnectivityManager.startDiscovery(discoveryFilter, discoveredDeviceReceiver);
+
+        assertThat(mCdmConnectivityManager.hasPendingCallbacks(discoveredDeviceReceiver)).isTrue();
+
+        mCdmConnectivityManager.stopDiscovery(discoveryFilter, discoveredDeviceReceiver);
+
+        assertThat(mCdmConnectivityManager.hasPendingCallbacks(discoveredDeviceReceiver)).isFalse();
+    }
+
+    @Test
+    public void testStopDiscovery_DoesNotRunCallback() {
+        SettableFuture future = SettableFuture.create();
+        DiscoveredDeviceReceiver discoveredDeviceReceiver =
+                new DiscoveredDeviceReceiver() {
+                    @Override
+                    public void onDiscovered(DiscoveredDevice unused) {
+                        future.set(true);
+                    }
+                };
+
+        DiscoveryFilter discoveryFilter = Utils.getFakeDiscoveryFilter();
+        mCdmConnectivityManager.startDiscovery(discoveryFilter, discoveredDeviceReceiver);
+        mCdmConnectivityManager.stopDiscovery(discoveryFilter, discoveredDeviceReceiver);
+
+        assertThat(future.isDone()).isFalse();
+    }
+}
+
diff --git a/remoteauth/tests/unit/src/com/android/server/remoteauth/connectivity/ConnectivityManagerFactoryTest.java b/remoteauth/tests/unit/src/com/android/server/remoteauth/connectivity/ConnectivityManagerFactoryTest.java
new file mode 100644
index 0000000..42eff90
--- /dev/null
+++ b/remoteauth/tests/unit/src/com/android/server/remoteauth/connectivity/ConnectivityManagerFactoryTest.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.remoteauth.connectivity;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit tests for {@link CdmConnectivityManager}. */
+@RunWith(AndroidJUnit4.class)
+public class ConnectivityManagerFactoryTest {
+
+    @Before
+    public void setUp() {}
+
+    @Test
+    public void testFactory_returnsConnectivityManager() {
+        final Context context = InstrumentationRegistry.getInstrumentation().getContext();
+        ConnectivityManager connectivityManager =
+                ConnectivityManagerFactory.getConnectivityManager(context);
+
+        assertThat(connectivityManager).isNotNull();
+    }
+}
diff --git a/remoteauth/tests/unit/src/com/android/server/remoteauth/connectivity/Utils.java b/remoteauth/tests/unit/src/com/android/server/remoteauth/connectivity/Utils.java
new file mode 100644
index 0000000..a5c992a
--- /dev/null
+++ b/remoteauth/tests/unit/src/com/android/server/remoteauth/connectivity/Utils.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.remoteauth.connectivity;
+
+import android.companion.AssociationInfo;
+import android.companion.AssociationRequest;
+import android.content.pm.PackageManager;
+import android.net.MacAddress;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public final class Utils {
+    public static final int FAKE_CONNECTION_ID = 1;
+    public static final int FAKE_USER_ID = 0;
+    public static final String FAKE_DISPLAY_NAME = "FAKE-DISPLAY-NAME";
+    public static final String FAKE_PEER_ADDRESS = "ff:ff:ff:ff:ff:ff";
+    public static final String FAKE_DEVICE_PROFILE = "FAKE-DEVICE-PROFILE";
+    public static final String FAKE_PACKAGE_NAME = "FAKE-PACKAGE-NAME";
+
+    public static DiscoveryFilter getFakeDiscoveryFilter() {
+        return DiscoveryFilter.Builder.builder()
+                .setDeviceName(FAKE_DISPLAY_NAME)
+                .setPeerAddress("FAKE-PEER-ADDRESS")
+                .setDeviceType(DiscoveryFilter.WATCH)
+                .build();
+    }
+
+    public static DiscoveredDeviceReceiver getFakeDiscoveredDeviceReceiver() {
+        return new DiscoveredDeviceReceiver() {
+            @Override
+            public void onDiscovered(DiscoveredDevice unused) {}
+
+            @Override
+            public void onLost(DiscoveredDevice unused) {}
+        };
+    }
+
+    /**
+     * Returns a fake CDM connection info.
+     *
+     * @return connection info.
+     */
+    public static CdmConnectionInfo getFakeCdmConnectionInfo()
+            throws PackageManager.NameNotFoundException {
+        return new CdmConnectionInfo(FAKE_CONNECTION_ID, getFakeAssociationInfoList(1).get(0));
+    }
+
+    /**
+     * Returns a fake discovered device.
+     *
+     * @return discovered device.
+     */
+    public static DiscoveredDevice getFakeCdmDiscoveredDevice()
+            throws PackageManager.NameNotFoundException {
+        return new DiscoveredDevice(getFakeCdmConnectionInfo(), FAKE_DISPLAY_NAME);
+    }
+
+    /**
+     * Returns fake association info array.
+     *
+     * <p> Creates an AssociationInfo object with fake values.
+     *
+     * @param associationsSize number of fake association info entries to return.
+     * @return list of {@link AssociationInfo} or null.
+     */
+    public static List<AssociationInfo> getFakeAssociationInfoList(int associationsSize) {
+        if (associationsSize > 0) {
+            List<AssociationInfo> associations = new ArrayList<>();
+            // Association id starts from 1.
+            for (int i = 1; i <= associationsSize; ++i) {
+                associations.add(
+                        (new AssociationInfo.Builder(i, FAKE_USER_ID, FAKE_PACKAGE_NAME))
+                                .setDeviceProfile(AssociationRequest.DEVICE_PROFILE_WATCH)
+                                .setDisplayName(FAKE_DISPLAY_NAME)
+                                .setDeviceMacAddress(MacAddress.fromString(FAKE_PEER_ADDRESS))
+                                .build());
+            }
+            return associations;
+        }
+        return new ArrayList<>();
+    }
+
+    private Utils() {}
+}