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