VDM Settings

Demo: go/vdm-settings-demo

Bug: 371713473
Bug: 338974320
Test: atest
Flag: android.companion.virtualdevice.flags.vdm_settings
Change-Id: I4a818b1b31ad59ee3de22105b969aec4c7f4d529
diff --git a/Android.bp b/Android.bp
index 907ff12..64be0eb 100644
--- a/Android.bp
+++ b/Android.bp
@@ -79,6 +79,8 @@
         "BiometricsSharedLib",
         "SystemUIUnfoldLib",
         "WifiTrackerLib",
+        "android.companion.flags-aconfig-java",
+        "android.companion.virtualdevice.flags-aconfig-java",
         "android.hardware.biometrics.flags-aconfig-java",
         "android.hardware.dumpstate-V1-java",
         "android.hardware.dumpstate-V1.0-java",
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 0eef210..26e7e12 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -148,6 +148,7 @@
     <uses-permission android:name="android.permission.GET_BACKGROUND_INSTALLED_PACKAGES" />
     <uses-permission android:name="android.permission.SATELLITE_COMMUNICATION" />
     <uses-permission android:name="android.permission.READ_SYSTEM_GRAMMATICAL_GENDER" />
+    <uses-permission android:name="android.permission.MANAGE_COMPANION_DEVICES" />
 
     <application
             android:name=".SettingsApplication"
diff --git a/res-product/values/strings.xml b/res-product/values/strings.xml
index 32e545b..059adbe 100644
--- a/res-product/values/strings.xml
+++ b/res-product/values/strings.xml
@@ -457,6 +457,18 @@
     <string name="bluetooth_unpair_dialog_body" product="tablet">Your tablet will no longer be paired with <xliff:g id="device_name">%1$s</xliff:g></string>
     <!--  Bluetooth device details. The body of a confirmation dialog for unpairing a paired device. -->
     <string name="bluetooth_unpair_dialog_body" product="device">Your device will no longer be paired with <xliff:g id="device_name">%1$s</xliff:g></string>
+    <!--  Virtual device details. The body of a confirmation dialog for unpairing a paired device. [CHAR LIMIT=none] -->
+    <string name="virtual_device_forget_dialog_body" product="default"><xliff:g id="device_name">%1$s</xliff:g> will no longer be connected to this phone. If you continue, some apps and app streaming may stop working.</string>
+    <!--  Virtual device details. The body of a confirmation dialog for unpairing a paired device. [CHAR LIMIT=none] -->
+    <string name="virtual_device_forget_dialog_body" product="tablet"><xliff:g id="device_name">%1$s</xliff:g> will no longer be connected to this tablet. If you continue, some apps and app streaming may stop working.</string>
+    <!--  Virtual device details. The body of a confirmation dialog for unpairing a paired device. [CHAR LIMIT=none] -->
+    <string name="virtual_device_forget_dialog_body" product="device"><xliff:g id="device_name">%1$s</xliff:g> will no longer be connected to this device. If you continue, some apps and app streaming may stop working.</string>
+    <!--  Virtual device details. Footer [CHAR LIMIT=none] -->
+    <string name="virtual_device_details_footer_title" product="default">If you forget <xliff:g id="device_name">%1$s</xliff:g>, it will no longer be associated to this phone. Some permissions will be removed and some apps and app streaming may stop working.</string>
+    <!--  Virtual device details. Footer [CHAR LIMIT=none] -->
+    <string name="virtual_device_details_footer_title" product="tablet">If you forget <xliff:g id="device_name">%1$s</xliff:g>, it will no longer be associated to this tablet. Some permissions will be removed and some apps and app streaming may stop working.</string>
+    <!--  Virtual device details. Footer [CHAR LIMIT=none] -->
+    <string name="virtual_device_details_footer_title" product="device">If you forget <xliff:g id="device_name">%1$s</xliff:g>, it will no longer be associated to this device. Some permissions will be removed and some apps and app streaming may stop working.</string>
     <!-- Description of Secure NFC in the 1st level settings screen. [CHAR LIMIT=NONE] -->
     <string name="nfc_secure_toggle_summary" product="default">Allow NFC use only when screen is unlocked</string>
     <!-- Summary for the panel of add Wi-Fi network from APP [CHAR LIMIT=NONE] -->
diff --git a/res/drawable/ic_devices_other.xml b/res/drawable/ic_devices_other.xml
index 3d29264..0fe691c 100644
--- a/res/drawable/ic_devices_other.xml
+++ b/res/drawable/ic_devices_other.xml
@@ -17,7 +17,8 @@
         android:width="24dp"
         android:height="24dp"
         android:viewportWidth="24.0"
-        android:viewportHeight="24.0">
+        android:viewportHeight="24.0"
+        android:tint="?android:attr/colorControlNormal">
     <path
         android:pathData="M3,6.003H21V4.003H3C1.9,4.003 1,4.903 1,6.003V18.003C1,19.103 1.9,20.003 3,20.003H7V18.003H3V6.003ZM13.002,12.001H9.002V13.781C8.392,14.331 8.002,15.111 8.002,16.001C8.002,16.891 8.392,17.671 9.002,18.221V20.001H13.002V18.221C13.612,17.671 14.002,16.881 14.002,16.001C14.002,15.121 13.612,14.331 13.002,13.781V12.001ZM9.501,16.003C9.501,16.833 10.171,17.503 11.001,17.503C11.831,17.503 12.501,16.833 12.501,16.003C12.501,15.173 11.831,14.503 11.001,14.503C10.171,14.503 9.501,15.173 9.501,16.003ZM22,8.004H16C15.5,8.004 15,8.504 15,9.004V19.004C15,19.504 15.5,20.004 16,20.004H22C22.5,20.004 23,19.504 23,19.004V9.004C23,8.504 22.5,8.004 22,8.004ZM17.002,18H21.002V10H17.002V18Z"
         android:fillType="evenOdd"
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 8e5e04f..1cbe737 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -13271,6 +13271,21 @@
     <!-- Label for button to not allow grant the permission for remote devices. [CHAR_LIMIT=50] -->
     <string name="request_manage_bluetooth_permission_dont_allow">Don\u2019t allow</string>
 
+    <!-- Summary for a connected Virtual Device [CHAR LIMIT=50] -->
+    <string name="virtual_device_connected">Connected</string>
+    <!-- Summary for a disconnected Virtual Device [CHAR LIMIT=50] -->
+    <string name="virtual_device_disconnected">Disconnected</string>
+    <!-- The name of virtual device if there's no display name available. [CHAR LIMIT=60] -->
+    <string name="virtual_device_unknown">Unknown device</string>
+    <!-- Title for virtual device details page [CHAR LIMIT=50] -->
+    <string name="virtual_device_details_title">Device details</string>
+    <!-- The title of a confirmation dialog for forgetting a virtual device. [CHAR LIMIT=30] -->
+    <string name="virtual_device_forget_dialog_title">Forget <xliff:g id="device_name">%1$s</xliff:g>?</string>
+    <!-- In the confirmation dialog for forgetting a virtual device, this is the label on the button that will complete the forget action. -->
+    <string name="virtual_device_forget_dialog_confirm_button">Forget</string>
+    <!-- The title of the companion app section in the virtual device details page [CHAR LIMIT=50] -->
+    <string name="virtual_device_companion_app_category">Connection managed by</string>
+
     <!-- Title for UWB preference [CHAR_LIMIT=60] -->
     <string name="uwb_settings_title">Ultra-Wideband (UWB)</string>
 
diff --git a/res/xml/connected_devices_advanced.xml b/res/xml/connected_devices_advanced.xml
index f491055..469dfae 100644
--- a/res/xml/connected_devices_advanced.xml
+++ b/res/xml/connected_devices_advanced.xml
@@ -74,6 +74,13 @@
         android:summary="@string/summary_placeholder"
         android:title="@string/print_settings" />
 
+    <PreferenceCategory
+        android:key="virtual_device_list"
+        android:layout="@layout/preference_category_no_label"
+        android:order="99"
+        settings:controller="com.android.settings.connecteddevice.virtual.VirtualDeviceListController"
+        settings:searchable="true"/>
+
     <com.android.settingslib.RestrictedSwitchPreference
         android:key="uwb_settings"
         android:order="100"
diff --git a/res/xml/virtual_device_details_fragment.xml b/res/xml/virtual_device_details_fragment.xml
new file mode 100644
index 0000000..2f02664
--- /dev/null
+++ b/res/xml/virtual_device_details_fragment.xml
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright (C) 2024 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.
+  -->
+<PreferenceScreen
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:settings="http://schemas.android.com/apk/res-auto"
+    android:title="@string/virtual_device_details_title">
+
+    <com.android.settingslib.widget.LayoutPreference
+        android:key="virtual_device_details_header"
+        android:layout="@layout/settings_entity_header"
+        android:selectable="false"
+        settings:allowDividerBelow="true"
+        settings:controller="com.android.settings.connecteddevice.virtual.VirtualDeviceDetailsHeaderController"/>
+
+    <com.android.settingslib.widget.ActionButtonsPreference
+        android:key="virtual_device_action_buttons"
+        settings:allowDividerBelow="true"
+        settings:controller="com.android.settings.connecteddevice.virtual.VirtualDeviceDetailsButtonsController"/>
+
+    <PreferenceCategory
+        android:key="recent_apps_category"
+        android:title="@string/virtual_device_companion_app_category"
+        settings:searchable="false">
+
+        <com.android.settingslib.widget.AppPreference
+            android:key="virtual_device_companion_app"
+            settings:controller="com.android.settings.connecteddevice.virtual.VirtualDeviceDetailsCompanionAppController"/>
+
+    </PreferenceCategory>
+
+    <com.android.settingslib.widget.FooterPreference
+        android:key="virtual_device_details_footer"
+        settings:controller="com.android.settings.connecteddevice.virtual.VirtualDeviceDetailsFooterController"
+        android:selectable="false"
+        settings:searchable="false" />
+
+</PreferenceScreen>
diff --git a/src/com/android/settings/connecteddevice/AdvancedConnectedDeviceDashboardFragment.java b/src/com/android/settings/connecteddevice/AdvancedConnectedDeviceDashboardFragment.java
index 8e230cb..8edcd2e 100644
--- a/src/com/android/settings/connecteddevice/AdvancedConnectedDeviceDashboardFragment.java
+++ b/src/com/android/settings/connecteddevice/AdvancedConnectedDeviceDashboardFragment.java
@@ -20,6 +20,7 @@
 import android.provider.SearchIndexableResource;
 
 import com.android.settings.R;
+import com.android.settings.connecteddevice.virtual.VirtualDeviceListController;
 import com.android.settings.dashboard.DashboardFragment;
 import com.android.settings.print.PrintSettingPreferenceController;
 import com.android.settings.search.BaseSearchIndexProvider;
@@ -74,6 +75,7 @@
                 getSettingsLifecycle().addObserver(uwbPreferenceController);
             }
         }
+        use(VirtualDeviceListController.class).setFragment(this);
     }
 
     @Override
diff --git a/src/com/android/settings/connecteddevice/virtual/ForgetDeviceDialogFragment.java b/src/com/android/settings/connecteddevice/virtual/ForgetDeviceDialogFragment.java
new file mode 100644
index 0000000..d6da223
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/virtual/ForgetDeviceDialogFragment.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright (C) 2024 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.settings.connecteddevice.virtual;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.app.settings.SettingsEnums;
+import android.companion.CompanionDeviceManager;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.appcompat.app.AlertDialog;
+
+import com.android.settings.R;
+import com.android.settings.core.instrumentation.InstrumentedDialogFragment;
+
+/**
+ * Implements an AlertDialog for confirming that a user wishes to unpair or "forget" a paired
+ * device.
+ */
+public class ForgetDeviceDialogFragment extends InstrumentedDialogFragment {
+
+    public static final String TAG = ForgetDeviceDialogFragment.class.getSimpleName();
+
+    private static final String DEVICE_ARG = "virtual_device_arg";
+
+    @VisibleForTesting
+    CompanionDeviceManager mCompanionDeviceManager;
+    @VisibleForTesting
+    VirtualDeviceWrapper mDevice;
+
+    static ForgetDeviceDialogFragment newInstance(VirtualDeviceWrapper device) {
+        Bundle args = new Bundle(1);
+        args.putParcelable(DEVICE_ARG, device);
+        ForgetDeviceDialogFragment dialog = new ForgetDeviceDialogFragment();
+        dialog.setArguments(args);
+        return dialog;
+    }
+
+    @Override
+    public int getMetricsCategory() {
+        return SettingsEnums.DIALOG_VIRTUAL_DEVICE_FORGET;
+    }
+
+    @Override
+    public void onAttach(@NonNull Context context) {
+        super.onAttach(context);
+        mCompanionDeviceManager = context.getSystemService(CompanionDeviceManager.class);
+        mDevice = getArguments().getParcelable(DEVICE_ARG, VirtualDeviceWrapper.class);
+    }
+
+    @Override
+    @NonNull
+    public Dialog onCreateDialog(@Nullable Bundle inState) {
+        Context context = getContext();
+        CharSequence deviceName = mDevice.getDeviceName(context);
+
+        AlertDialog dialog = new AlertDialog.Builder(context)
+                .setPositiveButton(R.string.virtual_device_forget_dialog_confirm_button,
+                        this::onForgetButtonClick)
+                .setNegativeButton(android.R.string.cancel, null)
+                .create();
+        dialog.setTitle(
+                context.getString(R.string.virtual_device_forget_dialog_title, deviceName));
+        dialog.setMessage(
+                context.getString(R.string.virtual_device_forget_dialog_body, deviceName));
+        return dialog;
+    }
+
+    private void onForgetButtonClick(DialogInterface dialog, int which) {
+        mCompanionDeviceManager.disassociate(mDevice.getAssociationInfo().getId());
+        Activity activity = getActivity();
+        if (activity != null) {
+            activity.finish();
+        }
+    }
+}
diff --git a/src/com/android/settings/connecteddevice/virtual/VirtualDeviceDetailsButtonsController.java b/src/com/android/settings/connecteddevice/virtual/VirtualDeviceDetailsButtonsController.java
new file mode 100644
index 0000000..002767b
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/virtual/VirtualDeviceDetailsButtonsController.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2024 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.settings.connecteddevice.virtual;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.preference.PreferenceFragmentCompat;
+import androidx.preference.PreferenceScreen;
+
+import com.android.settings.R;
+import com.android.settings.core.BasePreferenceController;
+import com.android.settingslib.widget.ActionButtonsPreference;
+
+import java.util.Objects;
+
+/** This class adds one button to "forget" (ie unpair) the device. */
+public class VirtualDeviceDetailsButtonsController extends BasePreferenceController {
+
+    private static final String KEY_VIRTUAL_DEVICE_ACTION_BUTTONS = "virtual_device_action_buttons";
+
+    @Nullable
+    private PreferenceFragmentCompat mFragment;
+    @Nullable
+    private VirtualDeviceWrapper mDevice;
+
+    public VirtualDeviceDetailsButtonsController(@NonNull Context context) {
+        super(context, KEY_VIRTUAL_DEVICE_ACTION_BUTTONS);
+    }
+
+    /** One-time initialization when the controller is first created. */
+    void init(@NonNull PreferenceFragmentCompat fragment, @NonNull VirtualDeviceWrapper device) {
+        mFragment = fragment;
+        mDevice = device;
+    }
+
+    private void onForgetButtonPressed() {
+        ForgetDeviceDialogFragment fragment =
+                ForgetDeviceDialogFragment.newInstance(Objects.requireNonNull(mDevice));
+        fragment.show(Objects.requireNonNull(mFragment).getParentFragmentManager(),
+                ForgetDeviceDialogFragment.TAG);
+    }
+
+    @Override
+    public void displayPreference(@NonNull PreferenceScreen screen) {
+        ((ActionButtonsPreference) screen.findPreference(getPreferenceKey()))
+                .setButton1Text(R.string.forget)
+                .setButton1Icon(R.drawable.ic_settings_delete)
+                .setButton1OnClickListener((view) -> onForgetButtonPressed())
+                .setButton1Enabled(true);
+    }
+
+    @Override
+    public int getAvailabilityStatus() {
+        return AVAILABLE;
+    }
+}
diff --git a/src/com/android/settings/connecteddevice/virtual/VirtualDeviceDetailsCompanionAppController.java b/src/com/android/settings/connecteddevice/virtual/VirtualDeviceDetailsCompanionAppController.java
new file mode 100644
index 0000000..ba86ae3
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/virtual/VirtualDeviceDetailsCompanionAppController.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2024 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.settings.connecteddevice.virtual;
+
+import static com.android.settings.spa.app.appinfo.AppInfoSettingsProvider.startAppInfoSettings;
+
+import android.app.Application;
+import android.content.Context;
+import android.os.UserHandle;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.preference.PreferenceFragmentCompat;
+import androidx.preference.PreferenceScreen;
+
+import com.android.settings.core.BasePreferenceController;
+import com.android.settingslib.Utils;
+import com.android.settingslib.applications.ApplicationsState;
+import com.android.settingslib.widget.AppPreference;
+
+import java.util.Objects;
+
+/** This class adds the details about the virtual device companion app. */
+public class VirtualDeviceDetailsCompanionAppController extends BasePreferenceController {
+
+    private static final String KEY_VIRTUAL_DEVICE_COMPANION_APP = "virtual_device_companion_app";
+
+    @Nullable
+    private PreferenceFragmentCompat mFragment;
+    @Nullable
+    private String mPackageName;
+
+    public VirtualDeviceDetailsCompanionAppController(@NonNull Context context) {
+        super(context, KEY_VIRTUAL_DEVICE_COMPANION_APP);
+    }
+
+    /** One-time initialization when the controller is first created. */
+    void init(@NonNull PreferenceFragmentCompat fragment, @NonNull String packageName) {
+        mFragment = fragment;
+        mPackageName = packageName;
+    }
+
+    @Override
+    public void displayPreference(@NonNull PreferenceScreen screen) {
+        ApplicationsState applicationsState = ApplicationsState.getInstance(
+                (Application) mContext.getApplicationContext());
+        final ApplicationsState.AppEntry appEntry = applicationsState.getEntry(
+                mPackageName, UserHandle.myUserId());
+
+        final AppPreference preference = screen.findPreference(getPreferenceKey());
+
+        preference.setTitle(appEntry.label);
+        preference.setIcon(Utils.getBadgedIcon(mContext, appEntry.info));
+        preference.setOnPreferenceClickListener(pref -> {
+            startAppInfoSettings(Objects.requireNonNull(mPackageName), appEntry.info.uid,
+                    Objects.requireNonNull(mFragment), /* request= */ 1001,
+                    getMetricsCategory());
+            return true;
+        });
+    }
+
+    @Override
+    public int getAvailabilityStatus() {
+        return AVAILABLE;
+    }
+}
diff --git a/src/com/android/settings/connecteddevice/virtual/VirtualDeviceDetailsFooterController.java b/src/com/android/settings/connecteddevice/virtual/VirtualDeviceDetailsFooterController.java
new file mode 100644
index 0000000..ee3ff2b
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/virtual/VirtualDeviceDetailsFooterController.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2024 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.settings.connecteddevice.virtual;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceScreen;
+
+import com.android.settings.R;
+import com.android.settings.core.BasePreferenceController;
+
+/** Adds footer text on the virtual device details page. */
+public class VirtualDeviceDetailsFooterController extends BasePreferenceController {
+
+    private static final String KEY_VIRTUAL_DEVICE_FOOTER = "virtual_device_details_footer";
+
+    @Nullable
+    private CharSequence mDeviceName;
+
+    public VirtualDeviceDetailsFooterController(@NonNull Context context) {
+        super(context, KEY_VIRTUAL_DEVICE_FOOTER);
+    }
+
+    /** One-time initialization when the controller is first created. */
+    void init(@NonNull CharSequence deviceName) {
+        mDeviceName = deviceName;
+    }
+
+    @Override
+    public void displayPreference(@NonNull PreferenceScreen screen) {
+        super.displayPreference(screen);
+        Preference preference = screen.findPreference(getPreferenceKey());
+        preference.setTitle(mContext.getString(R.string.virtual_device_details_footer_title,
+                mDeviceName));
+    }
+
+    @Override
+    public int getAvailabilityStatus() {
+        return AVAILABLE;
+    }
+}
diff --git a/src/com/android/settings/connecteddevice/virtual/VirtualDeviceDetailsFragment.java b/src/com/android/settings/connecteddevice/virtual/VirtualDeviceDetailsFragment.java
new file mode 100644
index 0000000..4fa14e7
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/virtual/VirtualDeviceDetailsFragment.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2024 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.settings.connecteddevice.virtual;
+
+import android.app.settings.SettingsEnums;
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+
+import com.android.settings.R;
+import com.android.settings.dashboard.DashboardFragment;
+
+/**
+ * Dedicated screen displaying the information for a single virtual device to the user and allowing
+ * them to manage that device.
+ */
+public class VirtualDeviceDetailsFragment extends DashboardFragment {
+
+    private static final String TAG = VirtualDeviceDetailsFragment.class.getSimpleName();
+
+    static final String DEVICE_ARG = "virtual_device_arg";
+
+    @Override
+    public void onAttach(@NonNull Context context) {
+        super.onAttach(context);
+
+        VirtualDeviceWrapper device =
+                getArguments().getParcelable(DEVICE_ARG, VirtualDeviceWrapper.class);
+
+        use(VirtualDeviceDetailsHeaderController.class).init(device);
+        use(VirtualDeviceDetailsButtonsController.class).init(this, device);
+        use(VirtualDeviceDetailsCompanionAppController.class)
+                .init(this, device.getAssociationInfo().getPackageName());
+        use(VirtualDeviceDetailsFooterController.class).init(device.getDeviceName(context));
+    }
+
+    @Override
+    public int getMetricsCategory() {
+        return SettingsEnums.VIRTUAL_DEVICE_DETAILS;
+    }
+
+    @Override
+    protected String getLogTag() {
+        return TAG;
+    }
+
+    @Override
+    protected int getPreferenceScreenResId() {
+        return R.xml.virtual_device_details_fragment;
+    }
+}
diff --git a/src/com/android/settings/connecteddevice/virtual/VirtualDeviceDetailsHeaderController.java b/src/com/android/settings/connecteddevice/virtual/VirtualDeviceDetailsHeaderController.java
new file mode 100644
index 0000000..261e207
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/virtual/VirtualDeviceDetailsHeaderController.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright (C) 2024 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.settings.connecteddevice.virtual;
+
+import android.companion.virtual.VirtualDevice;
+import android.companion.virtual.VirtualDeviceManager;
+import android.content.Context;
+import android.graphics.drawable.Icon;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.Lifecycle;
+import androidx.lifecycle.LifecycleObserver;
+import androidx.lifecycle.OnLifecycleEvent;
+import androidx.preference.PreferenceScreen;
+
+import com.android.settings.R;
+import com.android.settings.core.BasePreferenceController;
+import com.android.settingslib.widget.LayoutPreference;
+
+import java.util.Objects;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+/** This class adds a header for a virtual device with a heading and icon. */
+public class VirtualDeviceDetailsHeaderController extends BasePreferenceController implements
+        LifecycleObserver, VirtualDeviceManager.VirtualDeviceListener {
+
+    private static final String KEY_VIRTUAL_DEVICE_DETAILS_HEADER = "virtual_device_details_header";
+
+    @Nullable
+    private final VirtualDeviceManager mVirtualDeviceManager;
+    @Nullable
+    private VirtualDeviceWrapper mDevice;
+    @Nullable
+    private TextView mSummaryView;
+
+    private final Executor mExecutor = Executors.newSingleThreadExecutor();
+
+    public VirtualDeviceDetailsHeaderController(@NonNull Context context) {
+        super(context, KEY_VIRTUAL_DEVICE_DETAILS_HEADER);
+        mVirtualDeviceManager =
+                Objects.requireNonNull(context.getSystemService(VirtualDeviceManager.class));
+    }
+
+    /** One-time initialization when the controller is first created. */
+    void init(@NonNull VirtualDeviceWrapper device) {
+        mDevice = device;
+    }
+
+    @OnLifecycleEvent(Lifecycle.Event.ON_START)
+    void onStart() {
+        if (mVirtualDeviceManager != null) {
+            mVirtualDeviceManager.registerVirtualDeviceListener(mExecutor, this);
+        }
+    }
+
+    @OnLifecycleEvent(Lifecycle.Event.ON_STOP)
+    void onStop() {
+        if (mVirtualDeviceManager != null) {
+            mVirtualDeviceManager.unregisterVirtualDeviceListener(this);
+        }
+    }
+
+    @Override
+    public void onVirtualDeviceCreated(int deviceId) {
+        VirtualDevice device =
+                Objects.requireNonNull(mVirtualDeviceManager).getVirtualDevice(deviceId);
+        if (mDevice != null && device != null
+                && mDevice.getPersistentDeviceId().equals(device.getPersistentDeviceId())) {
+            mDevice.setDeviceId(deviceId);
+            mContext.getMainExecutor().execute(this::updateSummary);
+        }
+    }
+
+    @Override
+    public void onVirtualDeviceClosed(int deviceId) {
+        if (mDevice != null && deviceId == mDevice.getDeviceId()) {
+            mDevice.setDeviceId(Context.DEVICE_ID_INVALID);
+            mContext.getMainExecutor().execute(this::updateSummary);
+        }
+    }
+
+    @Override
+    public void displayPreference(@NonNull PreferenceScreen screen) {
+        super.displayPreference(screen);
+        LayoutPreference headerPreference = screen.findPreference(getPreferenceKey());
+        View view = headerPreference.findViewById(R.id.entity_header);
+        TextView titleView = view.findViewById(R.id.entity_header_title);
+        ImageView iconView = headerPreference.findViewById(R.id.entity_header_icon);
+        mSummaryView = view.findViewById(R.id.entity_header_summary);
+        updateSummary();
+        if (mDevice != null) {
+            titleView.setText(mDevice.getDeviceName(mContext));
+            Icon deviceIcon = android.companion.Flags.associationDeviceIcon()
+                    ? mDevice.getAssociationInfo().getDeviceIcon() : null;
+            if (deviceIcon == null) {
+                iconView.setImageResource(R.drawable.ic_devices_other);
+            } else {
+                iconView.setImageIcon(deviceIcon);
+            }
+        }
+        iconView.setContentDescription("Icon for device");
+    }
+
+    private void updateSummary() {
+        if (mSummaryView != null && mDevice != null) {
+            mSummaryView.setText(mDevice.getDeviceId() != Context.DEVICE_ID_INVALID
+                    ? R.string.virtual_device_connected : R.string.virtual_device_disconnected);
+        }
+    }
+
+    @Override
+    public int getAvailabilityStatus() {
+        return AVAILABLE;
+    }
+}
diff --git a/src/com/android/settings/connecteddevice/virtual/VirtualDeviceListController.java b/src/com/android/settings/connecteddevice/virtual/VirtualDeviceListController.java
new file mode 100644
index 0000000..81bf2fd
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/virtual/VirtualDeviceListController.java
@@ -0,0 +1,182 @@
+/*
+ * Copyright (C) 2024 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.settings.connecteddevice.virtual;
+
+import android.content.Context;
+import android.graphics.drawable.Icon;
+import android.os.Bundle;
+import android.util.ArrayMap;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.lifecycle.Lifecycle;
+import androidx.lifecycle.LifecycleObserver;
+import androidx.lifecycle.OnLifecycleEvent;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceGroup;
+import androidx.preference.PreferenceScreen;
+
+import com.android.settings.R;
+import com.android.settings.core.BasePreferenceController;
+import com.android.settings.core.SubSettingLauncher;
+import com.android.settings.dashboard.DashboardFragment;
+import com.android.settings.overlay.FeatureFactory;
+import com.android.settingslib.core.instrumentation.MetricsFeatureProvider;
+import com.android.settingslib.search.SearchIndexableRaw;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+
+/** Displays the list of all virtual devices. */
+public class VirtualDeviceListController extends BasePreferenceController
+        implements LifecycleObserver, VirtualDeviceUpdater.DeviceListener {
+
+    private final MetricsFeatureProvider mMetricsFeatureProvider;
+
+    @VisibleForTesting
+    VirtualDeviceUpdater mVirtualDeviceUpdater;
+    @VisibleForTesting
+    ArrayMap<String, Preference> mPreferences = new ArrayMap<>();
+    @Nullable
+    private PreferenceGroup mPreferenceGroup;
+    @Nullable
+    private DashboardFragment mFragment;
+
+    public VirtualDeviceListController(@NonNull Context context, @NonNull String preferenceKey) {
+        super(context, preferenceKey);
+        mMetricsFeatureProvider = FeatureFactory.getFeatureFactory().getMetricsFeatureProvider();
+        mVirtualDeviceUpdater = new VirtualDeviceUpdater(context, this);
+    }
+
+    public void setFragment(@NonNull DashboardFragment fragment) {
+        mFragment = fragment;
+    }
+
+    @OnLifecycleEvent(Lifecycle.Event.ON_START)
+    void onStart() {
+        if (isAvailable()) {
+            mVirtualDeviceUpdater.registerListener();
+        }
+    }
+
+    @OnLifecycleEvent(Lifecycle.Event.ON_STOP)
+    void onStop() {
+        if (isAvailable()) {
+            mVirtualDeviceUpdater.unregisterListener();
+        }
+    }
+
+    @Override
+    public void displayPreference(@NonNull PreferenceScreen screen) {
+        super.displayPreference(screen);
+        mPreferenceGroup = screen.findPreference(getPreferenceKey());
+        if (isAvailable()) {
+            mVirtualDeviceUpdater.loadDevices();
+        }
+    }
+
+    @Override
+    public void onDeviceAdded(@NonNull VirtualDeviceWrapper device) {
+        Preference preference = new Preference(mContext);
+        CharSequence deviceName = device.getDeviceName(mContext);
+        preference.setTitle(deviceName);
+        preference.setKey(device.getPersistentDeviceId() + "_" + deviceName);
+        final CharSequence title = preference.getTitle();
+
+        Icon deviceIcon = android.companion.Flags.associationDeviceIcon()
+                ? device.getAssociationInfo().getDeviceIcon() : null;
+        if (deviceIcon == null) {
+            preference.setIcon(R.drawable.ic_devices_other);
+        } else {
+            preference.setIcon(deviceIcon.loadDrawable(mContext));
+        }
+        if (device.getDeviceId() != Context.DEVICE_ID_INVALID) {
+            preference.setSummary(R.string.virtual_device_connected);
+        } else {
+            preference.setSummary(R.string.virtual_device_disconnected);
+        }
+
+        preference.setOnPreferenceClickListener((Preference p) -> {
+            mMetricsFeatureProvider.logClickedPreference(p, getMetricsCategory());
+            final Bundle args = new Bundle();
+            args.putParcelable(VirtualDeviceDetailsFragment.DEVICE_ARG, device);
+            if (mFragment != null) {
+                new SubSettingLauncher(mFragment.getContext())
+                        .setDestination(VirtualDeviceDetailsFragment.class.getName())
+                        .setTitleText(title)
+                        .setArguments(args)
+                        .setSourceMetricsCategory(getMetricsCategory())
+                        .launch();
+            }
+            return true;
+        });
+        mPreferences.put(device.getPersistentDeviceId(), preference);
+        if (mPreferenceGroup != null) {
+            mContext.getMainExecutor().execute(() ->
+                    Objects.requireNonNull(mPreferenceGroup).addPreference(preference));
+        }
+    }
+
+    @Override
+    public void onDeviceRemoved(@NonNull VirtualDeviceWrapper device) {
+        Preference preference = mPreferences.remove(device.getPersistentDeviceId());
+        if (mPreferenceGroup != null) {
+            mContext.getMainExecutor().execute(() ->
+                    Objects.requireNonNull(mPreferenceGroup).removePreference(preference));
+        }
+    }
+
+    @Override
+    public void onDeviceChanged(@NonNull VirtualDeviceWrapper device) {
+        Preference preference = mPreferences.get(device.getPersistentDeviceId());
+        if (preference != null) {
+            int summaryResId = device.getDeviceId() != Context.DEVICE_ID_INVALID
+                    ? R.string.virtual_device_connected : R.string.virtual_device_disconnected;
+            mContext.getMainExecutor().execute(() ->
+                    Objects.requireNonNull(preference).setSummary(summaryResId));
+        }
+    }
+
+    @Override
+    public void updateDynamicRawDataToIndex(@NonNull List<SearchIndexableRaw> rawData) {
+        if (!isAvailable()) {
+            return;
+        }
+        Collection<VirtualDeviceWrapper> devices = mVirtualDeviceUpdater.loadDevices();
+        for (VirtualDeviceWrapper device : devices) {
+            SearchIndexableRaw data = new SearchIndexableRaw(mContext);
+            String deviceName = device.getDeviceName(mContext).toString();
+            data.key = device.getPersistentDeviceId() + "_" + deviceName;
+            data.title = deviceName;
+            data.summaryOn = mContext.getString(R.string.connected_device_connections_title);
+            rawData.add(data);
+        }
+    }
+
+    @Override
+    public int getAvailabilityStatus() {
+        if (!mContext.getResources().getBoolean(
+                com.android.internal.R.bool.config_enableVirtualDeviceManager)) {
+            return UNSUPPORTED_ON_DEVICE;
+        }
+        if (!android.companion.virtualdevice.flags.Flags.vdmSettings()) {
+            return CONDITIONALLY_UNAVAILABLE;
+        }
+        return AVAILABLE;
+    }
+}
diff --git a/src/com/android/settings/connecteddevice/virtual/VirtualDeviceUpdater.java b/src/com/android/settings/connecteddevice/virtual/VirtualDeviceUpdater.java
new file mode 100644
index 0000000..bcdca2d
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/virtual/VirtualDeviceUpdater.java
@@ -0,0 +1,178 @@
+/*
+ * Copyright (C) 2024 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.settings.connecteddevice.virtual;
+
+import static com.android.settingslib.drawer.TileUtils.IA_SETTINGS_ACTION;
+
+import android.companion.AssociationInfo;
+import android.companion.CompanionDeviceManager;
+import android.companion.virtual.VirtualDevice;
+import android.companion.virtual.VirtualDeviceManager;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.os.UserHandle;
+import android.util.ArrayMap;
+import android.util.ArraySet;
+
+import com.google.common.collect.ImmutableSet;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+/** Maintains a collection of all virtual devices and propagates any changes to its listener. */
+class VirtualDeviceUpdater implements VirtualDeviceManager.VirtualDeviceListener {
+
+    private static final String CDM_PERSISTENT_DEVICE_ID_PREFIX = "companion:";
+
+    // TODO(b/384400670): Detect these packages via PackageManager instead of hardcoding them.
+    private static final ImmutableSet<String> IGNORED_PACKAGES =
+            ImmutableSet.of("com.google.ambient.streaming");
+
+    private final VirtualDeviceManager mVirtualDeviceManager;
+    private final CompanionDeviceManager mCompanionDeviceManager;
+    private final PackageManager mPackageManager;
+    private final DeviceListener mDeviceListener;
+    private final Executor mBackgroundExecutor = Executors.newSingleThreadExecutor();
+
+    // Up-to-date list of active and inactive devices, keyed by persistent device id.
+    @VisibleForTesting
+    ArrayMap<String, VirtualDeviceWrapper> mDevices = new ArrayMap<>();
+
+    interface DeviceListener {
+        void onDeviceAdded(@NonNull VirtualDeviceWrapper device);
+        void onDeviceRemoved(@NonNull VirtualDeviceWrapper device);
+        void onDeviceChanged(@NonNull VirtualDeviceWrapper device);
+    }
+
+    VirtualDeviceUpdater(Context context, DeviceListener deviceListener) {
+        mVirtualDeviceManager = context.getSystemService(VirtualDeviceManager.class);
+        mCompanionDeviceManager = context.getSystemService(CompanionDeviceManager.class);
+        mPackageManager = context.getPackageManager();
+        mDeviceListener = deviceListener;
+    }
+
+    void registerListener() {
+        mVirtualDeviceManager.registerVirtualDeviceListener(mBackgroundExecutor, this);
+        mBackgroundExecutor.execute(this::loadDevices);
+    }
+
+    void unregisterListener() {
+        mVirtualDeviceManager.unregisterVirtualDeviceListener(this);
+    }
+
+    @Override
+    public void onVirtualDeviceCreated(int deviceId) {
+        loadDevices();
+    }
+
+    @Override
+    public void onVirtualDeviceClosed(int deviceId) {
+        loadDevices();
+    }
+
+    Collection<VirtualDeviceWrapper> loadDevices() {
+        final Set<String> persistentDeviceIds = mVirtualDeviceManager.getAllPersistentDeviceIds();
+        final Set<String> deviceIdsToRemove = new ArraySet<>();
+        for (String persistentDeviceId : mDevices.keySet()) {
+            if (!persistentDeviceIds.contains(persistentDeviceId)) {
+                deviceIdsToRemove.add(persistentDeviceId);
+            }
+        }
+        for (String persistentDeviceId : deviceIdsToRemove) {
+            mDeviceListener.onDeviceRemoved(mDevices.remove(persistentDeviceId));
+        }
+
+        if (!persistentDeviceIds.isEmpty()) {
+            for (VirtualDevice device : mVirtualDeviceManager.getVirtualDevices()) {
+                String persistentDeviceId = device.getPersistentDeviceId();
+                persistentDeviceIds.remove(persistentDeviceId);
+                addOrUpdateDevice(persistentDeviceId, device.getDeviceId());
+            }
+        }
+
+        for (String persistentDeviceId : persistentDeviceIds) {
+            addOrUpdateDevice(persistentDeviceId, Context.DEVICE_ID_INVALID);
+        }
+
+        return mDevices.values();
+    }
+
+    private void addOrUpdateDevice(String persistentDeviceId, int deviceId) {
+        VirtualDeviceWrapper device = mDevices.get(persistentDeviceId);
+        if (device == null) {
+            AssociationInfo associationInfo = getAssociationInfo(persistentDeviceId);
+            if (associationInfo == null) {
+                return;
+            }
+            device = new VirtualDeviceWrapper(associationInfo, persistentDeviceId, deviceId);
+            mDevices.put(persistentDeviceId, device);
+            mDeviceListener.onDeviceAdded(device);
+        }
+        if (device.getDeviceId() != deviceId) {
+            device.setDeviceId(deviceId);
+            mDeviceListener.onDeviceChanged(device);
+        }
+    }
+
+    @Nullable
+    private AssociationInfo getAssociationInfo(String persistentDeviceId) {
+        if (persistentDeviceId == null) {
+            return null;
+        }
+        VirtualDeviceWrapper device = mDevices.get(persistentDeviceId);
+        if (device != null) {
+            return device.getAssociationInfo();
+        }
+        if (!persistentDeviceId.startsWith(CDM_PERSISTENT_DEVICE_ID_PREFIX)) {
+            return null;
+        }
+        final int associationId = Integer.parseInt(
+                persistentDeviceId.replaceFirst(CDM_PERSISTENT_DEVICE_ID_PREFIX, ""));
+        final List<AssociationInfo> associations =
+                mCompanionDeviceManager.getAllAssociations(UserHandle.USER_ALL);
+        final AssociationInfo associationInfo = associations.stream()
+                .filter(a -> a.getId() == associationId)
+                .findFirst()
+                .orElse(null);
+        if (associationInfo == null) {
+            return null;
+        }
+        if (shouldExcludePackageFromSettings(associationInfo.getPackageName())) {
+            return null;
+        }
+        return associationInfo;
+    }
+
+    // Some packages already inject custom settings entries that allow the users to manage the
+    // virtual devices and the companion associations, so they should be ignored from the generic
+    // settings page.
+    private boolean shouldExcludePackageFromSettings(String packageName) {
+        if (packageName == null || IGNORED_PACKAGES.contains(packageName)) {
+            return true;
+        }
+        final Intent intent = new Intent(IA_SETTINGS_ACTION);
+        intent.setPackage(packageName);
+        return intent.resolveActivity(mPackageManager) != null;
+    }
+}
diff --git a/src/com/android/settings/connecteddevice/virtual/VirtualDeviceWrapper.java b/src/com/android/settings/connecteddevice/virtual/VirtualDeviceWrapper.java
new file mode 100644
index 0000000..4b047b6
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/virtual/VirtualDeviceWrapper.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2024 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.settings.connecteddevice.virtual;
+
+import android.companion.AssociationInfo;
+import android.content.Context;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.android.settings.R;
+
+import java.util.Objects;
+
+/** Parcelable representing a virtual device along with its association properties. */
+class VirtualDeviceWrapper implements Parcelable {
+
+    /** The CDM Association for this device. */
+    @NonNull
+    private final AssociationInfo mAssociationInfo;
+    /** The unique VDM identifier for the device, persisted even when the device is inactive. */
+    @NonNull
+    private final String mPersistentDeviceId;
+    /** The identifier for the device if it's active, Context.DEVICE_ID_INVALID otherwise. */
+    private int mDeviceId;
+
+    VirtualDeviceWrapper(@NonNull AssociationInfo associationInfo,
+            @NonNull String persistentDeviceId, int deviceId) {
+        mAssociationInfo = associationInfo;
+        mPersistentDeviceId = persistentDeviceId;
+        mDeviceId = deviceId;
+    }
+
+    @NonNull
+    AssociationInfo getAssociationInfo() {
+        return mAssociationInfo;
+    }
+
+    @NonNull
+    String getPersistentDeviceId() {
+        return mPersistentDeviceId;
+    }
+
+    @NonNull
+    CharSequence getDeviceName(Context context) {
+        return mAssociationInfo.getDisplayName() != null
+                ? mAssociationInfo.getDisplayName()
+                : context.getString(R.string.virtual_device_unknown);
+    }
+
+    int getDeviceId() {
+        return mDeviceId;
+    }
+
+    void setDeviceId(int deviceId) {
+        mDeviceId = deviceId;
+    }
+
+    private VirtualDeviceWrapper(Parcel in) {
+        mAssociationInfo = in.readTypedObject(AssociationInfo.CREATOR);
+        mPersistentDeviceId = in.readString8();
+        mDeviceId = in.readInt();
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeTypedObject(mAssociationInfo, flags);
+        dest.writeString8(mPersistentDeviceId);
+        dest.writeInt(mDeviceId);
+    }
+
+    @Override
+    public boolean equals(@Nullable Object o) {
+        if (this == o) return true;
+        if (!(o instanceof VirtualDeviceWrapper that)) return false;
+        return Objects.equals(mAssociationInfo, that.mAssociationInfo)
+                && Objects.equals(mPersistentDeviceId, that.mPersistentDeviceId)
+                && mDeviceId == that.mDeviceId;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mAssociationInfo, mPersistentDeviceId, mDeviceId);
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    public static final Parcelable.Creator<VirtualDeviceWrapper> CREATOR =
+            new Parcelable.Creator<>() {
+                @NonNull
+                public VirtualDeviceWrapper createFromParcel(@NonNull Parcel in) {
+                    return new VirtualDeviceWrapper(in);
+                }
+
+                @NonNull
+                public VirtualDeviceWrapper[] newArray(int size) {
+                    return new VirtualDeviceWrapper[size];
+                }
+            };
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/virtual/ForgetDeviceDialogFragmentTest.java b/tests/robotests/src/com/android/settings/connecteddevice/virtual/ForgetDeviceDialogFragmentTest.java
new file mode 100644
index 0000000..4dedb55
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/virtual/ForgetDeviceDialogFragmentTest.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2024 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.settings.connecteddevice.virtual;
+
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.companion.AssociationInfo;
+import android.companion.CompanionDeviceManager;
+import android.content.Context;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.FragmentActivity;
+
+import com.android.settings.testutils.shadow.ShadowAlertDialogCompat;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowLooper;
+import org.robolectric.shadows.androidx.fragment.FragmentController;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(shadows = {ShadowAlertDialogCompat.class})
+public class ForgetDeviceDialogFragmentTest {
+
+    private static final int ASSOCIATION_ID = 42;
+
+    @Mock
+    private AssociationInfo mAssociationInfo;
+    @Mock
+    private CompanionDeviceManager mCompanionDeviceManager;
+    private AlertDialog mDialog;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        when(mAssociationInfo.getId()).thenReturn(ASSOCIATION_ID);
+        VirtualDeviceWrapper device = new VirtualDeviceWrapper(
+                mAssociationInfo, "PersistentDeviceId", Context.DEVICE_ID_INVALID);
+        ForgetDeviceDialogFragment fragment = ForgetDeviceDialogFragment.newInstance(device);
+        FragmentController.setupFragment(fragment, FragmentActivity.class,
+                0 /* containerViewId */, null /* bundle */);
+        fragment.mDevice = device;
+        fragment.mCompanionDeviceManager = mCompanionDeviceManager;
+        mDialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+    }
+
+    @Test
+    public void cancelDialog() {
+        mDialog.getButton(AlertDialog.BUTTON_NEGATIVE).performClick();
+        ShadowLooper.idleMainLooper();
+        verify(mCompanionDeviceManager, never()).disassociate(anyInt());
+    }
+
+    @Test
+    public void confirmDialog() {
+        mDialog.getButton(AlertDialog.BUTTON_POSITIVE).performClick();
+        ShadowLooper.idleMainLooper();
+        verify(mCompanionDeviceManager).disassociate(ASSOCIATION_ID);
+    }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/virtual/VirtualDeviceDetailsHeaderControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/virtual/VirtualDeviceDetailsHeaderControllerTest.java
new file mode 100644
index 0000000..083dd3f
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/virtual/VirtualDeviceDetailsHeaderControllerTest.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright (C) 2024 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.settings.connecteddevice.virtual;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
+
+import android.companion.AssociationInfo;
+import android.companion.Flags;
+import android.companion.virtual.VirtualDevice;
+import android.companion.virtual.VirtualDeviceManager;
+import android.content.Context;
+import android.graphics.drawable.Icon;
+import android.platform.test.annotations.DisableFlags;
+import android.platform.test.annotations.EnableFlags;
+import android.platform.test.flag.junit.SetFlagsRule;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.preference.PreferenceScreen;
+import androidx.test.core.app.ApplicationProvider;
+
+import com.android.settings.R;
+import com.android.settingslib.widget.LayoutPreference;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.Shadows;
+import org.robolectric.shadows.ShadowLooper;
+
+@RunWith(RobolectricTestRunner.class)
+public class VirtualDeviceDetailsHeaderControllerTest {
+
+    private static final CharSequence DEVICE_NAME = "Device Name";
+    private static final int DEVICE_ID = 42;
+    private static final String PERSISTENT_DEVICE_ID = "PersistentDeviceId";
+
+    @Rule
+    public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
+    @Mock
+    VirtualDeviceManager mVirtualDeviceManager;
+    @Mock
+    AssociationInfo mAssociationInfo;
+    @Mock
+    PreferenceScreen mScreen;
+
+    private VirtualDeviceWrapper mDevice;
+    private VirtualDeviceDetailsHeaderController mController;
+    private TextView mTitle;
+    private ImageView mIcon;
+    private TextView mSummary;
+    private Context mContext;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+
+        mContext = spy(ApplicationProvider.getApplicationContext());
+        when(mContext.getSystemService(VirtualDeviceManager.class))
+                .thenReturn(mVirtualDeviceManager);
+        mDevice = new VirtualDeviceWrapper(mAssociationInfo, PERSISTENT_DEVICE_ID, DEVICE_ID);
+        LayoutPreference headerPreference = new LayoutPreference(mContext,
+                LayoutInflater.from(mContext).inflate(R.layout.settings_entity_header, null));
+        View view = headerPreference.findViewById(R.id.entity_header);
+        mTitle = view.findViewById(R.id.entity_header_title);
+        mIcon = headerPreference.findViewById(R.id.entity_header_icon);
+        mSummary = view.findViewById(R.id.entity_header_summary);
+        when(mScreen.findPreference(any())).thenReturn(headerPreference);
+
+        mController = new VirtualDeviceDetailsHeaderController(mContext);
+        mController.init(mDevice);
+    }
+
+    @Test
+    public void title_matchesDeviceName() {
+        when(mAssociationInfo.getDisplayName()).thenReturn(DEVICE_NAME);
+
+        mController.displayPreference(mScreen);
+        assertThat(mTitle.getText().toString()).isEqualTo(DEVICE_NAME.toString());
+    }
+
+    @Test
+    @DisableFlags(Flags.FLAG_ASSOCIATION_DEVICE_ICON)
+    public void icon_genericIcon() {
+        mController.displayPreference(mScreen);
+        assertThat(Shadows.shadowOf(mIcon.getDrawable()).getCreatedFromResId())
+                .isEqualTo(R.drawable.ic_devices_other);
+    }
+
+    @Test
+    @EnableFlags(Flags.FLAG_ASSOCIATION_DEVICE_ICON)
+    public void icon_noAssociationIcon_genericIcon() {
+        mController.displayPreference(mScreen);
+        assertThat(Shadows.shadowOf(mIcon.getDrawable()).getCreatedFromResId())
+                .isEqualTo(R.drawable.ic_devices_other);
+    }
+
+    @Test
+    @EnableFlags(Flags.FLAG_ASSOCIATION_DEVICE_ICON)
+    public void icon_fromAssociation() {
+        Icon icon = Icon.createWithResource(mContext, R.drawable.ic_android);
+        when(mAssociationInfo.getDeviceIcon()).thenReturn(icon);
+
+        mController.displayPreference(mScreen);
+        assertThat(Shadows.shadowOf(mIcon.getDrawable()).getCreatedFromResId())
+                .isEqualTo(R.drawable.ic_android);
+    }
+
+    @Test
+    public void summary_activeDevice_changeToInactive() {
+        mDevice.setDeviceId(DEVICE_ID);
+        mController.displayPreference(mScreen);
+        assertThat(mSummary.getText().toString())
+                .isEqualTo(mContext.getString(R.string.virtual_device_connected));
+
+        mController.onVirtualDeviceClosed(DEVICE_ID);
+        ShadowLooper.idleMainLooper();
+
+        assertThat(mSummary.getText().toString())
+                .isEqualTo(mContext.getString(R.string.virtual_device_disconnected));
+    }
+
+    @Test
+    public void summary_inactiveDevice_changeToActive() {
+        mDevice.setDeviceId(Context.DEVICE_ID_INVALID);
+        mController.displayPreference(mScreen);
+        assertThat(mSummary.getText().toString())
+                .isEqualTo(mContext.getString(R.string.virtual_device_disconnected));
+
+        VirtualDevice virtualDevice = mock(VirtualDevice.class);
+        when(mDevice.getPersistentDeviceId()).thenReturn(PERSISTENT_DEVICE_ID);
+        when(mVirtualDeviceManager.getVirtualDevice(DEVICE_ID)).thenReturn(virtualDevice);
+        when(virtualDevice.getPersistentDeviceId()).thenReturn(PERSISTENT_DEVICE_ID);
+
+        mController.onVirtualDeviceCreated(DEVICE_ID);
+        ShadowLooper.idleMainLooper();
+
+        assertThat(mSummary.getText().toString())
+                .isEqualTo(mContext.getString(R.string.virtual_device_connected));
+    }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/virtual/VirtualDeviceListControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/virtual/VirtualDeviceListControllerTest.java
new file mode 100644
index 0000000..4066855
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/virtual/VirtualDeviceListControllerTest.java
@@ -0,0 +1,194 @@
+/*
+ * Copyright (C) 2024 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.settings.connecteddevice.virtual;
+
+import static com.android.settings.core.BasePreferenceController.AVAILABLE;
+import static com.android.settings.core.BasePreferenceController.CONDITIONALLY_UNAVAILABLE;
+import static com.android.settings.core.BasePreferenceController.UNSUPPORTED_ON_DEVICE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assume.assumeTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
+
+import android.companion.AssociationInfo;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.res.Resources;
+import android.platform.test.annotations.DisableFlags;
+import android.platform.test.annotations.EnableFlags;
+import android.platform.test.flag.junit.SetFlagsRule;
+
+import androidx.preference.Preference;
+import androidx.preference.PreferenceGroup;
+import androidx.preference.PreferenceManager;
+import androidx.preference.PreferenceScreen;
+import androidx.test.core.app.ApplicationProvider;
+
+import com.android.settings.R;
+import com.android.settings.dashboard.DashboardFragment;
+import com.android.settingslib.search.SearchIndexableRaw;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.Shadows;
+import org.robolectric.shadows.ShadowLooper;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@RunWith(RobolectricTestRunner.class)
+public class VirtualDeviceListControllerTest {
+
+    private static final String PREFERENCE_KEY = "virtual_device_list";
+
+    private static final CharSequence DEVICE_NAME = "Device Name";
+    private static final int DEVICE_ID = 42;
+    private static final String PERSISTENT_DEVICE_ID = "PersistentDeviceId";
+    private static final String DEVICE_PREFERENCE_KEY = PERSISTENT_DEVICE_ID + "_" + DEVICE_NAME;
+
+    @Rule
+    public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
+    @Mock
+    PreferenceManager mPreferenceManager;
+    @Mock
+    AssociationInfo mAssociationInfo;
+    @Mock
+    PreferenceScreen mScreen;
+
+    private VirtualDeviceWrapper mDevice;
+    private VirtualDeviceListController mController;
+    private PreferenceGroup mPreferenceGroup;
+    private Context mContext;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+
+        mContext = spy(ApplicationProvider.getApplicationContext());
+        mPreferenceGroup = spy(new PreferenceScreen(mContext, null));
+        when(mPreferenceManager.getSharedPreferences()).thenReturn(mock(SharedPreferences.class));
+        when(mPreferenceGroup.getPreferenceManager()).thenReturn(mPreferenceManager);
+        when(mScreen.findPreference(PREFERENCE_KEY)).thenReturn(mPreferenceGroup);
+        when(mScreen.getContext()).thenReturn(mContext);
+        mController = new VirtualDeviceListController(mContext, PREFERENCE_KEY);
+        DashboardFragment fragment = mock(DashboardFragment.class);
+        when(fragment.getContext()).thenReturn(mContext);
+        mController.setFragment(fragment);
+
+        when(mAssociationInfo.getDisplayName()).thenReturn(DEVICE_NAME);
+        mDevice = new VirtualDeviceWrapper(mAssociationInfo, PERSISTENT_DEVICE_ID, DEVICE_ID);
+    }
+
+    @Test
+    public void getAvailabilityStatus_vdmDisabled() {
+        Resources resources = spy(mContext.getResources());
+        when(mContext.getResources()).thenReturn(resources);
+        when(resources.getBoolean(com.android.internal.R.bool.config_enableVirtualDeviceManager))
+                .thenReturn(false);
+
+        assertThat(mController.getAvailabilityStatus()).isEqualTo(UNSUPPORTED_ON_DEVICE);
+    }
+
+    @Test
+    @DisableFlags(android.companion.virtualdevice.flags.Flags.FLAG_VDM_SETTINGS)
+    public void getAvailabilityStatus_flagDisabled() {
+        assertThat(mController.getAvailabilityStatus()).isEqualTo(CONDITIONALLY_UNAVAILABLE);
+    }
+
+    @Test
+    @EnableFlags(android.companion.virtualdevice.flags.Flags.FLAG_VDM_SETTINGS)
+    public void getAvailabilityStatus_available() {
+        assertThat(mController.getAvailabilityStatus()).isEqualTo(AVAILABLE);
+    }
+
+    @Test
+    public void onDeviceAdded_createPreference() {
+        mController.displayPreference(mScreen);
+        mController.onDeviceAdded(mDevice);
+        ShadowLooper.idleMainLooper();
+
+        assertThat(mPreferenceGroup.getPreferenceCount()).isEqualTo(1);
+        Preference preference = mPreferenceGroup.findPreference(DEVICE_PREFERENCE_KEY);
+        assertThat(preference).isNotNull();
+        assertThat(preference.getTitle().toString()).isEqualTo(DEVICE_NAME.toString());
+        assertThat(Shadows.shadowOf(preference.getIcon()).getCreatedFromResId())
+                .isEqualTo(R.drawable.ic_devices_other);
+        assertThat(preference.getSummary().toString())
+                .isEqualTo(mContext.getString(R.string.virtual_device_connected));
+
+        assertThat(preference).isEqualTo(mController.mPreferences.get(PERSISTENT_DEVICE_ID));
+    }
+
+    @Test
+    public void onDeviceChanged_updateSummary() {
+        mController.displayPreference(mScreen);
+        mController.onDeviceAdded(mDevice);
+        ShadowLooper.idleMainLooper();
+
+        mDevice.setDeviceId(Context.DEVICE_ID_INVALID);
+        mController.onDeviceChanged(mDevice);
+        ShadowLooper.idleMainLooper();
+
+        assertThat(mPreferenceGroup.getPreferenceCount()).isEqualTo(1);
+        Preference preference = mPreferenceGroup.findPreference(DEVICE_PREFERENCE_KEY);
+        assertThat(preference).isNotNull();
+        assertThat(preference.getSummary().toString())
+                .isEqualTo(mContext.getString(R.string.virtual_device_disconnected));
+
+        assertThat(preference).isEqualTo(mController.mPreferences.get(PERSISTENT_DEVICE_ID));
+    }
+
+    @Test
+    public void onDeviceRemoved_removePreference() {
+        mController.displayPreference(mScreen);
+        mController.onDeviceAdded(mDevice);
+        ShadowLooper.idleMainLooper();
+
+        mDevice.setDeviceId(Context.DEVICE_ID_INVALID);
+        mController.onDeviceRemoved(mDevice);
+        ShadowLooper.idleMainLooper();
+
+        assertThat(mPreferenceGroup.getPreferenceCount()).isEqualTo(0);
+        assertThat(mController.mPreferences).isEmpty();
+    }
+
+    @Test
+    public void updateDynamicRawDataToIndex_available() {
+        assumeTrue(mController.isAvailable());
+
+        mController.mVirtualDeviceUpdater = mock(VirtualDeviceUpdater.class);
+        when(mController.mVirtualDeviceUpdater.loadDevices()).thenReturn(List.of(mDevice));
+
+        ArrayList<SearchIndexableRaw> searchData = new ArrayList<>();
+        mController.updateDynamicRawDataToIndex(searchData);
+
+        assertThat(searchData).hasSize(1);
+        SearchIndexableRaw data = searchData.getFirst();
+        assertThat(data.key).isEqualTo(DEVICE_PREFERENCE_KEY);
+        assertThat(data.title).isEqualTo(DEVICE_NAME.toString());
+        assertThat(data.summaryOn)
+                .isEqualTo(mContext.getString(R.string.connected_device_connections_title));
+    }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/virtual/VirtualDeviceUpdaterTest.java b/tests/robotests/src/com/android/settings/connecteddevice/virtual/VirtualDeviceUpdaterTest.java
new file mode 100644
index 0000000..29123c6
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/virtual/VirtualDeviceUpdaterTest.java
@@ -0,0 +1,289 @@
+/*
+ * Copyright (C) 2024 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.settings.connecteddevice.virtual;
+
+import static com.android.settingslib.drawer.TileUtils.IA_SETTINGS_ACTION;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import android.companion.AssociationInfo;
+import android.companion.CompanionDeviceManager;
+import android.companion.virtual.VirtualDevice;
+import android.companion.virtual.VirtualDeviceManager;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.ResolveInfo;
+import android.os.UserHandle;
+import android.util.ArraySet;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.core.content.pm.ApplicationInfoBuilder;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.Shadows;
+import org.robolectric.shadows.ShadowPackageManager;
+
+import java.util.List;
+
+@RunWith(RobolectricTestRunner.class)
+public class VirtualDeviceUpdaterTest {
+
+    private static final String PERSISTENT_DEVICE_ID = "companion:42";
+    private static final int ASSOCIATION_ID = 42;
+    private static final int DEVICE_ID = 7;
+    private static final String PACKAGE_NAME = "test.package.name";
+
+    @Mock
+    private VirtualDeviceManager mVirtualDeviceManager;
+    @Mock
+    private CompanionDeviceManager mCompanionDeviceManager;
+    @Mock
+    private VirtualDeviceUpdater.DeviceListener mDeviceListener;
+    @Mock
+    private AssociationInfo mAssociationInfo;
+    @Mock
+    private VirtualDevice mVirtualDevice;
+    private ShadowPackageManager mPackageManager;
+
+    private VirtualDeviceUpdater mVirtualDeviceUpdater;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        Context context = spy(ApplicationProvider.getApplicationContext());
+        when(context.getSystemService(VirtualDeviceManager.class))
+                .thenReturn(mVirtualDeviceManager);
+        when(context.getSystemService(CompanionDeviceManager.class))
+                .thenReturn(mCompanionDeviceManager);
+        mPackageManager = Shadows.shadowOf(context.getPackageManager());
+        mVirtualDeviceUpdater = new VirtualDeviceUpdater(context, mDeviceListener);
+    }
+
+    @Test
+    public void loadDevices_noDevices() {
+        mVirtualDeviceUpdater.loadDevices();
+        verifyNoMoreInteractions(mDeviceListener);
+        assertThat(mVirtualDeviceUpdater.mDevices).isEmpty();
+    }
+
+    @Test
+    public void loadDevices_noAssociationInfo() {
+        when(mVirtualDeviceManager.getAllPersistentDeviceIds())
+                .thenReturn(new ArraySet<>(new String[]{PERSISTENT_DEVICE_ID}));
+
+        mVirtualDeviceUpdater.loadDevices();
+        verifyNoMoreInteractions(mDeviceListener);
+        assertThat(mVirtualDeviceUpdater.mDevices).isEmpty();
+    }
+
+    @Test
+    public void loadDevices_invalidAssociationId() {
+        when(mVirtualDeviceManager.getAllPersistentDeviceIds())
+                .thenReturn(new ArraySet<>(new String[]{"NotACompanionPersistentId"}));
+
+        mVirtualDeviceUpdater.loadDevices();
+        verifyNoMoreInteractions(mDeviceListener);
+        assertThat(mVirtualDeviceUpdater.mDevices).isEmpty();
+    }
+
+    @Test
+    public void loadDevices_noMatchingAssociationId() {
+        when(mVirtualDeviceManager.getAllPersistentDeviceIds())
+                .thenReturn(new ArraySet<>(new String[]{PERSISTENT_DEVICE_ID}));
+        when(mCompanionDeviceManager.getAllAssociations(UserHandle.USER_ALL))
+                .thenReturn(List.of(mAssociationInfo));
+        when(mAssociationInfo.getId()).thenReturn(ASSOCIATION_ID + 1);
+
+        mVirtualDeviceUpdater.loadDevices();
+        verifyNoMoreInteractions(mDeviceListener);
+        assertThat(mVirtualDeviceUpdater.mDevices).isEmpty();
+    }
+
+    @Test
+    public void loadDevices_excludePackageFromSettings() {
+        when(mVirtualDeviceManager.getAllPersistentDeviceIds())
+                .thenReturn(new ArraySet<>(new String[]{PERSISTENT_DEVICE_ID}));
+        when(mCompanionDeviceManager.getAllAssociations(UserHandle.USER_ALL))
+                .thenReturn(List.of(mAssociationInfo));
+        when(mAssociationInfo.getId()).thenReturn(ASSOCIATION_ID);
+        when(mAssociationInfo.getPackageName()).thenReturn(PACKAGE_NAME);
+        final ApplicationInfo appInfo =
+                ApplicationInfoBuilder.newBuilder().setPackageName(PACKAGE_NAME).build();
+        final ActivityInfo activityInfo = new ActivityInfo();
+        activityInfo.packageName = PACKAGE_NAME;
+        activityInfo.name = PACKAGE_NAME;
+        activityInfo.applicationInfo = appInfo;
+        final ResolveInfo resolveInfo = new ResolveInfo();
+        resolveInfo.activityInfo = activityInfo;
+        final Intent intent = new Intent(IA_SETTINGS_ACTION);
+        intent.setPackage(PACKAGE_NAME);
+        mPackageManager.addResolveInfoForIntent(intent, resolveInfo);
+
+        mVirtualDeviceUpdater.loadDevices();
+        verifyNoMoreInteractions(mDeviceListener);
+        assertThat(mVirtualDeviceUpdater.mDevices).isEmpty();
+    }
+
+    @Test
+    public void loadDevices_newDevice_inactive() {
+        when(mVirtualDeviceManager.getAllPersistentDeviceIds())
+                .thenReturn(new ArraySet<>(new String[]{PERSISTENT_DEVICE_ID}));
+        when(mCompanionDeviceManager.getAllAssociations(UserHandle.USER_ALL))
+                .thenReturn(List.of(mAssociationInfo));
+        when(mAssociationInfo.getId()).thenReturn(ASSOCIATION_ID);
+        when(mAssociationInfo.getPackageName()).thenReturn(PACKAGE_NAME);
+
+        mVirtualDeviceUpdater.loadDevices();
+        VirtualDeviceWrapper device = new VirtualDeviceWrapper(
+                mAssociationInfo, PERSISTENT_DEVICE_ID, Context.DEVICE_ID_INVALID);
+        verify(mDeviceListener).onDeviceAdded(device);
+        verifyNoMoreInteractions(mDeviceListener);
+        assertThat(mVirtualDeviceUpdater.mDevices).containsExactly(PERSISTENT_DEVICE_ID, device);
+    }
+
+    @Test
+    public void loadDevices_newDevice_active() {
+        when(mVirtualDeviceManager.getAllPersistentDeviceIds())
+                .thenReturn(new ArraySet<>(new String[]{PERSISTENT_DEVICE_ID}));
+        when(mVirtualDeviceManager.getVirtualDevices())
+                .thenReturn(List.of(mVirtualDevice));
+        when(mCompanionDeviceManager.getAllAssociations(UserHandle.USER_ALL))
+                .thenReturn(List.of(mAssociationInfo));
+        when(mAssociationInfo.getId()).thenReturn(ASSOCIATION_ID);
+        when(mAssociationInfo.getPackageName()).thenReturn(PACKAGE_NAME);
+        when(mVirtualDevice.getDeviceId()).thenReturn(DEVICE_ID);
+        when(mVirtualDevice.getPersistentDeviceId()).thenReturn(PERSISTENT_DEVICE_ID);
+
+        mVirtualDeviceUpdater.loadDevices();
+        VirtualDeviceWrapper device = new VirtualDeviceWrapper(
+                mAssociationInfo, PERSISTENT_DEVICE_ID, DEVICE_ID);
+        verify(mDeviceListener).onDeviceAdded(device);
+        verifyNoMoreInteractions(mDeviceListener);
+        assertThat(mVirtualDeviceUpdater.mDevices).containsExactly(PERSISTENT_DEVICE_ID, device);
+    }
+
+    @Test
+    public void loadDevices_removeDevice() {
+        VirtualDeviceWrapper device = new VirtualDeviceWrapper(
+                mAssociationInfo, PERSISTENT_DEVICE_ID, DEVICE_ID);
+        mVirtualDeviceUpdater.mDevices.put(PERSISTENT_DEVICE_ID, device);
+
+        mVirtualDeviceUpdater.loadDevices();
+        verify(mDeviceListener).onDeviceRemoved(device);
+        verifyNoMoreInteractions(mDeviceListener);
+        assertThat(mVirtualDeviceUpdater.mDevices).isEmpty();
+    }
+
+    @Test
+    public void loadDevices_noChanges_activeDevice() {
+        VirtualDeviceWrapper device = new VirtualDeviceWrapper(
+                mAssociationInfo, PERSISTENT_DEVICE_ID, DEVICE_ID);
+        mVirtualDeviceUpdater.mDevices.put(PERSISTENT_DEVICE_ID, device);
+
+        when(mVirtualDeviceManager.getAllPersistentDeviceIds())
+                .thenReturn(new ArraySet<>(new String[]{PERSISTENT_DEVICE_ID}));
+        when(mVirtualDeviceManager.getVirtualDevices())
+                .thenReturn(List.of(mVirtualDevice));
+        when(mCompanionDeviceManager.getAllAssociations(UserHandle.USER_ALL))
+                .thenReturn(List.of(mAssociationInfo));
+        when(mAssociationInfo.getId()).thenReturn(ASSOCIATION_ID);
+        when(mAssociationInfo.getPackageName()).thenReturn(PACKAGE_NAME);
+        when(mVirtualDevice.getDeviceId()).thenReturn(DEVICE_ID);
+        when(mVirtualDevice.getPersistentDeviceId()).thenReturn(PERSISTENT_DEVICE_ID);
+
+        mVirtualDeviceUpdater.loadDevices();
+        verifyNoMoreInteractions(mDeviceListener);
+        assertThat(mVirtualDeviceUpdater.mDevices).containsExactly(PERSISTENT_DEVICE_ID, device);
+    }
+
+    @Test
+    public void loadDevices_noChanges_inactiveDevice() {
+        VirtualDeviceWrapper device = new VirtualDeviceWrapper(
+                mAssociationInfo, PERSISTENT_DEVICE_ID, Context.DEVICE_ID_INVALID);
+        mVirtualDeviceUpdater.mDevices.put(PERSISTENT_DEVICE_ID, device);
+
+        when(mVirtualDeviceManager.getAllPersistentDeviceIds())
+                .thenReturn(new ArraySet<>(new String[]{PERSISTENT_DEVICE_ID}));
+        when(mCompanionDeviceManager.getAllAssociations(UserHandle.USER_ALL))
+                .thenReturn(List.of(mAssociationInfo));
+        when(mAssociationInfo.getId()).thenReturn(ASSOCIATION_ID);
+        when(mAssociationInfo.getPackageName()).thenReturn(PACKAGE_NAME);
+
+        mVirtualDeviceUpdater.loadDevices();
+        verifyNoMoreInteractions(mDeviceListener);
+        assertThat(mVirtualDeviceUpdater.mDevices).containsExactly(PERSISTENT_DEVICE_ID, device);
+    }
+
+    @Test
+    public void loadDevices_deviceChange_activeToInactive() {
+        VirtualDeviceWrapper device = new VirtualDeviceWrapper(
+                mAssociationInfo, PERSISTENT_DEVICE_ID, DEVICE_ID);
+        mVirtualDeviceUpdater.mDevices.put(PERSISTENT_DEVICE_ID, device);
+
+        when(mVirtualDeviceManager.getAllPersistentDeviceIds())
+                .thenReturn(new ArraySet<>(new String[]{PERSISTENT_DEVICE_ID}));
+        when(mCompanionDeviceManager.getAllAssociations(UserHandle.USER_ALL))
+                .thenReturn(List.of(mAssociationInfo));
+        when(mAssociationInfo.getId()).thenReturn(ASSOCIATION_ID);
+        when(mAssociationInfo.getPackageName()).thenReturn(PACKAGE_NAME);
+
+        mVirtualDeviceUpdater.loadDevices();
+
+        device.setDeviceId(Context.DEVICE_ID_INVALID);
+        verify(mDeviceListener).onDeviceChanged(device);
+        verifyNoMoreInteractions(mDeviceListener);
+        assertThat(mVirtualDeviceUpdater.mDevices).containsExactly(PERSISTENT_DEVICE_ID, device);
+    }
+
+    @Test
+    public void loadDevices_deviceChange_inactiveToActive() {
+        VirtualDeviceWrapper device = new VirtualDeviceWrapper(
+                mAssociationInfo, PERSISTENT_DEVICE_ID, Context.DEVICE_ID_INVALID);
+        mVirtualDeviceUpdater.mDevices.put(PERSISTENT_DEVICE_ID, device);
+
+        when(mVirtualDeviceManager.getAllPersistentDeviceIds())
+                .thenReturn(new ArraySet<>(new String[]{PERSISTENT_DEVICE_ID}));
+        when(mVirtualDeviceManager.getVirtualDevices())
+                .thenReturn(List.of(mVirtualDevice));
+        when(mCompanionDeviceManager.getAllAssociations(UserHandle.USER_ALL))
+                .thenReturn(List.of(mAssociationInfo));
+        when(mAssociationInfo.getId()).thenReturn(ASSOCIATION_ID);
+        when(mAssociationInfo.getPackageName()).thenReturn(PACKAGE_NAME);
+        when(mVirtualDevice.getDeviceId()).thenReturn(DEVICE_ID);
+        when(mVirtualDevice.getPersistentDeviceId()).thenReturn(PERSISTENT_DEVICE_ID);
+
+
+        mVirtualDeviceUpdater.loadDevices();
+
+        device.setDeviceId(DEVICE_ID);
+        verify(mDeviceListener).onDeviceChanged(device);
+        verifyNoMoreInteractions(mDeviceListener);
+        assertThat(mVirtualDeviceUpdater.mDevices).containsExactly(PERSISTENT_DEVICE_ID, device);
+    }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/virtual/VirtualDeviceWrapperTest.java b/tests/robotests/src/com/android/settings/connecteddevice/virtual/VirtualDeviceWrapperTest.java
new file mode 100644
index 0000000..6ddb7a5
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/virtual/VirtualDeviceWrapperTest.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2024 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.settings.connecteddevice.virtual;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.when;
+
+import android.companion.AssociationInfo;
+import android.content.Context;
+
+import com.android.settings.R;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public class VirtualDeviceWrapperTest {
+
+    private static final String PERSISTENT_DEVICE_ID = "PersistentDeviceIdForTest";
+    private static final String DEVICE_NAME = "DEVICE NAME";
+
+    @Mock
+    private AssociationInfo mAssociationInfo;
+    @Mock
+    private Context mContext;
+    private VirtualDeviceWrapper mVirtualDeviceWrapper;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        mVirtualDeviceWrapper = new VirtualDeviceWrapper(mAssociationInfo, PERSISTENT_DEVICE_ID,
+                Context.DEVICE_ID_INVALID);
+    }
+
+    @Test
+    public void setDeviceId() {
+        assertThat(mVirtualDeviceWrapper.getDeviceId()).isEqualTo(Context.DEVICE_ID_INVALID);
+        mVirtualDeviceWrapper.setDeviceId(42);
+        assertThat(mVirtualDeviceWrapper.getDeviceId()).isEqualTo(42);
+    }
+
+    @Test
+    public void getDisplayName_fromAssociationInfo() {
+        when(mAssociationInfo.getDisplayName()).thenReturn(DEVICE_NAME);
+        assertThat(mVirtualDeviceWrapper.getDeviceName(mContext).toString()).isEqualTo(DEVICE_NAME);
+    }
+
+    @Test
+    public void getDisplayName_fromResources() {
+        when(mAssociationInfo.getDisplayName()).thenReturn(null);
+        when(mContext.getString(R.string.virtual_device_unknown)).thenReturn(DEVICE_NAME);
+        assertThat(mVirtualDeviceWrapper.getDeviceName(mContext).toString()).isEqualTo(DEVICE_NAME);
+    }
+}